@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,183 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type InputHTMLAttributes,
|
|
3
|
+
type Ref,
|
|
4
|
+
useEffect,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from "react"
|
|
9
|
+
import { useMediaQuery } from "../../hooks/use-media-query"
|
|
10
|
+
import { SearchIcon } from "../../icons/icons"
|
|
11
|
+
import { cn } from "../../lib/class-names"
|
|
12
|
+
import { springs } from "@passui/tokens/motion"
|
|
13
|
+
|
|
14
|
+
/** One object: the field stays inside the pill, and the lens never moves within it. */
|
|
15
|
+
export function ExpandingSearchField({
|
|
16
|
+
inputRef,
|
|
17
|
+
className,
|
|
18
|
+
placeholder = "Search",
|
|
19
|
+
...props
|
|
20
|
+
}: InputHTMLAttributes<HTMLInputElement> & { inputRef: Ref<HTMLInputElement> }) {
|
|
21
|
+
const [expanded, setExpanded] = useState(Boolean(props.value ?? props.defaultValue))
|
|
22
|
+
const reduced = useMediaQuery("(prefers-reduced-motion: reduce)")
|
|
23
|
+
const frame = useRef<HTMLDivElement>(null)
|
|
24
|
+
const pill = useRef<HTMLLabelElement>(null)
|
|
25
|
+
const input = useRef<HTMLInputElement>(null)
|
|
26
|
+
const geometry = useRef({
|
|
27
|
+
width: expanded ? 320 : 44,
|
|
28
|
+
velocity: 0,
|
|
29
|
+
x: 0,
|
|
30
|
+
y: 0,
|
|
31
|
+
vx: 0,
|
|
32
|
+
vy: 0,
|
|
33
|
+
pullX: 0,
|
|
34
|
+
pullY: 0,
|
|
35
|
+
capacity: 320,
|
|
36
|
+
raf: 0,
|
|
37
|
+
})
|
|
38
|
+
const current = useRef({ expanded, reduced })
|
|
39
|
+
const wake = useRef<() => void>(() => {})
|
|
40
|
+
useLayoutEffect(() => {
|
|
41
|
+
const box = frame.current,
|
|
42
|
+
label = pill.current,
|
|
43
|
+
field = input.current
|
|
44
|
+
if (!box || !label || !field) return
|
|
45
|
+
const s = geometry.current
|
|
46
|
+
let previous = 0
|
|
47
|
+
const tick = (now: number) => {
|
|
48
|
+
const dt = previous ? Math.min(0.032, (now - previous) / 1000) : 1 / 60
|
|
49
|
+
previous = now
|
|
50
|
+
const target = current.current.expanded ? s.capacity : Math.min(44, s.capacity)
|
|
51
|
+
const pullX = current.current.expanded ? 0 : s.pullX
|
|
52
|
+
const pullY = current.current.expanded ? 0 : s.pullY
|
|
53
|
+
const { stiffness, damping, mass } = springs.smooth
|
|
54
|
+
if (current.current.reduced) {
|
|
55
|
+
s.width = target
|
|
56
|
+
s.x = 0
|
|
57
|
+
s.y = 0
|
|
58
|
+
s.velocity = 0
|
|
59
|
+
s.vx = 0
|
|
60
|
+
s.vy = 0
|
|
61
|
+
} else {
|
|
62
|
+
s.velocity += (((target - s.width) * stiffness - s.velocity * damping) / mass) * dt
|
|
63
|
+
s.width += s.velocity * dt
|
|
64
|
+
s.vx += (((pullX - s.x) * stiffness - s.vx * damping) / mass) * dt
|
|
65
|
+
s.vy += (((pullY - s.y) * stiffness - s.vy * damping) / mass) * dt
|
|
66
|
+
s.x += s.vx * dt
|
|
67
|
+
s.y += s.vy * dt
|
|
68
|
+
}
|
|
69
|
+
label.style.width = `${Math.max(0, s.width)}px`
|
|
70
|
+
label.style.transform = `translate(${s.x}px, ${s.y}px)`
|
|
71
|
+
/* One number drives the field's reveal; focus lands with the
|
|
72
|
+
expansion, so the caret arrives while the shape is growing. */
|
|
73
|
+
const progress = (s.width - 44) / Math.max(1, s.capacity - 44)
|
|
74
|
+
field.style.opacity = String(Math.max(0, Math.min(1, (progress - 0.55) / 0.45)))
|
|
75
|
+
if (
|
|
76
|
+
Math.abs(s.width - target) < 0.05 &&
|
|
77
|
+
Math.abs(s.velocity) < 0.05 &&
|
|
78
|
+
Math.abs(s.x - pullX) + Math.abs(s.y - pullY) + Math.abs(s.vx) + Math.abs(s.vy) <
|
|
79
|
+
0.05
|
|
80
|
+
) {
|
|
81
|
+
label.style.width = `${target}px`
|
|
82
|
+
s.raf = 0
|
|
83
|
+
previous = 0
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
s.raf = requestAnimationFrame(tick)
|
|
87
|
+
}
|
|
88
|
+
wake.current = () => {
|
|
89
|
+
if (!s.raf) s.raf = requestAnimationFrame(tick)
|
|
90
|
+
}
|
|
91
|
+
const measure = () => {
|
|
92
|
+
s.capacity = Math.min(320, box.clientWidth)
|
|
93
|
+
wake.current()
|
|
94
|
+
}
|
|
95
|
+
measure()
|
|
96
|
+
const observer = new ResizeObserver(measure)
|
|
97
|
+
observer.observe(box)
|
|
98
|
+
return () => {
|
|
99
|
+
observer.disconnect()
|
|
100
|
+
cancelAnimationFrame(s.raf)
|
|
101
|
+
s.raf = 0
|
|
102
|
+
}
|
|
103
|
+
}, [])
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
current.current = { expanded, reduced }
|
|
106
|
+
wake.current()
|
|
107
|
+
}, [expanded, reduced])
|
|
108
|
+
return (
|
|
109
|
+
<div
|
|
110
|
+
ref={frame}
|
|
111
|
+
className={cn(
|
|
112
|
+
"relative flex h-11 w-full max-w-xs items-center justify-center",
|
|
113
|
+
className
|
|
114
|
+
)}
|
|
115
|
+
onPointerMove={(event) => {
|
|
116
|
+
if (expanded || reduced || event.pointerType !== "mouse") return
|
|
117
|
+
// Measure the frame, which stays still; measuring the object being
|
|
118
|
+
// moved creates a feedback loop and a visible shiver.
|
|
119
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
120
|
+
const dx = event.clientX - rect.left - rect.width / 2,
|
|
121
|
+
dy = event.clientY - rect.top - rect.height / 2
|
|
122
|
+
const distance = Math.hypot(dx, dy),
|
|
123
|
+
pull = Math.max(0, 1 - distance / 72) ** 1.4 * 7
|
|
124
|
+
geometry.current.pullX = distance ? (dx / distance) * pull : 0
|
|
125
|
+
geometry.current.pullY = distance ? (dy / distance) * pull : 0
|
|
126
|
+
wake.current()
|
|
127
|
+
}}
|
|
128
|
+
onPointerLeave={() => {
|
|
129
|
+
geometry.current.pullX = 0
|
|
130
|
+
geometry.current.pullY = 0
|
|
131
|
+
wake.current()
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
<label
|
|
135
|
+
ref={pill}
|
|
136
|
+
className={cn(
|
|
137
|
+
"passui-search-pill control-field relative block h-11 max-w-full overflow-hidden rounded-full",
|
|
138
|
+
props.disabled && "opacity-40"
|
|
139
|
+
)}
|
|
140
|
+
style={{ width: expanded ? 320 : 44 }}
|
|
141
|
+
>
|
|
142
|
+
<SearchIcon
|
|
143
|
+
aria-hidden="true"
|
|
144
|
+
size={18}
|
|
145
|
+
className="pointer-events-none absolute top-1/2 start-[13px] -translate-y-1/2 text-label-secondary"
|
|
146
|
+
/>
|
|
147
|
+
<input
|
|
148
|
+
{...props}
|
|
149
|
+
ref={(node) => {
|
|
150
|
+
input.current = node
|
|
151
|
+
if (typeof inputRef === "function") inputRef(node)
|
|
152
|
+
else if (inputRef) inputRef.current = node
|
|
153
|
+
}}
|
|
154
|
+
type="search"
|
|
155
|
+
placeholder={placeholder}
|
|
156
|
+
aria-label={
|
|
157
|
+
props["aria-labelledby"] ? undefined : (props["aria-label"] ?? placeholder)
|
|
158
|
+
}
|
|
159
|
+
className="h-11 w-full min-w-0 bg-transparent ps-11 pe-3 text-subheadline text-label outline-none placeholder:text-label-tertiary"
|
|
160
|
+
onFocus={(event) => {
|
|
161
|
+
setExpanded(true)
|
|
162
|
+
props.onFocus?.(event)
|
|
163
|
+
}}
|
|
164
|
+
onBlur={(event) => {
|
|
165
|
+
if (!event.currentTarget.value) setExpanded(false)
|
|
166
|
+
props.onBlur?.(event)
|
|
167
|
+
}}
|
|
168
|
+
onKeyDown={(event) => {
|
|
169
|
+
props.onKeyDown?.(event)
|
|
170
|
+
if (
|
|
171
|
+
!event.defaultPrevented &&
|
|
172
|
+
event.key === "Escape" &&
|
|
173
|
+
!event.currentTarget.value
|
|
174
|
+
) {
|
|
175
|
+
setExpanded(false)
|
|
176
|
+
event.currentTarget.blur()
|
|
177
|
+
}
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
180
|
+
</label>
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
@@ -1,31 +1,47 @@
|
|
|
1
1
|
import { forwardRef, type InputHTMLAttributes } from "react"
|
|
2
2
|
import { SearchIcon } from "../../icons/icons"
|
|
3
3
|
import { cn } from "../../lib/class-names"
|
|
4
|
+
import { ExpandingSearchField } from "./expanding-search-field"
|
|
4
5
|
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
export interface SearchFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
7
|
+
/** Expand a single pill from its search icon on focus. @default false */
|
|
8
|
+
collapsible?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(function SearchField(
|
|
12
|
+
{ className, placeholder = "Search", collapsible = false, ...props },
|
|
13
|
+
ref
|
|
14
|
+
) {
|
|
15
|
+
if (collapsible)
|
|
10
16
|
return (
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
<span aria-hidden="true" className="shrink-0">
|
|
18
|
-
<SearchIcon size={15} strokeWidth={2} />
|
|
19
|
-
</span>
|
|
20
|
-
<input
|
|
21
|
-
ref={ref}
|
|
22
|
-
type="search"
|
|
23
|
-
placeholder={placeholder}
|
|
24
|
-
aria-label={accessibleName}
|
|
25
|
-
className="min-w-0 grow bg-transparent text-label outline-none placeholder:text-label-tertiary"
|
|
26
|
-
{...props}
|
|
27
|
-
/>
|
|
28
|
-
</label>
|
|
17
|
+
<ExpandingSearchField
|
|
18
|
+
{...props}
|
|
19
|
+
inputRef={ref}
|
|
20
|
+
className={className}
|
|
21
|
+
placeholder={placeholder}
|
|
22
|
+
/>
|
|
29
23
|
)
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
const accessibleName = props["aria-labelledby"]
|
|
25
|
+
? undefined
|
|
26
|
+
: (props["aria-label"] ?? placeholder)
|
|
27
|
+
return (
|
|
28
|
+
<label
|
|
29
|
+
className={cn(
|
|
30
|
+
"control-field flex items-center gap-2 rounded-full px-3 py-2 text-subheadline text-label-tertiary",
|
|
31
|
+
className
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
<span aria-hidden="true" className="shrink-0">
|
|
35
|
+
<SearchIcon size={15} strokeWidth={2} />
|
|
36
|
+
</span>
|
|
37
|
+
<input
|
|
38
|
+
ref={ref}
|
|
39
|
+
type="search"
|
|
40
|
+
placeholder={placeholder}
|
|
41
|
+
aria-label={accessibleName}
|
|
42
|
+
className="min-w-0 grow bg-transparent text-label outline-none placeholder:text-label-tertiary"
|
|
43
|
+
{...props}
|
|
44
|
+
/>
|
|
45
|
+
</label>
|
|
46
|
+
)
|
|
47
|
+
})
|
|
@@ -37,6 +37,10 @@ export function SearchList({
|
|
|
37
37
|
open?: boolean
|
|
38
38
|
}) {
|
|
39
39
|
const [query, setQuery] = useState("")
|
|
40
|
+
const updateQuery = useCallback((next: string) => {
|
|
41
|
+
setQuery(next)
|
|
42
|
+
setActive("")
|
|
43
|
+
}, [])
|
|
40
44
|
const [active, setActive] = useState("")
|
|
41
45
|
const [results, setResults] = useState<Record<string, boolean>>({})
|
|
42
46
|
const listId = useId()
|
|
@@ -54,14 +58,16 @@ export function SearchList({
|
|
|
54
58
|
}),
|
|
55
59
|
[]
|
|
56
60
|
)
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!open) return
|
|
63
|
+
const available = Object.keys(results).filter((id) => results[id])
|
|
64
|
+
if (!available.includes(active)) setActive(available[0] ?? "")
|
|
65
|
+
}, [open, results, active])
|
|
57
66
|
return (
|
|
58
67
|
<SearchContext
|
|
59
68
|
value={{
|
|
60
69
|
query,
|
|
61
|
-
setQuery:
|
|
62
|
-
setQuery(next)
|
|
63
|
-
setActive("")
|
|
64
|
-
},
|
|
70
|
+
setQuery: updateQuery,
|
|
65
71
|
active,
|
|
66
72
|
listId,
|
|
67
73
|
open,
|
|
@@ -75,7 +81,7 @@ export function SearchList({
|
|
|
75
81
|
className={cn("relative", className)}
|
|
76
82
|
onKeyDown={(event) => {
|
|
77
83
|
onKeyDown?.(event)
|
|
78
|
-
if (event.defaultPrevented || !open) return
|
|
84
|
+
if (event.defaultPrevented || !open || event.nativeEvent.isComposing) return
|
|
79
85
|
const items = Array.from(
|
|
80
86
|
ref.current?.querySelectorAll<HTMLButtonElement>(
|
|
81
87
|
"[data-search-result]:not([hidden]):not(:disabled)"
|
|
@@ -125,13 +131,16 @@ export function SearchListField({
|
|
|
125
131
|
onValueChange?: (value: string) => void
|
|
126
132
|
}) {
|
|
127
133
|
const search = useSearch()
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (value !== undefined) search.setQuery(value)
|
|
136
|
+
}, [value, search.setQuery])
|
|
128
137
|
return (
|
|
129
138
|
<input
|
|
130
139
|
{...props}
|
|
131
140
|
role="combobox"
|
|
132
141
|
aria-autocomplete="list"
|
|
133
142
|
aria-expanded={search.open}
|
|
134
|
-
aria-controls={search.listId}
|
|
143
|
+
aria-controls={search.open ? search.listId : undefined}
|
|
135
144
|
aria-activedescendant={search.open && search.active ? search.active : undefined}
|
|
136
145
|
aria-label={props["aria-label"] ?? props.placeholder}
|
|
137
146
|
value={value ?? search.query}
|
|
@@ -223,7 +232,7 @@ export function SearchListItem({
|
|
|
223
232
|
aria-selected={search.active === id}
|
|
224
233
|
data-selected={search.active === id ? "true" : undefined}
|
|
225
234
|
className={cn(
|
|
226
|
-
"flex w-full items-center gap-2 rounded-
|
|
235
|
+
"flex min-h-9 w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-subheadline text-label hover:bg-fill aria-selected:bg-fill [@media(pointer:coarse)]:min-h-11",
|
|
227
236
|
className
|
|
228
237
|
)}
|
|
229
238
|
onMouseDown={(event) => {
|
|
@@ -13,6 +13,8 @@ export interface SheetProps {
|
|
|
13
13
|
open: boolean
|
|
14
14
|
/** Called on Escape or an outside press. */
|
|
15
15
|
onClose: () => void
|
|
16
|
+
/** Allow tapping the backdrop to close. Escape still calls onClose. @default true */
|
|
17
|
+
dismissOnOutsidePress?: boolean
|
|
16
18
|
/** Read out by screen readers as the sheet's name. */
|
|
17
19
|
label: string
|
|
18
20
|
/** ID of a visible heading to use instead of label for the accessible name. */
|
|
@@ -41,6 +43,7 @@ export function Sheet({ open, ...props }: SheetProps) {
|
|
|
41
43
|
|
|
42
44
|
function OpenSheet({
|
|
43
45
|
onClose,
|
|
46
|
+
dismissOnOutsidePress = true,
|
|
44
47
|
label,
|
|
45
48
|
labelledBy,
|
|
46
49
|
placement = "adaptive",
|
|
@@ -77,7 +80,7 @@ function OpenSheet({
|
|
|
77
80
|
initial={{ opacity: 0 }}
|
|
78
81
|
animate={{ opacity: 1 }}
|
|
79
82
|
exit={{ opacity: 0 }}
|
|
80
|
-
onClick={onClose}
|
|
83
|
+
onClick={dismissOnOutsidePress ? onClose : undefined}
|
|
81
84
|
/>
|
|
82
85
|
<motion.div
|
|
83
86
|
ref={panel}
|
|
@@ -8,7 +8,7 @@ export interface SidebarSectionProps {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export interface SidebarItemContentProps {
|
|
11
|
-
icon
|
|
11
|
+
icon?: ReactNode
|
|
12
12
|
/** Optional trailing count. */
|
|
13
13
|
count?: number
|
|
14
14
|
/** Matches the router link's active state. */
|
|
@@ -32,24 +32,33 @@ export function SidebarSection({ title, children }: SidebarSectionProps) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/** Classes for a sidebar row; apply to the app's router link. */
|
|
35
|
-
export
|
|
35
|
+
export type SidebarDensity = "regular" | "compact"
|
|
36
|
+
|
|
37
|
+
export function sidebarItemClass(active?: boolean, density: SidebarDensity = "regular"): string {
|
|
36
38
|
return cn(
|
|
37
|
-
"group flex items-center gap-2.5
|
|
38
|
-
|
|
39
|
+
"group flex w-full items-center gap-2.5 text-label no-underline transition-colors duration-fast focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-text",
|
|
40
|
+
density === "compact"
|
|
41
|
+
? "min-h-8 rounded-lg px-2 py-1 text-footnote [@media(pointer:coarse)]:min-h-10"
|
|
42
|
+
: "rounded-2xl px-2.5 py-[7px] text-subheadline",
|
|
43
|
+
active
|
|
44
|
+
? "bg-fill font-medium"
|
|
45
|
+
: "font-normal text-label-secondary hover:bg-fill/70 hover:text-label"
|
|
39
46
|
)
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
export function SidebarItemContent({ icon, count, active, children }: SidebarItemContentProps) {
|
|
43
50
|
return (
|
|
44
51
|
<>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
{icon && (
|
|
53
|
+
<span
|
|
54
|
+
className={cn(
|
|
55
|
+
"flex transition-colors",
|
|
56
|
+
active ? "text-accent-text" : "text-label-secondary"
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
{icon}
|
|
60
|
+
</span>
|
|
61
|
+
)}
|
|
53
62
|
<span className="grow truncate">{children}</span>
|
|
54
63
|
{count !== undefined && (
|
|
55
64
|
<span
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ComponentProps } from "react"
|
|
2
|
+
import { cn } from "../../lib/class-names"
|
|
3
|
+
|
|
4
|
+
export interface SkeletonProps extends ComponentProps<"div"> {
|
|
5
|
+
label?: string
|
|
6
|
+
}
|
|
7
|
+
/** A loading placeholder that becomes still when Reduce Motion is enabled. */
|
|
8
|
+
export function Skeleton({ label, className, ...props }: SkeletonProps) {
|
|
9
|
+
return (
|
|
10
|
+
<div
|
|
11
|
+
{...props}
|
|
12
|
+
role={label ? "status" : undefined}
|
|
13
|
+
aria-hidden={label ? undefined : true}
|
|
14
|
+
className={cn("min-h-4 rounded-md bg-fill motion-safe:animate-pulse", className)}
|
|
15
|
+
>
|
|
16
|
+
{label && <span className="sr-only">{label}</span>}
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { clampRange } from "../../lib/range-value"
|
|
2
|
+
|
|
3
|
+
export interface LiquidState {
|
|
4
|
+
fill: number
|
|
5
|
+
velocity: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Bencho's frame-normalized liquid spring. See THIRD_PARTY_NOTICES.md. */
|
|
9
|
+
export function stepLiquid(
|
|
10
|
+
state: LiquidState,
|
|
11
|
+
target: number,
|
|
12
|
+
viscosity: number,
|
|
13
|
+
dt: number
|
|
14
|
+
): LiquidState {
|
|
15
|
+
const soft = clampRange(viscosity, 0, 100) / 100
|
|
16
|
+
target = clampRange(target, 0, 100)
|
|
17
|
+
dt = clampRange(dt, 0, 2.5)
|
|
18
|
+
if (soft === 0) {
|
|
19
|
+
/* 0 is an ordinary slider and has to be exactly that,
|
|
20
|
+
not a very stiff spring that still rings. */
|
|
21
|
+
return { fill: target, velocity: 0 }
|
|
22
|
+
}
|
|
23
|
+
/* Stiffness falls and damping rises together: thin liquid is slow to
|
|
24
|
+
answer and slow to forget, which is why these are one knob. */
|
|
25
|
+
const stiff = 0.34 - soft * 0.29
|
|
26
|
+
const damp = 0.74 + soft * 0.22
|
|
27
|
+
let velocity = (state.velocity + (target - state.fill) * stiff * dt) * damp ** dt
|
|
28
|
+
let fill = state.fill + velocity * dt
|
|
29
|
+
/* The end stop is a wall, and a wall gives some back. Without this
|
|
30
|
+
the overshoot is swallowed exactly where you are looking at it. */
|
|
31
|
+
if (fill > 100) {
|
|
32
|
+
fill = 100
|
|
33
|
+
velocity = -velocity * 0.42
|
|
34
|
+
}
|
|
35
|
+
if (fill < 0) {
|
|
36
|
+
fill = 0
|
|
37
|
+
velocity = -velocity * 0.42
|
|
38
|
+
}
|
|
39
|
+
if (Math.abs(target - fill) < 0.02 && Math.abs(velocity) < 0.02)
|
|
40
|
+
return { fill: target, velocity: 0 }
|
|
41
|
+
return { fill, velocity }
|
|
42
|
+
}
|