refactor(ui): improve segmented control accessibility and update danger color tokens

Update SegmentedControl to support full keyboard navigation and focus management,
enhancing accessibility. Replace string indicator in Select with icon, and update
button danger variant to use new --danger-strong variable for hover states. Add
danger-strong token to Tailwind config and globals. Refine dropzone disabled
behavior, adjust focus ring for better contrast, and apply minor UI consistency
tweaks across components.
This commit is contained in:
Richard R 2026-06-01 15:20:28 -06:00
parent 3fc0938aec
commit 432ace778c
18 changed files with 67 additions and 34 deletions

View file

@ -103,7 +103,7 @@ export default async function LandingPage() {
) : (
<ButtonLink href="/signin" variant="outline" size="lg">Sign in</ButtonLink>
)}
<ButtonLink href="https://docs.openreader.richardr.dev/" variant="ghost" size="lg">Read the docs </ButtonLink>
<ButtonAnchor href="https://docs.openreader.richardr.dev/" target="_blank" rel="noopener noreferrer" variant="ghost" size="lg">Read the docs </ButtonAnchor>
</div>
<div className="public-formats" aria-label="Supported formats">

View file

@ -220,6 +220,7 @@ html.custom {
--accent-strong: color-mix(in srgb, var(--accent) 88%, var(--foreground));
--danger: #dc2626;
--danger-strong: #b91c1c;
--danger-wash: color-mix(in srgb, var(--danger) 12%, transparent);
--elev-1: 0 1px 2px rgb(var(--shadow-rgb) / 0.10);

View file

@ -370,7 +370,6 @@ export function AdminProvidersPanel() {
onClick={startCreate}
variant="primary"
size="icon"
className="h-7 w-7"
aria-label="Add provider"
title="Add provider"
>

View file

@ -86,9 +86,7 @@ export function UserMenu({
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
)}
>
{session.user.email || 'Account'}
</SidebarNavItem>
/>
);
}

View file

