ui phase 12
This commit is contained in:
@@ -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 150–200ms 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;
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user