hlmnhkl-ui 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -65,7 +65,8 @@ Setup for other tools: [MCP server](https://ui.hlmnhkl.my/mcp).
65
65
  ## What you get
66
66
 
67
67
  - **The source, not a package.** The command copies plain files into your app. Change anything; there is no update to wait for.
68
- - **68 free components**, from Button, Dialog, Select and Table to Toast and Retime, one speed setting for every animation. See [COMPONENTS.md](COMPONENTS.md).
68
+ - **71 free components**, from Button, Dialog, Select and Table to Toast and Retime, one speed setting for every animation. See [COMPONENTS.md](COMPONENTS.md).
69
+ - **Controls that feel made by hand.** Dial, RulerScrubber and PickerWheel are drawn onto their marks like a magnet, glide after a flick without flying past, and settle without a bounce. Sheet rests at set heights, HoldToConfirm burns a line round its edge, SwipeDeck throws its cards and SplitFlap flips like a station board.
69
70
  - **One look, set by tokens.** Every colour, size and timing comes from one file, `tokens.css`. Colour appears only to mean something: good, caution or bad.
70
71
  - **Light and dark.** Dark follows the system, or set `data-theme="dark"` on `<html>`.
71
72
  - **Accessible.** Keyboard support per the WAI-ARIA patterns, visible focus, screen reader labels, and reduced motion. On touch screens every control gives at least a 44px target, even the small sizes; only the six PIN boxes and the seven calendar days get narrower when the row has no room, about 36px wide on a 320px phone. CalendarHeatmap's days are small by design, since a year must fit on screen; its month table gives the same numbers at full size.
@@ -74,7 +75,7 @@ Setup for other tools: [MCP server](https://ui.hlmnhkl.my/mcp).
74
75
 
75
76
  ## Pro
76
77
 
77
- 72 more components (such as DataGrid, Calendar, Chart, KanbanBoard and Combobox), page blocks, full templates and brand themes are coming as hlmnhkl/ui Pro. You can see them on the [docs site](https://ui.hlmnhkl.my).
78
+ 76 more components (such as DataGrid, Calendar, Chart, KanbanBoard, Combobox and WeekSchedule), page blocks, full templates and brand themes are coming as hlmnhkl/ui Pro. You can see them on the [docs site](https://ui.hlmnhkl.my).
78
79
 
79
80
  ## Licence
80
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hlmnhkl-ui",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "React components in black, white and grey with one blue accent. Copy them into your app with npx hlmnhkl-ui add <name>.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ui.hlmnhkl.my",
@@ -0,0 +1,191 @@
1
+ .root {
2
+ /* The row is the pill plus the card's inner padding, so the label keeps its pixels as the card grows round it. */
3
+ --pill-pad: calc(var(--hl-space-6) - (var(--hl-hit-min) - var(--hl-text-sm) * var(--hl-leading-normal)) / 2);
4
+ position: relative;
5
+ inline-size: 100%;
6
+ max-inline-size: var(--hl-panel-w-sm);
7
+ block-size: var(--pill-row, calc(var(--hl-hit-min) + var(--pill-pad)));
8
+ /* Only the pill takes taps; the clear row either side of it lets them through to the page. */
9
+ pointer-events: none;
10
+ }
11
+
12
+ .root[data-fixed] {
13
+ position: fixed;
14
+ z-index: var(--hl-z-sticky);
15
+ inset-inline: var(--hl-space-5);
16
+ inline-size: auto;
17
+ margin-inline: auto;
18
+ }
19
+
20
+ .root[data-fixed][data-placement='bottom'] {
21
+ bottom: calc(var(--hl-space-5) + env(safe-area-inset-bottom));
22
+ }
23
+
24
+ .root[data-fixed][data-placement='top'] {
25
+ top: calc(var(--hl-space-5) + env(safe-area-inset-top));
26
+ }
27
+
28
+ .shell {
29
+ --pill-bg: var(--hl-color-surface-sunken);
30
+ --pill-fg: var(--hl-color-fg);
31
+ --pill-muted: var(--hl-color-fg-muted);
32
+ --pill-line: var(--hl-color-border);
33
+ --pill-key: var(--hl-color-border-strong);
34
+ --pill-fill: var(--hl-color-inverse);
35
+ --pill-fill-fg: var(--hl-color-inverse-fg);
36
+ position: absolute;
37
+ inset-inline: 0;
38
+ display: flex;
39
+ flex-direction: column;
40
+ border-radius: var(--hl-radius-sheet);
41
+ background: var(--pill-bg);
42
+ color: var(--pill-fg);
43
+ box-shadow: inset 0 0 0 var(--hl-line) var(--pill-key);
44
+ pointer-events: auto;
45
+ clip-path: var(--pill-shut);
46
+ }
47
+
48
+ .shell[data-tone='ink'] {
49
+ --pill-bg: var(--hl-color-inverse);
50
+ --pill-fg: var(--hl-color-inverse-fg);
51
+ --pill-muted: color-mix(in srgb, var(--pill-fg) 62%, var(--pill-bg));
52
+ --pill-line: color-mix(in srgb, var(--pill-fg) 18%, var(--pill-bg));
53
+ --pill-key: transparent;
54
+ --pill-fill: var(--hl-color-surface);
55
+ --pill-fill-fg: var(--hl-color-fg);
56
+ }
57
+
58
+ /* Shut, it closes fast and falls away. Open, the surface pours out of the pill on the liquid curve. */
59
+ .shell[data-measured] {
60
+ transition: clip-path var(--hl-motion-fast) var(--hl-ease-in);
61
+ }
62
+
63
+ .shell[data-open] {
64
+ z-index: 1;
65
+ clip-path: inset(0 0 0 0 round var(--hl-radius-sheet));
66
+ transition: clip-path var(--hl-motion-liquid) var(--hl-ease-liquid);
67
+ }
68
+
69
+ /* Before it is measured (the first paint of a server page), only the pill's words show. */
70
+ .shell:not([data-measured]):not([data-open]) {
71
+ visibility: hidden;
72
+ }
73
+
74
+ .shell:not([data-measured]):not([data-open]) .button {
75
+ visibility: visible;
76
+ }
77
+
78
+ .root[data-placement='bottom'] .shell {
79
+ bottom: 0;
80
+ flex-direction: column-reverse;
81
+ }
82
+
83
+ .root[data-placement='top'] .shell {
84
+ top: 0;
85
+ }
86
+
87
+ .row {
88
+ display: flex;
89
+ justify-content: center;
90
+ }
91
+
92
+ .root[data-placement='bottom'] .row {
93
+ padding-block-end: var(--pill-pad);
94
+ }
95
+
96
+ .root[data-placement='top'] .row {
97
+ padding-block-start: var(--pill-pad);
98
+ }
99
+
100
+ .button {
101
+ position: relative;
102
+ display: inline-flex;
103
+ align-items: center;
104
+ min-block-size: var(--hl-hit-min);
105
+ max-inline-size: 100%;
106
+ padding: 0;
107
+ border: 0;
108
+ border-radius: var(--hl-radius-full);
109
+ background: transparent;
110
+ color: inherit;
111
+ box-shadow: inset 0 0 0 var(--hl-line) var(--pill-key);
112
+ font: inherit;
113
+ font-size: var(--hl-text-sm);
114
+ font-weight: var(--hl-weight-semibold);
115
+ font-variant-numeric: tabular-nums;
116
+ white-space: nowrap;
117
+ overflow: clip;
118
+ cursor: pointer;
119
+ -webkit-tap-highlight-color: transparent;
120
+ transition: box-shadow var(--hl-motion-fast) var(--hl-ease-out);
121
+ }
122
+
123
+ .button:focus-visible {
124
+ outline: var(--hl-line-focus) solid var(--hl-color-accent);
125
+ outline-offset: calc(var(--hl-line-focus) * -2);
126
+ }
127
+
128
+ /* Open, the pill stops being a chip: its outline goes, and its words read as the card's last row. */
129
+ .shell[data-open] .button {
130
+ box-shadow: inset 0 0 0 var(--hl-line) transparent;
131
+ }
132
+
133
+ .stack {
134
+ display: grid;
135
+ }
136
+
137
+ .face {
138
+ grid-area: 1 / 1;
139
+ display: inline-flex;
140
+ align-items: center;
141
+ gap: var(--hl-space-3);
142
+ block-size: var(--hl-hit-min);
143
+ padding-inline: var(--hl-space-5);
144
+ }
145
+
146
+ .face svg {
147
+ flex: none;
148
+ font-size: var(--hl-icon-md);
149
+ }
150
+
151
+ /* The pill as its own progress bar: words under the fill turn to the fill's own ink. */
152
+ .over {
153
+ background: var(--pill-fill);
154
+ color: var(--pill-fill-fg);
155
+ clip-path: inset(0 calc(100% - var(--p) * 100%) 0 0);
156
+ transition:
157
+ clip-path var(--hl-motion-slow) var(--hl-ease-out),
158
+ opacity var(--hl-motion-fast) var(--hl-ease-out);
159
+ }
160
+
161
+ .over:dir(rtl) {
162
+ clip-path: inset(0 0 0 calc(100% - var(--p) * 100%));
163
+ }
164
+
165
+ .shell[data-open] .over {
166
+ opacity: 0;
167
+ }
168
+
169
+ .body {
170
+ padding: var(--hl-space-2) var(--hl-space-6) var(--hl-space-6);
171
+ opacity: 0;
172
+ transition: opacity var(--hl-motion-instant) var(--hl-ease-in);
173
+ }
174
+
175
+ .root[data-placement='bottom'] .body {
176
+ padding: var(--hl-space-6) var(--hl-space-6) var(--hl-space-2);
177
+ }
178
+
179
+ .shell[data-open] .body {
180
+ opacity: 1;
181
+ transition: opacity var(--hl-motion-base) var(--hl-ease-out) var(--hl-motion-fast);
182
+ }
183
+
184
+ .sr {
185
+ position: absolute;
186
+ width: 1px;
187
+ height: 1px;
188
+ overflow: hidden;
189
+ clip-path: inset(50%);
190
+ white-space: nowrap;
191
+ }
@@ -0,0 +1,181 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useLayoutEffect, useRef, useState, type ComponentPropsWithRef, type CSSProperties, type ReactNode } from 'react'
4
+ import { cx } from '../../internal/cx'
5
+ import { motionMs } from '../../internal/motion'
6
+ import { useControllable } from '../../internal/useControllable'
7
+ import styles from './LivePill.module.css'
8
+
9
+ export type LivePillProps = Omit<ComponentPropsWithRef<'div'>, 'children' | 'title'> & {
10
+ /** Screen reader name of the pill, the whole state in words, such as "Map download, 63%". */
11
+ label: string
12
+ /** What the small pill shows, such as an icon, a word and a number. It stays on its spot when the pill opens. */
13
+ compact: ReactNode
14
+ /** What the open card shows above (or below) the pill's own row. */
15
+ children: ReactNode
16
+ /** Whether it is open, when you control it. */
17
+ open?: boolean
18
+ /** Starting open state when you do not control it. */
19
+ defaultOpen?: boolean
20
+ /** Called with the new open state. */
21
+ onOpenChange?: (open: boolean) => void
22
+ /** Where it sits. At the bottom it is in thumb reach and grows up; at the top it grows down. */
23
+ placement?: 'bottom' | 'top'
24
+ /** Pins it to the screen edge, clear of the safe area. Leave off to place it yourself. */
25
+ fixed?: boolean
26
+ /** From 0 to 1: the pill fills from its start like a progress bar, and its words change ink under the fill. */
27
+ progress?: number
28
+ /** "paper" is a light pill with an outline; "ink" is a solid dark one. */
29
+ tone?: 'paper' | 'ink'
30
+ /** News for screen readers, said once each time it changes, such as "Download paused". Never a ticking number. */
31
+ news?: string
32
+ /** News that opens the pill by itself, such as "Rider is at your door". Said once, and the pill shrinks back after alertMs. */
33
+ alert?: string
34
+ /** How long an alert keeps it open, in ms. A hand or focus on the pill holds it open. */
35
+ alertMs?: number
36
+ }
37
+
38
+ // What the pill can hand focus to when it closes from a tap outside, so a tap on another control keeps it.
39
+ const TAKES_FOCUS = 'a[href], button, input, select, textarea, label, [tabindex]:not([tabindex="-1"])'
40
+
41
+ export function LivePill({
42
+ label,
43
+ compact,
44
+ children,
45
+ open: openProp,
46
+ defaultOpen = false,
47
+ onOpenChange,
48
+ placement = 'bottom',
49
+ fixed = false,
50
+ progress,
51
+ tone = 'paper',
52
+ news,
53
+ alert,
54
+ alertMs = 5000,
55
+ className,
56
+ style,
57
+ ...rest
58
+ }: LivePillProps) {
59
+ const [open, setOpen] = useControllable(openProp, defaultOpen, onOpenChange)
60
+ const shell = useRef<HTMLDivElement>(null)
61
+ const button = useRef<HTMLButtonElement>(null)
62
+ const [shut, setShut] = useState<string>()
63
+ const [rowH, setRowH] = useState<number>()
64
+ const [byAlert, setByAlert] = useState(false)
65
+ const [held, setHeld] = useState(false)
66
+ const [said, setSaid] = useState('')
67
+ const lastNews = useRef(news)
68
+ const lastAlert = useRef(alert)
69
+
70
+ // Shut, the card is cut back to the button's own box, so the pill grows out of its own spot.
71
+ useLayoutEffect(() => {
72
+ const sh = shell.current
73
+ const b = button.current
74
+ if (!sh || !b) return
75
+ const measure = () => {
76
+ const right = sh.offsetWidth - b.offsetLeft - b.offsetWidth
77
+ const bottom = sh.offsetHeight - b.offsetTop - b.offsetHeight
78
+ setShut(`inset(${b.offsetTop}px ${right}px ${bottom}px ${b.offsetLeft}px round ${b.offsetHeight / 2}px)`)
79
+ const row = b.parentElement as HTMLElement
80
+ setRowH(row.offsetHeight)
81
+ }
82
+ measure()
83
+ const ro = new ResizeObserver(measure)
84
+ ro.observe(sh)
85
+ ro.observe(b)
86
+ return () => ro.disconnect()
87
+ }, [])
88
+
89
+ useEffect(() => {
90
+ if (news === lastNews.current) return
91
+ lastNews.current = news
92
+ if (news) setSaid(news)
93
+ }, [news])
94
+
95
+ useEffect(() => {
96
+ if (alert === lastAlert.current) return
97
+ lastAlert.current = alert
98
+ if (!alert) return
99
+ setSaid(alert)
100
+ // Only a card the alert opened shrinks back by itself; one the person opened stays.
101
+ if (open) return
102
+ setByAlert(true)
103
+ setOpen(true)
104
+ }, [alert, setOpen])
105
+
106
+ const close = (refocus: boolean) => {
107
+ setByAlert(false)
108
+ setOpen(false)
109
+ if (refocus) button.current?.focus({ preventScroll: true })
110
+ }
111
+ const focusInside = () => !!shell.current?.contains(document.activeElement) && document.activeElement !== button.current
112
+
113
+ useEffect(() => {
114
+ if (!open) {
115
+ setByAlert(false)
116
+ return
117
+ }
118
+ const outside = (e: PointerEvent) => {
119
+ const target = e.target instanceof Element ? e.target : null
120
+ if (!target || shell.current?.contains(target)) return
121
+ const inside = focusInside()
122
+ close(false)
123
+ if (inside && !target.closest(TAKES_FOCUS)) window.setTimeout(() => button.current?.focus({ preventScroll: true }))
124
+ }
125
+ document.addEventListener('pointerdown', outside)
126
+ return () => document.removeEventListener('pointerdown', outside)
127
+ }, [open])
128
+
129
+ useEffect(() => {
130
+ if (!open || !byAlert || held) return
131
+ const t = window.setTimeout(() => close(focusInside()), Math.max(alertMs, motionMs('liquid')))
132
+ return () => window.clearTimeout(t)
133
+ }, [open, byAlert, held, alertMs])
134
+
135
+ const p = progress === undefined ? undefined : Math.min(1, Math.max(0, progress))
136
+ const vars = { '--pill-shut': shut, '--pill-row': rowH === undefined ? undefined : `${rowH}px`, '--p': p, ...style } as CSSProperties
137
+
138
+ return (
139
+ <div className={cx(styles.root, className)} data-placement={placement} data-fixed={fixed || undefined} style={vars} {...rest}>
140
+ <div
141
+ ref={shell}
142
+ className={styles.shell}
143
+ data-open={open || undefined}
144
+ data-measured={shut ? '' : undefined}
145
+ data-tone={tone}
146
+ onKeyDown={(e) => {
147
+ if (e.key !== 'Escape' || !open) return
148
+ e.stopPropagation()
149
+ close(true)
150
+ }}
151
+ onPointerEnter={() => setHeld(true)}
152
+ onPointerLeave={() => setHeld(false)}
153
+ onFocus={() => setHeld(true)}
154
+ onBlur={(e) => {
155
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) setHeld(false)
156
+ }}
157
+ >
158
+ <div className={styles.row}>
159
+ <button ref={button} type="button" className={styles.button} data-fill={p === undefined ? undefined : ''} aria-expanded={open} aria-label={label} onClick={() => (open ? close(false) : setOpen(true))}>
160
+ {p === undefined ? (
161
+ <span className={styles.face}>{compact}</span>
162
+ ) : (
163
+ <span className={styles.stack}>
164
+ <span className={styles.face}>{compact}</span>
165
+ <span className={cx(styles.face, styles.over)} aria-hidden="true">
166
+ {compact}
167
+ </span>
168
+ </span>
169
+ )}
170
+ </button>
171
+ </div>
172
+ <div className={styles.body} inert={!open} aria-hidden={!open || undefined}>
173
+ {children}
174
+ </div>
175
+ </div>
176
+ <span className={styles.sr} role="status" aria-live="polite" aria-atomic="true">
177
+ {said}
178
+ </span>
179
+ </div>
180
+ )
181
+ }
@@ -0,0 +1 @@
1
+ export * from './LivePill'
@@ -109,6 +109,10 @@
109
109
  /* Segments touch, so the box itself is the 44px target. The floor sits on the content, so the
110
110
  overflow check still sees the natural width, and tight mode shrinks no further than 44px. */
111
111
  @media (pointer: coarse) {
112
+ .item,
113
+ .sm .item {
114
+ min-height: var(--hl-hit-min);
115
+ }
112
116
  .text,
113
117
  .icon:only-child {
114
118
  min-width: calc(var(--hl-hit-min) - var(--pad) * 2);
@@ -0,0 +1,107 @@
1
+ .root {
2
+ display: inline-flex;
3
+ vertical-align: middle;
4
+ font-size: var(--hl-text-xl);
5
+ font-weight: var(--hl-weight-semibold);
6
+ font-variant-numeric: tabular-nums;
7
+ line-height: 1;
8
+ }
9
+
10
+ .root[data-size='sm'] {
11
+ font-size: var(--hl-text-md);
12
+ }
13
+
14
+ .root[data-size='lg'] {
15
+ font-size: var(--hl-text-4xl);
16
+ }
17
+
18
+ .sr {
19
+ position: absolute;
20
+ width: 1px;
21
+ height: 1px;
22
+ overflow: hidden;
23
+ clip-path: inset(50%);
24
+ white-space: nowrap;
25
+ }
26
+
27
+ .board {
28
+ display: inline-flex;
29
+ gap: var(--hl-space-1);
30
+ border-radius: var(--hl-radius-xs);
31
+ }
32
+
33
+ .root[data-size='lg'] .board {
34
+ gap: var(--hl-space-2);
35
+ }
36
+
37
+ .cell {
38
+ position: relative;
39
+ display: block;
40
+ inline-size: var(--hl-flap-w);
41
+ block-size: var(--hl-flap-h);
42
+ perspective: calc(var(--hl-flap-h) * 3);
43
+ color: var(--hl-color-fg);
44
+ }
45
+
46
+ /* Two halves with a hairline seam between them, where the page shows through. */
47
+ .top,
48
+ .bottom,
49
+ .front,
50
+ .back {
51
+ position: absolute;
52
+ inset-inline: 0;
53
+ block-size: calc(50% - var(--hl-line) / 2);
54
+ overflow: hidden;
55
+ }
56
+
57
+ .top,
58
+ .front {
59
+ top: 0;
60
+ border-radius: var(--hl-radius-xs) var(--hl-radius-xs) 0 0;
61
+ background: var(--hl-flap-top);
62
+ }
63
+
64
+ .bottom,
65
+ .back {
66
+ bottom: 0;
67
+ border-radius: 0 0 var(--hl-radius-xs) var(--hl-radius-xs);
68
+ background: var(--hl-flap-bottom);
69
+ }
70
+
71
+ .glyph {
72
+ position: absolute;
73
+ inset-inline: 0;
74
+ top: 0;
75
+ block-size: var(--hl-flap-h);
76
+ line-height: var(--hl-flap-h);
77
+ text-align: center;
78
+ }
79
+
80
+ .bottom .glyph,
81
+ .back .glyph {
82
+ top: auto;
83
+ bottom: 0;
84
+ }
85
+
86
+ /* The falling flap turns on the seam, so after half a turn its back lies over the bottom half. */
87
+ .flap {
88
+ position: absolute;
89
+ inset-inline: 0;
90
+ top: 0;
91
+ z-index: 1;
92
+ block-size: calc(50% - var(--hl-line) / 2);
93
+ transform-origin: 50% calc(100% + var(--hl-line) / 2);
94
+ transform-style: preserve-3d;
95
+ }
96
+
97
+ .front,
98
+ .back {
99
+ block-size: 100%;
100
+ backface-visibility: hidden;
101
+ }
102
+
103
+ .back {
104
+ top: 0;
105
+ bottom: auto;
106
+ transform: rotateX(180deg);
107
+ }
@@ -0,0 +1,224 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState, type ComponentPropsWithRef } from 'react'
4
+ import { cx } from '../../internal/cx'
5
+ import { easing, readMotionMs } from '../../internal/motion'
6
+ import styles from './SplitFlap.module.css'
7
+
8
+ export type SplitFlapProps = Omit<ComponentPropsWithRef<'span'>, 'children'> & {
9
+ /** The text the board shows. A change flips each cell forward through the drum to its new letter. */
10
+ value: string
11
+ /** At least this many cells: shorter text is padded with blank ones, so a column keeps its width. */
12
+ length?: number
13
+ /** Text size of the cells. */
14
+ size?: 'sm' | 'md' | 'lg'
15
+ /** The drum's letters, in the order a cell passes them. A letter that is not on it flips straight in. */
16
+ chars?: string
17
+ /** Says the new text to screen readers once, when the board has landed. Turn off when one message covers several boards. */
18
+ announce?: boolean
19
+ /** Called once with the text when every cell has landed. */
20
+ onSettle?: (value: string) => void
21
+ }
22
+
23
+ /** The drum of a real board: a blank, letters, digits and a few marks. */
24
+ export const SPLIT_FLAP_CHARS = ' ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789:.,-+%/'
25
+
26
+ // A fold of about 90ms, fast steps of 40ms, and the last three slow down. A cell passes 8 letters at
27
+ // most, and the cells start one after another within 350ms, so a whole change ends inside a second.
28
+ const FOLD = 90
29
+ const FAST = 40
30
+ const TAIL = [70, 100, 140]
31
+ const MAX_STEPS = 8
32
+ const STAGGER = 35
33
+ const CASCADE_CAP = 350
34
+ // The fast motion token at its base, so Retime scales the board with everything else.
35
+ const FAST_TOKEN = 140
36
+
37
+ type Cell = { root: HTMLSpanElement; top: HTMLSpanElement; bottom: HTMLSpanElement; shown: string }
38
+
39
+ /** The letters a cell passes on its way from `from` to `to`, at most MAX_STEPS, ending on `to`. */
40
+ function route(from: string, to: string, drum: string) {
41
+ const a = drum.indexOf(from)
42
+ const b = drum.indexOf(to)
43
+ if (a < 0 || b < 0) return from === to ? [] : [to]
44
+ const n = Math.min((b - a + drum.length) % drum.length, MAX_STEPS)
45
+ return Array.from({ length: n }, (_, i) => drum[(b - n + 1 + i + drum.length) % drum.length])
46
+ }
47
+
48
+ /** The wait before each step: fast in the middle, slow for the last three. */
49
+ const gaps = (n: number) => Array.from({ length: n }, (_, i) => (n - 1 - i < TAIL.length ? TAIL[TAIL.length - n + i] : FAST))
50
+
51
+ const NBSP = ' '
52
+ const shownChar = (ch: string) => (ch === ' ' ? NBSP : ch)
53
+ const escape = (ch: string) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[ch] ?? ch
54
+
55
+ const halfHtml = (cls: string, ch: string) => `<span class="${cls}"><span class="${styles.glyph}">${escape(shownChar(ch))}</span></span>`
56
+ const cellHtml = (ch: string) => `<span class="${styles.cell}">${halfHtml(styles.top, ch)}${halfHtml(styles.bottom, ch)}</span>`
57
+
58
+ const fromHtml = (html: string) => {
59
+ const t = document.createElement('template')
60
+ t.innerHTML = html
61
+ return t.content.firstElementChild as HTMLSpanElement
62
+ }
63
+
64
+ const makeCell = (ch: string): Cell => {
65
+ const root = fromHtml(cellHtml(ch))
66
+ return { root, top: root.children[0] as HTMLSpanElement, bottom: root.children[1] as HTMLSpanElement, shown: ch }
67
+ }
68
+
69
+ const setHalf = (h: Element, ch: string) => {
70
+ ;(h.firstElementChild as HTMLElement).textContent = shownChar(ch)
71
+ }
72
+
73
+ export function SplitFlap({ value, length = 0, size = 'md', chars = SPLIT_FLAP_CHARS, announce = true, onSettle, className, ...rest }: SplitFlapProps) {
74
+ const target = value.padEnd(length, ' ')
75
+ // The first cells come with the markup, so a server page shows the board before any script runs.
76
+ const [first] = useState(() => ({ __html: [...target].map(cellHtml).join('') }))
77
+ const [said, setSaid] = useState(value)
78
+ const board = useRef<HTMLSpanElement>(null)
79
+ const cells = useRef<Cell[] | null>(null)
80
+ const timers = useRef<number[]>([])
81
+ const anims = useRef(new Set<Animation>())
82
+ const run = useRef(0)
83
+ const shrinkEnd = useRef<(() => void) | null>(null)
84
+ const settle = useRef(onSettle)
85
+ settle.current = onSettle
86
+
87
+ const stopAll = () => {
88
+ timers.current.forEach(clearTimeout)
89
+ timers.current = []
90
+ anims.current.forEach((a) => a.finish())
91
+ anims.current.clear()
92
+ shrinkEnd.current?.()
93
+ // Caught mid-run, each cell carries on from the letter it was showing.
94
+ for (const c of cells.current ?? []) setHalf(c.bottom, c.shown)
95
+ }
96
+
97
+ useEffect(() => {
98
+ const b = board.current
99
+ if (!b) return
100
+ if (!cells.current) {
101
+ cells.current = [...b.children].map((root, i) => ({ root: root as HTMLSpanElement, top: root.children[0] as HTMLSpanElement, bottom: root.children[1] as HTMLSpanElement, shown: [...target][i] ?? ' ' }))
102
+ return
103
+ }
104
+ const id = ++run.current
105
+ const live = () => id === run.current
106
+ const running = timers.current.length > 0 || anims.current.size > 0
107
+ stopAll()
108
+ const done = () => {
109
+ if (!live()) return
110
+ timers.current = []
111
+ setSaid(value)
112
+ settle.current?.(value)
113
+ }
114
+ const list = cells.current
115
+ if (list.map((c) => c.shown).join('') === target) {
116
+ if (running) done()
117
+ return
118
+ }
119
+
120
+ const scale = readMotionMs('fast') / FAST_TOKEN
121
+ // Reduced motion: the new text at once, with no flips and no morph.
122
+ if (!scale) {
123
+ cells.current = [...target].map(makeCell)
124
+ b.replaceChildren(...cells.current.map((c) => c.root))
125
+ done()
126
+ return
127
+ }
128
+
129
+ const n0 = list.length
130
+ const n1 = target.length
131
+ const radius = getComputedStyle(b).borderRadius || '0px'
132
+ const liquid = { duration: readMotionMs('liquid'), easing: easing('liquid') }
133
+ let left = 0
134
+ const landed = () => {
135
+ if (--left === 0) done()
136
+ }
137
+
138
+ // Longer: the new cells are laid out at once, and the board's edge grows over them.
139
+ if (n1 > n0) {
140
+ const before = b.offsetWidth
141
+ for (let i = n0; i < n1; i++) {
142
+ const c = makeCell(' ')
143
+ list.push(c)
144
+ b.append(c.root)
145
+ }
146
+ const grow = b.offsetWidth - before
147
+ const a = b.animate({ clipPath: [`inset(0 ${grow}px 0 0 round ${radius})`, `inset(0 0 0 0 round ${radius})`] }, liquid)
148
+ anims.current.add(a)
149
+ a.finished.then(() => anims.current.delete(a), () => {})
150
+ }
151
+
152
+ // Shorter: the board's edge folds over the extra cells while the rest flip, then they go.
153
+ if (n1 < n0) {
154
+ const keep = list.slice(0, n1)
155
+ const box = b.getBoundingClientRect()
156
+ const edge = keep.length ? keep[keep.length - 1].root.getBoundingClientRect().right : box.left
157
+ const a = b.animate({ clipPath: [`inset(0 0 0 0 round ${radius})`, `inset(0 ${box.right - edge}px 0 0 round ${radius})`] }, { ...liquid, fill: 'forwards' })
158
+ left++
159
+ const end = () => {
160
+ shrinkEnd.current = null
161
+ list.slice(n1).forEach((c) => c.root.remove())
162
+ cells.current = keep
163
+ a.cancel()
164
+ if (live()) landed()
165
+ }
166
+ shrinkEnd.current = end
167
+ a.finished.then(() => shrinkEnd.current === end && end(), () => {})
168
+ }
169
+
170
+ const goal = [...target]
171
+ const changed = goal.map((ch, i) => (list[i].shown !== ch ? i : -1)).filter((i) => i >= 0)
172
+ const stagger = Math.min(STAGGER, CASCADE_CAP / Math.max(1, changed.length))
173
+ left += changed.length
174
+ if (!left) return done()
175
+
176
+ const flip = (cell: Cell, next: string, last: boolean, fold: number) => {
177
+ const prev = cell.shown
178
+ cell.shown = next
179
+ setHalf(cell.top, next)
180
+ // The falling flap: the old letter's top half in front, the new letter's bottom half behind.
181
+ const flap = fromHtml(`<span class="${styles.flap}">${halfHtml(styles.front, prev)}${halfHtml(styles.back, next)}</span>`)
182
+ cell.root.append(flap)
183
+ const a = flap.animate([{ transform: 'rotateX(0deg)' }, { transform: 'rotateX(-180deg)' }], { duration: fold, easing: easing('in') })
184
+ anims.current.add(a)
185
+ const end = () => {
186
+ anims.current.delete(a)
187
+ setHalf(cell.bottom, next)
188
+ flap.remove()
189
+ if (last && live()) landed()
190
+ }
191
+ a.finished.then(end, end)
192
+ }
193
+
194
+ changed.forEach((i, order) => {
195
+ const cell = list[i]
196
+ const path = route(cell.shown, goal[i], chars)
197
+ const wait = gaps(path.length)
198
+ let t = order * stagger
199
+ path.forEach((next, k) => {
200
+ timers.current.push(window.setTimeout(() => flip(cell, next, k === path.length - 1, Math.max(FOLD, wait[k]) * scale), t * scale))
201
+ t += wait[k]
202
+ })
203
+ })
204
+ }, [target])
205
+
206
+ useEffect(
207
+ () => () => {
208
+ // Gone: the run is over, so a cancelled flip ends without calling onSettle.
209
+ run.current++
210
+ timers.current.forEach(clearTimeout)
211
+ anims.current.forEach((a) => a.cancel())
212
+ },
213
+ [],
214
+ )
215
+
216
+ return (
217
+ <span className={cx(styles.root, className)} data-size={size} {...rest}>
218
+ <span className={styles.sr} aria-live={announce ? 'polite' : undefined} aria-atomic="true">
219
+ {said}
220
+ </span>
221
+ <span ref={board} className={styles.board} aria-hidden="true" dangerouslySetInnerHTML={first} />
222
+ </span>
223
+ )
224
+ }
@@ -0,0 +1 @@
1
+ export * from './SplitFlap'
@@ -0,0 +1,238 @@
1
+ .root {
2
+ display: grid;
3
+ gap: var(--hl-space-5);
4
+ inline-size: 100%;
5
+ /* Clipped sideways only, at the deck's own outer edge (the screen edge on a phone), so a thrown card
6
+ never makes the page scroll sideways and a tilted one keeps its corners. */
7
+ overflow-x: clip;
8
+ }
9
+
10
+ /* Room above for the corner of a tilted card. */
11
+ .stage {
12
+ padding-block: var(--hl-space-7) var(--hl-space-6);
13
+ }
14
+
15
+ .deck {
16
+ --rise: 0;
17
+ position: relative;
18
+ display: grid;
19
+ max-inline-size: var(--hl-deck-w);
20
+ margin-inline: auto;
21
+ border-radius: var(--hl-radius-panel);
22
+ outline: none;
23
+ }
24
+
25
+ .deck:focus-visible {
26
+ box-shadow: var(--hl-focus-ring);
27
+ }
28
+
29
+ .card {
30
+ position: relative;
31
+ z-index: 3;
32
+ grid-area: 1 / 1;
33
+ display: grid;
34
+ min-block-size: var(--hl-deck-card-h);
35
+ border: var(--hl-line) solid var(--hl-color-border);
36
+ border-radius: var(--hl-radius-panel);
37
+ background: var(--hl-color-surface-raised);
38
+ box-shadow: var(--hl-shadow-2);
39
+ transform-origin: 50% 100%;
40
+ /* A sideways drag throws the card; up and down still scrolls the page. */
41
+ touch-action: pan-y;
42
+ user-select: none;
43
+ -webkit-user-select: none;
44
+ cursor: grab;
45
+ }
46
+
47
+ .deck[data-dragging] .card[data-depth='0'] {
48
+ cursor: grabbing;
49
+ box-shadow: var(--hl-shadow-3);
50
+ }
51
+
52
+ /* The cards behind rise with the pull: one step up as the top card nears its mark. */
53
+ .card[data-depth='1'] {
54
+ z-index: 2;
55
+ transform: translateY(calc((1 - var(--rise)) * var(--hl-space-3))) scale(calc(0.94 + 0.06 * var(--rise)));
56
+ box-shadow: var(--hl-shadow-1);
57
+ cursor: default;
58
+ }
59
+
60
+ .card[data-depth='2'] {
61
+ z-index: 1;
62
+ transform: translateY(calc(var(--hl-space-5) - var(--rise) * var(--hl-space-3))) scale(calc(0.88 + 0.06 * var(--rise)));
63
+ box-shadow: none;
64
+ cursor: default;
65
+ }
66
+
67
+ .deck:not([data-dragging]) .card[data-depth='1'],
68
+ .deck:not([data-dragging]) .card[data-depth='2'] {
69
+ transition: transform var(--hl-motion-base) var(--hl-ease-out);
70
+ }
71
+
72
+ .empty {
73
+ grid-area: 1 / 1;
74
+ display: grid;
75
+ place-content: center;
76
+ min-block-size: var(--hl-deck-card-h);
77
+ padding: var(--hl-space-6);
78
+ border: var(--hl-line) dashed var(--hl-color-border-strong);
79
+ border-radius: var(--hl-radius-panel);
80
+ color: var(--hl-color-fg-muted);
81
+ text-align: center;
82
+ }
83
+
84
+ /* The ruler under the card: a knob follows it, and the two throw marks pull it in. */
85
+ .ruler {
86
+ position: relative;
87
+ inline-size: 100%;
88
+ max-inline-size: var(--hl-deck-w);
89
+ block-size: var(--hl-space-6);
90
+ margin-inline: auto;
91
+ background: linear-gradient(var(--hl-color-border), var(--hl-color-border)) center / 100% var(--hl-line) no-repeat;
92
+ }
93
+
94
+ .mark,
95
+ .centre {
96
+ position: absolute;
97
+ top: 50%;
98
+ inline-size: var(--hl-line-thick);
99
+ block-size: var(--hl-space-5);
100
+ background: var(--hl-color-border-strong);
101
+ translate: -50% -50%;
102
+ transition:
103
+ background-color var(--hl-motion-fast) var(--hl-ease-out),
104
+ inline-size var(--hl-motion-fast) var(--hl-ease-out);
105
+ }
106
+
107
+ .centre {
108
+ left: 50%;
109
+ block-size: var(--hl-space-3);
110
+ }
111
+
112
+ .mark[data-side='left'] {
113
+ left: calc(50% - var(--hl-deck-throw) * 100%);
114
+ }
115
+
116
+ .mark[data-side='right'] {
117
+ left: calc(50% + var(--hl-deck-throw) * 100%);
118
+ }
119
+
120
+ .mark[data-armed] {
121
+ inline-size: calc(var(--hl-line-thick) * 2);
122
+ background: var(--hl-color-accent);
123
+ }
124
+
125
+ .knob {
126
+ position: absolute;
127
+ top: 50%;
128
+ left: 50%;
129
+ inline-size: var(--hl-space-4);
130
+ block-size: var(--hl-space-4);
131
+ margin: calc(var(--hl-space-4) / -2);
132
+ border-radius: var(--hl-radius-full);
133
+ background: var(--hl-color-fg);
134
+ }
135
+
136
+ .knob[data-armed] {
137
+ background: var(--hl-color-accent);
138
+ }
139
+
140
+ .bar {
141
+ display: grid;
142
+ grid-template-columns: 1fr auto 1fr;
143
+ gap: var(--hl-space-4);
144
+ align-items: center;
145
+ max-inline-size: var(--hl-deck-w);
146
+ inline-size: 100%;
147
+ margin-inline: auto;
148
+ }
149
+
150
+ .choice {
151
+ --fill: 0;
152
+ position: relative;
153
+ display: grid;
154
+ min-block-size: var(--hl-control-h-lg);
155
+ padding: 0;
156
+ border: var(--hl-line) solid var(--hl-color-border-strong);
157
+ border-radius: var(--hl-radius-full);
158
+ background: var(--hl-color-surface);
159
+ color: var(--hl-color-fg);
160
+ font: inherit;
161
+ font-weight: var(--hl-weight-semibold);
162
+ overflow: clip;
163
+ transform: scale(calc(1 + var(--fill) * 0.08));
164
+ cursor: pointer;
165
+ -webkit-tap-highlight-color: transparent;
166
+ }
167
+
168
+ .word {
169
+ display: grid;
170
+ place-items: center;
171
+ padding-inline: var(--hl-space-5);
172
+ white-space: nowrap;
173
+ }
174
+
175
+ /* The fill pours in from the button's inner edge toward the side the card goes, and the word under it turns to its ink. */
176
+ .fill {
177
+ position: absolute;
178
+ inset: 0;
179
+ display: grid;
180
+ background: var(--hl-color-inverse);
181
+ color: var(--hl-color-inverse-fg);
182
+ clip-path: inset(0 0 0 calc((1 - var(--fill)) * 100%));
183
+ }
184
+
185
+ .choice[data-side='end'] .fill,
186
+ .choice[data-side='start']:dir(rtl) .fill {
187
+ clip-path: inset(0 calc((1 - var(--fill)) * 100%) 0 0);
188
+ }
189
+
190
+ .choice[data-side='end']:dir(rtl) .fill {
191
+ clip-path: inset(0 0 0 calc((1 - var(--fill)) * 100%));
192
+ }
193
+
194
+ .choice[aria-disabled] {
195
+ border-color: var(--hl-color-border);
196
+ color: var(--hl-color-fg-disabled);
197
+ cursor: not-allowed;
198
+ }
199
+
200
+ @media (hover: hover) {
201
+ .choice:not([aria-disabled]):hover {
202
+ background: var(--hl-color-surface-hover);
203
+ }
204
+ }
205
+
206
+ .choice:focus-visible,
207
+ .undo:focus-visible {
208
+ outline: none;
209
+ box-shadow: var(--hl-focus-ring);
210
+ }
211
+
212
+ .undo {
213
+ display: grid;
214
+ place-items: center;
215
+ inline-size: var(--hl-control-h-lg);
216
+ block-size: var(--hl-control-h-lg);
217
+ padding: 0;
218
+ border: 0;
219
+ border-radius: var(--hl-radius-full);
220
+ background: var(--hl-color-surface-sunken);
221
+ color: var(--hl-color-fg);
222
+ font-size: var(--hl-icon-lg);
223
+ cursor: pointer;
224
+ }
225
+
226
+ .undo[aria-disabled] {
227
+ color: var(--hl-color-fg-disabled);
228
+ cursor: not-allowed;
229
+ }
230
+
231
+ .sr {
232
+ position: absolute;
233
+ width: 1px;
234
+ height: 1px;
235
+ overflow: hidden;
236
+ clip-path: inset(50%);
237
+ white-space: nowrap;
238
+ }
@@ -0,0 +1,349 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useLayoutEffect, useRef, useState, type ComponentPropsWithRef, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react'
4
+ import { cx } from '../../internal/cx'
5
+ import { clamp, magnetTo, pull, speedTracker, springTo } from '../../internal/feel'
6
+ import { UndoIcon } from '../../internal/icons'
7
+ import { easing, readMotionMs } from '../../internal/motion'
8
+ import { tokenNumber } from '../../internal/useScrub'
9
+ import styles from './SwipeDeck.module.css'
10
+
11
+ export type SwipeDeckSide = 'start' | 'end'
12
+
13
+ export type SwipeDeckProps<T> = Omit<ComponentPropsWithRef<'div'>, 'children' | 'onChange'> & {
14
+ /** The cards, top card first. The top card is the first whose key is not decided yet, so you may remove decided ones. */
15
+ items: T[]
16
+ /** A key that stays the same for each item. */
17
+ getKey: (item: T) => string
18
+ /** A short name for an item, said by screen readers after each decision. */
19
+ getName: (item: T) => string
20
+ /** Draws one card. */
21
+ renderCard: (item: T) => ReactNode
22
+ /** Screen reader name of the deck, such as "Flats to rent". */
23
+ label: string
24
+ /** The two button words, for the start side then the end side. */
25
+ actions?: [string, string]
26
+ /** What screen readers say was done, start then end, such as "Passed" and "Saved". */
27
+ decided?: [string, string]
28
+ /** Called once for each card, when it has been thrown or a button or key decided it. */
29
+ onDecide?: (item: T, side: SwipeDeckSide) => void
30
+ /** Called when Undo brings a card back. If you removed it from items, put it back here. */
31
+ onUndo?: (item: T) => void
32
+ /** Screen reader name of the undo button. */
33
+ undoLabel?: string
34
+ /** Shown when every card has been decided. */
35
+ empty?: ReactNode
36
+ }
37
+
38
+ type Side = -1 | 1
39
+ type Done<T> = { item: T; side: Side; dir: SwipeDeckSide }
40
+
41
+ // A press is a drag only once it moves this far sideways, so a tap on a card's own button stays a tap.
42
+ const SLOP = 8
43
+ // The throw rule: past --hl-deck-throw of the card's width, or a flick this fast that has moved at least FLICK_MIN.
44
+ const FLICK = 600
45
+ const FLICK_MIN = 40
46
+ const TILT = 0.06
47
+ const TILT_MAX = 14
48
+ // How far round each throw mark it draws the card in, as a share of the distance to the mark.
49
+ const MARK_REACH = 0.3
50
+ // The speed a card leaves at, or comes back at on undo, when no hand threw it.
51
+ const THROW_SPEED = 1800
52
+
53
+ export function SwipeDeck<T>({
54
+ items,
55
+ getKey,
56
+ getName,
57
+ renderCard,
58
+ label,
59
+ actions = ['Pass', 'Save'],
60
+ decided = ['Passed', 'Saved'],
61
+ onDecide,
62
+ onUndo,
63
+ undoLabel = 'Undo the last decision',
64
+ empty = 'That was the last card.',
65
+ className,
66
+ ...rest
67
+ }: SwipeDeckProps<T>) {
68
+ const [done, setDone] = useState<Done<T>[]>([])
69
+ const [news, setNews] = useState('')
70
+ const [armed, setArmed] = useState<Side | 0>(0)
71
+ const root = useRef<HTMLDivElement>(null)
72
+ const deck = useRef<HTMLDivElement>(null)
73
+ const top = useRef<HTMLDivElement>(null)
74
+ const knob = useRef<HTMLSpanElement>(null)
75
+ const buttons = useRef<Record<Side, HTMLButtonElement | null>>({ [-1]: null, [1]: null })
76
+ const at = useRef(0)
77
+ const stop = useRef(() => {})
78
+ const busy = useRef(false)
79
+ const queued = useRef<Side | null>(null)
80
+ const moved = useRef(false)
81
+ const undoing = useRef<Side | null>(null)
82
+ const drag = useRef<{ x: number; y: number; base: number; id: number; sideways: boolean; speed: ReturnType<typeof speedTracker> } | null>(null)
83
+ // The top card is the first one not yet decided, found by key, so the app may trim or replace items.
84
+ const gone = new Set(done.map((d) => getKey(d.item)))
85
+ const left = items.filter((item) => !gone.has(getKey(item)))
86
+ const latest = useRef({ left, count: done.length, onDecide, getName, decided })
87
+ latest.current = { left, count: done.length, onDecide, getName, decided }
88
+
89
+ const rtl = () => !!root.current && getComputedStyle(root.current).direction === 'rtl'
90
+ // Right is the end side, or the start side in a right-to-left page.
91
+ const named = (side: Side): SwipeDeckSide => ((side > 0) !== rtl() ? 'end' : 'start')
92
+ const physical = (dir: SwipeDeckSide): Side => ((dir === 'end') !== rtl() ? 1 : -1)
93
+ const widthOf = () => top.current?.offsetWidth || deck.current?.offsetWidth || 300
94
+ const share = useRef(0.35)
95
+ const line = () => widthOf() * share.current
96
+ const reduced = () => readMotionMs('fling') === 0
97
+
98
+ const paint = (x: number) => {
99
+ at.current = x
100
+ const el = top.current
101
+ if (el) {
102
+ el.style.transform = x ? `translate3d(${x.toFixed(1)}px, 0, 0) rotate(${clamp(x * TILT, -TILT_MAX, TILT_MAX).toFixed(2)}deg)` : ''
103
+ // Past a card's width away it fades as it goes, so it leaves before any edge cuts it.
104
+ const w = widthOf()
105
+ const fade = clamp(1 - (Math.abs(x) - w) / (w * 0.8), 0, 1)
106
+ el.style.opacity = fade < 1 ? fade.toFixed(3) : ''
107
+ }
108
+ const T = line()
109
+ const near = pull(T - Math.min(Math.abs(x), T), T)
110
+ deck.current?.style.setProperty('--rise', near.toFixed(3))
111
+ const side: Side | 0 = x > 0 ? 1 : x < 0 ? -1 : 0
112
+ for (const dir of ['start', 'end'] as SwipeDeckSide[]) {
113
+ const btn = buttons.current[dir === 'start' ? -1 : 1]
114
+ if (btn) btn.style.setProperty('--fill', side === physical(dir) ? near.toFixed(3) : '0')
115
+ }
116
+ setArmed(Math.abs(x) >= T - 0.5 ? side : 0)
117
+ if (knob.current) knob.current.style.transform = `translateX(${clamp(x, -widthOf() / 2, widthOf() / 2).toFixed(1)}px)`
118
+ }
119
+
120
+ const offStage = (side: Side) => side * ((root.current?.offsetWidth ?? 400) / 2 + widthOf() * 1.2)
121
+
122
+ const commit = (side: Side) => {
123
+ const { left, count, onDecide, getName, decided } = latest.current
124
+ const [item, next] = left
125
+ if (!item) return void (busy.current = false)
126
+ const dir = named(side)
127
+ setDone((d) => [...d, { item, side, dir }])
128
+ latest.current = { ...latest.current, left: left.slice(1), count: count + 1 }
129
+ const said = `${decided[dir === 'start' ? 0 : 1]}: ${getName(item)}.`
130
+ setNews(next ? `${said} Card ${count + 2} of ${count + left.length}: ${getName(next)}.` : `${said} No more cards.`)
131
+ onDecide?.(item, dir)
132
+ }
133
+
134
+ const finish = (side: Side) => {
135
+ stop.current = () => {}
136
+ paint(0)
137
+ commit(side)
138
+ }
139
+
140
+ const fly = (side: Side, velocity: number) => {
141
+ const el = top.current
142
+ if (!el || !latest.current.left.length) return
143
+ if (busy.current) {
144
+ queued.current = side
145
+ return
146
+ }
147
+ busy.current = true
148
+ stop.current()
149
+ if (reduced()) {
150
+ const anim = el.animate({ opacity: [1, 0] }, { duration: readMotionMs('base'), easing: easing('out'), fill: 'forwards' })
151
+ let ended = false
152
+ const end = () => {
153
+ if (ended) return
154
+ ended = true
155
+ anim.cancel()
156
+ finish(side)
157
+ }
158
+ anim.finished.then(end, end)
159
+ // Stopped (the deck went away mid-fade): the fade ends and nothing is decided.
160
+ stop.current = () => {
161
+ ended = true
162
+ anim.cancel()
163
+ }
164
+ return
165
+ }
166
+ const speed = side * Math.max(Math.abs(velocity), THROW_SPEED)
167
+ stop.current = springTo({ from: at.current, to: offStage(side), velocity: speed, period: readMotionMs('fling'), rest: 2, onFrame: paint, onDone: () => finish(side) })
168
+ }
169
+
170
+ const back = (velocity = 0) => {
171
+ stop.current()
172
+ stop.current = springTo({ from: at.current, to: 0, velocity, period: readMotionMs('fling'), damping: 1, rest: 0.5, onFrame: paint })
173
+ }
174
+
175
+ const undo = () => {
176
+ if (!done.length) return
177
+ if (busy.current) return
178
+ const last = done[done.length - 1]
179
+ busy.current = true
180
+ queued.current = null
181
+ undoing.current = last.side
182
+ setDone((d) => d.slice(0, -1))
183
+ const count = done.length - 1
184
+ setNews(`Brought back: ${getName(last.item)}. Card ${count + 1} of ${count + left.length + 1}.`)
185
+ onUndo?.(last.item)
186
+ }
187
+
188
+ // A key pressed mid-move plays once, when the move has ended.
189
+ const playQueue = () => {
190
+ busy.current = false
191
+ const q = queued.current
192
+ queued.current = null
193
+ if (q !== null) fly(q, 0)
194
+ }
195
+
196
+ // Undo plays the throw backwards: the card comes in from where it left.
197
+ useLayoutEffect(() => {
198
+ const side = undoing.current
199
+ const el = top.current
200
+ if (side === null) return playQueue()
201
+ undoing.current = null
202
+ if (!el) return playQueue()
203
+ if (reduced()) {
204
+ const anim = el.animate({ opacity: [0, 1] }, { duration: readMotionMs('base'), easing: easing('out') })
205
+ let ended = false
206
+ anim.finished.then(
207
+ () => !ended && playQueue(),
208
+ () => !ended && playQueue(),
209
+ )
210
+ stop.current = () => {
211
+ ended = true
212
+ anim.cancel()
213
+ }
214
+ return
215
+ }
216
+ paint(offStage(side))
217
+ stop.current = springTo({ from: offStage(side), to: 0, velocity: -side * THROW_SPEED, period: readMotionMs('fling'), damping: 1, rest: 0.5, onFrame: paint, onDone: playQueue })
218
+ }, [done.length])
219
+
220
+ useLayoutEffect(() => {
221
+ share.current = tokenNumber(root.current, '--hl-deck-throw') || share.current
222
+ }, [])
223
+ useEffect(() => () => stop.current(), [])
224
+
225
+ const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
226
+ if ((e.pointerType === 'mouse' && e.button !== 0) || busy.current) return
227
+ moved.current = false
228
+ drag.current = { x: e.clientX, y: e.clientY, base: at.current, id: e.pointerId, sideways: false, speed: speedTracker() }
229
+ }
230
+
231
+ const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
232
+ const d = drag.current
233
+ if (!d || d.id !== e.pointerId) return
234
+ const dx = e.clientX - d.x
235
+ const dy = e.clientY - d.y
236
+ if (!d.sideways) {
237
+ // Up or down first means the page scrolls, and the card lets go.
238
+ if (Math.abs(dy) > SLOP && Math.abs(dy) > Math.abs(dx)) return void (drag.current = null)
239
+ if (Math.abs(dx) < SLOP) return
240
+ d.sideways = true
241
+ moved.current = true
242
+ stop.current()
243
+ d.base = at.current - dx
244
+ try {
245
+ e.currentTarget.setPointerCapture(e.pointerId)
246
+ } catch {
247
+ // A pointer the browser no longer knows cannot be captured, and needs no capture.
248
+ }
249
+ deck.current?.setAttribute('data-dragging', '')
250
+ }
251
+ const raw = d.base + dx
252
+ d.speed.add(raw, e.timeStamp)
253
+ const T = line()
254
+ paint(magnetTo(raw, [-T, T], T * MARK_REACH))
255
+ }
256
+
257
+ const letGo = (e: PointerEvent<HTMLDivElement>, cancelled: boolean) => {
258
+ const d = drag.current
259
+ if (!d || d.id !== e.pointerId) return
260
+ drag.current = null
261
+ if (!d.sideways) return
262
+ deck.current?.removeAttribute('data-dragging')
263
+ if (cancelled) return back()
264
+ const x = at.current
265
+ const v = d.speed.speed(e.timeStamp)
266
+ if (Math.abs(x) >= line()) return fly(x > 0 ? 1 : -1, v)
267
+ if (Math.abs(v) > FLICK && Math.abs(x) >= FLICK_MIN && Math.sign(v) === Math.sign(x)) return fly(v > 0 ? 1 : -1, v)
268
+ back(v)
269
+ }
270
+
271
+ const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
272
+ if (e.target !== e.currentTarget || e.altKey || e.ctrlKey || e.metaKey) return
273
+ if (e.key === 'ArrowLeft') fly(-1, 0)
274
+ else if (e.key === 'ArrowRight') fly(1, 0)
275
+ else if (e.key === 'Backspace') undo()
276
+ else return
277
+ e.preventDefault()
278
+ }
279
+
280
+ const shown = left.slice(0, 3)
281
+ const over = !left.length
282
+ const armedDir = armed ? named(armed) : undefined
283
+ const choice = (dir: SwipeDeckSide) => (
284
+ <button
285
+ ref={(b) => void (buttons.current[dir === 'start' ? -1 : 1] = b)}
286
+ type="button"
287
+ className={styles.choice}
288
+ data-side={dir}
289
+ aria-disabled={over || undefined}
290
+ onClick={() => !over && fly(physical(dir), 0)}
291
+ >
292
+ <span className={styles.word}>{actions[dir === 'start' ? 0 : 1]}</span>
293
+ <span className={styles.fill} aria-hidden="true">
294
+ <span className={styles.word}>{actions[dir === 'start' ? 0 : 1]}</span>
295
+ </span>
296
+ </button>
297
+ )
298
+
299
+ return (
300
+ <div ref={root} className={cx(styles.root, className)} data-armed={armedDir} {...rest}>
301
+ <div className={styles.stage}>
302
+ <div ref={deck} className={styles.deck} role="group" aria-roledescription="card deck" aria-label={label} aria-keyshortcuts="ArrowLeft ArrowRight Backspace" tabIndex={0} onKeyDown={onKeyDown}>
303
+ {over && <div className={styles.empty}>{empty}</div>}
304
+ {shown
305
+ .map((item, depth) => (
306
+ <div
307
+ key={getKey(item)}
308
+ ref={depth === 0 ? top : undefined}
309
+ className={styles.card}
310
+ data-depth={depth}
311
+ inert={depth > 0}
312
+ aria-hidden={depth > 0 || undefined}
313
+ onPointerDown={depth === 0 ? onPointerDown : undefined}
314
+ onPointerMove={depth === 0 ? onPointerMove : undefined}
315
+ onPointerUp={depth === 0 ? (e) => letGo(e, false) : undefined}
316
+ onPointerCancel={depth === 0 ? (e) => letGo(e, true) : undefined}
317
+ onClickCapture={(e) => {
318
+ if (!moved.current) return
319
+ moved.current = false
320
+ e.preventDefault()
321
+ e.stopPropagation()
322
+ }}
323
+ onDragStart={(e) => e.preventDefault()}
324
+ >
325
+ {renderCard(item)}
326
+ </div>
327
+ ))
328
+ .reverse()}
329
+ </div>
330
+ </div>
331
+ <div className={styles.ruler} aria-hidden="true">
332
+ <span className={styles.mark} data-side="left" data-armed={armed === -1 || undefined} />
333
+ <span className={styles.centre} />
334
+ <span className={styles.mark} data-side="right" data-armed={armed === 1 || undefined} />
335
+ <span ref={knob} className={styles.knob} data-armed={armed !== 0 || undefined} />
336
+ </div>
337
+ <div className={styles.bar}>
338
+ {choice('start')}
339
+ <button type="button" className={styles.undo} aria-label={undoLabel} aria-disabled={!done.length || undefined} onClick={undo}>
340
+ <UndoIcon />
341
+ </button>
342
+ {choice('end')}
343
+ </div>
344
+ <span className={styles.sr} role="status" aria-live="polite" aria-atomic="true">
345
+ {news}
346
+ </span>
347
+ </div>
348
+ )
349
+ }
@@ -0,0 +1 @@
1
+ export * from './SwipeDeck'
package/src/tokens.css CHANGED
@@ -598,6 +598,29 @@
598
598
  /* RulerScrubber: the space between two ticks, and its height (the speed arc's too) */