@ -133,8 +133,6 @@ export function DexieMigrationModal({
}
}, [loadLocalDexieDocs, onComplete, refreshDocuments]);
if (!isOpen) return null;
return (
<ModalFrame
open={isOpen}

View file

@ -107,14 +107,7 @@ export function DocumentHeaderMenu({
<div className="flex justify-center">
<ZoomControl
value={zoomLevel}
onIncrease={() => {
// We wrap in a handler to stop propagation if needed,
// but ZoomControl buttons handle their own clicks.
// However, Menu might close on click?
// Headless UI Menu closes on click inside MenuItem, but these are just buttons in a div.
// It should NOT close unless we click a MenuItem.
onZoomIncrease();
}}
onIncrease={onZoomIncrease}
onDecrease={onZoomDecrease}
min={minZoom}
max={maxZoom}

View file

@ -203,7 +203,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
<span>{pdf.parseStatus ?? 'pending'}</span>
<IconButton
size="xs"
className="!h-5 !w-5 shrink-0"
className="shrink-0"
onClick={pdf.onForceReparse}
disabled={isForceReparseDisabled(pdf.parseStatus)}
title="Force reparse"

View file

@ -7,9 +7,7 @@ import { DrawerFrame } from '@/components/ui';
const NARROW_QUERY = '(max-width: 767px)';
export function useIsNarrow(): boolean {
const [isNarrow, setIsNarrow] = useState(() =>
typeof window !== 'undefined' ? window.matchMedia(NARROW_QUERY).matches : false,
);
const [isNarrow, setIsNarrow] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') return;
const mq = window.matchMedia(NARROW_QUERY);
@ -48,9 +46,11 @@ export function LibraryFrame({
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">{children}</div>
</div>
{statusBar}
<LibrarySidebarDrawer open={isNarrow && sidebarOpen} onClose={onRequestSidebarClose}>
{sidebar}
</LibrarySidebarDrawer>
{isNarrow && (
<LibrarySidebarDrawer open={sidebarOpen} onClose={onRequestSidebarClose}>
{sidebar}
</LibrarySidebarDrawer>
)}
</div>
);
}

View file

@ -18,7 +18,7 @@ export const buttonStyles = variants({
primary: 'bg-accent text-background hover:bg-secondary-accent',
secondary: 'border border-line bg-surface text-foreground hover:bg-accent-wash',
outline: 'border border-line bg-surface-sunken text-foreground hover:bg-accent-wash hover:text-accent',
danger: 'border border-danger bg-danger text-background hover:bg-danger',
danger: 'border border-danger bg-danger text-background hover:bg-danger-strong hover:border-danger-strong',
ghost: 'bg-transparent text-foreground hover:bg-accent-wash hover:text-accent',
},
size: {

View file

@ -135,7 +135,7 @@ export function DrawerFrame({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className={cn('fixed inset-y-0 flex max-w-full', sideClass)}>
<TransitionChild

View file

@ -35,6 +35,6 @@ export function dropzoneSurfaceClass({
return dropzoneStyles({
variant,
active: active ? 'true' : 'false',
className: cn(disabled && 'cursor-not-allowed opacity-50', className),
className: cn(disabled && 'pointer-events-none cursor-not-allowed opacity-50', className),
});
}

View file

@ -54,7 +54,7 @@ export function RangeInput({
className,
style,
...props
}: InputHTMLAttributes<HTMLInputElement>) {
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>) {
const rangeStyle: RangeStyle = {
...style,
'--range-progress': `${resolveRangeProgress(props)}%`,

View file

@ -1,6 +1,6 @@
import type { InputHTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
import { focusRing, motionColors } from './tokens';
import { motionColors } from './tokens';
export function SearchField({
icon,
@ -16,7 +16,6 @@ export function SearchField({
className={cn(
'flex min-w-0 items-center gap-1.5 rounded-md border border-line bg-surface-sunken px-2 py-1',
'focus-within:border-accent-line focus-within:ring-1 focus-within:ring-accent-line hover:border-accent-line',
focusRing,
motionColors,
className,
)}

View file

@ -1,4 +1,6 @@
import type { ReactNode } from 'react';
'use client';
import { useRef, type KeyboardEvent, type ReactNode } from 'react';
import { cn } from './cn';
import { focusRing, motionColors } from './tokens';
@ -25,17 +27,54 @@ export function SegmentedControl<T extends string>({
ariaLabel: string;
className?: string;
}) {
const buttonRefs = useRef<Array<HTMLButtonElement | null>>([]);
const focusOption = (index: number) => {
const option = options[index];
if (!option) return;
onChange(option.value);
buttonRefs.current[index]?.focus();
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
switch (event.key) {
case 'ArrowLeft':
case 'ArrowUp':
event.preventDefault();
focusOption((index - 1 + options.length) % options.length);
break;
case 'ArrowRight':
case 'ArrowDown':
event.preventDefault();
focusOption((index + 1) % options.length);
break;
case 'Home':
event.preventDefault();
focusOption(0);
break;
case 'End':
event.preventDefault();
focusOption(options.length - 1);
break;
default:
break;
}
};
return (
<div role="radiogroup" aria-label={ariaLabel} className={cn(segmentedGroupClass, className)}>
{options.map((option) => {
{options.map((option, index) => {
const active = value === option.value;
return (
<button
key={option.value}
ref={(el) => { buttonRefs.current[index] = el; }}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
onClick={() => onChange(option.value)}
onKeyDown={(event) => handleKeyDown(event, index)}
className={segmentedButtonClass(active)}
>
{option.label}

View file

@ -3,6 +3,7 @@
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
import type { ComponentProps } from 'react';
import { cn } from './cn';
import { CheckIcon, ChevronRightIcon } from '@/components/icons/Icons';
const listboxButtonClass =
'relative w-full cursor-pointer rounded-md bg-surface-sunken border border-line py-1.5 pl-2.5 pr-9 text-left text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent-line hover:bg-accent-wash transition-colors duration-fast ease-standard';
@ -118,14 +119,18 @@ export function Select({
<Listbox value={value} onChange={onChange}>
<SharedListboxButton>
<span>{activeOption?.label ?? 'Select'}</span>
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-soft">v</span>
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-soft">
<ChevronRightIcon className="h-4 w-4 rotate-90" aria-hidden="true" />
</span>
</SharedListboxButton>
<SharedListboxOptions anchor="bottom">
{options.map((option) => (
<SharedListboxOption key={option.value} value={option.value}>
{({ selected }) => (
<>
<span className="absolute left-2 text-accent">{selected ? '*' : ''}</span>
<span className="absolute left-2 flex items-center text-accent">
{selected ? <CheckIcon className="h-4 w-4" aria-hidden="true" /> : null}
</span>
<span>{option.label}</span>
</>
)}

View file

@ -63,7 +63,7 @@ export function sidebarNavItemClass({
} = {}) {
return cn(
'group w-full min-w-0 border text-left font-medium',
'inline-flex items-center transition duration-base ease-standard',
'inline-flex items-center',
focusRing,
motionColors,
compact ? 'gap-1.5 rounded-md px-2 py-1 text-xs' : 'gap-2 rounded-md px-2.5 py-1.5 text-sm',

View file

@ -1,3 +1,3 @@
export const focusRing = 'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2';
export const focusRing = 'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background';
export const motionColors = 'transition-colors duration-fast ease-standard';
export const motionSurface = 'transition-[transform,box-shadow,border-color,background-color] duration-base ease-standard';

View file

@ -30,6 +30,7 @@ export default {
"accent-line": "var(--accent-line)",
"accent-strong": "var(--accent-strong)",
danger: "var(--danger)",
"danger-strong": "var(--danger-strong)",
"danger-wash": "var(--danger-wash)",
},
boxShadow: {