Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/01-standards/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ The floor, not a cleanup pass.
1. **WCAG 2.0 AA (AODA) is the baseline:** semantic landmarks (`header`/`main`/`footer`/`nav`/`section`), one `h1` per page, accordion and nav keyboard-operable with correct ARIA, focus trapped in the nav overlay while open, visible `ring` focus states, decorative art `aria-hidden`.
2. **Respect `prefers-reduced-motion`**: ambient and entrance animations pause or reduce; every motion moment has a static equivalent, and nothing is readable only through motion.
3. **Contrast is checked in every theme scope**: AA contrast for text tokens (`muted-foreground` on `background` included) in the base theme and inside any inverse-color section.
4. **Self-starting motion carries a control**: anything that begins moving on its own and runs past a few seconds (the marquee drift) pairs with a keyboard-operable pause. A hover pause is not one: it reaches neither keyboard nor touch, and the reduced-motion path only serves the readers who set the preference.
5. **Each landmark of a kind is named**: a page with more than one `nav` labels each (`Primary`, `Footer`, `Menu`), since an unnamed pair is indistinguishable to the reader listing them. The name is passed at the call site, never baked into the component.
4 changes: 2 additions & 2 deletions src/app/design/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -515,11 +515,11 @@ const DesignPage = () => {
</div>
<div className="border-t pt-8">
<p className="text-e4 font-expressive text-muted-foreground mb-4 uppercase">Nav</p>
<Nav entries={headerEntries} />
<Nav aria-label="Nav specimen" entries={headerEntries} />
</div>
<div className="border-t pt-8">
<p className="text-e4 font-expressive text-muted-foreground mb-4 uppercase">Menu</p>
<Menu entries={headerEntries} />
<Menu aria-label="Menu specimen" entries={headerEntries} />
</div>
<div className="border-t pt-8">
<p className="text-e4 font-expressive text-muted-foreground mb-4 uppercase">Emblem</p>
Expand Down
84 changes: 63 additions & 21 deletions src/components/atoms/marquee.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,87 @@
import { forwardRef, type HTMLAttributes } from 'react'
'use client'

import { forwardRef, useState, type HTMLAttributes } from 'react'
import { PauseIcon, PlayIcon } from 'lucide-react'
import { Button } from '@/components/atoms/button'
import { Icon } from '@/components/atoms/icon'
import { cn, cva, type VariantProps } from '@/utils/theme'

const styles = {
// the drift is the only thing bringing offscreen entries into view, so under reduced
// motion the row hands that job to the reader as a plain scroller
root: cva([
root: cva('relative w-full'),
viewport: cva([
'w-full overflow-hidden',
'motion-reduce:overflow-x-auto motion-reduce:overscroll-x-contain'
]),
track: cva(['animate-marquee flex w-max', 'hover:paused', 'motion-reduce:animate-none'], {
variants: {
direction: {
left: '',
right: 'direction-reverse'
track: cva(
[
'animate-marquee flex w-max',
'hover:paused data-[paused=true]:paused',
'motion-reduce:animate-none'
],
{
variants: {
direction: {
left: '',
right: 'direction-reverse'
}
},
defaultVariants: {
direction: 'left'
}
},
defaultVariants: {
direction: 'left'
}
}),
),
group: cva('flex shrink-0 items-stretch gap-8 pr-8'),
// the duplicate exists only to hide the loop's seam; scrolling it would just repeat
duplicate: cva('motion-reduce:hidden')
duplicate: cva('motion-reduce:hidden'),
control: cva(['absolute top-4 right-4 z-10', 'text-muted-foreground', 'motion-reduce:hidden'])
}

type MarqueeRef = HTMLDivElement
type MarqueeProps = HTMLAttributes<MarqueeRef> & VariantProps<typeof styles.track>
type MarqueeProps = HTMLAttributes<MarqueeRef> &
VariantProps<typeof styles.track> & {
/** Accessible label for the control that stops the drift. */
pauseLabel?: string
/** Accessible label for the control once the drift is stopped. */
resumeLabel?: string
}

const Marquee = forwardRef<MarqueeRef, MarqueeProps>((props, ref) => {
// props
const { direction, children, className, ...rest } = props
const {
direction,
pauseLabel = 'Pause scrolling',
resumeLabel = 'Resume scrolling',
children,
className,
...rest
} = props

// hooks
const [paused, setPaused] = useState(false)

// render vars
const handleToggle = () => setPaused((prev) => !prev)

// jsx
return (
<div ref={ref} className={cn(styles.root({ className }))} {...rest}>
<div className={cn(styles.track({ direction }))}>
<div className={cn(styles.group())}>{children}</div>
<div className={cn(styles.group(), styles.duplicate())} aria-hidden>
{children}
<div className={cn(styles.viewport())}>
<div className={cn(styles.track({ direction }))} data-paused={paused}>
<div className={cn(styles.group())}>{children}</div>
<div className={cn(styles.group(), styles.duplicate())} aria-hidden>
{children}
</div>
</div>
</div>
<Button
type="button"
className={cn(styles.control())}
aria-label={paused ? resumeLabel : pauseLabel}
size="icon-sm"
variant="ghost"
onClick={handleToggle}
>
<Icon icon={paused ? PlayIcon : PauseIcon} />
</Button>
</div>
)
})
Expand Down
2 changes: 1 addition & 1 deletion src/components/organisms/global-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const GlobalMenu = (props: GlobalMenuProps) => {
</DrawerClose>
</DrawerHeader>
<div className={cn(styles.body())}>
<Menu entries={entries} onNavigate={handleNavigate} />
<Menu aria-label="Menu" entries={entries} onNavigate={handleNavigate} />
</div>
<div className={cn(styles.footer())}>
<p>{'// Menu //'}</p>
Expand Down
2 changes: 1 addition & 1 deletion src/components/templates/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const Footer: FC<FooterProps> = (props) => {
<div className={cn(styles.bar({ variant }))}>
<div className={cn(styles.left())}>© Tony Ko {year}</div>
<div className={cn(styles.right())}>
<Nav entries={footerEntries} />
<Nav aria-label="Footer" entries={footerEntries} />
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/templates/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const Header: FC<HeaderProps> = (props) => {
<Identity {...identityProps} />
</div>
<div className={cn(styles.right())}>
<Nav className={cn(styles.nav())} entries={headerEntries} />
<Nav className={cn(styles.nav())} aria-label="Primary" entries={headerEntries} />
<GlobalMenu entries={headerEntries} identityProps={identityProps}>
<Button className={cn(styles.trigger())} variant="link">
<Icon icon={MenuIcon} size="sm" />
Expand Down
Loading