diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 1575203f..2b7b8dbc 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -889,6 +889,7 @@ export default { 'ui.sidebar.back': '', 'ui.sidebar.close': '', 'ui.sidebar.open': '', + 'ui.sidebar.skip_to_content': '', 'ui.switcher.label': '', 'ui.switcher.single_locale': '', 'ui.topbar.log_out': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index b21567cf..565accb8 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -1134,6 +1134,7 @@ export const keys = { back: 'ui.sidebar.back', close: 'ui.sidebar.close', open: 'ui.sidebar.open', + skip_to_content: 'ui.sidebar.skip_to_content', }, switcher: { label: 'ui.switcher.label', diff --git a/packages/ui/locales/en.json b/packages/ui/locales/en.json index 70430cb8..075030fa 100644 --- a/packages/ui/locales/en.json +++ b/packages/ui/locales/en.json @@ -55,7 +55,8 @@ "sidebar": { "open": "Open sidebar", "close": "Close sidebar", - "back": "Back" + "back": "Back", + "skip_to_content": "Skip to content" }, "public_nav": { "docs": "Docs", diff --git a/packages/ui/locales/es.json b/packages/ui/locales/es.json index be44a9e8..246b8fb0 100644 --- a/packages/ui/locales/es.json +++ b/packages/ui/locales/es.json @@ -55,7 +55,8 @@ "sidebar": { "open": "Abrir barra lateral", "close": "Cerrar barra lateral", - "back": "Volver" + "back": "Volver", + "skip_to_content": "Saltar al contenido" }, "public_nav": { "docs": "Documentación", diff --git a/packages/ui/src/components/PageShell.test.tsx b/packages/ui/src/components/PageShell.test.tsx index 163f6ff1..5936b11c 100644 --- a/packages/ui/src/components/PageShell.test.tsx +++ b/packages/ui/src/components/PageShell.test.tsx @@ -186,6 +186,22 @@ function LegacyPage() { return null; } +describe('PageShell actions', () => { + it('lets the actions wrap and shrink instead of overflowing', () => { + render( + + New}> + body + + , + ); + const slot = screen.getByRole('button', { name: 'New' }).parentElement as HTMLElement; + expect(slot.className).toContain('flex-wrap'); + expect(slot.className).toContain('min-w-0'); + expect(slot.className).not.toContain('flex-shrink-0'); + }); +}); + describe('useReportPageHeading positional overload', () => { it('still reports title and section', () => { render( diff --git a/packages/ui/src/components/PageShell.tsx b/packages/ui/src/components/PageShell.tsx index 4d505341..cbc59c3a 100644 --- a/packages/ui/src/components/PageShell.tsx +++ b/packages/ui/src/components/PageShell.tsx @@ -97,7 +97,7 @@ export function PageShell({ // control in the phone bar; repeating it here is a duplicate button.
diff --git a/packages/ui/src/components/ui/alert-dialog.test.tsx b/packages/ui/src/components/ui/alert-dialog.test.tsx new file mode 100644 index 00000000..d10b9395 --- /dev/null +++ b/packages/ui/src/components/ui/alert-dialog.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react'; +import type React from 'react'; +import { describe, expect, it } from 'vitest'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from './alert-dialog'; + +function open(action: React.ReactNode, cancel?: React.ReactNode) { + render( + + + Delete + Sure? + {cancel} + {action} + + , + ); +} + +describe('AlertDialogAction', () => { + it('lets a caller colour class win over the variant via tailwind-merge', () => { + open(Go); + const btn = screen.getByRole('button', { name: 'Go' }); + expect(btn.className).toContain('bg-destructive'); + expect(btn.className.split(/\s+/)).not.toContain('bg-primary'); + expect(btn.getAttribute('data-slot')).toBe('alert-dialog-action'); + }); + + it('honours the variant prop', () => { + open(Go); + expect(screen.getByRole('button', { name: 'Go' }).className).toContain('bg-destructive'); + }); +}); + +describe('AlertDialogCancel', () => { + it('lets a caller class win over the outline variant', () => { + open(, No); + const btn = screen.getByRole('button', { name: 'No' }); + expect(btn.className).toContain('bg-red-500'); + expect(btn.className).not.toContain('bg-background'); + }); +}); diff --git a/packages/ui/src/components/ui/alert-dialog.tsx b/packages/ui/src/components/ui/alert-dialog.tsx index 5da65f4b..588fd9a6 100644 --- a/packages/ui/src/components/ui/alert-dialog.tsx +++ b/packages/ui/src/components/ui/alert-dialog.tsx @@ -1,7 +1,8 @@ 'use client'; -import { Button } from '@simple-module-py/ui/components/ui/button'; +import { buttonVariants } from '@simple-module-py/ui/components/ui/button'; import { cn } from '@simple-module-py/ui/lib/utils'; +import type { VariantProps } from 'class-variance-authority'; import { AlertDialog as AlertDialogPrimitive } from 'radix-ui'; import type * as React from 'react'; @@ -131,16 +132,13 @@ function AlertDialogAction({ variant = 'default', size = 'default', ...props -}: React.ComponentProps & - Pick, 'variant' | 'size'>) { +}: React.ComponentProps & VariantProps) { return ( - + ); } @@ -149,16 +147,13 @@ function AlertDialogCancel({ variant = 'outline', size = 'default', ...props -}: React.ComponentProps & - Pick, 'variant' | 'size'>) { +}: React.ComponentProps & VariantProps) { return ( - + ); } diff --git a/packages/ui/src/layouts/SidebarLayout.toast.test.tsx b/packages/ui/src/layouts/SidebarLayout.toast.test.tsx index 8e64e7b8..86dbc8b1 100644 --- a/packages/ui/src/layouts/SidebarLayout.toast.test.tsx +++ b/packages/ui/src/layouts/SidebarLayout.toast.test.tsx @@ -35,6 +35,7 @@ vi.mock('./SidebarUserMenu', () => ({ SidebarUserMenu: () => null })); import { toast } from 'sonner'; import { AdminLayout } from './AdminLayout'; +import { AuthenticatedLayout } from './AuthenticatedLayout'; describe('AdminLayout toasts', () => { it('renders a toast fired from an admin page', async () => { @@ -49,4 +50,35 @@ describe('AdminLayout toasts', () => { expect(await screen.findByText('Managed key refused')).toBeTruthy(); expect(document.querySelectorAll('[data-sonner-toaster]').length).toBe(1); }); + + it('mounts exactly one toaster in the app layout too', async () => { + render( + +

page

+
, + ); + await act(async () => { + toast.success('Saved from the app layout'); + }); + expect(await screen.findByText('Saved from the app layout')).toBeTruthy(); + expect(document.querySelectorAll('[data-sonner-toaster]').length).toBe(1); + }); +}); + +describe('SidebarLayout skip link', () => { + it('renders a skip link as the first focusable element targeting #main', () => { + render( + +

page

+
, + ); + const link = document.querySelector('a[href="#main"]') as HTMLElement; + expect(link.getAttribute('href')).toBe('#main'); + expect(link.className).toContain('sr-only'); + expect(link.className).toContain('focus:not-sr-only'); + expect(document.querySelector('a[href], button, [tabindex="0"]')).toBe(link); + const main = document.getElementById('main'); + expect(main?.getAttribute('tabindex')).toBe('-1'); + expect(main?.textContent).toContain('page'); + }); }); diff --git a/packages/ui/src/layouts/SidebarLayout.tsx b/packages/ui/src/layouts/SidebarLayout.tsx index 457089e8..ca68c068 100644 --- a/packages/ui/src/layouts/SidebarLayout.tsx +++ b/packages/ui/src/layouts/SidebarLayout.tsx @@ -120,6 +120,12 @@ function SidebarShell({ children, menuKey, theme, headerSlot, footerNavSlot }: S + + {t(keys.ui.sidebar.skip_to_content)} + {/* --app-chrome-h names the height of the bar above the content — the topbar on lg, the mobile bar below it, both mutually exclusive. Both bars size themselves off this one variable (h-[var(--app-chrome-h)]) @@ -259,7 +265,9 @@ function SidebarShell({ children, menuKey, theme, headerSlot, footerNavSlot }: S currentUrl={currentUrl} activeMenuItem={active} /> -
{children}
+
+ {children} +