ui phase 12

This commit is contained in:
hamid
2026-07-19 21:31:59 +03:30
parent 87fa4cd497
commit d33568bf31
39 changed files with 1049 additions and 247 deletions
@@ -126,4 +126,16 @@ describe('<AppIconButton/> component', () => {
expect(tooltip).toHaveTextContent(title);
expect(tooltip).toHaveClass('MuiTooltip-popper');
});
it('keeps an accessible name from .title even when disabled (no Tooltip is rendered)', () => {
const testId = randomText(8);
const title = randomText(16);
render(<ComponentToTest data-testid={testId} title={title} disabled />);
const button = screen.getByTestId(testId);
expect(button).toBeDisabled();
expect(button).toHaveAttribute('aria-label', title);
// No Tooltip popper mounted for a disabled button — the name comes from the button itself.
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
});
@@ -75,6 +75,10 @@ const AppIconButton: FunctionComponent<AppIconButtonProps> = ({
component={componentToRender}
color={colorToRender}
disabled={disabled}
// Named directly on the button (not only via the Tooltip wrap below) so an icon-only button
// keeps an accessible name even when disabled, when the Tooltip isn't rendered at all. A
// caller-supplied `aria-label` in restOfProps still wins (spread last).
aria-label={title}
sx={sxToRender}
{...restOfProps}
>
@@ -82,7 +86,7 @@ const AppIconButton: FunctionComponent<AppIconButtonProps> = ({
{children}
</IconButton>
);
}, [color, componentToRender, children, disabled, icon, isMuiColor, sx, iconProps, restOfProps]);
}, [color, componentToRender, children, disabled, icon, isMuiColor, sx, iconProps, restOfProps, title]);
// When title is set, wrap the IconButton with Tooltip.
// Note: when IconButton is disabled the Tooltip is not working, so we don't need it
@@ -53,7 +53,10 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, State> {
if (this.state.hasError) {
const { error, errorInfo } = this.state;
return (
<Stack sx={{ alignItems: 'center', justifyContent: 'center', gap: 1.5, py: 6, px: 2, textAlign: 'center' }}>
<Stack
role="alert"
sx={{ alignItems: 'center', justifyContent: 'center', gap: 1.5, py: 6, px: 2, textAlign: 'center' }}
>
<AppIcon icon="warning" size={36} color="var(--bal-warning)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{this.props.title}
@@ -21,13 +21,16 @@ export interface ErrorStateProps {
* The calm, branded "something went wrong" panel with a **required** retry affordance — the fix for the
* false-empty class of defect (a failed query rendering as "no data" instead of an error). `onRetry` is
* mandatory by the type; there is no way to render this component without a way back. Presentational,
* caller-owned i18n throughout — everything is already-translated copy.
* caller-owned i18n throughout — everything is already-translated copy. `role="alert"` (implicit
* `aria-live="assertive"`) announces the failure to assistive tech without a per-call-site aria-live —
* used at ~55 sites, this one change covers every query-failed moment in the app.
* @component ErrorState
*/
const ErrorState: FunctionComponent<ErrorStateProps> = ({ message, retryLabel, onRetry }) => (
<Paper
elevation={0}
data-error-state
role="alert"
sx={{
p: { xs: 3, sm: 5 },
textAlign: 'center',
@@ -0,0 +1,59 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
let pathname = '/nurse/earnings';
jest.mock('@/i18n/navigation', () => ({
usePathname: () => pathname,
}));
import RouteFadeIn from './RouteFadeIn';
describe('<RouteFadeIn/> component', () => {
beforeEach(() => {
pathname = '/nurse/earnings';
});
it('renders its children', () => {
render(
<ThemeProvider>
<RouteFadeIn>
<span>content</span>
</RouteFadeIn>
</ThemeProvider>,
);
expect(screen.getByText('content')).toBeInTheDocument();
});
it('marks the wrapper with the shared fade-in animation hook', () => {
render(
<ThemeProvider>
<RouteFadeIn>
<span>content</span>
</RouteFadeIn>
</ThemeProvider>,
);
expect(screen.getByText('content').parentElement).toHaveAttribute('data-bal-route-fade');
});
it('remounts (and would replay the animation) when the route changes', () => {
const { rerender } = render(
<ThemeProvider>
<RouteFadeIn>
<span data-testid="marker">a</span>
</RouteFadeIn>
</ThemeProvider>,
);
const first = screen.getByTestId('marker');
pathname = '/nurse/visits';
rerender(
<ThemeProvider>
<RouteFadeIn>
<span data-testid="marker">b</span>
</RouteFadeIn>
</ThemeProvider>,
);
expect(screen.getByTestId('marker')).not.toBe(first);
});
});
@@ -0,0 +1,24 @@
'use client';
import { FunctionComponent, PropsWithChildren } from 'react';
import Box from '@mui/material/Box';
import { usePathname } from '@/i18n/navigation';
/**
* The one route/content-transition primitive for the app-wide motion pass: a calm 150200ms fade +
* small slide (the `bal-fade-in` keyframe in `globals.css`) that plays once when its subtree mounts.
* Keyed on the locale-stripped pathname so React remounts (and replays the animation) on navigation,
* but never on an in-place re-render (a refetch, a state update) — motion marks a *route change*, not
* "data changed". The keyframe itself carries no reduced-motion branch; the single app-wide gate in
* `globals.css` handles that for every consumer, this one included.
* @component RouteFadeIn
*/
const RouteFadeIn: FunctionComponent<PropsWithChildren> = ({ children }) => {
const pathname = usePathname();
return (
<Box key={pathname} data-bal-route-fade sx={{ minWidth: 0 }}>
{children}
</Box>
);
};
export default RouteFadeIn;
@@ -0,0 +1,3 @@
import RouteFadeIn from './RouteFadeIn';
export default RouteFadeIn;
+2
View File
@@ -22,6 +22,7 @@ import StickyActionBar from './StickyActionBar';
import Pager from './Pager';
import InitialsAvatar from './InitialsAvatar';
import FormDialogShell from './FormDialogShell';
import RouteFadeIn from './RouteFadeIn';
export {
ErrorBoundary,
@@ -48,6 +49,7 @@ export {
Pager,
InitialsAvatar,
FormDialogShell,
RouteFadeIn,
};
export type { EmptyStateProps } from './EmptyState';
export type { ErrorStateProps } from './ErrorState';