@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.
Files changed (84) hide show
  1. package/README.md +38 -0
  2. package/THIRD_PARTY_NOTICES.md +25 -0
  3. package/dist/types/src/components/accordion/accordion.d.ts +19 -0
  4. package/dist/types/src/components/aspect-ratio/aspect-ratio.d.ts +7 -0
  5. package/dist/types/src/components/breadcrumb/breadcrumb.d.ts +16 -0
  6. package/dist/types/src/components/button/button.d.ts +2 -0
  7. package/dist/types/src/components/code-block/code-block.d.ts +10 -0
  8. package/dist/types/src/components/combo-box/combo-box.d.ts +9 -0
  9. package/dist/types/src/components/command/command.d.ts +18 -0
  10. package/dist/types/src/components/content-unavailable-view/content-unavailable-view.d.ts +9 -0
  11. package/dist/types/src/components/context-menu/context-menu-store.d.ts +2 -2
  12. package/dist/types/src/components/context-menu/menu-reveal.d.ts +3 -8
  13. package/dist/types/src/components/dialog/dialog-store.d.ts +3 -1
  14. package/dist/types/src/components/disclosure-group/disclosure-group.d.ts +3 -1
  15. package/dist/types/src/components/keyboard-shortcut/keyboard-shortcut.d.ts +5 -0
  16. package/dist/types/src/components/menu/menu.d.ts +1 -1
  17. package/dist/types/src/components/one-time-code/one-time-code.d.ts +23 -0
  18. package/dist/types/src/components/picker/picker.d.ts +7 -1
  19. package/dist/types/src/components/popover/anchored-surface.d.ts +13 -0
  20. package/dist/types/src/components/popover/popover.d.ts +5 -1
  21. package/dist/types/src/components/progress-view/progress-view.d.ts +1 -1
  22. package/dist/types/src/components/progress-view/use-liquid-progress.d.ts +3 -0
  23. package/dist/types/src/components/radio-group/radio-group.d.ts +17 -0
  24. package/dist/types/src/components/search-field/expanding-search-field.d.ts +5 -0
  25. package/dist/types/src/components/search-field/search-field.d.ts +5 -1
  26. package/dist/types/src/components/sheet/sheet.d.ts +2 -0
  27. package/dist/types/src/components/sidebar/sidebar.d.ts +3 -2
  28. package/dist/types/src/components/skeleton/skeleton.d.ts +6 -0
  29. package/dist/types/src/components/slosh-slider/liquid-step.d.ts +6 -0
  30. package/dist/types/src/components/slosh-slider/slosh-slider.d.ts +18 -0
  31. package/dist/types/src/components/stepper/rolling-digit.d.ts +11 -0
  32. package/dist/types/src/components/text-field/text-field.d.ts +3 -1
  33. package/dist/types/src/components/wake-slider/wake-slider.d.ts +12 -0
  34. package/dist/types/src/hooks/use-anchored-disclosure.d.ts +9 -0
  35. package/dist/types/src/hooks/use-anchored-placement.d.ts +1 -1
  36. package/dist/types/src/index.d.ts +13 -0
  37. package/dist/types/src/lib/anchored-placement.d.ts +2 -2
  38. package/package.json +60 -4
  39. package/src/components/accordion/accordion.tsx +48 -0
  40. package/src/components/aspect-ratio/aspect-ratio.tsx +22 -0
  41. package/src/components/breadcrumb/breadcrumb.tsx +53 -0
  42. package/src/components/button/button.tsx +5 -1
  43. package/src/components/code-block/code-block.tsx +41 -0
  44. package/src/components/combo-box/combo-box.tsx +13 -0
  45. package/src/components/command/command.tsx +119 -0
  46. package/src/components/compact-date-picker/compact-date-picker.tsx +40 -84
  47. package/src/components/compact-time-picker/compact-time-picker.tsx +44 -81
  48. package/src/components/content-unavailable-view/content-unavailable-view.tsx +37 -0
  49. package/src/components/context-menu/context-menu-host.tsx +28 -24
  50. package/src/components/context-menu/context-menu-store.ts +3 -4
  51. package/src/components/context-menu/menu-reveal.ts +5 -2
  52. package/src/components/dialog/dialog-host.tsx +13 -7
  53. package/src/components/dialog/dialog-store.ts +15 -4
  54. package/src/components/disclosure-group/disclosure-group.tsx +19 -10
  55. package/src/components/keyboard-shortcut/keyboard-shortcut.tsx +17 -0
  56. package/src/components/menu/menu.tsx +1 -1
  57. package/src/components/one-time-code/one-time-code.tsx +198 -0
  58. package/src/components/picker/picker.tsx +121 -108
  59. package/src/components/popover/anchored-surface.tsx +223 -0
  60. package/src/components/popover/popover.tsx +149 -20
  61. package/src/components/progress-view/progress-view.tsx +6 -1
  62. package/src/components/progress-view/use-liquid-progress.ts +43 -0
  63. package/src/components/radio-group/radio-group.tsx +63 -0
  64. package/src/components/search-field/expanding-search-field.tsx +183 -0
  65. package/src/components/search-field/search-field.tsx +41 -25
  66. package/src/components/search-list/search-list.tsx +16 -7
  67. package/src/components/sheet/sheet.tsx +4 -1
  68. package/src/components/sidebar/sidebar.tsx +21 -12
  69. package/src/components/skeleton/skeleton.tsx +19 -0
  70. package/src/components/slosh-slider/liquid-step.ts +42 -0
  71. package/src/components/slosh-slider/slosh-slider.tsx +251 -0
  72. package/src/components/stepper/rolling-digit.tsx +77 -0
  73. package/src/components/stepper/stepper.tsx +50 -29
  74. package/src/components/text-field/text-field.tsx +45 -7
  75. package/src/components/toggle/toggle.tsx +147 -30
  76. package/src/components/wake-slider/wake-slider.tsx +237 -0
  77. package/src/hooks/use-anchored-disclosure.ts +23 -0
  78. package/src/hooks/use-anchored-placement.ts +7 -4
  79. package/src/icons/icons.tsx +150 -150
  80. package/src/index.ts +18 -0
  81. package/src/lib/anchored-placement.ts +7 -3
  82. package/src/styles/code-block.css +33 -0
  83. package/src/styles/motion-controls.css +189 -0
  84. package/src/styles/styles.css +3 -0
