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}
/>
-