From 27031f08374f8aae71a8c61abea5068e9d4f478d Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 6 Oct 2026 09:58:21 -0700 Subject: [PATCH 1/2] Keyboard and accessibility: shortcuts, high contrast, reduced motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Account menu › Accessibility chooses contrast and motion, each "follow the system" by default and tracking it live. High contrast uses its own colors over any palette, light and dark, every text pair at 7:1 or better, with a thick focus ring and underlined links. Reduced motion stops animations and transitions. Shortcuts: ? shows them all, / opens the command bar, g then h, p, d, s or a goes to the dashboard, people, domains, settings or your account, and n creates a new one on a list. Single keys never fire while typing in a field, with a modifier held, or with a dialog or menu open. Keyboard fixes: a "Skip to content" link, list rows that take focus and open with Enter, and row action buttons named for their row (they read as unlabeled). Admin UX roadmap, item 13; the ? sheet is part of item 12. --- src/a11y.css | 102 ++++++++++++++++ src/components/common/KeyboardShortcuts.tsx | 126 ++++++++++++++++++++ src/components/layout/TopBar.tsx | 52 +++++++- src/components/lists/DynamicList.tsx | 36 +++++- src/i18n/en.json | 29 ++++- src/index.css | 1 + src/lib/a11yPrefs.test.ts | 60 ++++++++++ src/lib/a11yPrefs.ts | 56 +++++++++ src/lib/shortcuts.ts | 53 ++++++++ src/pages/AdminPanel.tsx | 10 +- src/stores/uiStore.ts | 23 ++++ 11 files changed, 542 insertions(+), 6 deletions(-) create mode 100644 src/a11y.css create mode 100644 src/components/common/KeyboardShortcuts.tsx create mode 100644 src/lib/a11yPrefs.test.ts create mode 100644 src/lib/a11yPrefs.ts create mode 100644 src/lib/shortcuts.ts diff --git a/src/a11y.css b/src/a11y.css new file mode 100644 index 0000000..93adcd5 --- /dev/null +++ b/src/a11y.css @@ -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 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; +} diff --git a/src/components/common/KeyboardShortcuts.tsx b/src/components/common/KeyboardShortcuts.tsx new file mode 100644 index 0000000..27c9299 --- /dev/null +++ b/src/components/common/KeyboardShortcuts.tsx @@ -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 = { + 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 ( + + {keys.map((k) => ( + + {k} + + ))} + + ); +} + +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 ( + + + + {t('shortcuts.title', 'Keyboard shortcuts')} + + {t('shortcuts.hint', 'Single keys work anywhere except while you type in a field.')} + + +
    + {rows.map((r) => ( +
  • + {r.label} + +
  • + ))} +
+
+
+ ); +} diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index 862fda1..553ca84 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -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,41 @@ export function TopBar() { + + {/* inbuxa: contrast and motion, over any theme (roadmap item 13) */} + + + + {t('a11y.menu', 'Accessibility')} + + + {t('a11y.contrast', 'Contrast')} + isContrastPref(v) && setContrast(v)}> + + {t('a11y.followSystem', 'Follow the system')} + + + {t('a11y.contrastStandard', 'Standard')} + + {t('a11y.contrastHigh', 'High')} + + + {t('a11y.motion', 'Motion')} + isMotionPref(v) && setMotion(v)}> + + {t('a11y.followSystem', 'Follow the system')} + + {t('a11y.motionReduce', 'Reduce')} + {t('a11y.motionFull', 'Full')} + + + window.dispatchEvent(new Event(OPEN_SHORTCUTS))}> + + {t('a11y.shortcuts', 'Keyboard shortcuts')} + ? + + + diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index e9143f2..b8e94c3 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -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 { + const shown = schema && resolved ? item[getDisplayProperty(schema, resolved.obj.objectName)] : undefined; + return typeof shown === 'string' && shown ? shown : String(item.id ?? ''); + } + function renderItemActions(item: Record): React.ReactNode { // inbuxa: Lock… or Unlock… for a person (AL-1) const lockItems = directoryLocks.menuItems(item); @@ -1114,7 +1131,14 @@ export function DynamicList({ viewName }: DynamicListProps) { return ( - @@ -1348,8 +1372,16 @@ export function DynamicList({ viewName }: DynamicListProps) { return ( 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 && ( e.stopPropagation()}> diff --git a/src/i18n/en.json b/src/i18n/en.json index cdeb8cd..9d4976e 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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" } } diff --git a/src/index.css b/src/index.css index be6f511..4414502 100644 --- a/src/index.css +++ b/src/index.css @@ -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. */ diff --git a/src/lib/a11yPrefs.test.ts b/src/lib/a11yPrefs.test.ts new file mode 100644 index 0000000..edd4788 --- /dev/null +++ b/src/lib/a11yPrefs.test.ts @@ -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 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(''))).toBe(true); + expect(isTyping(el(''))).toBe(true); + expect(isTyping(el(''))).toBe(true); + expect(isTyping(el(''))).toBe(false); + expect(isTyping(el(''))).toBe(false); + expect(isTyping(el('
'))).toBe(true); + expect(isTyping(null)).toBe(false); + }); +}); diff --git a/src/lib/a11yPrefs.ts b/src/lib/a11yPrefs.ts new file mode 100644 index 0000000..11c3fea --- /dev/null +++ b/src/lib/a11yPrefs.ts @@ -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 , 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); + } +} diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts new file mode 100644 index 0000000..36b0143 --- /dev/null +++ b/src/lib/shortcuts.ts @@ -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(); + +/** 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; +} diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 8d68bfb..0c545c2 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -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 (
+ {/* inbuxa: keyboard users can jump past the menus (roadmap item 13) */} + + {t('a11y.skip', 'Skip to content')} + + {useSectionNav && currentLayout && }
diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index 2ed25e1..e9e3844 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -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()( sidebarCollapsed: false, palette: DEFAULT_PALETTE, adminLayout: DEFAULT_ADMIN_LAYOUT, + contrast: 'system', + motion: 'system', activeSection: '', toggleTheme: () => { @@ -101,6 +109,16 @@ export const useUIStore = create()( 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 = {}; if (palette) { @@ -130,6 +148,8 @@ export const useUIStore = create()( 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()( 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); } }; }, -- 2.54.0 From 62b64016f52fde9bba9c59bf4828924f4dc677d7 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 6 Oct 2026 11:10:48 -0700 Subject: [PATCH 2/2] Shortcuts work with a row's action button focused The button stopped key presses from bubbling, so ?, / and g did nothing while it had focus; the row's own Enter handler already ignores presses from inside the row. The ? beside Keyboard shortcuts is hidden from screen readers, which read it out. --- src/components/layout/TopBar.tsx | 4 +++- src/components/lists/DynamicList.tsx | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index 553ca84..8c4b193 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -328,7 +328,9 @@ export function TopBar() { window.dispatchEvent(new Event(OPEN_SHORTCUTS))}> {t('a11y.shortcuts', 'Keyboard shortcuts')} - ? + diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index b8e94c3..38158c9 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -1136,7 +1136,6 @@ export function DynamicList({ viewName }: DynamicListProps) { size="sm" className="h-8 w-8 p-0" onClick={(e) => e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} aria-label={t('list.rowActions', 'Actions for {{name}}', { name: rowName(item) })} > -- 2.54.0