@@ -1,5 +1,13 @@
1
- import { motion, useReducedMotion } from "motion/react"
2
- import { useEffect, useRef } from "react"
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 className="flex cursor-pointer items-center gap-3 text-subheadline">
42
- <input
43
- ref={input}
44
- type="checkbox"
45
- aria-checked={mixed ? "mixed" : checked}
46
- checked={checked}
47
- disabled={disabled}
48
- onChange={(event) => onChange(event.target.checked)}
49
- className={cn(
50
- "size-5 accent-accent",
51
- "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-text disabled:opacity-40"
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
- haptic("selection")
67
- onChange(!checked)
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
- "group/switch flex h-[30px] w-[50px] shrink-0 items-center rounded-full p-[2px] transition-colors duration-normal",
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
- {/* The thumb stretches while pressed, as on iOS. */}
78
- <motion.span
79
- layout={!reducedMotion}
80
- transition={reducedMotion ? { duration: 0 } : spring.snappy}
81
- className={cn(
82
- "h-[26px] w-[26px] rounded-full bg-white shadow-[0_0_0_0.5px_rgb(0_0_0/0.04),0_2px_4px_rgb(0_0_0/0.16),0_3px_8px_rgb(0_0_0/0.10)]",
83
- !reducedMotion &&
84
- "transition-[width] duration-fast group-active/switch:w-[32px]"
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(() => placeAnchored(anchor, width, needed))
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
  }