ui phase 9

This commit is contained in:
hamid
2026-07-19 15:14:44 +03:30
parent 1ef4feb911
commit b638e25a0e
47 changed files with 2628 additions and 666 deletions
@@ -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';
+6
View File
@@ -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';