599
599
  --hl-ruler-tick-gap: 12px;
600
600
  --hl-ruler-h: 120px;
601
+ /* SwipeDeck: the card's width and least height, and the share of its width a drag must pass to throw it */
602
+ --hl-deck-w: 332px;
603
+ --hl-deck-card-h: 404px;
604
+ --hl-deck-throw: 0.35;
605
+ /* SplitFlap: one cell's width and height, as a share of its letter size, and its two halves */
606
+ --hl-flap-w: 0.92em;
607
+ --hl-flap-h: 1.36em;
608
+ --hl-flap-top: var(--hl-color-surface-sunken);
609
+ --hl-flap-bottom: var(--hl-color-surface-hover);
610
+ }
611
+
612
+ /* In the dark the top half is the lighter one, as on a lit board. Both are set again here, so a dark
613
+ part inside a light page takes its own greys. */
614
+ @media (prefers-color-scheme: dark) {
615
+ :root:not([data-theme="light"]) {
616
+ --hl-flap-top: var(--hl-color-surface-active);
617
+ --hl-flap-bottom: var(--hl-color-surface-hover);
618
+ }
619
+ }
620
+
621
+ [data-theme="dark"] {
622
+ --hl-flap-top: var(--hl-color-surface-active);
623
+ --hl-flap-bottom: var(--hl-color-surface-hover);
601
624
  }
602
625
 
603
626
  :root,