@passui/react 0.1.19 → 0.1.20
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 +38 -0
- package/THIRD_PARTY_NOTICES.md +25 -0
- package/dist/types/src/components/accordion/accordion.d.ts +19 -0
- package/dist/types/src/components/aspect-ratio/aspect-ratio.d.ts +7 -0
- package/dist/types/src/components/breadcrumb/breadcrumb.d.ts +16 -0
- package/dist/types/src/components/button/button.d.ts +2 -0
- package/dist/types/src/components/code-block/code-block.d.ts +10 -0
- package/dist/types/src/components/combo-box/combo-box.d.ts +9 -0
- package/dist/types/src/components/command/command.d.ts +18 -0
- package/dist/types/src/components/content-unavailable-view/content-unavailable-view.d.ts +9 -0
- package/dist/types/src/components/context-menu/context-menu-store.d.ts +2 -2
- package/dist/types/src/components/context-menu/menu-reveal.d.ts +3 -8
- package/dist/types/src/components/dialog/dialog-store.d.ts +3 -1
- package/dist/types/src/components/disclosure-group/disclosure-group.d.ts +3 -1
- package/dist/types/src/components/keyboard-shortcut/keyboard-shortcut.d.ts +5 -0
- package/dist/types/src/components/menu/menu.d.ts +1 -1
- package/dist/types/src/components/one-time-code/one-time-code.d.ts +18 -0
- package/dist/types/src/components/picker/picker.d.ts +7 -1
- package/dist/types/src/components/popover/anchored-surface.d.ts +13 -0
- package/dist/types/src/components/popover/popover.d.ts +5 -1
- package/dist/types/src/components/progress-view/progress-view.d.ts +1 -1
- package/dist/types/src/components/progress-view/use-liquid-progress.d.ts +3 -0
- package/dist/types/src/components/radio-group/radio-group.d.ts +17 -0
- package/dist/types/src/components/search-field/expanding-search-field.d.ts +5 -0
- package/dist/types/src/components/search-field/search-field.d.ts +5 -1
- package/dist/types/src/components/sidebar/sidebar.d.ts +3 -2
- package/dist/types/src/components/skeleton/skeleton.d.ts +6 -0
- package/dist/types/src/components/slosh-slider/liquid-step.d.ts +6 -0
- package/dist/types/src/components/slosh-slider/slosh-slider.d.ts +18 -0
- package/dist/types/src/components/stepper/rolling-digit.d.ts +11 -0
- package/dist/types/src/components/text-field/text-field.d.ts +3 -1
- package/dist/types/src/components/wake-slider/wake-slider.d.ts +12 -0
- package/dist/types/src/hooks/use-anchored-disclosure.d.ts +9 -0
- package/dist/types/src/hooks/use-anchored-placement.d.ts +1 -1
- package/dist/types/src/index.d.ts +13 -0
- package/dist/types/src/lib/anchored-placement.d.ts +2 -2
- package/package.json +60 -4
- package/src/components/accordion/accordion.tsx +48 -0
- package/src/components/aspect-ratio/aspect-ratio.tsx +22 -0
- package/src/components/breadcrumb/breadcrumb.tsx +53 -0
- package/src/components/button/button.tsx +5 -1
- package/src/components/code-block/code-block.tsx +41 -0
- package/src/components/combo-box/combo-box.tsx +13 -0
- package/src/components/command/command.tsx +119 -0
- package/src/components/compact-date-picker/compact-date-picker.tsx +40 -84
- package/src/components/compact-time-picker/compact-time-picker.tsx +44 -81
- package/src/components/content-unavailable-view/content-unavailable-view.tsx +37 -0
- package/src/components/context-menu/context-menu-host.tsx +28 -24
- package/src/components/context-menu/context-menu-store.ts +3 -4
- package/src/components/context-menu/menu-reveal.ts +5 -2
- package/src/components/dialog/dialog-host.tsx +13 -7
- package/src/components/dialog/dialog-store.ts +15 -4
- package/src/components/disclosure-group/disclosure-group.tsx +19 -10
- package/src/components/keyboard-shortcut/keyboard-shortcut.tsx +17 -0
- package/src/components/menu/menu.tsx +1 -1
- package/src/components/one-time-code/one-time-code.tsx +168 -0
- package/src/components/picker/picker.tsx +121 -108
- package/src/components/popover/anchored-surface.tsx +223 -0
- package/src/components/popover/popover.tsx +149 -20
- package/src/components/progress-view/progress-view.tsx +6 -1
- package/src/components/progress-view/use-liquid-progress.ts +43 -0
- package/src/components/radio-group/radio-group.tsx +63 -0
- package/src/components/search-field/expanding-search-field.tsx +183 -0
- package/src/components/search-field/search-field.tsx +41 -25
- package/src/components/search-list/search-list.tsx +16 -7
- package/src/components/sidebar/sidebar.tsx +21 -12
- package/src/components/skeleton/skeleton.tsx +19 -0
- package/src/components/slosh-slider/liquid-step.ts +42 -0
- package/src/components/slosh-slider/slosh-slider.tsx +251 -0
- package/src/components/stepper/rolling-digit.tsx +77 -0
- package/src/components/stepper/stepper.tsx +50 -29
- package/src/components/text-field/text-field.tsx +45 -7
- package/src/components/toggle/toggle.tsx +147 -30
- package/src/components/wake-slider/wake-slider.tsx +237 -0
- package/src/hooks/use-anchored-disclosure.ts +23 -0
- package/src/hooks/use-anchored-placement.ts +7 -4
- package/src/icons/icons.tsx +150 -150
- package/src/index.ts +18 -0
- package/src/lib/anchored-placement.ts +7 -3
- package/src/styles/code-block.css +33 -0
- package/src/styles/motion-controls.css +189 -0
- package/src/styles/styles.css +3 -0
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { CheckIcon } from "../../icons/icons"
|
|
2
|
+
import {
|
|
3
|
+
animate,
|
|
4
|
+
motion,
|
|
5
|
+
useMotionValue,
|
|
6
|
+
useSpring,
|
|
7
|
+
useTransform,
|
|
8
|
+
useReducedMotion,
|
|
9
|
+
} from "motion/react"
|
|
10
|
+
import { useEffect, useId, useRef } from "react"
|
|
3
11
|
import { cn } from "../../lib/class-names"
|
|
4
12
|
import { haptic } from "../../lib/haptics"
|
|
5
13
|
import { spring } from "../../motion/transitions"
|
|
@@ -32,29 +40,80 @@ export function Toggle({
|
|
|
32
40
|
labelHidden = false,
|
|
33
41
|
}: ToggleProps) {
|
|
34
42
|
const input = useRef<HTMLInputElement>(null)
|
|
35
|
-
const reducedMotion = useReducedMotion()
|
|
36
43
|
useEffect(() => {
|
|
37
44
|
if (input.current) input.current.indeterminate = mixed
|
|
38
45
|
}, [mixed])
|
|
39
46
|
if (style === "checkbox") {
|
|
40
47
|
return (
|
|
41
|
-
<label
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
|
|
48
|
+
<label
|
|
49
|
+
className={cn(
|
|
50
|
+
"flex cursor-pointer items-center gap-3 text-subheadline",
|
|
51
|
+
disabled && "cursor-not-allowed opacity-40"
|
|
52
|
+
)}
|
|
53
|
+
>
|
|
54
|
+
<span className="relative inline-flex size-5 shrink-0">
|
|
55
|
+
<input
|
|
56
|
+
ref={input}
|
|
57
|
+
type="checkbox"
|
|
58
|
+
aria-checked={mixed ? "mixed" : checked}
|
|
59
|
+
checked={checked}
|
|
60
|
+
disabled={disabled}
|
|
61
|
+
onChange={(event) => onChange(event.target.checked)}
|
|
62
|
+
className={cn(
|
|
63
|
+
"peer size-5 cursor-inherit appearance-none rounded-sm border border-separator/80 bg-elevated transition-colors checked:border-transparent checked:bg-accent indeterminate:border-transparent indeterminate:bg-accent motion-reduce:transition-none",
|
|
64
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-text"
|
|
65
|
+
)}
|
|
66
|
+
/>
|
|
67
|
+
{mixed ? (
|
|
68
|
+
<span
|
|
69
|
+
aria-hidden="true"
|
|
70
|
+
className="pointer-events-none absolute inset-0 m-auto h-0.5 w-2.5 rounded-full bg-on-accent"
|
|
71
|
+
/>
|
|
72
|
+
) : (
|
|
73
|
+
<CheckIcon
|
|
74
|
+
aria-hidden="true"
|
|
75
|
+
size={14}
|
|
76
|
+
strokeWidth={2.5}
|
|
77
|
+
className="pointer-events-none absolute inset-0 m-auto text-on-accent opacity-0 peer-checked:opacity-100"
|
|
78
|
+
/>
|
|
52
79
|
)}
|
|
53
|
-
|
|
80
|
+
</span>
|
|
54
81
|
<span className={labelHidden ? "sr-only" : undefined}>{label}</span>
|
|
55
82
|
</label>
|
|
56
83
|
)
|
|
57
84
|
}
|
|
85
|
+
return <SwitchToggle checked={checked} onChange={onChange} label={label} disabled={disabled} />
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function SwitchToggle({
|
|
89
|
+
checked,
|
|
90
|
+
onChange,
|
|
91
|
+
label,
|
|
92
|
+
disabled,
|
|
93
|
+
}: Pick<ToggleProps, "checked" | "onChange" | "label" | "disabled">) {
|
|
94
|
+
const reducedMotion = useReducedMotion()
|
|
95
|
+
const id = `toggle-goo-${useId().replace(/:/g, "")}`
|
|
96
|
+
const x = useMotionValue(checked ? 22 : 2)
|
|
97
|
+
// One position owns the thumb. The drop follows that position, not a delayed target.
|
|
98
|
+
const chase = useSpring(x, spring.snappy)
|
|
99
|
+
const dropX = useTransform(chase, (value) => value + 5)
|
|
100
|
+
const gesture = useRef({ start: 0, x: 0, moved: false, skipClick: false, held: false })
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (reducedMotion) {
|
|
103
|
+
x.set(checked ? 22 : 2)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
const animation = animate(x, checked ? 22 : 2, spring.snappy)
|
|
107
|
+
return () => animation.stop()
|
|
108
|
+
}, [checked, reducedMotion, x])
|
|
109
|
+
const choose = (next: boolean) => {
|
|
110
|
+
if (disabled) return
|
|
111
|
+
haptic("selection")
|
|
112
|
+
if (next !== checked) onChange(next)
|
|
113
|
+
// Releasing on the current side still needs a settling spring.
|
|
114
|
+
if (reducedMotion) x.set(next ? 22 : 2)
|
|
115
|
+
else animate(x, next ? 22 : 2, spring.snappy)
|
|
116
|
+
}
|
|
58
117
|
return (
|
|
59
118
|
<button
|
|
60
119
|
type="button"
|
|
@@ -63,27 +122,85 @@ export function Toggle({
|
|
|
63
122
|
aria-label={label}
|
|
64
123
|
disabled={disabled}
|
|
65
124
|
onClick={() => {
|
|
66
|
-
|
|
67
|
-
|
|
125
|
+
if (gesture.current.skipClick) {
|
|
126
|
+
gesture.current.skipClick = false
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
choose(!checked)
|
|
130
|
+
}}
|
|
131
|
+
onPointerDown={(event) => {
|
|
132
|
+
if (event.button !== 0 || disabled) return
|
|
133
|
+
event.currentTarget.setPointerCapture(event.pointerId)
|
|
134
|
+
gesture.current = {
|
|
135
|
+
start: event.clientX,
|
|
136
|
+
x: x.get(),
|
|
137
|
+
moved: false,
|
|
138
|
+
skipClick: false,
|
|
139
|
+
held: true,
|
|
140
|
+
}
|
|
141
|
+
}}
|
|
142
|
+
onPointerMove={(event) => {
|
|
143
|
+
const drag = gesture.current
|
|
144
|
+
if (!drag.held || disabled) return
|
|
145
|
+
const direction = getComputedStyle(event.currentTarget).direction === "rtl" ? -1 : 1
|
|
146
|
+
const delta = (event.clientX - drag.start) * direction
|
|
147
|
+
if (Math.abs(delta) > 3) drag.moved = true
|
|
148
|
+
if (drag.moved) x.set(Math.max(2, Math.min(22, drag.x + delta)))
|
|
149
|
+
}}
|
|
150
|
+
onPointerUp={() => {
|
|
151
|
+
const drag = gesture.current
|
|
152
|
+
if (!drag.held) return
|
|
153
|
+
drag.held = false
|
|
154
|
+
if (!drag.moved) return
|
|
155
|
+
drag.skipClick = true
|
|
156
|
+
choose(x.get() >= 12)
|
|
157
|
+
}}
|
|
158
|
+
onPointerCancel={() => {
|
|
159
|
+
gesture.current.held = false
|
|
160
|
+
gesture.current.skipClick = false
|
|
161
|
+
choose(checked)
|
|
68
162
|
}}
|
|
69
163
|
className={cn(
|
|
70
|
-
"
|
|
164
|
+
"relative h-[30px] w-[50px] shrink-0 touch-pan-y rounded-full transition-colors duration-normal motion-reduce:transition-none",
|
|
71
165
|
"shadow-[inset_0_1px_2px_rgb(0_0_0/0.10)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:opacity-45",
|
|
72
|
-
checked
|
|
73
|
-
? "justify-end bg-accent ring-1 ring-accent-text"
|
|
74
|
-
: "justify-start bg-separator/80"
|
|
166
|
+
checked ? "bg-accent" : "bg-separator/80"
|
|
75
167
|
)}
|
|
76
168
|
>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"
|
|
169
|
+
<svg aria-hidden="true" width="0" height="0" className="absolute">
|
|
170
|
+
<defs>
|
|
171
|
+
<filter
|
|
172
|
+
id={id}
|
|
173
|
+
x="-50%"
|
|
174
|
+
y="-100%"
|
|
175
|
+
width="200%"
|
|
176
|
+
height="300%"
|
|
177
|
+
colorInterpolationFilters="sRGB"
|
|
178
|
+
>
|
|
179
|
+
<feGaussianBlur stdDeviation="2.5" result="smear" />
|
|
180
|
+
<feColorMatrix
|
|
181
|
+
in="smear"
|
|
182
|
+
type="matrix"
|
|
183
|
+
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9"
|
|
184
|
+
/>
|
|
185
|
+
</filter>
|
|
186
|
+
</defs>
|
|
187
|
+
</svg>
|
|
188
|
+
<span
|
|
189
|
+
aria-hidden="true"
|
|
190
|
+
className="pointer-events-none absolute inset-0 rtl:-scale-x-100"
|
|
191
|
+
style={{ filter: reducedMotion ? undefined : `url(#${id})` }}
|
|
192
|
+
>
|
|
193
|
+
{!reducedMotion && (
|
|
194
|
+
<motion.span
|
|
195
|
+
className="absolute top-[7px] left-0 size-4 rounded-full bg-white"
|
|
196
|
+
style={{ x: dropX }}
|
|
197
|
+
/>
|
|
85
198
|
)}
|
|
86
|
-
|
|
199
|
+
<motion.span
|
|
200
|
+
className="absolute top-0.5 left-0 size-[26px] rounded-full bg-white"
|
|
201
|
+
style={{ x }}
|
|
202
|
+
/>
|
|
203
|
+
</span>
|
|
87
204
|
</button>
|
|
88
205
|
)
|
|
89
206
|
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { clampRange as clamp } from "../../lib/range-value"
|
|
2
|
+
// Adapted from Bencho. See THIRD_PARTY_NOTICES.md for the MIT notice.
|
|
3
|
+
import { type CSSProperties, useEffect, useRef, useState } from "react"
|
|
4
|
+
import { useMediaQuery } from "../../hooks/use-media-query"
|
|
5
|
+
import { cn } from "../../lib/class-names"
|
|
6
|
+
|
|
7
|
+
/* ══ Wake slider ══════════════════════════════════════════
|
|
8
|
+
A range with no thumb: a row of thin bars, solid up to the
|
|
9
|
+
value and faint after it, with the reading beside them. Drag
|
|
10
|
+
anywhere along it. Drag slowly and the bars simply fill; drag
|
|
11
|
+
fast and every bar the value sweeps past springs up, taller the
|
|
12
|
+
faster you went, and settles back — a wake left behind the hand.
|
|
13
|
+
|
|
14
|
+
── EACH BAR HOLDS A LITTLE ENERGY ──────────────────────
|
|
15
|
+
When the value crosses a bar, that bar takes energy from the
|
|
16
|
+
speed of the drag. Every frame all of it decays at the same
|
|
17
|
+
rate, so the bars crossed longest ago have fallen furthest: the
|
|
18
|
+
tallest ones are always at the hand and the run tails off
|
|
19
|
+
behind it. That falloff IS the wake; nothing draws it.
|
|
20
|
+
|
|
21
|
+
── WRITTEN, NOT RENDERED ───────────────────────────────
|
|
22
|
+
The heights change every frame, so they go straight onto each
|
|
23
|
+
bar's transform from a ref. React renders when the value
|
|
24
|
+
changes, which is what the reading and the colours need, and
|
|
25
|
+
the loop runs only while some bar still has energy. */
|
|
26
|
+
|
|
27
|
+
const H = 20 // a bar at rest, px
|
|
28
|
+
|
|
29
|
+
export interface WakeSliderProps {
|
|
30
|
+
label: string
|
|
31
|
+
value?: number
|
|
32
|
+
defaultValue?: number
|
|
33
|
+
onChange?: (value: number) => void
|
|
34
|
+
bars?: number
|
|
35
|
+
wake?: number
|
|
36
|
+
disabled?: boolean
|
|
37
|
+
className?: string
|
|
38
|
+
formatValue?: (value: number) => string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function WakeSlider({
|
|
42
|
+
label,
|
|
43
|
+
value: controlledValue,
|
|
44
|
+
defaultValue = 50,
|
|
45
|
+
onChange,
|
|
46
|
+
disabled = false,
|
|
47
|
+
className,
|
|
48
|
+
formatValue,
|
|
49
|
+
/* how many bars make the track */
|
|
50
|
+
bars = 36,
|
|
51
|
+
/* how high the wake rises, 0..100 */
|
|
52
|
+
wake = 60,
|
|
53
|
+
}: WakeSliderProps) {
|
|
54
|
+
const n = Math.round(clamp(Number.isFinite(bars) ? bars : 36, 12, 64))
|
|
55
|
+
const reduced = useMediaQuery("(prefers-reduced-motion: reduce)")
|
|
56
|
+
const [value, setValue] = useState(() => clamp(controlledValue ?? defaultValue, 0, 100))
|
|
57
|
+
const track = useRef<HTMLDivElement | null>(null)
|
|
58
|
+
const els = useRef<(HTMLSpanElement | null)[]>([])
|
|
59
|
+
const st = useRef({
|
|
60
|
+
energy: new Float32Array(n),
|
|
61
|
+
down: false,
|
|
62
|
+
lastX: 0,
|
|
63
|
+
lastT: 0,
|
|
64
|
+
speed: 0,
|
|
65
|
+
raf: 0,
|
|
66
|
+
at: value / 100, // the value as a fraction, without React in the way
|
|
67
|
+
lastBar: -1,
|
|
68
|
+
})
|
|
69
|
+
const knob = useRef(wake)
|
|
70
|
+
knob.current = reduced ? 0 : clamp(wake, 0, 100)
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (controlledValue === undefined) return
|
|
73
|
+
const next = clamp(controlledValue, 0, 100)
|
|
74
|
+
st.current.at = next / 100
|
|
75
|
+
setValue(next)
|
|
76
|
+
}, [controlledValue])
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!reduced) return
|
|
80
|
+
cancelAnimationFrame(st.current.raf)
|
|
81
|
+
st.current.raf = 0
|
|
82
|
+
st.current.energy.fill(0)
|
|
83
|
+
for (const bar of els.current) if (bar) bar.style.transform = "none"
|
|
84
|
+
}, [reduced])
|
|
85
|
+
|
|
86
|
+
/* a new bar count is a new row */
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
st.current.energy = new Float32Array(n)
|
|
89
|
+
}, [n])
|
|
90
|
+
|
|
91
|
+
const loop = () => {
|
|
92
|
+
const s = st.current
|
|
93
|
+
if (s.raf) return
|
|
94
|
+
let prev = performance.now()
|
|
95
|
+
const run = (now: number) => {
|
|
96
|
+
const dt = Math.min(0.05, (now - prev) / 1000)
|
|
97
|
+
prev = now
|
|
98
|
+
let live = false
|
|
99
|
+
const fall = Math.exp(-dt * 6.5)
|
|
100
|
+
for (let i = 0; i < s.energy.length; i++) {
|
|
101
|
+
s.energy[i] *= fall
|
|
102
|
+
if (s.energy[i] < 0.002) s.energy[i] = 0
|
|
103
|
+
else live = true
|
|
104
|
+
const b = els.current[i]
|
|
105
|
+
/* up to 2.6 times as tall at the very top of the knob */
|
|
106
|
+
if (b) b.style.transform = `scaleY(${(1 + s.energy[i] * 1.6).toFixed(3)})`
|
|
107
|
+
}
|
|
108
|
+
s.raf = live ? requestAnimationFrame(run) : 0
|
|
109
|
+
}
|
|
110
|
+
s.raf = requestAnimationFrame(run)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
useEffect(() => () => cancelAnimationFrame(st.current.raf), [])
|
|
114
|
+
|
|
115
|
+
/* move the value to a fraction, waking every bar it passes */
|
|
116
|
+
const to = (f: number, speed: number) => {
|
|
117
|
+
const s = st.current
|
|
118
|
+
f = clamp(f, 0, 1)
|
|
119
|
+
const from = s.at
|
|
120
|
+
s.at = f
|
|
121
|
+
const a = Math.round(from * (n - 1)),
|
|
122
|
+
b = Math.round(f * (n - 1))
|
|
123
|
+
/* how hard: the drag's speed in bars per second, through a
|
|
124
|
+
curve that saturates, scaled by the knob */
|
|
125
|
+
const push = (1 - Math.exp(-speed / 900)) * (knob.current / 60)
|
|
126
|
+
const lo = Math.min(a, b),
|
|
127
|
+
hi = Math.max(a, b)
|
|
128
|
+
for (let i = lo; i <= hi; i++) s.energy[i] = Math.max(s.energy[i], push)
|
|
129
|
+
if (b !== s.lastBar) {
|
|
130
|
+
s.lastBar = b
|
|
131
|
+
}
|
|
132
|
+
const next = Math.round(f * 100)
|
|
133
|
+
setValue(next)
|
|
134
|
+
onChange?.(next)
|
|
135
|
+
if (!reduced && knob.current > 0) loop()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const frac = (clientX: number) => {
|
|
139
|
+
const r = track.current!.getBoundingClientRect()
|
|
140
|
+
const fraction = r.width ? (clientX - r.left) / r.width : 0
|
|
141
|
+
return getComputedStyle(track.current!).direction === "rtl" ? 1 - fraction : fraction
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const lit = Math.round((value / 100) * (n - 1))
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div className={cn("passui-wake-slider not-typeset", disabled && "opacity-40", className)}>
|
|
148
|
+
<div
|
|
149
|
+
ref={track}
|
|
150
|
+
className="passui-wake-track"
|
|
151
|
+
role="slider"
|
|
152
|
+
tabIndex={disabled ? -1 : 0}
|
|
153
|
+
aria-label={label}
|
|
154
|
+
aria-disabled={disabled || undefined}
|
|
155
|
+
aria-valuetext={formatValue?.(value)}
|
|
156
|
+
aria-valuemin={0}
|
|
157
|
+
aria-valuemax={100}
|
|
158
|
+
aria-valuenow={value}
|
|
159
|
+
style={{ height: H * 2.6, "--wake-bars": n } as CSSProperties}
|
|
160
|
+
onPointerDown={(e) => {
|
|
161
|
+
if (disabled || e.button !== 0) return
|
|
162
|
+
e.currentTarget.focus()
|
|
163
|
+
const s = st.current
|
|
164
|
+
try {
|
|
165
|
+
e.currentTarget.setPointerCapture(e.pointerId)
|
|
166
|
+
} catch {
|
|
167
|
+
/* a scripted pointer */
|
|
168
|
+
}
|
|
169
|
+
s.down = true
|
|
170
|
+
s.lastX = e.clientX
|
|
171
|
+
s.lastT = performance.now()
|
|
172
|
+
s.speed = 0
|
|
173
|
+
to(frac(e.clientX), 0)
|
|
174
|
+
}}
|
|
175
|
+
onPointerMove={(e) => {
|
|
176
|
+
const s = st.current
|
|
177
|
+
if (!s.down || disabled) return
|
|
178
|
+
const now = performance.now()
|
|
179
|
+
const dt = Math.max(0.008, (now - s.lastT) / 1000)
|
|
180
|
+
/* speed in px per second, smoothed so one jittery frame
|
|
181
|
+
cannot fake a fling */
|
|
182
|
+
const v = Math.abs(e.clientX - s.lastX) / dt
|
|
183
|
+
s.speed = s.speed * 0.5 + v * 0.5
|
|
184
|
+
s.lastX = e.clientX
|
|
185
|
+
s.lastT = now
|
|
186
|
+
to(frac(e.clientX), s.speed)
|
|
187
|
+
}}
|
|
188
|
+
onPointerUp={(e) => {
|
|
189
|
+
st.current.down = false
|
|
190
|
+
try {
|
|
191
|
+
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
192
|
+
} catch {
|
|
193
|
+
/* never captured */
|
|
194
|
+
}
|
|
195
|
+
}}
|
|
196
|
+
onLostPointerCapture={() => {
|
|
197
|
+
st.current.down = false
|
|
198
|
+
}}
|
|
199
|
+
onPointerCancel={() => {
|
|
200
|
+
st.current.down = false
|
|
201
|
+
}}
|
|
202
|
+
onKeyDown={(e) => {
|
|
203
|
+
if (disabled) return
|
|
204
|
+
const step = e.shiftKey ? 10 : 1
|
|
205
|
+
let next: number | null = null
|
|
206
|
+
const rtl = getComputedStyle(e.currentTarget).direction === "rtl"
|
|
207
|
+
if (e.key === "ArrowUp" || e.key === (rtl ? "ArrowLeft" : "ArrowRight"))
|
|
208
|
+
next = value + step
|
|
209
|
+
if (e.key === "ArrowDown" || e.key === (rtl ? "ArrowRight" : "ArrowLeft"))
|
|
210
|
+
next = value - step
|
|
211
|
+
if (e.key === "Home") next = 0
|
|
212
|
+
if (e.key === "End") next = 100
|
|
213
|
+
if (next === null) return
|
|
214
|
+
e.preventDefault()
|
|
215
|
+
/* a key press is a small, quick step: a little wake */
|
|
216
|
+
to(clamp(next, 0, 100) / 100, 600)
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
{Array.from({ length: n }, (_, i) => (
|
|
220
|
+
<span
|
|
221
|
+
key={i}
|
|
222
|
+
ref={(b) => {
|
|
223
|
+
els.current[i] = b
|
|
224
|
+
}}
|
|
225
|
+
aria-hidden="true"
|
|
226
|
+
className="passui-wake-bar"
|
|
227
|
+
data-on={(value > 0 && i <= lit) || undefined}
|
|
228
|
+
style={{ height: H }}
|
|
229
|
+
/>
|
|
230
|
+
))}
|
|
231
|
+
</div>
|
|
232
|
+
<span aria-hidden="true" className="passui-wake-value">
|
|
233
|
+
{formatValue?.(value) ?? value}
|
|
234
|
+
</span>
|
|
235
|
+
</div>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react"
|
|
2
|
+
|
|
3
|
+
/** Keep the panel mounted until its material has returned to the trigger. */
|
|
4
|
+
export function useAnchoredDisclosure(disabled = false) {
|
|
5
|
+
const [open, setOpen] = useState(false)
|
|
6
|
+
const [mounted, setMounted] = useState(false)
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
if (disabled) setOpen(false)
|
|
9
|
+
}, [disabled])
|
|
10
|
+
const show = useCallback(() => {
|
|
11
|
+
setMounted(true)
|
|
12
|
+
setOpen(true)
|
|
13
|
+
}, [])
|
|
14
|
+
const toggle = useCallback(() => {
|
|
15
|
+
setMounted(true)
|
|
16
|
+
setOpen((current) => !current)
|
|
17
|
+
}, [])
|
|
18
|
+
const close = useCallback(() => setOpen(false), [])
|
|
19
|
+
const onExited = useCallback(() => {
|
|
20
|
+
if (!open || disabled) setMounted(false)
|
|
21
|
+
}, [open, disabled])
|
|
22
|
+
return { open: open && !disabled, mounted, show, toggle, close, onExited }
|
|
23
|
+
}
|
|
@@ -9,12 +9,15 @@ import { type AnchoredPlacement, placeAnchored } from "../lib/anchored-placement
|
|
|
9
9
|
export function useAnchoredPlacement(
|
|
10
10
|
anchor: HTMLElement,
|
|
11
11
|
width: number,
|
|
12
|
-
needed: number
|
|
12
|
+
needed: number,
|
|
13
|
+
overlap = false
|
|
13
14
|
): AnchoredPlacement {
|
|
14
|
-
const [placement, setPlacement] = useState(() =>
|
|
15
|
+
const [placement, setPlacement] = useState(() =>
|
|
16
|
+
placeAnchored(anchor, width, needed, 6, overlap)
|
|
17
|
+
)
|
|
15
18
|
|
|
16
19
|
useLayoutEffect(() => {
|
|
17
|
-
const replace = () => setPlacement(placeAnchored(anchor, width, needed))
|
|
20
|
+
const replace = () => setPlacement(placeAnchored(anchor, width, needed, 6, overlap))
|
|
18
21
|
replace()
|
|
19
22
|
const viewport = window.visualViewport
|
|
20
23
|
viewport?.addEventListener("resize", replace)
|
|
@@ -27,7 +30,7 @@ export function useAnchoredPlacement(
|
|
|
27
30
|
window.removeEventListener("resize", replace)
|
|
28
31
|
window.removeEventListener("scroll", replace, true)
|
|
29
32
|
}
|
|
30
|
-
}, [anchor, width, needed])
|
|
33
|
+
}, [anchor, width, needed, overlap])
|
|
31
34
|
|
|
32
35
|
return placement
|
|
33
36
|
}
|