@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
@@ -0,0 +1,223 @@
1
+ import { springs } from "@passui/tokens/motion"
2
+ import { useMediaQuery } from "../../hooks/use-media-query"
3
+ import { forwardRef, type HTMLAttributes, useLayoutEffect, useRef, useState } from "react"
4
+ import { useAnchoredPlacement } from "../../hooks/use-anchored-placement"
5
+ import { FLOATING_LAYER } from "../../hooks/use-dismiss"
6
+ import { useFloatingDismiss } from "../../hooks/use-floating-dismiss"
7
+ import { cn } from "../../lib/class-names"
8
+ import { registerFloatingLayer } from "../../lib/floating-layers"
9
+ import { concealMenuSource } from "../context-menu/conceal-menu-source"
10
+ import { menuSurfaceReveal } from "../context-menu/menu-reveal"
11
+
12
+ interface AnchoredSurfaceProps extends HTMLAttributes<HTMLDivElement> {
13
+ open: boolean
14
+ onExited: () => void
15
+ anchor: HTMLElement
16
+ width: number
17
+ needed: number
18
+ onClose: (restoreFocus: boolean) => void
19
+ contentClassName?: string
20
+ }
21
+
22
+ /** Shared material and lifetime for portaled pickers; their content owns selection. */
23
+ export const AnchoredSurface = forwardRef<HTMLDivElement, AnchoredSurfaceProps>(
24
+ function AnchoredSurface(
25
+ {
26
+ open: present,
27
+ onExited: remove,
28
+ anchor,
29
+ width,
30
+ needed,
31
+ onClose,
32
+ children,
33
+ className,
34
+ contentClassName,
35
+ ...props
36
+ },
37
+ forwardedRef
38
+ ) {
39
+ const root = useRef<HTMLDivElement>(null)
40
+ const reducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)")
41
+ const material = useRef<HTMLDivElement>(null)
42
+ const content = useRef<HTMLDivElement>(null)
43
+ const animated = useRef<{ values: number[]; velocities: number[]; opacity: number } | null>(
44
+ null
45
+ )
46
+ const placement = useAnchoredPlacement(anchor, width, needed, true)
47
+ const [geometry, setGeometry] = useState<{
48
+ source: ReturnType<typeof menuSurfaceReveal>
49
+ width: number
50
+ height: number
51
+ radius: number
52
+ } | null>(null)
53
+
54
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Placement can move without resizing; remeasure the source-relative reveal.
55
+ useLayoutEffect(() => {
56
+ const panel = root.current
57
+ if (!panel || !present) return
58
+ const measure = () => {
59
+ const target = panel.getBoundingClientRect()
60
+ const source = anchor.getBoundingClientRect()
61
+ const radius = Number.parseFloat(getComputedStyle(panel).borderTopLeftRadius) || 0
62
+ const reveal = menuSurfaceReveal(source, target)
63
+ setGeometry((current) =>
64
+ current &&
65
+ current.width === target.width &&
66
+ current.height === target.height &&
67
+ current.radius === radius &&
68
+ current.source.x === reveal.x &&
69
+ current.source.y === reveal.y &&
70
+ current.source.width === reveal.width &&
71
+ current.source.height === reveal.height
72
+ ? current
73
+ : { source: reveal, width: target.width, height: target.height, radius }
74
+ )
75
+ }
76
+ measure()
77
+ const observer = new ResizeObserver(measure)
78
+ observer.observe(panel)
79
+ return () => observer.disconnect()
80
+ }, [anchor, present, placement.left, placement.top, placement.bottom, placement.width])
81
+
82
+ useLayoutEffect(() => {
83
+ if (!root.current) return
84
+ const unregister = registerFloatingLayer(root.current, anchor)
85
+ const restore = concealMenuSource(anchor, false)
86
+ return () => {
87
+ unregister()
88
+ restore()
89
+ }
90
+ }, [anchor])
91
+
92
+ useLayoutEffect(() => {
93
+ if (!geometry || !material.current || !content.current) {
94
+ if (!present) remove?.()
95
+ return
96
+ }
97
+ const pane = material.current,
98
+ body = content.current
99
+ const source = [
100
+ geometry.source.x,
101
+ geometry.source.y,
102
+ geometry.source.width,
103
+ geometry.source.height,
104
+ geometry.source.borderRadius,
105
+ ]
106
+ const target = present
107
+ ? [0, 0, geometry.width, geometry.height, geometry.radius]
108
+ : source
109
+ const state = animated.current ?? {
110
+ values: [...source],
111
+ velocities: source.map(() => 0),
112
+ opacity: 0,
113
+ }
114
+ animated.current = state
115
+ let frame = 0,
116
+ previous = 0
117
+ const paint = () => {
118
+ const [x, y, width, height, radius] = state.values
119
+ pane.style.transform = `translate(${x}px, ${y}px)`
120
+ pane.style.width = `${Math.max(0, width)}px`
121
+ pane.style.height = `${Math.max(0, height)}px`
122
+ pane.style.borderRadius = `${Math.max(0, radius)}px`
123
+ body.style.opacity = String(state.opacity)
124
+ }
125
+ const tick = (now: number) => {
126
+ const dt = previous ? Math.min(0.032, (now - previous) / 1000) : 1 / 60
127
+ previous = now
128
+ const { stiffness, damping, mass } = springs.smooth
129
+ let resting = true
130
+ for (let index = 0; index < target.length; index++) {
131
+ state.velocities[index] +=
132
+ (((target[index] - state.values[index]) * stiffness -
133
+ state.velocities[index] * damping) /
134
+ mass) *
135
+ dt
136
+ state.values[index] += state.velocities[index] * dt
137
+ if (
138
+ Math.abs(target[index] - state.values[index]) > 0.05 ||
139
+ Math.abs(state.velocities[index]) > 0.05
140
+ )
141
+ resting = false
142
+ }
143
+ const progress = Math.max(
144
+ 0,
145
+ Math.min(
146
+ 1,
147
+ 1 -
148
+ Math.abs(geometry.height - state.values[3]) /
149
+ Math.max(1, Math.abs(geometry.height - source[3]))
150
+ )
151
+ )
152
+ const opacity = present ? Math.max(0, (progress - 0.25) / 0.75) : 0
153
+ state.opacity += (opacity - state.opacity) * (1 - Math.exp(-dt / 0.04))
154
+ paint()
155
+ if (resting && Math.abs(opacity - state.opacity) < 0.01) {
156
+ state.values = [...target]
157
+ state.velocities.fill(0)
158
+ state.opacity = present ? 1 : 0
159
+ paint()
160
+ if (!present) remove?.()
161
+ return
162
+ }
163
+ frame = requestAnimationFrame(tick)
164
+ }
165
+ if (reducedMotion) {
166
+ state.values = [...target]
167
+ state.velocities.fill(0)
168
+ state.opacity = present ? 1 : 0
169
+ paint()
170
+ if (!present) remove?.()
171
+ } else {
172
+ paint()
173
+ frame = requestAnimationFrame(tick)
174
+ }
175
+ return () => cancelAnimationFrame(frame)
176
+ }, [geometry, present, remove, reducedMotion])
177
+
178
+ useFloatingDismiss(root, anchor, (restore) => {
179
+ if (present) onClose(restore)
180
+ })
181
+
182
+ return (
183
+ <div
184
+ {...props}
185
+ ref={(node) => {
186
+ root.current = node
187
+ if (typeof forwardedRef === "function") forwardedRef(node)
188
+ else if (forwardedRef) forwardedRef.current = node
189
+ }}
190
+ {...{ [FLOATING_LAYER]: "" }}
191
+ inert={!present}
192
+ className={cn(
193
+ "fixed z-[80] isolate flex min-w-0 flex-col rounded-3xl outline-none",
194
+ !present && "pointer-events-none",
195
+ className
196
+ )}
197
+ style={{
198
+ left: placement.left,
199
+ top: placement.top,
200
+ bottom: placement.bottom,
201
+ width: placement.width,
202
+ maxHeight: placement.maxHeight ?? 420,
203
+ }}
204
+ >
205
+ <div
206
+ ref={material}
207
+ aria-hidden="true"
208
+ className="glass-menu pointer-events-none absolute top-0 left-0 -z-10"
209
+ />
210
+ <div
211
+ ref={content}
212
+ style={{ opacity: 0 }}
213
+ className={cn(
214
+ "flex min-h-0 flex-col overflow-y-auto overscroll-contain",
215
+ contentClassName
216
+ )}
217
+ >
218
+ {children}
219
+ </div>
220
+ </div>
221
+ )
222
+ }
223
+ )
@@ -1,16 +1,27 @@
1
- import { AnimatePresence, type HTMLMotionProps, motion, useReducedMotion } from "motion/react"
2
- import { useLayoutEffect, useRef, useState } from "react"
1
+ import {
2
+ AnimatePresence,
3
+ type HTMLMotionProps,
4
+ motion,
5
+ useIsPresent,
6
+ useReducedMotion,
7
+ } from "motion/react"
8
+ import { type ReactNode, type RefObject, useLayoutEffect, useRef, useState } from "react"
9
+ import { concealMenuSource } from "../context-menu/conceal-menu-source"
10
+ import { menuSurfaceReveal } from "../context-menu/menu-reveal"
3
11
  import { cn } from "../../lib/class-names"
