ui phase 9
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import FormDialogShell from './FormDialogShell';
|
||||
|
||||
describe('<FormDialogShell/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
const baseProps = {
|
||||
open: true,
|
||||
title: 'Add patient',
|
||||
closeLabel: 'Close',
|
||||
discardTitle: 'Discard changes?',
|
||||
discardBody: 'Your changes will be lost.',
|
||||
discardConfirmLabel: 'Discard',
|
||||
discardCancelLabel: 'Keep editing',
|
||||
};
|
||||
|
||||
it('renders the title and children', () => {
|
||||
wrap(
|
||||
<FormDialogShell {...baseProps} dirty={false} onClose={jest.fn()}>
|
||||
<div>form body</div>
|
||||
</FormDialogShell>,
|
||||
);
|
||||
expect(screen.getByText('Add patient')).toBeInTheDocument();
|
||||
expect(screen.getByText('form body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes immediately when not dirty', () => {
|
||||
const onClose = jest.fn();
|
||||
wrap(
|
||||
<FormDialogShell {...baseProps} dirty={false} onClose={onClose}>
|
||||
<div />
|
||||
</FormDialogShell>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText('Close'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows a discard confirm instead of closing when dirty', () => {
|
||||
const onClose = jest.fn();
|
||||
wrap(
|
||||
<FormDialogShell {...baseProps} dirty onClose={onClose}>
|
||||
<div />
|
||||
</FormDialogShell>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText('Close'));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Discard changes?')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps editing (does not close) when the discard confirm is cancelled', () => {
|
||||
const onClose = jest.fn();
|
||||
wrap(
|
||||
<FormDialogShell {...baseProps} dirty onClose={onClose}>
|
||||
<div />
|
||||
</FormDialogShell>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText('Close'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Keep editing' }));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode, useState } from 'react';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import AppIcon from '../AppIcon';
|
||||
import ConfirmDialog from '../ConfirmDialog';
|
||||
|
||||
export interface FormDialogShellProps {
|
||||
open: boolean;
|
||||
/** App-bar title (the form's add/edit heading). */
|
||||
title: string;
|
||||
/** Whether the hosted form has unsaved changes — gates the discard-confirm on close/backdrop/escape. */
|
||||
dirty: boolean;
|
||||
/** The real close — called immediately when not dirty, or after the discard is confirmed. */
|
||||
onClose: () => void;
|
||||
closeLabel: string;
|
||||
discardTitle: string;
|
||||
discardBody: string;
|
||||
discardConfirmLabel: string;
|
||||
discardCancelLabel: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen-on-mobile form dialog: below the `sm` breakpoint the form takes over the whole
|
||||
* viewport (a single scroll region, an app-bar header with a close button) instead of a cramped
|
||||
* `maxWidth="sm"` modal double-scrolling against the keyboard; at `sm`+ it's a normal sized
|
||||
* dialog. Closing (the app-bar button, backdrop click, or Escape) while the form is `dirty` shows
|
||||
* a discard-confirm instead of silently dropping the draft. The hosted form keeps its own
|
||||
* save/cancel row inside `children` — this shell only owns the chrome + the dirty guard.
|
||||
* @component FormDialogShell
|
||||
*/
|
||||
const FormDialogShell: FunctionComponent<FormDialogShellProps> = ({
|
||||
open,
|
||||
title,
|
||||
dirty,
|
||||
onClose,
|
||||
closeLabel,
|
||||
discardTitle,
|
||||
discardBody,
|
||||
discardConfirmLabel,
|
||||
discardCancelLabel,
|
||||
children,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const [discardOpen, setDiscardOpen] = useState(false);
|
||||
|
||||
const requestClose = () => {
|
||||
if (dirty) setDiscardOpen(true);
|
||||
else onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onClose={requestClose} fullScreen={fullScreen} fullWidth maxWidth="sm">
|
||||
<AppBar
|
||||
position="relative"
|
||||
color="default"
|
||||
elevation={0}
|
||||
sx={{ bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton edge="start" onClick={requestClose} aria-label={closeLabel} sx={{ marginInlineEnd: 1 }}>
|
||||
<AppIcon icon="close" />
|
||||
</IconButton>
|
||||
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<DialogContent sx={{ pt: 2 }}>{children}</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={discardOpen}
|
||||
title={discardTitle}
|
||||
body={discardBody}
|
||||
confirmLabel={discardConfirmLabel}
|
||||
cancelLabel={discardCancelLabel}
|
||||
confirmColor="error"
|
||||
onConfirm={() => {
|
||||
setDiscardOpen(false);
|
||||
onClose();
|
||||
}}
|
||||
onClose={() => setDiscardOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormDialogShell;
|
||||
@@ -0,0 +1,4 @@
|
||||
import FormDialogShell from './FormDialogShell';
|
||||
|
||||
export default FormDialogShell;
|
||||
export type { FormDialogShellProps } from './FormDialogShell';
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import InitialsAvatar from './InitialsAvatar';
|
||||
|
||||
describe('<InitialsAvatar/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
it('renders initials from the first two words of the name', () => {
|
||||
wrap(<InitialsAvatar name="مریم رضایی" />);
|
||||
expect(screen.getByText('مر')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a single-word name as one initial', () => {
|
||||
wrap(<InitialsAvatar name="مریم" />);
|
||||
expect(screen.getByText('م')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is hidden from assistive tech (decorative next to a visible name)', () => {
|
||||
wrap(<InitialsAvatar name="مریم رضایی" />);
|
||||
expect(screen.getByText('مر')).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('picks the same color index for the same name every render', () => {
|
||||
const { container: first } = render(
|
||||
<ThemeProvider>
|
||||
<InitialsAvatar name="سارا محمدی" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const { container: second } = render(
|
||||
<ThemeProvider>
|
||||
<InitialsAvatar name="سارا محمدی" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const bg = (el: HTMLElement) => el.querySelector('.MuiAvatar-root')?.getAttribute('style');
|
||||
expect(bg(first.firstChild as HTMLElement)).toEqual(bg(second.firstChild as HTMLElement));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useMemo } from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
|
||||
/** Number of `--bal-avatar-*` color pairs defined in tokens.css (both scheme blocks). */
|
||||
const AVATAR_TOKEN_COUNT = 6;
|
||||
|
||||
// Two-letter initials from the first two whitespace-separated words; Persian names have no case,
|
||||
// so this stays a plain substring pick (never .toUpperCase() on non-Latin scripts).
|
||||
function initialsOf(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '';
|
||||
const first = parts[0].charAt(0);
|
||||
const second = parts.length > 1 ? parts[1].charAt(0) : '';
|
||||
return `${first}${second}`;
|
||||
}
|
||||
|
||||
// A stable (non-cryptographic) hash so the same name always lands on the same token pair,
|
||||
// across reloads and between the E1 card and E2 record header.
|
||||
function colorIndexOf(name: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i += 1) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return (hash % AVATAR_TOKEN_COUNT) + 1;
|
||||
}
|
||||
|
||||
export interface InitialsAvatarProps {
|
||||
/** The person's display name — source of both the initials and the deterministic color. */
|
||||
name: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm auto-colored initials avatar — the identity face for a person with no photo (a care-circle
|
||||
* patient, the account holder). Deterministic per name (stable hash → one of 6 `--bal-avatar-*`
|
||||
* token pairs, both color-scheme blocks in `tokens.css`), so the same person always renders the
|
||||
* same color. Purely decorative next to a visible name, so it's hidden from assistive tech.
|
||||
* @component InitialsAvatar
|
||||
*/
|
||||
const InitialsAvatar: FunctionComponent<InitialsAvatarProps> = ({ name, size = 40 }) => {
|
||||
const index = useMemo(() => colorIndexOf(name), [name]);
|
||||
const initials = useMemo(() => initialsOf(name), [name]);
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
aria-hidden
|
||||
sx={{
|
||||
width: size,
|
||||
height: size,
|
||||
bgcolor: `var(--bal-avatar-${index})`,
|
||||
color: `var(--bal-avatar-${index}-contrast)`,
|
||||
fontWeight: 700,
|
||||
fontSize: size * 0.4,
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</Avatar>
|
||||
);
|
||||
};
|
||||
|
||||
export default InitialsAvatar;
|
||||
@@ -0,0 +1,4 @@
|
||||
import InitialsAvatar from './InitialsAvatar';
|
||||
|
||||
export default InitialsAvatar;
|
||||
export type { InitialsAvatarProps } from './InitialsAvatar';
|
||||
@@ -20,6 +20,8 @@ import JalaliDateIntentPicker from './JalaliDateIntentPicker';
|
||||
import LocaleSwitcher from './LocaleSwitcher';
|
||||
import StickyActionBar from './StickyActionBar';
|
||||
import Pager from './Pager';
|
||||
import InitialsAvatar from './InitialsAvatar';
|
||||
import FormDialogShell from './FormDialogShell';
|
||||
|
||||
export {
|
||||
ErrorBoundary,
|
||||
@@ -44,6 +46,8 @@ export {
|
||||
LocaleSwitcher,
|
||||
StickyActionBar,
|
||||
Pager,
|
||||
InitialsAvatar,
|
||||
FormDialogShell,
|
||||
};
|
||||
export type { EmptyStateProps } from './EmptyState';
|
||||
export type { ErrorStateProps } from './ErrorState';
|
||||
@@ -59,3 +63,5 @@ export type { JalaliDateFieldProps } from './JalaliDateField';
|
||||
export type { JalaliDateIntentPickerProps } from './JalaliDateIntentPicker';
|
||||
export type { StickyActionBarProps } from './StickyActionBar';
|
||||
export type { PagerProps } from './Pager';
|
||||
export type { InitialsAvatarProps } from './InitialsAvatar';
|
||||
export type { FormDialogShellProps } from './FormDialogShell';
|
||||
|
||||
Reference in New Issue
Block a user