hlmnhkl-ui 0.32.1 → 0.33.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 +1 -1
- package/package.json +1 -1
- package/src/components/HoldToConfirm/HoldToConfirm.module.css +166 -0
- package/src/components/HoldToConfirm/HoldToConfirm.tsx +332 -0
- package/src/components/HoldToConfirm/index.ts +1 -0
- package/src/components/MorphButton/MorphButton.module.css +151 -0
- package/src/components/MorphButton/MorphButton.tsx +174 -0
- package/src/components/MorphButton/index.ts +1 -0
- package/src/components/Sheet/Sheet.module.css +82 -0
- package/src/components/Sheet/Sheet.tsx +376 -4
- package/src/internal/focus.ts +1 -1
- package/src/internal/layers.ts +16 -7
- package/src/tokens.css +9 -1
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ 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
|
+
- **65 free components**, from Button, Dialog, Select and Table to Toast and Retime, one speed setting for every animation. See [COMPONENTS.md](COMPONENTS.md).
|
|
69
69
|
- **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
70
|
- **Light and dark.** Dark follows the system, or set `data-theme="dark"` on `<html>`.
|
|
71
71
|
- **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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hlmnhkl-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.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,166 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
align-items: flex-start;
|
|
5
|
+
gap: var(--hl-space-3);
|
|
6
|
+
min-width: 0;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.shell {
|
|
10
|
+
position: relative;
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
max-width: 100%;
|
|
14
|
+
height: var(--hl-control-h-lg);
|
|
15
|
+
overflow: hidden;
|
|
16
|
+
border-radius: calc(var(--hl-control-h-lg) / 2);
|
|
17
|
+
background: var(--hl-color-surface);
|
|
18
|
+
box-shadow: inset 0 0 0 var(--hl-line-thick) var(--hl-color-border-strong);
|
|
19
|
+
transition:
|
|
20
|
+
width var(--hl-motion-base) var(--hl-ease-liquid),
|
|
21
|
+
height var(--hl-motion-base) var(--hl-ease-liquid),
|
|
22
|
+
border-radius var(--hl-motion-base) var(--hl-ease-liquid),
|
|
23
|
+
box-shadow var(--hl-motion-fast) var(--hl-ease-out);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.shell:has(.main:focus-visible) {
|
|
27
|
+
box-shadow: inset 0 0 0 var(--hl-line-thick) var(--hl-color-border-strong), var(--hl-focus-ring);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.shell[data-phase="done"] { box-shadow: inset 0 0 0 var(--hl-hold-stroke) var(--hl-color-fg); }
|
|
31
|
+
.shell[data-disabled] { opacity: var(--hl-disabled-opacity); }
|
|
32
|
+
|
|
33
|
+
.main {
|
|
34
|
+
position: relative;
|
|
35
|
+
flex: none;
|
|
36
|
+
height: 100%;
|
|
37
|
+
padding: 0 var(--hl-space-7);
|
|
38
|
+
border: 0;
|
|
39
|
+
border-radius: var(--hl-radius-full);
|
|
40
|
+
background: transparent;
|
|
41
|
+
color: var(--hl-color-fg);
|
|
42
|
+
font: inherit;
|
|
43
|
+
font-weight: var(--hl-weight-medium);
|
|
44
|
+
white-space: nowrap;
|
|
45
|
+
cursor: pointer;
|
|
46
|
+
user-select: none;
|
|
47
|
+
-webkit-user-select: none;
|
|
48
|
+
-webkit-touch-callout: none;
|
|
49
|
+
-webkit-tap-highlight-color: transparent;
|
|
50
|
+
touch-action: manipulation;
|
|
51
|
+
outline: none;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.shell[data-phase="step"] .main,
|
|
55
|
+
.shell[data-phase="done"] .main { cursor: default; }
|
|
56
|
+
.shell[data-disabled] .main { cursor: not-allowed; }
|
|
57
|
+
|
|
58
|
+
.shell[data-stack] {
|
|
59
|
+
flex-direction: column;
|
|
60
|
+
align-items: flex-start;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.shell[data-stack][data-phase="step"] { border-radius: var(--hl-radius-panel); }
|
|
64
|
+
.shell[data-stack] .main { height: var(--hl-control-h-lg); }
|
|
65
|
+
.shell[data-stack] .step { padding: 0 var(--hl-space-2) var(--hl-space-2); }
|
|
66
|
+
|
|
67
|
+
.faces { display: grid; }
|
|
68
|
+
|
|
69
|
+
.face {
|
|
70
|
+
grid-area: 1 / 1;
|
|
71
|
+
display: inline-flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
justify-content: center;
|
|
74
|
+
gap: var(--hl-space-3);
|
|
75
|
+
transition: opacity var(--hl-motion-fast) var(--hl-ease-out);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.face svg {
|
|
79
|
+
width: var(--hl-icon-md);
|
|
80
|
+
height: var(--hl-icon-md);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.face[data-face="done"],
|
|
84
|
+
.shell[data-phase="done"] .face[data-face="idle"] { opacity: 0; }
|
|
85
|
+
.shell[data-phase="done"] .face[data-face="done"] { opacity: 1; }
|
|
86
|
+
|
|
87
|
+
.fuse {
|
|
88
|
+
position: absolute;
|
|
89
|
+
inset: 0;
|
|
90
|
+
overflow: visible;
|
|
91
|
+
pointer-events: none;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.run {
|
|
95
|
+
fill: none;
|
|
96
|
+
stroke: var(--hl-color-fg);
|
|
97
|
+
stroke-width: var(--hl-hold-stroke);
|
|
98
|
+
stroke-dasharray: 0 1;
|
|
99
|
+
opacity: 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.step {
|
|
103
|
+
display: flex;
|
|
104
|
+
flex: none;
|
|
105
|
+
align-items: center;
|
|
106
|
+
gap: var(--hl-space-2);
|
|
107
|
+
padding-right: var(--hl-space-1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.step[inert] {
|
|
111
|
+
visibility: hidden;
|
|
112
|
+
transition: visibility 0s linear var(--hl-motion-base);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.cancel,
|
|
116
|
+
.confirm {
|
|
117
|
+
height: var(--hl-hit-min);
|
|
118
|
+
padding: 0 var(--hl-space-5);
|
|
119
|
+
border: 0;
|
|
120
|
+
border-radius: var(--hl-radius-full);
|
|
121
|
+
font: inherit;
|
|
122
|
+
font-weight: var(--hl-weight-medium);
|
|
123
|
+
white-space: nowrap;
|
|
124
|
+
cursor: pointer;
|
|
125
|
+
outline: none;
|
|
126
|
+
transition: background-color var(--hl-motion-fast) var(--hl-ease-out);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.cancel {
|
|
130
|
+
background: transparent;
|
|
131
|
+
color: var(--hl-color-fg);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.confirm {
|
|
135
|
+
background: var(--hl-color-danger-solid);
|
|
136
|
+
color: var(--hl-color-on-danger);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@media (hover: hover) {
|
|
140
|
+
.cancel:hover { background: var(--hl-color-surface-hover); }
|
|
141
|
+
.confirm:hover { background: var(--hl-color-danger-solid-hover); }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.cancel:focus-visible,
|
|
145
|
+
.confirm:focus-visible {
|
|
146
|
+
box-shadow:
|
|
147
|
+
inset 0 0 0 var(--hl-line-focus) var(--hl-color-fg),
|
|
148
|
+
inset 0 0 0 calc(var(--hl-line-focus) * 2) var(--hl-color-surface);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.hint {
|
|
152
|
+
min-height: calc(var(--hl-text-sm) * var(--hl-leading-snug) * 2);
|
|
153
|
+
margin: 0;
|
|
154
|
+
color: var(--hl-color-fg-muted);
|
|
155
|
+
font-size: var(--hl-text-sm);
|
|
156
|
+
line-height: var(--hl-leading-snug);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.srOnly {
|
|
160
|
+
position: absolute;
|
|
161
|
+
width: 1px;
|
|
162
|
+
height: 1px;
|
|
163
|
+
overflow: hidden;
|
|
164
|
+
clip-path: inset(50%);
|
|
165
|
+
white-space: nowrap;
|
|
166
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type PointerEvent, type ReactNode } from 'react'
|
|
4
|
+
import { cx } from '../../internal/cx'
|
|
5
|
+
import { CheckIcon } from '../../internal/icons'
|
|
6
|
+
import { prefersReducedMotion, readMotionMs } from '../../internal/motion'
|
|
7
|
+
import styles from './HoldToConfirm.module.css'
|
|
8
|
+
|
|
9
|
+
export type HoldToConfirmProps = {
|
|
10
|
+
/** The action, as the button's label, such as "Delete workspace". */
|
|
11
|
+
children: ReactNode
|
|
12
|
+
/** Called once the hold runs to the end, or Confirm is pressed in the confirm step. */
|
|
13
|
+
onConfirm: () => void
|
|
14
|
+
/** Called when a hold is let go early, or the confirm step is cancelled. */
|
|
15
|
+
onCancel?: () => void
|
|
16
|
+
/** The confirm step's button, such as "Delete". Keep it short. */
|
|
17
|
+
confirmLabel?: string
|
|
18
|
+
/** The confirm step's way back. */
|
|
19
|
+
cancelLabel?: string
|
|
20
|
+
/** Shown in place of the label once it is done, such as "Workspace deleted". */
|
|
21
|
+
doneLabel?: ReactNode
|
|
22
|
+
/** The line under the button. Defaults to how to use it. */
|
|
23
|
+
hint?: ReactNode
|
|
24
|
+
/** The line under the button while the confirm step is open. */
|
|
25
|
+
stepHint?: ReactNode
|
|
26
|
+
/** Blocks the button. Say why in the hint. */
|
|
27
|
+
disabled?: boolean
|
|
28
|
+
/** Extra class for the outer box. */
|
|
29
|
+
className?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Phase = 'idle' | 'step' | 'done'
|
|
33
|
+
|
|
34
|
+
// A press shorter than this is a tap: it opens the confirm step, like one key press does.
|
|
35
|
+
const TAP = 250
|
|
36
|
+
// Held this long, the hint changes to "Keep holding".
|
|
37
|
+
const HINT_AFTER = 300
|
|
38
|
+
// A finger that slides this far off the button lets go.
|
|
39
|
+
const LEAVE = 16
|
|
40
|
+
// How long the done state shows before the button can be used again.
|
|
41
|
+
const DONE_FOR = 2400
|
|
42
|
+
|
|
43
|
+
// The pill's outline as one closed path that starts on the bottom edge at x and runs left first. The
|
|
44
|
+
// line is drawn at both ends of the path at once, so it burns out both ways from x and meets opposite.
|
|
45
|
+
function outline(w: number, h: number, inset: number, x: number) {
|
|
46
|
+
const r = h / 2 - inset
|
|
47
|
+
const t = inset
|
|
48
|
+
const b = h - inset
|
|
49
|
+
const l = h / 2
|
|
50
|
+
const rr = w - h / 2
|
|
51
|
+
const px = Math.max(l, Math.min(rr, x))
|
|
52
|
+
return `M${px},${b} H${l} A${r},${r} 0 0 1 ${l},${t} H${rr} A${r},${r} 0 0 1 ${rr},${b} Z`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ms = (el: Element | null, name: string, fallback: number) => {
|
|
56
|
+
const raw = el ? getComputedStyle(el).getPropertyValue(name).trim() : ''
|
|
57
|
+
const v = parseFloat(raw)
|
|
58
|
+
if (!Number.isFinite(v)) return fallback
|
|
59
|
+
return raw.endsWith('ms') ? v : raw.endsWith('s') ? v * 1000 : v
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function HoldToConfirm({
|
|
63
|
+
children,
|
|
64
|
+
onConfirm,
|
|
65
|
+
onCancel,
|
|
66
|
+
confirmLabel = 'Confirm',
|
|
67
|
+
cancelLabel = 'Cancel',
|
|
68
|
+
doneLabel = 'Done',
|
|
69
|
+
hint = 'Hold to confirm, or tap for a confirm step.',
|
|
70
|
+
stepHint,
|
|
71
|
+
disabled = false,
|
|
72
|
+
className,
|
|
73
|
+
}: HoldToConfirmProps) {
|
|
74
|
+
const id = useId()
|
|
75
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
76
|
+
const shellRef = useRef<HTMLDivElement>(null)
|
|
77
|
+
const mainRef = useRef<HTMLButtonElement>(null)
|
|
78
|
+
const stepRef = useRef<HTMLDivElement>(null)
|
|
79
|
+
const cancelRef = useRef<HTMLButtonElement>(null)
|
|
80
|
+
const runRef = useRef<SVGPathElement>(null)
|
|
81
|
+
const [phase, setPhase] = useState<Phase>('idle')
|
|
82
|
+
const [size, setSize] = useState({ w: 0, h: 0, step: 0, stepH: 0, room: 0, inset: 1 })
|
|
83
|
+
const [path, setPath] = useState('')
|
|
84
|
+
const [keep, setKeep] = useState(false)
|
|
85
|
+
const [said, setSaid] = useState('')
|
|
86
|
+
|
|
87
|
+
const p = useRef(0)
|
|
88
|
+
const raf = useRef(0)
|
|
89
|
+
const tapTimer = useRef(0)
|
|
90
|
+
const press = useRef<{ t0: number; pointer: number | 'key'; key?: string } | null>(null)
|
|
91
|
+
const phaseRef = useRef(phase)
|
|
92
|
+
phaseRef.current = phase
|
|
93
|
+
const calls = useRef({ onConfirm, onCancel })
|
|
94
|
+
calls.current = { onConfirm, onCancel }
|
|
95
|
+
|
|
96
|
+
useLayoutEffect(() => {
|
|
97
|
+
const root = rootRef.current
|
|
98
|
+
const main = mainRef.current
|
|
99
|
+
const step = stepRef.current
|
|
100
|
+
if (!root || !main || !step) return
|
|
101
|
+
const measure = () => {
|
|
102
|
+
const w = main.offsetWidth
|
|
103
|
+
const h = main.offsetHeight
|
|
104
|
+
const inset = parseFloat(getComputedStyle(root).getPropertyValue('--hl-hold-stroke')) / 2 || 1
|
|
105
|
+
setSize({ w, h, step: step.offsetWidth, stepH: step.offsetHeight, room: root.clientWidth, inset })
|
|
106
|
+
setPath(outline(w, h, inset, w / 2))
|
|
107
|
+
}
|
|
108
|
+
measure()
|
|
109
|
+
const ro = new ResizeObserver(measure)
|
|
110
|
+
ro.observe(main)
|
|
111
|
+
ro.observe(step)
|
|
112
|
+
ro.observe(root)
|
|
113
|
+
return () => ro.disconnect()
|
|
114
|
+
}, [])
|
|
115
|
+
|
|
116
|
+
const paint = (v: number) => {
|
|
117
|
+
p.current = v
|
|
118
|
+
const run = runRef.current
|
|
119
|
+
if (!run) return
|
|
120
|
+
run.style.strokeDasharray = `${v / 2} ${1 - v} ${v / 2} 0`
|
|
121
|
+
run.style.opacity = v > 0 ? '1' : '0'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const finish = () => {
|
|
125
|
+
cancelAnimationFrame(raf.current)
|
|
126
|
+
press.current = null
|
|
127
|
+
paint(1)
|
|
128
|
+
setKeep(false)
|
|
129
|
+
// From the step, focus goes back to the button: the step closes under it. Safari never focused Confirm.
|
|
130
|
+
if (phaseRef.current === 'step') mainRef.current?.focus({ preventScroll: true })
|
|
131
|
+
setPhase('done')
|
|
132
|
+
setSaid(typeof doneLabel === 'string' ? doneLabel : 'Done')
|
|
133
|
+
if (!prefersReducedMotion()) navigator.vibrate?.(12)
|
|
134
|
+
calls.current.onConfirm()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Forward at an even speed: it is a clock.
|
|
138
|
+
const run = () => {
|
|
139
|
+
cancelAnimationFrame(raf.current)
|
|
140
|
+
const total = ms(shellRef.current, '--hl-hold-time', 1500)
|
|
141
|
+
let last = performance.now()
|
|
142
|
+
const step = (now: number) => {
|
|
143
|
+
const next = Math.min(1, p.current + (now - last) / total)
|
|
144
|
+
last = now
|
|
145
|
+
paint(next)
|
|
146
|
+
if (press.current && now - press.current.t0 > HINT_AFTER) setKeep(true)
|
|
147
|
+
if (next >= 1) return finish()
|
|
148
|
+
raf.current = requestAnimationFrame(step)
|
|
149
|
+
}
|
|
150
|
+
raf.current = requestAnimationFrame(step)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Back fast and easing out: a rubber band, not a clock.
|
|
154
|
+
const rewind = () => {
|
|
155
|
+
cancelAnimationFrame(raf.current)
|
|
156
|
+
const from = p.current
|
|
157
|
+
const length = readMotionMs('fast')
|
|
158
|
+
if (!length || !from) return paint(0)
|
|
159
|
+
const t0 = performance.now()
|
|
160
|
+
const step = (now: number) => {
|
|
161
|
+
const t = Math.min(1, (now - t0) / length)
|
|
162
|
+
paint(from * Math.pow(1 - t, 3))
|
|
163
|
+
if (t < 1) raf.current = requestAnimationFrame(step)
|
|
164
|
+
}
|
|
165
|
+
raf.current = requestAnimationFrame(step)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const openStep = () => {
|
|
169
|
+
window.clearTimeout(tapTimer.current)
|
|
170
|
+
tapTimer.current = 0
|
|
171
|
+
cancelAnimationFrame(raf.current)
|
|
172
|
+
paint(0)
|
|
173
|
+
setPhase('step')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const closeStep = () => {
|
|
177
|
+
setPhase('idle')
|
|
178
|
+
mainRef.current?.focus({ preventScroll: true })
|
|
179
|
+
calls.current.onCancel?.()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (phase === 'step') cancelRef.current?.focus({ preventScroll: true })
|
|
184
|
+
if (phase !== 'done') return
|
|
185
|
+
const t = window.setTimeout(() => {
|
|
186
|
+
paint(0)
|
|
187
|
+
setPhase('idle')
|
|
188
|
+
setSaid('')
|
|
189
|
+
}, DONE_FOR)
|
|
190
|
+
return () => window.clearTimeout(t)
|
|
191
|
+
}, [phase])
|
|
192
|
+
|
|
193
|
+
useEffect(
|
|
194
|
+
() => () => {
|
|
195
|
+
cancelAnimationFrame(raf.current)
|
|
196
|
+
window.clearTimeout(tapTimer.current)
|
|
197
|
+
},
|
|
198
|
+
[],
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
const start = (pointer: number | 'key', x: number, key?: string) => {
|
|
202
|
+
press.current = { t0: performance.now(), pointer, key }
|
|
203
|
+
setPath(outline(size.w, size.h, size.inset, x))
|
|
204
|
+
shellRef.current?.setAttribute('data-held', '')
|
|
205
|
+
run()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const letGo = (tapAllowed: boolean) => {
|
|
209
|
+
const pr = press.current
|
|
210
|
+
press.current = null
|
|
211
|
+
shellRef.current?.removeAttribute('data-held')
|
|
212
|
+
setKeep(false)
|
|
213
|
+
if (!pr || phaseRef.current !== 'idle') return
|
|
214
|
+
const tap = tapAllowed && performance.now() - pr.t0 < TAP
|
|
215
|
+
rewind()
|
|
216
|
+
if (!tap) return calls.current.onCancel?.()
|
|
217
|
+
if (pr.pointer === 'key') return openStep()
|
|
218
|
+
// A tapped step opens on the click that follows, after the browser has focused the button, so focus
|
|
219
|
+
// can then move to Cancel. If no click comes, it opens anyway.
|
|
220
|
+
tapTimer.current = window.setTimeout(openStep, TAP)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const onPointerDown = (e: PointerEvent<HTMLButtonElement>) => {
|
|
224
|
+
if (disabled || phaseRef.current !== 'idle' || press.current || (e.pointerType === 'mouse' && e.button !== 0)) return
|
|
225
|
+
e.currentTarget.setPointerCapture?.(e.pointerId)
|
|
226
|
+
start(e.pointerId, e.clientX - e.currentTarget.getBoundingClientRect().left)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const onPointerMove = (e: PointerEvent<HTMLButtonElement>) => {
|
|
230
|
+
const pr = press.current
|
|
231
|
+
if (!pr || pr.pointer !== e.pointerId) return
|
|
232
|
+
const r = e.currentTarget.getBoundingClientRect()
|
|
233
|
+
if (e.clientX < r.left - LEAVE || e.clientX > r.right + LEAVE || e.clientY < r.top - LEAVE || e.clientY > r.bottom + LEAVE) letGo(false)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const onPointerEnd = (e: PointerEvent<HTMLButtonElement>) => {
|
|
237
|
+
if (press.current?.pointer === e.pointerId) letGo(e.type === 'pointerup')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Held Enter or Space works like a held finger, from the middle of the button. One press opens the
|
|
241
|
+
// confirm step, since holding a key is hard for many people.
|
|
242
|
+
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
|
243
|
+
if (e.key !== 'Enter' && e.key !== ' ') return
|
|
244
|
+
e.preventDefault()
|
|
245
|
+
if (e.repeat || disabled || phaseRef.current !== 'idle' || press.current) return
|
|
246
|
+
start('key', size.w / 2, e.key)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const onKeyUp = (e: KeyboardEvent<HTMLButtonElement>) => {
|
|
250
|
+
if (e.key !== 'Enter' && e.key !== ' ') return
|
|
251
|
+
e.preventDefault()
|
|
252
|
+
if (press.current?.key === e.key) letGo(true)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// A screen reader or voice control sends a click with no press: it opens the confirm step.
|
|
256
|
+
const onClick = (e: MouseEvent<HTMLButtonElement>) => {
|
|
257
|
+
if (disabled || phaseRef.current !== 'idle' || press.current) return
|
|
258
|
+
if (e.detail === 0 || tapTimer.current) openStep()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const onStepKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
262
|
+
if (e.key !== 'Escape') return
|
|
263
|
+
e.stopPropagation()
|
|
264
|
+
closeStep()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// No room beside the label, as on a phone: the step opens under it and the pill grows into a card.
|
|
268
|
+
const stack = size.room > 0 && size.w + size.step > size.room
|
|
269
|
+
const open = phase === 'step'
|
|
270
|
+
const shellW = open ? (stack ? Math.max(size.w, size.step) : size.w + size.step) : size.w
|
|
271
|
+
const shellH = open && stack ? size.h + size.stepH : size.h
|
|
272
|
+
const line = open ? (stepHint ?? `Choose ${cancelLabel} or ${confirmLabel}.`) : phase === 'done' ? '' : keep ? 'Keep holding' : hint
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<div ref={rootRef} className={cx(styles.root, className)}>
|
|
276
|
+
<div
|
|
277
|
+
ref={shellRef}
|
|
278
|
+
className={styles.shell}
|
|
279
|
+
data-phase={phase}
|
|
280
|
+
data-stack={stack || undefined}
|
|
281
|
+
data-disabled={disabled || undefined}
|
|
282
|
+
style={{ width: shellW || undefined, height: shellH || undefined }}
|
|
283
|
+
>
|
|
284
|
+
<button
|
|
285
|
+
ref={mainRef}
|
|
286
|
+
type="button"
|
|
287
|
+
className={styles.main}
|
|
288
|
+
aria-describedby={`${id}-hint`}
|
|
289
|
+
aria-expanded={open}
|
|
290
|
+
aria-controls={`${id}-step`}
|
|
291
|
+
aria-disabled={disabled || undefined}
|
|
292
|
+
onPointerDown={onPointerDown}
|
|
293
|
+
onPointerMove={onPointerMove}
|
|
294
|
+
onPointerUp={onPointerEnd}
|
|
295
|
+
onPointerCancel={onPointerEnd}
|
|
296
|
+
onContextMenu={(e) => e.preventDefault()}
|
|
297
|
+
onKeyDown={onKeyDown}
|
|
298
|
+
onKeyUp={onKeyUp}
|
|
299
|
+
onBlur={() => press.current?.pointer === 'key' && letGo(false)}
|
|
300
|
+
onClick={onClick}
|
|
301
|
+
>
|
|
302
|
+
<span className={styles.faces}>
|
|
303
|
+
<span className={styles.face} data-face="idle" aria-hidden={phase === 'done' || undefined}>
|
|
304
|
+
{children}
|
|
305
|
+
</span>
|
|
306
|
+
<span className={styles.face} data-face="done" aria-hidden={phase !== 'done' || undefined}>
|
|
307
|
+
<CheckIcon />
|
|
308
|
+
{doneLabel}
|
|
309
|
+
</span>
|
|
310
|
+
</span>
|
|
311
|
+
<svg className={styles.fuse} width={size.w} height={size.h} aria-hidden="true">
|
|
312
|
+
<path ref={runRef} d={path} className={styles.run} pathLength={1} />
|
|
313
|
+
</svg>
|
|
314
|
+
</button>
|
|
315
|
+
<div ref={stepRef} id={`${id}-step`} className={styles.step} role="group" aria-labelledby={`${id}-hint`} inert={!open} onKeyDown={onStepKey}>
|
|
316
|
+
<button ref={cancelRef} type="button" className={styles.cancel} onClick={closeStep}>
|
|
317
|
+
{cancelLabel}
|
|
318
|
+
</button>
|
|
319
|
+
<button type="button" className={styles.confirm} onClick={finish}>
|
|
320
|
+
{confirmLabel}
|
|
321
|
+
</button>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
<p id={`${id}-hint`} className={styles.hint}>
|
|
325
|
+
{line}
|
|
326
|
+
</p>
|
|
327
|
+
<p className={styles.srOnly} aria-live="polite">
|
|
328
|
+
{said}
|
|
329
|
+
</p>
|
|
330
|
+
</div>
|
|
331
|
+
)
|
|
332
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './HoldToConfirm'
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/* The anchor keeps the button's place in the page. The shell is the panel's final box, laid out from
|
|
2
|
+
the start, so nothing inside it reflows while it grows. */
|
|
3
|
+
.anchor {
|
|
4
|
+
position: relative;
|
|
5
|
+
display: inline-block;
|
|
6
|
+
vertical-align: top;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.shell {
|
|
10
|
+
position: absolute;
|
|
11
|
+
z-index: var(--hl-z-popover);
|
|
12
|
+
width: min(var(--hl-panel-w-sm), calc(100vw - var(--hl-space-5) * 2));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.shell[data-width="md"] { width: min(var(--hl-panel-w-md), calc(100vw - var(--hl-space-5) * 2)); }
|
|
16
|
+
|
|
17
|
+
/* Shut, only the button takes the pointer: the rest of the box is empty space over the page. */
|
|
18
|
+
.shell:not([data-open]) { pointer-events: none; }
|
|
19
|
+
.shell:not([data-open]) .trigger { pointer-events: auto; }
|
|
20
|
+
|
|
21
|
+
.shell[data-corner="tl"] { top: 0; left: 0; }
|
|
22
|
+
.shell[data-corner="tr"] { top: 0; right: 0; }
|
|
23
|
+
.shell[data-corner="bl"] { bottom: 0; left: 0; }
|
|
24
|
+
.shell[data-corner="br"] { bottom: 0; right: 0; }
|
|
25
|
+
|
|
26
|
+
/* Opening, the edges travel on the liquid curve and settle on the panel's box. Closing is quicker. */
|
|
27
|
+
.surface {
|
|
28
|
+
position: absolute;
|
|
29
|
+
inset: var(--t) var(--r) var(--b) var(--l);
|
|
30
|
+
border: var(--hl-line) solid var(--hl-color-border);
|
|
31
|
+
border-radius: var(--pill);
|
|
32
|
+
background: var(--hl-color-surface);
|
|
33
|
+
box-shadow: var(--hl-shadow-1);
|
|
34
|
+
transition:
|
|
35
|
+
inset var(--hl-motion-fast) var(--hl-ease-out),
|
|
36
|
+
border-radius var(--hl-motion-fast) var(--hl-ease-out),
|
|
37
|
+
box-shadow var(--hl-motion-fast) var(--hl-ease-out);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.content {
|
|
41
|
+
position: relative;
|
|
42
|
+
clip-path: inset(var(--t) var(--r) var(--b) var(--l) round var(--pill));
|
|
43
|
+
transition: clip-path var(--hl-motion-fast) var(--hl-ease-out);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.shell[data-open] .surface {
|
|
47
|
+
border-radius: var(--hl-radius-panel);
|
|
48
|
+
box-shadow: var(--hl-shadow-3);
|
|
49
|
+
transition:
|
|
50
|
+
inset var(--hl-motion-base) var(--hl-ease-liquid),
|
|
51
|
+
border-radius var(--hl-motion-base) var(--hl-ease-liquid),
|
|
52
|
+
box-shadow var(--hl-motion-base) var(--hl-ease-out);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.shell[data-open] .content {
|
|
56
|
+
clip-path: inset(var(--t) var(--r) var(--b) var(--l) round var(--hl-radius-panel));
|
|
57
|
+
transition: clip-path var(--hl-motion-base) var(--hl-ease-liquid);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.shell:not([data-open]):has(.trigger:focus-visible) .surface { box-shadow: var(--hl-focus-ring); }
|
|
61
|
+
|
|
62
|
+
@media (hover: hover) {
|
|
63
|
+
.shell:not([data-open]):has(.trigger:hover) .surface { background: var(--hl-color-surface-hover); }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.trigger {
|
|
67
|
+
position: absolute;
|
|
68
|
+
z-index: 1;
|
|
69
|
+
display: inline-flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
gap: var(--hl-space-3);
|
|
72
|
+
height: var(--hl-control-h-lg);
|
|
73
|
+
padding: 0 var(--hl-space-6);
|
|
74
|
+
border: 0;
|
|
75
|
+
border-radius: var(--hl-radius-full);
|
|
76
|
+
background: transparent;
|
|
77
|
+
color: var(--hl-color-fg);
|
|
78
|
+
font: inherit;
|
|
79
|
+
font-weight: var(--hl-weight-medium);
|
|
80
|
+
white-space: nowrap;
|
|
81
|
+
cursor: pointer;
|
|
82
|
+
outline: none;
|
|
83
|
+
-webkit-tap-highlight-color: transparent;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.icon {
|
|
87
|
+
display: inline-grid;
|
|
88
|
+
font-size: var(--hl-icon-md);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.icon svg {
|
|
92
|
+
width: var(--hl-icon-md);
|
|
93
|
+
height: var(--hl-icon-md);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.shell[data-corner="tl"] .trigger { top: 0; left: 0; }
|
|
97
|
+
.shell[data-corner="tr"] .trigger { top: 0; right: 0; }
|
|
98
|
+
.shell[data-corner="bl"] .trigger { bottom: 0; left: 0; }
|
|
99
|
+
.shell[data-corner="br"] .trigger { bottom: 0; right: 0; }
|
|
100
|
+
|
|
101
|
+
.shell[data-open] .trigger:focus-visible { box-shadow: inset 0 0 0 var(--hl-line-focus) var(--hl-color-fg); }
|
|
102
|
+
|
|
103
|
+
/* The panel's content clears the button's row, which is now its title, above or below it. */
|
|
104
|
+
.panel {
|
|
105
|
+
position: relative;
|
|
106
|
+
display: flex;
|
|
107
|
+
flex-direction: column;
|
|
108
|
+
gap: var(--hl-space-3);
|
|
109
|
+
padding: calc(var(--hl-control-h-lg) + var(--hl-space-3)) var(--hl-space-6) var(--hl-space-6);
|
|
110
|
+
outline: none;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* Shut, the panel leaves the accessibility tree and the tab order, once the fold has finished. */
|
|
114
|
+
.shell:not([data-open]) .panel {
|
|
115
|
+
visibility: hidden;
|
|
116
|
+
transition: visibility 0s linear var(--hl-motion-fast);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.shell[data-corner^="b"] .panel { padding: var(--hl-space-6) var(--hl-space-6) calc(var(--hl-control-h-lg) + var(--hl-space-3)); }
|
|
120
|
+
|
|
121
|
+
.close {
|
|
122
|
+
position: absolute;
|
|
123
|
+
top: var(--hl-space-1);
|
|
124
|
+
right: var(--hl-space-1);
|
|
125
|
+
display: grid;
|
|
126
|
+
place-items: center;
|
|
127
|
+
width: var(--hl-hit-min);
|
|
128
|
+
height: var(--hl-hit-min);
|
|
129
|
+
padding: 0;
|
|
130
|
+
border: 0;
|
|
131
|
+
border-radius: var(--hl-radius-full);
|
|
132
|
+
background: transparent;
|
|
133
|
+
color: var(--hl-color-fg-muted);
|
|
134
|
+
cursor: pointer;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.close svg {
|
|
138
|
+
width: var(--hl-icon-md);
|
|
139
|
+
height: var(--hl-icon-md);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.shell[data-corner="tr"] .close,
|
|
143
|
+
.shell[data-corner="br"] .close { right: auto; left: var(--hl-space-1); }
|
|
144
|
+
|
|
145
|
+
.shell[data-corner^="b"] .close { top: auto; bottom: var(--hl-space-1); }
|
|
146
|
+
|
|
147
|
+
@media (hover: hover) {
|
|
148
|
+
.close:hover { background: var(--hl-color-surface-hover); color: var(--hl-color-fg); }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.close:focus-visible { outline: none; box-shadow: inset 0 0 0 var(--hl-line-focus) var(--hl-color-fg); }
|