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:
parent
3fc0938aec
commit
432ace778c
18 changed files with 67 additions and 34 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -86,9 +86,7 @@ export function UserMenu({
|
|||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</svg>
|
||||
)}
|
||||
>
|
||||
{session.user.email || 'Account'}
|
||||
</SidebarNavItem>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -133,8 +133,6 @@ export function DexieMigrationModal({
|
|||
}
|
||||
}, [loadLocalDexieDocs, onComplete, refreshDocuments]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<ModalFrame
|
||||
open={isOpen}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export function RangeInput({
|
|||
className,
|
||||
style,
|
||||
...props
|
||||
}: InputHTMLAttributes<HTMLInputElement>) {
|
||||
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>) {
|
||||
const rangeStyle: RangeStyle = {
|
||||
...style,
|
||||
'--range-progress': `${resolveRangeProgress(props)}%`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue