Keyboard and accessibility: shortcuts, high contrast, reduced motion #91
No files matched your search
+102
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'),
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user