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