Keyboard and accessibility: shortcuts, high contrast, reduced motion #91

Merged
jcoffey-dev merged 2 commits from feature/keyboard-a11y into main 2026-10-06 18:13:23 +00:00
11 changed files with 543 additions and 6 deletions

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/*
* inbuxa: high contrast and reduced motion (admin UX roadmap, item 13).
* Set on <html> by src/lib/a11yPrefs.ts from the reader's choice or the
* system's. High contrast uses its own colors over any palette: text at
* 7:1 or better (WCAG AAA), borders and inputs that stand out, and a thick
* focus ring. Imported after palettes.css; the selectors are also more
* specific than a palette's, so they win in light and dark alike.
*/
:root[data-contrast='high']:not(.dark) {
--background: #ffffff;
--foreground: #000000;
--card: #ffffff;
--card-foreground: #000000;
--popover: #ffffff;
--popover-foreground: #000000;
--content-background: #ffffff;
--primary: #00524d;
--primary-foreground: #ffffff;
--secondary: #ebebeb;
--secondary-foreground: #000000;
--muted: #ebebeb;
--muted-foreground: #333333;
--accent: #d3efec;
--accent-foreground: #002e2b;
--highlight: #8a4300;
--highlight-soft: #fde6cc;
--destructive: #a3001b;
--destructive-foreground: #ffffff;
--border: #595959;
--input: #3d3d3d;
--ring: #000000;
}
:root.dark[data-contrast='high'] {
--background: #000000;
--foreground: #ffffff;
--card: #0a0a0a;
--card-foreground: #ffffff;
--popover: #0a0a0a;
--popover-foreground: #ffffff;
--content-background: #000000;
--primary: #7ff0e8;
--primary-foreground: #000000;
--secondary: #1f1f1f;
--secondary-foreground: #ffffff;
--muted: #1f1f1f;
--muted-foreground: #d9d9d9;
--accent: #003b38;
--accent-foreground: #c2fffa;
--highlight: #ffb866;
--highlight-soft: #3d2600;
--destructive: #ff9494;
--destructive-foreground: #000000;
--border: #a6a6a6;
--input: #c4c4c4;
--ring: #ffffff;
}
/* Where keyboard focus is, plainly. */
:root[data-contrast='high'] :focus-visible {
outline: 3px solid var(--ring) !important;
outline-offset: 2px !important;
}
/* Links read as links without relying on color. */
:root[data-contrast='high'] main a:not([role]):not(:has(> svg:only-child)) {
text-decoration: underline;
text-underline-offset: 2px;
}
/* Reduced motion: no animations or transitions, and no smooth scrolling. */
:root[data-motion='reduce'] *,
:root[data-motion='reduce'] *::before,
:root[data-motion='reduce'] *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
/* The "skip to content" link: out of sight until it has keyboard focus. */
.skip-link {
position: absolute;
left: 0.75rem;
top: -4rem;
z-index: 100;
border-radius: var(--radius);
background: var(--primary);
color: var(--primary-foreground);
padding: 0.5rem 0.875rem;
font-weight: 500;
}
.skip-link:focus {
top: 0.75rem;
}
+126
View File
@@ -0,0 +1,126 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: keyboard shortcuts (admin UX roadmap, item 13) and the `?` sheet
* that lists them (item 12). Single keys never fire while typing in a field,
* with a modifier held, or while a dialog is open.
*/
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useSchemaStore } from '@/stores/schemaStore';
import { SETTINGS_OVERVIEW_VIEW } from '@/lib/settingsLayout';
import { GO_WINDOW_MS, isTyping, OPEN_COMMAND_BAR, OPEN_SHORTCUTS, pageKeys } from '@/lib/shortcuts';
/** The `g` destinations: a fixed page, a whole section, or one view wherever the schema puts it. */
const GO: Record<string, { path?: string; section?: string; view?: string }> = {
h: { path: '/Management/CustomComponent/Dashboard' },
p: { view: 'x:Account/User' },
d: { view: 'x:Domain' },
s: { path: `/Settings/${SETTINGS_OVERVIEW_VIEW}` },
a: { section: 'Account' },
};
function Keys({ keys }: { keys: string[] }) {
return (
<span className="flex shrink-0 gap-1">
{keys.map((k) => (
<kbd
key={k}
className="inline-flex h-6 min-w-6 items-center justify-center rounded-md border bg-muted px-1.5 font-mono text-xs font-medium"
>
{k}
</kbd>
))}
</span>
);
}
export function KeyboardShortcuts() {
const { t } = useTranslation();
const navigate = useNavigate();
const viewToSection = useSchemaStore((s) => s.viewToSection);
const [sheetOpen, setSheetOpen] = useState(false);
const goUntil = useRef(0);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || isTyping(e.target)) return;
if (document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"]')) return;
if (goUntil.current > Date.now()) {
goUntil.current = 0;
const dest = GO[e.key.toLowerCase()];
if (!dest) return;
e.preventDefault();
if (dest.path) navigate(dest.path);
else if (dest.section) navigate(`/${dest.section}`);
else if (dest.view && viewToSection[dest.view]) navigate(`/${viewToSection[dest.view]}/${dest.view}`);
return;
}
if (e.key === '?') {
e.preventDefault();
setSheetOpen(true);
} else if (e.key === '/') {
e.preventDefault();
window.dispatchEvent(new Event(OPEN_COMMAND_BAR));
} else if (e.key === 'g') {
goUntil.current = Date.now() + GO_WINDOW_MS;
} else {
const handler = pageKeys.get(e.key);
if (handler) {
e.preventDefault();
handler();
}
}
}
const openSheet = () => setSheetOpen(true);
document.addEventListener('keydown', onKeyDown);
window.addEventListener(OPEN_SHORTCUTS, openSheet);
return () => {
document.removeEventListener('keydown', onKeyDown);
window.removeEventListener(OPEN_SHORTCUTS, openSheet);
};
}, [navigate, viewToSection]);
const rows: { keys: string[]; label: string }[] = [
{ keys: ['?'], label: t('shortcuts.sheet', 'Show these shortcuts') },
{ keys: ['/'], label: t('shortcuts.commandBar', 'Search pages and run actions') },
{ keys: ['Ctrl', 'K'], label: t('shortcuts.commandBarToo', 'The same, from anywhere, even in a field') },
{ keys: ['g', 'h'], label: t('shortcuts.goHome', 'Go to the dashboard') },
{ keys: ['g', 'p'], label: t('shortcuts.goPeople', 'Go to people') },
{ keys: ['g', 'd'], label: t('shortcuts.goDomains', 'Go to domains') },
{ keys: ['g', 's'], label: t('shortcuts.goSettings', 'Go to settings') },
{ keys: ['g', 'a'], label: t('shortcuts.goAccount', 'Go to your account') },
{ keys: ['n'], label: t('shortcuts.new', 'Create a new one, on a list') },
{ keys: ['Esc'], label: t('shortcuts.escape', 'Close a dialog or menu') },
];
return (
<Dialog open={sheetOpen} onOpenChange={setSheetOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('shortcuts.title', 'Keyboard shortcuts')}</DialogTitle>
<DialogDescription>
{t('shortcuts.hint', 'Single keys work anywhere except while you type in a field.')}
</DialogDescription>
</DialogHeader>
<ul className="divide-y text-sm">
{rows.map((r) => (
<li key={r.keys.join('+')} className="flex items-center justify-between gap-4 py-2">
<span>{r.label}</span>
<Keys keys={r.keys} />
</li>
))}
</ul>
</DialogContent>
</Dialog>
);
}
+52 -2
View File
@@ -10,9 +10,12 @@
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import * as LucideIcons from 'lucide-react';
const { Sun, Moon, User, LogOut, Check, Menu, Search, FileCode, Palette, LayoutTemplate } = LucideIcons;
const { Sun, Moon, User, LogOut, Check, Menu, Search, FileCode, Palette, LayoutTemplate, Accessibility, Keyboard } =
LucideIcons;
import { Button } from '@/components/ui/button';
import { CommandPalette } from '@/components/common/CommandPalette';
import { OPEN_COMMAND_BAR, OPEN_SHORTCUTS } from '@/lib/shortcuts';
import { isContrastPref, isMotionPref } from '@/lib/a11yPrefs';
import {
DropdownMenu,
DropdownMenuContent,
@@ -61,6 +64,10 @@ export function TopBar() {
const setPalette = useUIStore((s) => s.setPalette);
const adminLayout = useUIStore((s) => s.adminLayout);
const setAdminLayout = useUIStore((s) => s.setAdminLayout);
const contrast = useUIStore((s) => s.contrast);
const setContrast = useUIStore((s) => s.setContrast);
const motion = useUIStore((s) => s.motion);
const setMotion = useUIStore((s) => s.setMotion);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const setActiveSection = useUIStore((s) => s.setActiveSection);
const activeSection = useUIStore((s) => s.activeSection);
@@ -81,8 +88,14 @@ export function TopBar() {
setPaletteOpen((open) => !open);
}
}
// inbuxa: `/` asks for it too (keyboard shortcuts, roadmap item 13)
const open = () => setPaletteOpen(true);
document.addEventListener('keydown', handleGlobalKeyDown);
return () => document.removeEventListener('keydown', handleGlobalKeyDown);
window.addEventListener(OPEN_COMMAND_BAR, open);
return () => {
document.removeEventListener('keydown', handleGlobalKeyDown);
window.removeEventListener(OPEN_COMMAND_BAR, open);
};
}, []);
const navigableLayouts = schema
@@ -284,6 +297,43 @@ export function TopBar() {
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* inbuxa: contrast and motion, over any theme (roadmap item 13) */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Accessibility className="mr-2 h-4 w-4" />
{t('a11y.menu', 'Accessibility')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-60">
<DropdownMenuLabel>{t('a11y.contrast', 'Contrast')}</DropdownMenuLabel>
<DropdownMenuRadioGroup value={contrast} onValueChange={(v) => isContrastPref(v) && setContrast(v)}>
<DropdownMenuRadioItem value="system">
{t('a11y.followSystem', 'Follow the system')}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="standard">
{t('a11y.contrastStandard', 'Standard')}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="high">{t('a11y.contrastHigh', 'High')}</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t('a11y.motion', 'Motion')}</DropdownMenuLabel>
<DropdownMenuRadioGroup value={motion} onValueChange={(v) => isMotionPref(v) && setMotion(v)}>
<DropdownMenuRadioItem value="system">
{t('a11y.followSystem', 'Follow the system')}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="reduce">{t('a11y.motionReduce', 'Reduce')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="full">{t('a11y.motionFull', 'Full')}</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => window.dispatchEvent(new Event(OPEN_SHORTCUTS))}>
<Keyboard className="mr-2 h-4 w-4" />
{t('a11y.shortcuts', 'Keyboard shortcuts')}
<span aria-hidden="true" className="ml-auto font-mono text-xs text-muted-foreground">
?
</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
+33 -2
View File
@@ -60,6 +60,7 @@ import { ObjectPicker } from '@/components/common/ObjectPicker';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { toast } from '@/hooks/use-toast';
import { deleteWithUndo } from '@/components/common/undoableDelete';
import { useShortcut } from '@/lib/shortcuts';
import { isPendingDelete, usePendingDeletes } from '@/lib/pendingDeletes';
import { friendlySetError } from '@/lib/jmapErrors';
import { coerceLabel } from '@/lib/objectOptions';
@@ -852,6 +853,16 @@ export function DynamicList({ viewName }: DynamicListProps) {
);
const canCreate = resolved ? hasObjectPermission(resolved.obj.permissionPrefix, 'Create') : false;
// inbuxa: `n` creates a new one, as the Create button does (roadmap item 13)
useShortcut(
'n',
canCreate && resolved?.obj.objectType.type === 'object'
? () => {
const section = viewToSection[viewName];
if (section) navigate(`/${section}/${viewName}/new`);
}
: null,
);
const canUpdate = resolved ? hasObjectPermission(resolved.obj.permissionPrefix, 'Update') : false;
const canDelete = resolved ? hasObjectPermission(resolved.obj.permissionPrefix, 'Destroy') : false;
@@ -1088,6 +1099,12 @@ export function DynamicList({ viewName }: DynamicListProps) {
);
}
/** inbuxa: what a screen reader calls a row (roadmap item 13). */
function rowName(item: Record<string, unknown>): string {
const shown = schema && resolved ? item[getDisplayProperty(schema, resolved.obj.objectName)] : undefined;
return typeof shown === 'string' && shown ? shown : String(item.id ?? '');
}
function renderItemActions(item: Record<string, unknown>): React.ReactNode {
// inbuxa: Lock… or Unlock… for a person (AL-1)
const lockItems = directoryLocks.menuItems(item);
@@ -1114,7 +1131,13 @@ export function DynamicList({ viewName }: DynamicListProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => e.stopPropagation()}
aria-label={t('list.rowActions', 'Actions for {{name}}', { name: rowName(item) })}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -1348,8 +1371,16 @@ export function DynamicList({ viewName }: DynamicListProps) {
return (
<tr
key={itemId}
className="border-b cursor-pointer transition-colors hover:bg-muted/50"
className="border-b cursor-pointer transition-colors hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
onClick={() => handleRowClick(item)}
// inbuxa: a row opens from the keyboard too (roadmap item 13)
tabIndex={0}
onKeyDown={(e) => {
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
handleRowClick(item);
}
}}
>
{hasMassActions && (
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
+28 -1
View File
@@ -308,7 +308,8 @@
"sort": "Sort",
"unknownError": "Unknown error",
"emptyTitle": "Nothing here yet",
"showingRange": "Showing {{from}}-{{to}} {{name}}"
"showingRange": "Showing {{from}}-{{to}} {{name}}",
"rowActions": "Actions for {{name}}"
},
"login": {
"continue": "Continue",
@@ -2149,5 +2150,31 @@
"undo": "Undo",
"count_one": "{{count}} {{name}}",
"count_other": "{{count}} {{name}}"
},
"shortcuts": {
"sheet": "Show these shortcuts",
"commandBar": "Search pages and run actions",
"commandBarToo": "The same, from anywhere, even in a field",
"goHome": "Go to the dashboard",
"goPeople": "Go to people",
"goDomains": "Go to domains",
"goSettings": "Go to settings",
"goAccount": "Go to your account",
"new": "Create a new one, on a list",
"escape": "Close a dialog or menu",
"title": "Keyboard shortcuts",
"hint": "Single keys work anywhere except while you type in a field."
},
"a11y": {
"menu": "Accessibility",
"contrast": "Contrast",
"followSystem": "Follow the system",
"contrastStandard": "Standard",
"contrastHigh": "High",
"motion": "Motion",
"motionReduce": "Reduce",
"motionFull": "Full",
"shortcuts": "Keyboard shortcuts",
"skip": "Skip to content"
}
}
+1
View File
@@ -9,6 +9,7 @@
@import 'tailwindcss';
@import './palettes.css';
@import './a11y.css';
:root {
/* INBUXA, light: warm paper, the mark's navy for text, its teal to act. */
+60
View File
@@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { afterEach, describe, expect, it } from 'vitest';
import { applyA11yPrefs, effectiveContrast, effectiveMotion, isContrastPref, isMotionPref } from './a11yPrefs';
import { isTyping } from './shortcuts';
describe('a11y preferences', () => {
afterEach(() => {
delete document.documentElement.dataset.contrast;
delete document.documentElement.dataset.motion;
});
it('"system" follows the system; a choice overrides it', () => {
expect(effectiveContrast('system', true)).toBe('high');
expect(effectiveContrast('system', false)).toBe('standard');
expect(effectiveContrast('standard', true)).toBe('standard');
expect(effectiveContrast('high', false)).toBe('high');
expect(effectiveMotion('system', true)).toBe('reduce');
expect(effectiveMotion('system', false)).toBe('full');
expect(effectiveMotion('full', true)).toBe('full');
expect(effectiveMotion('reduce', false)).toBe('reduce');
});
it('marks <html> for the stylesheet', () => {
applyA11yPrefs('high', 'reduce');
expect(document.documentElement.dataset.contrast).toBe('high');
expect(document.documentElement.dataset.motion).toBe('reduce');
applyA11yPrefs('standard', 'full');
expect(document.documentElement.dataset.contrast).toBeUndefined();
expect(document.documentElement.dataset.motion).toBeUndefined();
});
it('rejects stored values it does not know', () => {
expect(isContrastPref('high')).toBe(true);
expect(isContrastPref('max')).toBe(false);
expect(isMotionPref('reduce')).toBe(true);
expect(isMotionPref(undefined)).toBe(false);
});
});
describe('isTyping', () => {
it('is true in text fields and false on buttons and checkboxes', () => {
const el = (html: string) => {
const box = document.createElement('div');
box.innerHTML = html;
return box.firstElementChild;
};
expect(isTyping(el('<input type="text">'))).toBe(true);
expect(isTyping(el('<input>'))).toBe(true);
expect(isTyping(el('<textarea></textarea>'))).toBe(true);
expect(isTyping(el('<input type="checkbox">'))).toBe(false);
expect(isTyping(el('<button>Go</button>'))).toBe(false);
expect(isTyping(el('<div role="combobox"></div>'))).toBe(true);
expect(isTyping(null)).toBe(false);
});
});
+56
View File
@@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: contrast and motion, each the reader's own choice or the system's
* (admin UX roadmap, item 13). They sit on top of any palette and of light
* or dark: `data-contrast="high"` and `data-motion="reduce"` on <html>, read
* by a11y.css.
*/
export type ContrastPref = 'system' | 'standard' | 'high';
export type MotionPref = 'system' | 'reduce' | 'full';
export const isContrastPref = (v: unknown): v is ContrastPref => v === 'system' || v === 'standard' || v === 'high';
export const isMotionPref = (v: unknown): v is MotionPref => v === 'system' || v === 'reduce' || v === 'full';
const query = (q: string) => (typeof window !== 'undefined' ? (window.matchMedia?.(q) ?? null) : null);
/** What the choice comes to, given what the system asks for. */
export function effectiveContrast(pref: ContrastPref, systemWantsMore: boolean): 'standard' | 'high' {
return pref === 'system' ? (systemWantsMore ? 'high' : 'standard') : pref;
}
export function effectiveMotion(pref: MotionPref, systemWantsLess: boolean): 'full' | 'reduce' {
return pref === 'system' ? (systemWantsLess ? 'reduce' : 'full') : pref;
}
let current: { contrast: ContrastPref; motion: MotionPref } = { contrast: 'system', motion: 'system' };
function apply() {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const high = effectiveContrast(current.contrast, query('(prefers-contrast: more)')?.matches ?? false) === 'high';
const reduce =
effectiveMotion(current.motion, query('(prefers-reduced-motion: reduce)')?.matches ?? false) === 'reduce';
if (high) root.dataset.contrast = 'high';
else delete root.dataset.contrast;
if (reduce) root.dataset.motion = 'reduce';
else delete root.dataset.motion;
}
let watching = false;
/** Sets the choices and keeps "system" in step with the system's own setting. */
export function applyA11yPrefs(contrast: ContrastPref, motion: MotionPref) {
current = { contrast, motion };
apply();
if (!watching) {
watching = true;
query('(prefers-contrast: more)')?.addEventListener?.('change', apply);
query('(prefers-reduced-motion: reduce)')?.addEventListener?.('change', apply);
}
}
+53
View File
@@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: what the keyboard shortcuts share (admin UX roadmap, item 13); the
* handler and the `?` sheet are components/common/KeyboardShortcuts.tsx.
*/
import { useEffect, useRef } from 'react';
/** Ask the top bar to open the command bar. */
export const OPEN_COMMAND_BAR = 'inbuxa:open-command-bar';
/** Open the shortcut sheet (the account menu's Keyboard shortcuts). */
export const OPEN_SHORTCUTS = 'inbuxa:open-shortcuts';
/** How long `g` waits for its second key. */
export const GO_WINDOW_MS = 1500;
type Handler = () => void;
export const pageKeys = new Map<string, Handler>();
/** A page's own single-key shortcut, while it is on screen (e.g. `n` for a new item). */
export function useShortcut(key: string, handler: Handler | null) {
const latest = useRef(handler);
useEffect(() => {
latest.current = handler;
});
const active = handler !== null;
useEffect(() => {
if (!active) return;
const run = () => latest.current?.();
pageKeys.set(key, run);
return () => {
if (pageKeys.get(key) === run) pageKeys.delete(key);
};
}, [key, active]);
}
/** Whether a key press belongs to whatever has focus, not to the shortcuts. */
export function isTyping(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tag = target.tagName;
if (tag === 'TEXTAREA' || tag === 'SELECT') return true;
if (tag === 'INPUT') {
const type = (target as HTMLInputElement).type;
return !['checkbox', 'radio', 'button', 'submit', 'reset', 'range', 'color', 'file'].includes(type);
}
return target.closest('[role="textbox"], [role="combobox"], [role="searchbox"]') !== null;
}
+9 -1
View File
@@ -19,6 +19,7 @@ import { getApiBaseUrl } from '@/services/api';
import { loadAccountTheme, setAccountSettingsTarget } from '@/lib/accountSettings';
import { setLocale } from '@/i18n';
import { TopBar } from '@/components/layout/TopBar';
import { KeyboardShortcuts } from '@/components/common/KeyboardShortcuts';
import { Sidebar } from '@/components/layout/Sidebar';
import { SectionNav } from '@/components/layout/SectionNav';
import { MainContent } from '@/components/layout/MainContent';
@@ -324,13 +325,20 @@ export default function AdminPanel() {
return (
<div className="flex min-h-screen flex-col">
{/* inbuxa: keyboard users can jump past the menus (roadmap item 13) */}
<a href="#main" className="skip-link">
{t('a11y.skip', 'Skip to content')}
</a>
<KeyboardShortcuts />
<TopBar />
{useSectionNav && currentLayout && <SectionNav layout={currentLayout} />}
<div className="flex flex-1">
<Sidebar mobileOnly={useSectionNav} />
<main
id="main"
tabIndex={-1}
className={cn(
'flex-1 overflow-auto bg-content-background p-6 transition-[margin]',
'flex-1 overflow-auto bg-content-background p-6 transition-[margin] focus:outline-none',
!useSectionNav && sidebarOpen && (sidebarCollapsed ? 'md:ml-[4.5rem]' : 'md:ml-64'),
)}
>
+23
View File
@@ -9,6 +9,7 @@
import { queueAccountTheme } from '@/lib/accountSettings';
import { applyPalette, DEFAULT_PALETTE, isPaletteId, type PaletteId } from '@/lib/palettes';
import { applyA11yPrefs, isContrastPref, isMotionPref, type ContrastPref, type MotionPref } from '@/lib/a11yPrefs';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
@@ -34,6 +35,9 @@ interface UIState {
palette: PaletteId;
/** INBUXA: the navigation shell, the reader's own choice. */
adminLayout: AdminLayout;
/** inbuxa: high contrast and reduced motion, or the system's setting (roadmap item 13). */
contrast: ContrastPref;
motion: MotionPref;
sidebarOpen: boolean;
/** INBUXA: the sidebar folded to a rail of icon tiles, on wide screens. */
sidebarCollapsed: boolean;
@@ -43,6 +47,8 @@ interface UIState {
setTheme: (theme: Theme) => void;
setPalette: (palette: PaletteId) => void;
setAdminLayout: (layout: AdminLayout) => void;
setContrast: (contrast: ContrastPref) => void;
setMotion: (motion: MotionPref) => void;
/** INBUXA: take the theme stored with the account, without writing it back. */
applyAccountTheme: (palette: PaletteId | null, mode: 'system' | 'light' | 'dark' | null) => void;
toggleSidebar: () => void;
@@ -68,6 +74,8 @@ export const useUIStore = create<UIState>()(
sidebarCollapsed: false,
palette: DEFAULT_PALETTE,
adminLayout: DEFAULT_ADMIN_LAYOUT,
contrast: 'system',
motion: 'system',
activeSection: '',
toggleTheme: () => {
@@ -101,6 +109,16 @@ export const useUIStore = create<UIState>()(
set({ adminLayout: layout });
},
setContrast: (contrast) => {
applyA11yPrefs(contrast, get().motion);
set({ contrast });
},
setMotion: (motion) => {
applyA11yPrefs(get().contrast, motion);
set({ motion });
},
applyAccountTheme: (palette, mode) => {
const next: Partial<UIState> = {};
if (palette) {
@@ -130,6 +148,8 @@ export const useUIStore = create<UIState>()(
theme: state.theme,
palette: state.palette,
adminLayout: state.adminLayout,
contrast: state.contrast,
motion: state.motion,
sidebarCollapsed: state.sidebarCollapsed,
}),
onRehydrateStorage: () => {
@@ -138,6 +158,9 @@ export const useUIStore = create<UIState>()(
applyThemeClass(state.theme);
applyPalette(isPaletteId(state.palette) ? state.palette : DEFAULT_PALETTE);
if (!isAdminLayout(state.adminLayout)) state.adminLayout = DEFAULT_ADMIN_LAYOUT;
if (!isContrastPref(state.contrast)) state.contrast = 'system';
if (!isMotionPref(state.motion)) state.motion = 'system';
applyA11yPrefs(state.contrast, state.motion);
}
};
},