@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
|
@@ -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 {
|
|
2
|
-
|
|
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
|
-
|
|
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={
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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:
|
|
237
|
+
overflowY:
|
|
238
|
+
fit.maxHeight !== undefined && (!anchorRef || (present && settled))
|
|
239
|
+
? "auto"
|
|
240
|
+
: undefined,
|
|
147
241
|
...style,
|
|
148
242
|
}}
|
|
149
|
-
className={cn(
|
|
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
|
|
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
|
+
}
|