4
12
  import { screenEdges } from "../../lib/screen-edges"
5
- import { floatingPanelMotion } from "../../motion/transitions"
13
+ import { floatingPanelMotion, spring } from "../../motion/transitions"
6
14
  import { fitPopover, type PopoverFit } from "./popover-fit"
7
15
 
8
16
  export type PopoverOrigin = "top-left" | "top-right"
9
17
 
10
- export interface PopoverProps extends HTMLMotionProps<"div"> {
18
+ export interface PopoverProps extends Omit<HTMLMotionProps<"div">, "children"> {
11
19
  open: boolean
20
+ children?: ReactNode
12
21
  /** Which corner the popover grows from. */
13
22
  origin?: PopoverOrigin
23
+ /** Trigger whose shape the popover uses during its opening and closing motion. */
24
+ anchorRef?: RefObject<HTMLElement | null>
14
25
  }
15
26
 
16
27
  /**
@@ -25,6 +36,7 @@ export function Popover({ open, ...props }: PopoverProps) {
25
36
 
26
37
  function OpenPopover({
27
38
  origin = "top-left",
39
+ anchorRef,
28
40
  className,
29
41
  style,
30
42
  children,
@@ -32,12 +44,16 @@ function OpenPopover({
32
44
  }: Omit<PopoverProps, "open">) {
33
45
  const ref = useRef<HTMLDivElement>(null)
34
46
  const reducedMotion = useReducedMotion()
47
+ const present = useIsPresent()
48
+ const [settled, setSettled] = useState(false)
49
+ const [material, setMaterial] = useState({ width: 0, height: 0, radius: 0 })
35
50
  const [fit, setFit] = useState<PopoverFit>({
36
51
  shiftX: 0,
37
52
  shiftY: 0,
38
53
  maxWidth: 0,
39
54
  above: false,
40
55
  })
56
+ const [reveal, setReveal] = useState<ReturnType<typeof menuSurfaceReveal> | null>(null)
41
57
  const popoverMotion = floatingPanelMotion(fit.above ? "above" : "below")
42
58
 
43
59
  useLayoutEffect(() => {
@@ -56,6 +72,14 @@ function OpenPopover({
56
72
  element.style.maxWidth = "none"
57
73
  const rect = element.getBoundingClientRect()
58
74
  const neededHeight = Math.max(rect.height, element.scrollHeight)
75
+ const radius = Number.parseFloat(getComputedStyle(element).borderTopLeftRadius) || 0
76
+ setMaterial((current) =>
77
+ current.width === rect.width &&
78
+ current.height === neededHeight &&
79
+ current.radius === radius
80
+ ? current
81
+ : { width: rect.width, height: neededHeight, radius }
82
+ )
59
83
  element.style.transform = transform
60
84
  element.style.translate = translate
61
85
  element.style.maxHeight = maxHeight
@@ -70,14 +94,20 @@ function OpenPopover({
70
94
  offsetParent instanceof HTMLElement && offsetParent !== document.body
71
95
  ? offsetParent.getBoundingClientRect()
72
96
  : undefined
97
+ const trigger = anchorRef?.current?.getBoundingClientRect()
98
+ const target = {
99
+ left: trigger
100
+ ? origin === "top-right"
101
+ ? trigger.right - rect.width
102
+ : trigger.left
103
+ : rect.left,
104
+ top: trigger?.top ?? rect.top,
105
+ width: rect.width,
106
+ height: neededHeight,
107
+ }
73
108
  const next = fitPopover(
74
- {
75
- left: rect.left,
76
- top: rect.top,
77
- width: rect.width,
78
- height: neededHeight,
79
- },
80
- anchor,
109
+ target,
110
+ trigger ?? anchor,
81
111
  {
82
112
  viewport: {
83
113
  width: offsetLeft + (visualViewport?.width ?? window.innerWidth),
@@ -89,9 +119,11 @@ function OpenPopover({
89
119
  top: offsetTop + edges.top,
90
120
  },
91
121
  },
92
- 6,
122
+ trigger ? -trigger.height : 6,
93
123
  origin
94
124
  )
125
+ next.shiftX += target.left - rect.left
126
+ next.shiftY += target.top - rect.top
95
127
  setFit((current) => {
96
128
  if (
97
129
  current.shiftX === next.shiftX &&
@@ -125,16 +157,75 @@ function OpenPopover({
125
157
  window.removeEventListener("resize", scheduleRefit)
126
158
  window.removeEventListener("scroll", scheduleRefit, true)
127
159
  }
128
- }, [origin])
160
+ }, [origin, anchorRef])
161
+
162
+ useLayoutEffect(() => {
163
+ const source = anchorRef?.current
164
+ const panel = ref.current
165
+ if (!source || !panel) return
166
+ const sourceRect = source.getBoundingClientRect()
167
+ const panelRect = panel.getBoundingClientRect()
168
+ setReveal(
169
+ menuSurfaceReveal(
170
+ {
171
+ left: sourceRect.left,
172
+ top: sourceRect.top,
173
+ width: sourceRect.width,
174
+ height: sourceRect.height,
175
+ },
176
+ {
177
+ left: panelRect.left,
178
+ top: panelRect.top,
179
+ width: fit.maxWidth || panelRect.width,
180
+ height: fit.maxHeight
181
+ ? Math.min(fit.maxHeight, panelRect.height)
182
+ : panelRect.height,
183
+ }
184
+ )
185
+ )
186
+ const restoreSource = concealMenuSource(source, false)
187
+ return () => {
188
+ restoreSource()
189
+ }
190
+ }, [anchorRef, fit])
129
191
 
130
192
  return (
131
193
  <motion.div
132
194
  ref={ref}
133
- {...popoverMotion}
134
- initial={reducedMotion ? { opacity: 0 } : popoverMotion.initial}
135
- animate={reducedMotion ? { opacity: 1 } : popoverMotion.animate}
136
- exit={reducedMotion ? { opacity: 0 } : popoverMotion.exit}
137
- transition={reducedMotion ? { duration: 0 } : popoverMotion.transition}
195
+ {...(!anchorRef && popoverMotion)}
196
+ initial={
197
+ anchorRef
198
+ ? { "--popover-content-opacity": reducedMotion ? 1 : 0 }
199
+ : reducedMotion
200
+ ? { opacity: 0 }
201
+ : popoverMotion.initial
202
+ }
203
+ animate={
204
+ anchorRef
205
+ ? { "--popover-content-opacity": 1 }
206
+ : reducedMotion
207
+ ? { opacity: 1 }
208
+ : popoverMotion.animate
209
+ }
210
+ exit={
211
+ anchorRef
212
+ ? {
213
+ "--popover-content-opacity": 0,
214
+ transition: { duration: reducedMotion ? 0 : 0.06 },
215
+ }
216
+ : reducedMotion
217
+ ? { opacity: 0 }
218
+ : popoverMotion.exit
219
+ }
220
+ transition={
221
+ anchorRef
222
+ ? reducedMotion
223
+ ? { duration: 0 }
224
+ : { duration: 0.16, delay: 0.06 }
225
+ : reducedMotion
226
+ ? { duration: 0 }
227
+ : popoverMotion.transition
228
+ }
138
229
  style={{
139
230
  transformOrigin:
140
231
  origin === "top-left"
@@ -143,12 +234,50 @@ function OpenPopover({
143
234
  translate: `${fit.shiftX}px ${fit.shiftY}px`,
144
235
  maxWidth: fit.maxWidth || undefined,
145
236
  maxHeight: fit.maxHeight,
146
- overflowY: fit.maxHeight !== undefined ? "auto" : undefined,
237
+ overflowY:
238
+ fit.maxHeight !== undefined && (!anchorRef || (present && settled))
239
+ ? "auto"
240
+ : undefined,
147
241
  ...style,
148
242
  }}
149
- className={cn("glass-menu absolute z-40 rounded-5xl", className)}
243
+ className={cn(
244
+ anchorRef
245
+ ? "isolate [&>:not([data-popover-surface])]:opacity-[var(--popover-content-opacity)]"
246
+ : "glass-menu",
247
+ "absolute z-40 rounded-5xl outline-none",
248
+ className
249
+ )}
150
250
  {...props}
151
251
  >
252
+ {anchorRef && (
253
+ <motion.div
254
+ key={reveal ? "surface" : "measure"}
255
+ aria-hidden="true"
256
+ data-popover-surface=""
257
+ onAnimationComplete={() => {
258
+ if (reveal) setSettled(true)
259
+ }}
260
+ className="glass-menu pointer-events-none absolute top-0 left-0 -z-10 rounded-5xl"
261
+ style={{ width: "100%", height: "100%" }}
262
+ initial={reducedMotion || !reveal ? false : reveal}
263
+ animate={
264
+ reveal
265
+ ? {
266
+ x: 0,
267
+ y: 0,
268
+ width: material.width,
269
+ height: fit.maxHeight
270
+ ? Math.min(material.height, fit.maxHeight)
271
+ : material.height,
272
+ opacity: 1,
273
+ borderRadius: material.radius,
274
+ }
275
+ : undefined
276
+ }
277
+ exit={reducedMotion ? { opacity: 0 } : (reveal ?? { opacity: 0 })}
278
+ transition={reducedMotion ? { duration: 0 } : spring.smooth}
279
+ />
280
+ )}
152
281
  {children}
153
282
  </motion.div>
154
283
  )
@@ -1,4 +1,5 @@
1
- import type { HTMLAttributes } from "react"
1
+ import { type HTMLAttributes, useRef } from "react"
2
+ import { useLiquidProgress } from "./use-liquid-progress"
2
3
  import { cn } from "../../lib/class-names"
3
4
  import { clampRange } from "../../lib/range-value"
4
5
 
@@ -33,6 +34,8 @@ export function ProgressView({
33
34
  const limit = Number.isFinite(max) && max > 0 ? max : 100
34
35
  const current = value === undefined ? undefined : clampRange(value, 0, limit)
35
36
  const progress = current === undefined ? undefined : (current / limit) * 100
37
+ const fill = useRef<HTMLDivElement>(null)
38
+ useLiquidProgress(fill, progress)
36
39
 
37
40
  return (
38
41
  <div {...props} className={cn("flex min-w-0 flex-col gap-2", className)}>
@@ -52,9 +55,11 @@ export function ProgressView({
52
55
  )}
53
56
  >
54
57
  <div
58
+ ref={fill}
55
59
  aria-hidden="true"
56
60
  className={cn(
57
61
  "h-full rounded-full bg-accent",
62
+ current !== undefined && "passui-progress-fill",
58
63
  current === undefined && "w-1/3 animate-pulse motion-reduce:animate-none"
59
64
  )}
60
65
  style={progress === undefined ? undefined : { width: `${progress}%` }}
@@ -0,0 +1,43 @@
1
+ import { type RefObject, useLayoutEffect, useRef } from "react"
2
+ import { useMediaQuery } from "../../hooks/use-media-query"
3
+ import { clampRange } from "../../lib/range-value"
4
+ import { stepLiquid } from "../slosh-slider/liquid-step"
5
+
6
+ /** Read-only progress shares the liquid fill without acquiring slider semantics. */
7
+ export function useLiquidProgress(
8
+ ref: RefObject<HTMLDivElement | null>,
9
+ target: number | undefined
10
+ ) {
11
+ const reduced = useMediaQuery("(prefers-reduced-motion: reduce)")
12
+ const state = useRef({ fill: target ?? 0, velocity: 0 })
13
+ useLayoutEffect(() => {
14
+ const element = ref.current
15
+ if (!element || target === undefined) return
16
+ let frame = 0
17
+ let previous = 0
18
+ const paint = () => {
19
+ element.style.width = `${state.current.fill}%`
20
+ element.style.setProperty(
21
+ "--progress-lean",
22
+ `${reduced ? 0 : clampRange(state.current.velocity * 0.4, -4, 4)}px`
23
+ )
24
+ }
25
+ if (reduced) {
26
+ state.current = { fill: target, velocity: 0 }
27
+ paint()
28
+ return
29
+ }
30
+ const tick = (now: number) => {
31
+ const dt = previous ? Math.min(2.5, (now - previous) / 16.67) : 1
32
+ previous = now
33
+ state.current = stepLiquid(state.current, target, 15, dt)
34
+ paint()
35
+ if (state.current.fill !== target || state.current.velocity)
36
+ frame = requestAnimationFrame(tick)
37
+ }
38
+ paint()
39
+ if (state.current.fill !== target || state.current.velocity)
40
+ frame = requestAnimationFrame(tick)
41
+ return () => cancelAnimationFrame(frame)
42
+ }, [ref, target, reduced])
43
+ }
@@ -0,0 +1,63 @@
1
+ import { useId } from "react"
2
+ import { cn } from "../../lib/class-names"
3
+
4
+ export interface RadioGroupOption<T extends string> {
5
+ value: T
6
+ label: string
7
+ description?: string
8
+ disabled?: boolean
9
+ }
10
+ export interface RadioGroupProps<T extends string> {
11
+ value: T
12
+ onChange: (value: NoInfer<T>) => void
13
+ label: string
14
+ options: readonly RadioGroupOption<T>[]
15
+ disabled?: boolean
16
+ name?: string
17
+ className?: string
18
+ }
19
+ /** A labelled choice group using the browser's native radio keyboard behavior. */
20
+ export function RadioGroup<T extends string>({
21
+ value,
22
+ onChange,
23
+ label,
24
+ options,
25
+ disabled,
26
+ name,
27
+ className,
28
+ }: RadioGroupProps<T>) {
29
+ const id = useId()
30
+ return (
31
+ <fieldset disabled={disabled} className={cn("flex min-w-0 flex-col gap-2", className)}>
32
+ <legend className="mb-2 text-footnote font-medium text-label-secondary">{label}</legend>
33
+ {options.map((option, index) => (
34
+ <label
35
+ key={option.value}
36
+ className="flex min-h-8 items-start gap-2 text-subheadline text-label has-disabled:opacity-40 [@media(pointer:coarse)]:min-h-11"
37
+ >
38
+ <input
39
+ type="radio"
40
+ name={name ?? id}
41
+ value={option.value}
42
+ checked={value === option.value}
43
+ disabled={option.disabled}
44
+ onChange={() => onChange(option.value)}
45
+ aria-describedby={option.description ? `${id}-${index}` : undefined}
46
+ className="mt-1 size-4 shrink-0 accent-accent-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-text"
47
+ />
48
+ <span>
49
+ {option.label}
50
+ {option.description && (
51
+ <span
52
+ id={`${id}-${index}`}
53
+ className="block text-footnote text-label-secondary"
54
+ >
55
+ {option.description}
56
+ </span>
57
+ )}
58
+ </span>
59
+ </label>
60
+ ))}
61
+ </fieldset>
62
+ )
63
+ }