@passui/react 0.1.19 → 0.1.21
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 +23 -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/sheet/sheet.d.ts +2 -0
- 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 +198 -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/sheet/sheet.tsx +4 -1
- 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
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// Adapted from Bencho. See THIRD_PARTY_NOTICES.md for the MIT notice.
|
|
2
|
+
import { type CSSProperties, useEffect, useRef } from "react"
|
|
3
|
+
import { useMediaQuery } from "../../hooks/use-media-query"
|
|
4
|
+
import { cn } from "../../lib/class-names"
|
|
5
|
+
import { clampRange as clamp } from "../../lib/range-value"
|
|
6
|
+
import { stepLiquid } from "./liquid-step"
|
|
7
|
+
|
|
8
|
+
export interface SloshSliderProps {
|
|
9
|
+
label: string
|
|
10
|
+
value?: number
|
|
11
|
+
defaultValue?: number
|
|
12
|
+
onChange?: (value: number) => void
|
|
13
|
+
/** 0 is rigid; 100 is loose liquid. @default 15 */
|
|
14
|
+
viscosity?: number
|
|
15
|
+
/** How far the handle coasts after release, 0–100. @default 55 */
|
|
16
|
+
momentum?: number
|
|
17
|
+
/** Lean of the liquid's leading edge, 0–100. @default 45 */
|
|
18
|
+
tilt?: number
|
|
19
|
+
/** Track corner, from square to a pill, 0–20. @default 20 */
|
|
20
|
+
corner?: number
|
|
21
|
+
disabled?: boolean
|
|
22
|
+
className?: string
|
|
23
|
+
formatValue?: (value: number) => string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* A slider whose value has mass.
|
|
27
|
+
|
|
28
|
+
The handle is rigid — it is under your finger and anything under a
|
|
29
|
+
finger that lags it is broken. The FILL is a second spring behind it,
|
|
30
|
+
softer and barely damped, so a fast drag throws it past the handle,
|
|
31
|
+
into the end stop, and rocks it back before it settles.
|
|
32
|
+
|
|
33
|
+
The tilt is what sells it as liquid. A bar that lags is a laggy bar;
|
|
34
|
+
a bar whose leading edge leans has a meniscus. Same lag, different object.
|
|
35
|
+
|
|
36
|
+
The loop writes geometry directly and parks as soon as it rests.
|
|
37
|
+
React receives only changed integer values for the application's state. */
|
|
38
|
+
export function SloshSlider({
|
|
39
|
+
label,
|
|
40
|
+
value,
|
|
41
|
+
defaultValue = 62,
|
|
42
|
+
onChange,
|
|
43
|
+
viscosity = 15,
|
|
44
|
+
momentum = 55,
|
|
45
|
+
tilt = 45,
|
|
46
|
+
corner = 20,
|
|
47
|
+
disabled = false,
|
|
48
|
+
className,
|
|
49
|
+
formatValue,
|
|
50
|
+
}: SloshSliderProps) {
|
|
51
|
+
const reduced = useMediaQuery("(prefers-reduced-motion: reduce)")
|
|
52
|
+
const track = useRef<HTMLDivElement>(null)
|
|
53
|
+
const fillEl = useRef<HTMLDivElement>(null)
|
|
54
|
+
const knobEl = useRef<HTMLDivElement>(null)
|
|
55
|
+
const initial = clamp(value ?? defaultValue, 0, 100)
|
|
56
|
+
const state = useRef({
|
|
57
|
+
value: initial,
|
|
58
|
+
handleVelocity: 0,
|
|
59
|
+
fill: initial,
|
|
60
|
+
velocity: 0,
|
|
61
|
+
held: false,
|
|
62
|
+
raf: 0,
|
|
63
|
+
previous: 0,
|
|
64
|
+
last: { value: initial, time: 0 },
|
|
65
|
+
notified: Math.round(initial),
|
|
66
|
+
})
|
|
67
|
+
const settings = useRef({ viscosity, momentum, tilt, reduced, onChange, formatValue })
|
|
68
|
+
settings.current = { viscosity, momentum, tilt, reduced, onChange, formatValue }
|
|
69
|
+
const run = useRef<() => void>(() => {})
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const s = state.current
|
|
73
|
+
const paint = () => {
|
|
74
|
+
if (!track.current || !fillEl.current || !knobEl.current) return
|
|
75
|
+
fillEl.current.style.width = `${s.fill.toFixed(2)}%`
|
|
76
|
+
knobEl.current.style.insetInlineStart = `${s.value.toFixed(2)}%`
|
|
77
|
+
/* Proportional to how fast the fill is actually moving: upright
|
|
78
|
+
at rest, so it cannot be decoration when nothing travels. */
|
|
79
|
+
const lean = settings.current.reduced
|
|
80
|
+
? 0
|
|
81
|
+
: clamp((clamp(settings.current.tilt, 0, 100) / 100) * s.velocity * 3, -20, 20)
|
|
82
|
+
fillEl.current.style.setProperty("--slosh-lean", `${lean.toFixed(2)}px`)
|
|
83
|
+
const next = Math.round(s.value)
|
|
84
|
+
track.current.setAttribute("aria-valuenow", String(next))
|
|
85
|
+
const spoken = settings.current.formatValue?.(next)
|
|
86
|
+
if (spoken) track.current.setAttribute("aria-valuetext", spoken)
|
|
87
|
+
else track.current.removeAttribute("aria-valuetext")
|
|
88
|
+
if (s.notified !== next) {
|
|
89
|
+
s.notified = next
|
|
90
|
+
settings.current.onChange?.(next)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const tick = (time: number) => {
|
|
94
|
+
const dt = s.previous ? clamp((time - s.previous) / 16.67, 0, 2.5) : 1
|
|
95
|
+
s.previous = time
|
|
96
|
+
const knobs = settings.current
|
|
97
|
+
/* Friction is per frame, raised to dt: a dropped frame must
|
|
98
|
+
not double the deceleration. The release is a throw, not a stop. */
|
|
99
|
+
if (!s.held && s.handleVelocity) {
|
|
100
|
+
s.value += s.handleVelocity * dt
|
|
101
|
+
s.handleVelocity *= (0.86 + (clamp(knobs.momentum, 0, 100) / 100) * 0.115) ** dt
|
|
102
|
+
if (s.value <= 0 || s.value >= 100) {
|
|
103
|
+
s.value = clamp(s.value, 0, 100)
|
|
104
|
+
s.handleVelocity = 0
|
|
105
|
+
}
|
|
106
|
+
if (Math.abs(s.handleVelocity) < 0.01) s.handleVelocity = 0
|
|
107
|
+
}
|
|
108
|
+
const next = stepLiquid(s, s.value, knobs.reduced ? 0 : knobs.viscosity, dt)
|
|
109
|
+
s.fill = next.fill
|
|
110
|
+
s.velocity = next.velocity
|
|
111
|
+
paint()
|
|
112
|
+
if (!s.handleVelocity && !s.velocity && Math.abs(s.fill - s.value) < 0.02) {
|
|
113
|
+
s.raf = 0
|
|
114
|
+
s.previous = 0
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
s.raf = requestAnimationFrame(tick)
|
|
118
|
+
}
|
|
119
|
+
run.current = () => {
|
|
120
|
+
// The handle paints on the input event; it never waits for the fill.
|
|
121
|
+
paint()
|
|
122
|
+
if (!s.raf) s.raf = requestAnimationFrame(tick)
|
|
123
|
+
}
|
|
124
|
+
paint()
|
|
125
|
+
return () => {
|
|
126
|
+
cancelAnimationFrame(s.raf)
|
|
127
|
+
s.raf = 0
|
|
128
|
+
s.previous = 0
|
|
129
|
+
}
|
|
130
|
+
}, [])
|
|
131
|
+
|
|
132
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: Motion settings can wake a parked loop without changing its value.
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
const s = state.current
|
|
135
|
+
if (value !== undefined && Math.round(clamp(value, 0, 100)) !== s.notified) {
|
|
136
|
+
s.value = clamp(value, 0, 100)
|
|
137
|
+
s.notified = Math.round(s.value)
|
|
138
|
+
s.handleVelocity = 0
|
|
139
|
+
}
|
|
140
|
+
if (reduced || disabled) {
|
|
141
|
+
s.handleVelocity = 0
|
|
142
|
+
s.held = false
|
|
143
|
+
}
|
|
144
|
+
run.current()
|
|
145
|
+
}, [value, reduced, disabled, viscosity, tilt, formatValue])
|
|
146
|
+
|
|
147
|
+
const readAt = (clientX: number) => {
|
|
148
|
+
const t = track.current
|
|
149
|
+
if (!t) return 0
|
|
150
|
+
const box = t.getBoundingClientRect()
|
|
151
|
+
const fraction = box.width ? (clientX - box.left) / box.width : 0
|
|
152
|
+
return clamp(
|
|
153
|
+
(getComputedStyle(t).direction === "rtl" ? 1 - fraction : fraction) * 100,
|
|
154
|
+
0,
|
|
155
|
+
100
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
const release = (cancelled = false) => {
|
|
159
|
+
const s = state.current
|
|
160
|
+
if (!s.held) return
|
|
161
|
+
s.held = false
|
|
162
|
+
/* Leaves along its measured velocity, capped so a full-track flick
|
|
163
|
+
does not simply pin it. A cancelled gesture never throws. */
|
|
164
|
+
s.handleVelocity = cancelled || reduced ? 0 : clamp(s.handleVelocity, -6, 6)
|
|
165
|
+
if (performance.now() - s.last.time > 100) s.handleVelocity = 0
|
|
166
|
+
run.current()
|
|
167
|
+
}
|
|
168
|
+
return (
|
|
169
|
+
<div
|
|
170
|
+
className={cn("passui-slosh-slider not-typeset", disabled && "opacity-40", className)}
|
|
171
|
+
style={{ "--slosh-radius": `${clamp(corner, 0, 20)}px` } as CSSProperties}
|
|
172
|
+
>
|
|
173
|
+
{/* Nothing but the track: the liquid is the value's motion, not a
|
|
174
|
+
second numeric readout that lags and overshoots beside it. */}
|
|
175
|
+
<div
|
|
176
|
+
ref={track}
|
|
177
|
+
className="passui-slosh-track"
|
|
178
|
+
role="slider"
|
|
179
|
+
tabIndex={disabled ? -1 : 0}
|
|
180
|
+
aria-label={label}
|
|
181
|
+
aria-disabled={disabled || undefined}
|
|
182
|
+
aria-valuemin={0}
|
|
183
|
+
aria-valuemax={100}
|
|
184
|
+
aria-valuenow={Math.round(initial)}
|
|
185
|
+
aria-valuetext={formatValue?.(Math.round(initial))}
|
|
186
|
+
onPointerDown={(event) => {
|
|
187
|
+
if (disabled || event.button !== 0) return
|
|
188
|
+
event.currentTarget.focus()
|
|
189
|
+
event.currentTarget.setPointerCapture(event.pointerId)
|
|
190
|
+
const s = state.current
|
|
191
|
+
s.held = true
|
|
192
|
+
s.handleVelocity = 0
|
|
193
|
+
s.value = readAt(event.clientX)
|
|
194
|
+
s.last = { value: s.value, time: performance.now() }
|
|
195
|
+
run.current()
|
|
196
|
+
}}
|
|
197
|
+
onPointerMove={(event) => {
|
|
198
|
+
const s = state.current
|
|
199
|
+
if (!s.held || disabled) return
|
|
200
|
+
const next = readAt(event.clientX),
|
|
201
|
+
now = performance.now()
|
|
202
|
+
/* Per frame, not per ms: the loop's release velocity must
|
|
203
|
+
use the same unit as the spring. */
|
|
204
|
+
s.handleVelocity =
|
|
205
|
+
((next - s.last.value) / Math.max(1, now - s.last.time)) * 16.67
|
|
206
|
+
s.last = { value: next, time: now }
|
|
207
|
+
s.value = next
|
|
208
|
+
run.current()
|
|
209
|
+
}}
|
|
210
|
+
onPointerUp={() => release()}
|
|
211
|
+
onPointerCancel={() => release(true)}
|
|
212
|
+
onLostPointerCapture={() => release(true)}
|
|
213
|
+
onKeyDown={(event) => {
|
|
214
|
+
if (disabled) return
|
|
215
|
+
const s = state.current,
|
|
216
|
+
step = event.shiftKey ? 10 : 2
|
|
217
|
+
const rtl = getComputedStyle(event.currentTarget).direction === "rtl"
|
|
218
|
+
const next =
|
|
219
|
+
event.key === "ArrowUp" || event.key === (rtl ? "ArrowLeft" : "ArrowRight")
|
|
220
|
+
? s.value + step
|
|
221
|
+
: event.key === "ArrowDown" ||
|
|
222
|
+
event.key === (rtl ? "ArrowRight" : "ArrowLeft")
|
|
223
|
+
? s.value - step
|
|
224
|
+
: event.key === "Home"
|
|
225
|
+
? 0
|
|
226
|
+
: event.key === "End"
|
|
227
|
+
? 100
|
|
228
|
+
: null
|
|
229
|
+
if (next === null) return
|
|
230
|
+
event.preventDefault()
|
|
231
|
+
s.handleVelocity = 0
|
|
232
|
+
s.value = clamp(next, 0, 100)
|
|
233
|
+
run.current()
|
|
234
|
+
}}
|
|
235
|
+
>
|
|
236
|
+
<div
|
|
237
|
+
aria-hidden="true"
|
|
238
|
+
ref={fillEl}
|
|
239
|
+
className="passui-slosh-fill"
|
|
240
|
+
style={{ width: `${initial}%` }}
|
|
241
|
+
/>
|
|
242
|
+
<div
|
|
243
|
+
aria-hidden="true"
|
|
244
|
+
ref={knobEl}
|
|
245
|
+
className="passui-slosh-knob"
|
|
246
|
+
style={{ insetInlineStart: `${initial}%` }}
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react"
|
|
2
|
+
import { useMediaQuery } from "../../hooks/use-media-query"
|
|
3
|
+
|
|
4
|
+
/** Counts at a place retain carries: the tens column of 1284 targets 128. */
|
|
5
|
+
export function digitAt(count: number) {
|
|
6
|
+
return ((Math.floor(count) % 10) + 10) % 10
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function advanceDigit(position: number, velocity: number, target: number, frames: number) {
|
|
10
|
+
const nextVelocity = (velocity + (target - position) * 0.16 * frames) * 0.7 ** frames
|
|
11
|
+
return { position: position + nextVelocity * frames, velocity: nextVelocity }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Three recycled cells keep the strip continuous through either side of zero. */
|
|
15
|
+
export function RollingDigit({ count, place }: { count: number; place: number }) {
|
|
16
|
+
const strip = useRef<HTMLSpanElement>(null)
|
|
17
|
+
const state = useRef({ position: count, velocity: 0 })
|
|
18
|
+
const reduced = useMediaQuery("(prefers-reduced-motion: reduce)")
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
let frame = 0
|
|
21
|
+
let previous = 0
|
|
22
|
+
let due = 0
|
|
23
|
+
const paint = (position: number, velocity: number) => {
|
|
24
|
+
const node = strip.current
|
|
25
|
+
if (!node) return
|
|
26
|
+
const base = Math.floor(position)
|
|
27
|
+
for (let index = 0; index < node.children.length; index++) {
|
|
28
|
+
node.children[index]!.textContent = String(digitAt(base + index - 1))
|
|
29
|
+
}
|
|
30
|
+
node.style.transform = `translateY(${(-(position - base + 1) * 100) / 3}%)`
|
|
31
|
+
node.style.filter = `blur(${Math.min(2, Math.abs(velocity) * 1.4)}px)`
|
|
32
|
+
}
|
|
33
|
+
if (reduced) {
|
|
34
|
+
state.current = { position: count, velocity: 0 }
|
|
35
|
+
paint(count, 0)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
const tick = (now: number) => {
|
|
39
|
+
if (!due) due = now + place * 28
|
|
40
|
+
const frames = previous ? Math.min((now - previous) / (1000 / 60), 2) : 1
|
|
41
|
+
previous = now
|
|
42
|
+
if (now >= due) {
|
|
43
|
+
state.current = advanceDigit(
|
|
44
|
+
state.current.position,
|
|
45
|
+
state.current.velocity,
|
|
46
|
+
count,
|
|
47
|
+
frames
|
|
48
|
+
)
|
|
49
|
+
if (
|
|
50
|
+
Math.abs(count - state.current.position) < 0.002 &&
|
|
51
|
+
Math.abs(state.current.velocity) < 0.002
|
|
52
|
+
) {
|
|
53
|
+
state.current = { position: count, velocity: 0 }
|
|
54
|
+
paint(count, 0)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
paint(state.current.position, state.current.velocity)
|
|
58
|
+
}
|
|
59
|
+
frame = requestAnimationFrame(tick)
|
|
60
|
+
}
|
|
61
|
+
frame = requestAnimationFrame(tick)
|
|
62
|
+
return () => cancelAnimationFrame(frame)
|
|
63
|
+
}, [count, place, reduced])
|
|
64
|
+
return (
|
|
65
|
+
<span className="relative inline-block h-[1.5em] w-[1ch] overflow-hidden align-middle">
|
|
66
|
+
<span
|
|
67
|
+
ref={strip}
|
|
68
|
+
className="absolute inset-x-0 top-0 flex flex-col leading-[1.5em]"
|
|
69
|
+
style={{ transform: "translateY(-33.333333%)" }}
|
|
70
|
+
>
|
|
71
|
+
<span>{digitAt(count - 1)}</span>
|
|
72
|
+
<span>{digitAt(count)}</span>
|
|
73
|
+
<span>{digitAt(count + 1)}</span>
|
|
74
|
+
</span>
|
|
75
|
+
</span>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useId } from "react"
|
|
2
|
+
import { Button } from "../button/button"
|
|
2
3
|
import { cn } from "../../lib/class-names"
|
|
3
4
|
import { clampRange, stepRange } from "../../lib/range-value"
|
|
5
|
+
import { RollingDigit } from "./rolling-digit"
|
|
4
6
|
|
|
5
7
|
export interface StepperProps {
|
|
6
8
|
value: number
|
|
@@ -35,45 +37,64 @@ export function Stepper({
|
|
|
35
37
|
const current = clampRange(value, min, upper)
|
|
36
38
|
const decrement = stepRange(current, -1, min, upper, step > 0 ? step : 1)
|
|
37
39
|
const increment = stepRange(current, 1, min, upper, step > 0 ? step : 1)
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"focus-visible:outline-accent-text disabled:cursor-not-allowed disabled:opacity-35"
|
|
42
|
-
)
|
|
40
|
+
const displayed = formatValue?.(current) ?? String(current)
|
|
41
|
+
const digits = displayed.replace(/[^0-9]/g, "")
|
|
42
|
+
let digitIndex = 0
|
|
43
43
|
|
|
44
44
|
return (
|
|
45
45
|
<div className={cn("flex min-w-0 items-center justify-between gap-3", className)}>
|
|
46
|
-
<span id={id} className="min-w-0 text-subheadline
|
|
46
|
+
<span id={id} className="min-w-0 text-subheadline text-label">
|
|
47
47
|
{label}
|
|
48
48
|
</span>
|
|
49
|
-
<div className="flex shrink-0 items-center gap-
|
|
50
|
-
<button
|
|
51
|
-
type="button"
|
|
52
|
-
aria-label={labels.decrement}
|
|
53
|
-
aria-describedby={id}
|
|
54
|
-
disabled={disabled || decrement === current}
|
|
55
|
-
onClick={() => onChange(decrement)}
|
|
56
|
-
className={buttonStyle}
|
|
57
|
-
>
|
|
58
|
-
−
|
|
59
|
-
</button>
|
|
49
|
+
<div className="flex shrink-0 items-center gap-3">
|
|
60
50
|
<output
|
|
61
51
|
aria-live="polite"
|
|
62
52
|
aria-labelledby={id}
|
|
63
|
-
className="
|
|
53
|
+
className="text-subheadline tabular-nums text-label"
|
|
64
54
|
>
|
|
65
|
-
|
|
55
|
+
<span aria-hidden="true" className="inline-flex items-center">
|
|
56
|
+
{Array.from(displayed).map((character, index) => {
|
|
57
|
+
if (!/[0-9]/.test(character))
|
|
58
|
+
return <span key={`separator-${index}`}>{character}</span>
|
|
59
|
+
const prefix = digits.slice(0, ++digitIndex)
|
|
60
|
+
const place = digits.length - digitIndex
|
|
61
|
+
return (
|
|
62
|
+
<RollingDigit
|
|
63
|
+
key={`place-${place}`}
|
|
64
|
+
count={Number(prefix)}
|
|
65
|
+
place={place}
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
})}
|
|
69
|
+
</span>
|
|
70
|
+
<span className="sr-only">{displayed}</span>
|
|
66
71
|
</output>
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
<div className="flex items-center divide-x divide-separator overflow-hidden rounded-lg bg-fill">
|
|
73
|
+
<Button
|
|
74
|
+
type="button"
|
|
75
|
+
variant="ghost"
|
|
76
|
+
size="icon"
|
|
77
|
+
aria-label={labels.decrement}
|
|
78
|
+
aria-describedby={id}
|
|
79
|
+
disabled={disabled || decrement === current}
|
|
80
|
+
onClick={() => onChange(decrement)}
|
|
81
|
+
className="size-8 rounded-none text-title3 text-accent-text [@media(pointer:coarse)]:size-11"
|
|
82
|
+
>
|
|
83
|
+
−
|
|
84
|
+
</Button>
|
|
85
|
+
<Button
|
|
86
|
+
type="button"
|
|
87
|
+
variant="ghost"
|
|
88
|
+
size="icon"
|
|
89
|
+
aria-label={labels.increment}
|
|
90
|
+
aria-describedby={id}
|
|
91
|
+
disabled={disabled || increment === current}
|
|
92
|
+
onClick={() => onChange(increment)}
|
|
93
|
+
className="size-8 rounded-none text-title3 text-accent-text [@media(pointer:coarse)]:size-11"
|
|
94
|
+
>
|
|
95
|
+
+
|
|
96
|
+
</Button>
|
|
97
|
+
</div>
|
|
77
98
|
</div>
|
|
78
99
|
</div>
|
|
79
100
|
)
|
|
@@ -22,6 +22,8 @@ export type TextFieldProps = ComponentProps<"input"> & {
|
|
|
22
22
|
/** An interactive control after the text, such as a clear button. Size it to the field: an `xs` or `icon-xs` button, never the default. */
|
|
23
23
|
action?: ReactNode
|
|
24
24
|
revealLabels?: { show: string; hide: string }
|
|
25
|
+
/** Float the label into the outline on focus or when filled. @default "above" */
|
|
26
|
+
labelPlacement?: "above" | "floating"
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export function TextField({
|
|
@@ -32,6 +34,8 @@ export function TextField({
|
|
|
32
34
|
trailingIcon,
|
|
33
35
|
action,
|
|
34
36
|
revealLabels,
|
|
37
|
+
labelPlacement = "above",
|
|
38
|
+
placeholder,
|
|
35
39
|
type = "text",
|
|
36
40
|
id: suppliedId,
|
|
37
41
|
className,
|
|
@@ -43,11 +47,38 @@ export function TextField({
|
|
|
43
47
|
const messages = Array.from(new Set(errors))
|
|
44
48
|
const reveal = type === "password" && revealLabels
|
|
45
49
|
return (
|
|
46
|
-
<div className="flex flex-col gap-2">
|
|
47
|
-
|
|
48
|
-
{
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
<div className="not-typeset flex flex-col gap-2">
|
|
51
|
+
{labelPlacement === "above" && (
|
|
52
|
+
<label htmlFor={id} className="text-subheadline font-semibold">
|
|
53
|
+
{label}
|
|
54
|
+
</label>
|
|
55
|
+
)}
|
|
56
|
+
<div
|
|
57
|
+
className={cn(
|
|
58
|
+
"relative flex items-center gap-2 rounded-xl bg-elevated px-3",
|
|
59
|
+
labelPlacement === "floating"
|
|
60
|
+
? "passui-floating-field"
|
|
61
|
+
: "border border-separator focus-within:ring-1 focus-within:ring-accent/40 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent-text/70"
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
{labelPlacement === "floating" && (
|
|
65
|
+
<>
|
|
66
|
+
<fieldset aria-hidden="true" className="passui-field-outline">
|
|
67
|
+
<legend>
|
|
68
|
+
<span>{label}</span>
|
|
69
|
+
</legend>
|
|
70
|
+
</fieldset>
|
|
71
|
+
<label
|
|
72
|
+
htmlFor={id}
|
|
73
|
+
className={cn(
|
|
74
|
+
"passui-floating-label",
|
|
75
|
+
icon && "passui-floating-label-with-icon"
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
78
|
+
{label}
|
|
79
|
+
</label>
|
|
80
|
+
</>
|
|
81
|
+
)}
|
|
51
82
|
{icon && (
|
|
52
83
|
<span aria-hidden className={fieldIconClasses}>
|
|
53
84
|
{icon}
|
|
@@ -57,12 +88,19 @@ export function TextField({
|
|
|
57
88
|
{...props}
|
|
58
89
|
id={id}
|
|
59
90
|
type={reveal && revealed ? "text" : type}
|
|
60
|
-
|
|
91
|
+
placeholder={placeholder ?? (labelPlacement === "floating" ? " " : label)}
|
|
92
|
+
aria-invalid={messages.length > 0 || props["aria-invalid"]}
|
|
61
93
|
aria-describedby={
|
|
62
|
-
|
|
94
|
+
[
|
|
95
|
+
props["aria-describedby"],
|
|
96
|
+
(messages.length || description) && `${id}-description`,
|
|
97
|
+
]
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.join(" ") || undefined
|
|
63
100
|
}
|
|
64
101
|
className={cn(
|
|
65
102
|
"h-11 w-full min-w-0 bg-transparent text-body text-label outline-none",
|
|
103
|
+
labelPlacement === "floating" && "h-12",
|
|
66
104
|
"placeholder:text-label-tertiary disabled:opacity-40",
|
|
67
105
|
className
|
|
68
106
|
)}
|