@passui/react 0.1.35 → 0.1.37

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 (33) hide show
  1. package/dist/types/src/components/accordion/accordion.d.ts +5 -1
  2. package/dist/types/src/components/date-picker/date-picker.d.ts +4 -1
  3. package/dist/types/src/components/dialog/dialog.d.ts +4 -1
  4. package/dist/types/src/components/disclosure-group/disclosure-group.d.ts +4 -1
  5. package/dist/types/src/components/morph/index.d.ts +2 -0
  6. package/dist/types/src/components/morph/morph.d.ts +29 -0
  7. package/dist/types/src/components/morph/use-morph-disclosure.d.ts +13 -0
  8. package/dist/types/src/components/radio-group/radio-group.d.ts +4 -1
  9. package/dist/types/src/components/segmented-control/segmented-control.d.ts +4 -1
  10. package/dist/types/src/components/sheet/sheet.d.ts +3 -0
  11. package/dist/types/src/components/slider/slider.d.ts +5 -2
  12. package/dist/types/src/components/stepper/stepper.d.ts +4 -1
  13. package/dist/types/src/components/swatch-picker/swatch-picker.d.ts +4 -1
  14. package/dist/types/src/components/tab-view/tab-view.d.ts +4 -1
  15. package/dist/types/src/components/toggle/toggle.d.ts +4 -1
  16. package/dist/types/src/index.d.ts +1 -0
  17. package/package.json +5 -1
  18. package/src/components/accordion/accordion.tsx +16 -3
  19. package/src/components/date-picker/date-picker.tsx +37 -6
  20. package/src/components/dialog/dialog.tsx +48 -5
  21. package/src/components/disclosure-group/disclosure-group.tsx +31 -9
  22. package/src/components/morph/index.ts +2 -0
  23. package/src/components/morph/morph.tsx +210 -0
  24. package/src/components/morph/use-morph-disclosure.ts +27 -0
  25. package/src/components/radio-group/radio-group.tsx +30 -10
  26. package/src/components/segmented-control/segmented-control.tsx +47 -6
  27. package/src/components/sheet/sheet.tsx +20 -2
  28. package/src/components/slider/slider.tsx +58 -9
  29. package/src/components/stepper/stepper.tsx +37 -11
  30. package/src/components/swatch-picker/swatch-picker.tsx +40 -5
  31. package/src/components/tab-view/tab-view.tsx +31 -4
  32. package/src/components/toggle/toggle.tsx +105 -20
  33. package/src/index.ts +2 -0
@@ -0,0 +1,210 @@
1
+ import { AnimatePresence, type HTMLMotionProps, motion, useReducedMotion } from "motion/react"
2
+ import { type ReactNode, type RefObject, useLayoutEffect, useRef, useState } from "react"
3
+ import { concealMenuSource } from "../context-menu/conceal-menu-source"
4
+ import { menuSurfaceExit, menuSurfaceReveal, type MenuRect } from "../context-menu/menu-reveal"
5
+ import { useSheetFocus } from "../sheet/use-sheet-focus"
6
+ import { SheetPortal } from "../sheet/sheet-portal"
7
+ import { cn } from "../../lib/class-names"
8
+ import { contentCrossfade, fluidCollapse, fluidOvershoot } from "../../motion/transitions"
9
+
10
+ export interface MorphProps extends Omit<HTMLMotionProps<"div">, "children"> {
11
+ /** Controls whether the overlay is presented. */
12
+ open: boolean
13
+ /** Called on Escape or outside press. */
14
+ onClose: () => void
15
+ /** The element whose rect the overlay grows from and collapses back to. */
16
+ sourceRef: RefObject<HTMLElement | null>
17
+ /** Accessible name (used when there is no labelledBy). */
18
+ label: string
19
+ /** ID of a visible heading inside the overlay. */
20
+ labelledBy?: string
21
+ /** Allow tapping the backdrop to close. Escape still calls onClose. @default true */
22
+ dismissOnOutsidePress?: boolean
23
+ className?: string
24
+ children?: ReactNode
25
+ }
26
+
27
+ /**
28
+ * A modal overlay that morphs its glass material from a source element's
29
+ * rect to its own resting rect, then collapses back — Apple's
30
+ * matched-geometry effect. The source is concealed while the overlay is
31
+ * mounted so the material reads as the source itself springing open.
32
+ *
33
+ * Reuses `SheetPortal` (theme inheritance), `useSheetFocus` (focus trap,
34
+ * Escape, modal environment) and the same `menuSurfaceReveal` FLIP
35
+ * primitive that powers anchored `Popover` and `ContextMenu`.
36
+ */
37
+ export function Morph({ open, ...props }: MorphProps) {
38
+ return <AnimatePresence>{open && <OpenMorph {...props} />}</AnimatePresence>
39
+ }
40
+
41
+ function OpenMorph({
42
+ onClose,
43
+ sourceRef,
44
+ label,
45
+ labelledBy,
46
+ dismissOnOutsidePress = true,
47
+ className,
48
+ children,
49
+ ...props
50
+ }: Omit<MorphProps, "open">) {
51
+ const panel = useRef<HTMLDivElement>(null)
52
+ const reducedMotion = useReducedMotion()
53
+ const [settled, setSettled] = useState(false)
54
+ const [target, setTarget] = useState<{ width: number; height: number; radius: number } | null>(
55
+ null
56
+ )
57
+ const [reveal, setReveal] = useState<ReturnType<typeof menuSurfaceReveal> | null>(null)
58
+ useSheetFocus(panel, onClose)
59
+
60
+ // Measure the panel's resting rect and the source's rect, then compute
61
+ // the reveal (initial material state) from menuSurfaceReveal. Re-measures
62
+ // on resize/scroll so the morph stays aligned if either moves.
63
+ useLayoutEffect(() => {
64
+ const surface = panel.current
65
+ const source = sourceRef.current
66
+ if (!surface) return
67
+ const measure = () => {
68
+ const panelRect = surface.getBoundingClientRect()
69
+ const radius = Number.parseFloat(getComputedStyle(surface).borderTopLeftRadius) || 0
70
+ setTarget((current) =>
71
+ current &&
72
+ current.width === panelRect.width &&
73
+ current.height === panelRect.height &&
74
+ current.radius === radius
75
+ ? current
76
+ : { width: panelRect.width, height: panelRect.height, radius }
77
+ )
78
+ const sourceRect = source?.getBoundingClientRect()
79
+ const sourceArg: MenuRect | undefined =
80
+ sourceRect && sourceRect.width > 0 && sourceRect.height > 0
81
+ ? {
82
+ left: sourceRect.left,
83
+ top: sourceRect.top,
84
+ width: sourceRect.width,
85
+ height: sourceRect.height,
86
+ }
87
+ : undefined
88
+ setReveal(
89
+ menuSurfaceReveal(sourceArg, {
90
+ left: panelRect.left,
91
+ top: panelRect.top,
92
+ width: panelRect.width,
93
+ height: panelRect.height,
94
+ radius,
95
+ })
96
+ )
97
+ }
98
+ measure()
99
+ let frame = 0
100
+ const schedule = () => {
101
+ cancelAnimationFrame(frame)
102
+ frame = requestAnimationFrame(measure)
103
+ }
104
+ const observer = new ResizeObserver(schedule)
105
+ observer.observe(surface)
106
+ if (source) observer.observe(source)
107
+ const viewport = window.visualViewport
108
+ viewport?.addEventListener("resize", schedule)
109
+ viewport?.addEventListener("scroll", schedule)
110
+ window.addEventListener("resize", schedule)
111
+ window.addEventListener("scroll", schedule, true)
112
+ return () => {
113
+ observer.disconnect()
114
+ cancelAnimationFrame(frame)
115
+ viewport?.removeEventListener("resize", schedule)
116
+ viewport?.removeEventListener("scroll", schedule)
117
+ window.removeEventListener("resize", schedule)
118
+ window.removeEventListener("scroll", schedule, true)
119
+ }
120
+ }, [sourceRef])
121
+
122
+ // Conceal the source while the morph is mounted; restore on unmount.
123
+ useLayoutEffect(() => {
124
+ const source = sourceRef.current
125
+ if (!source) return
126
+ const restore = concealMenuSource(source, false)
127
+ return () => {
128
+ restore()
129
+ }
130
+ }, [sourceRef])
131
+
132
+ return (
133
+ <SheetPortal>
134
+ <div className="fixed inset-0 z-[75] flex items-center justify-center p-6">
135
+ <motion.div
136
+ aria-hidden="true"
137
+ className="absolute inset-0 bg-black/30 backdrop-blur-md"
138
+ initial={{ opacity: 0 }}
139
+ animate={{ opacity: 1 }}
140
+ exit={{ opacity: 0 }}
141
+ transition={reducedMotion ? { duration: 0 } : { duration: 0.18 }}
142
+ onClick={dismissOnOutsidePress ? onClose : undefined}
143
+ />
144
+ <motion.div
145
+ ref={panel}
146
+ role="dialog"
147
+ aria-modal="true"
148
+ aria-label={labelledBy ? undefined : label}
149
+ aria-labelledby={labelledBy}
150
+ tabIndex={-1}
151
+ className={cn(
152
+ "relative isolate flex w-full flex-col rounded-5xl outline-none",
153
+ "max-h-[92%] max-w-[720px]",
154
+ settled && "overflow-hidden",
155
+ className
156
+ )}
157
+ {...props}
158
+ >
159
+ {/* The morphing material — mirrors Popover's data-popover-surface
160
+ pattern. Starts at the source's rect (offset relative to the
161
+ panel) and grows to fill the panel, with the fluid overshoot
162
+ bounce. Border radius interpolates from the source's
163
+ "pillified" radius to the panel's authored radius. */}
164
+ <motion.div
165
+ key={reveal ? "surface" : "measure"}
166
+ aria-hidden="true"
167
+ data-morph-surface=""
168
+ onAnimationComplete={() => {
169
+ if (reveal) setSettled(true)
170
+ }}
171
+ className="glass-menu pointer-events-none absolute top-0 left-0 -z-10 rounded-5xl"
172
+ style={{ width: "100%", height: "100%" }}
173
+ initial={reducedMotion || !reveal ? false : reveal}
174
+ animate={
175
+ target
176
+ ? {
177
+ x: 0,
178
+ y: 0,
179
+ width: target.width,
180
+ height: target.height,
181
+ opacity: 1,
182
+ borderRadius: target.radius,
183
+ }
184
+ : undefined
185
+ }
186
+ exit={
187
+ reducedMotion || !reveal
188
+ ? { opacity: 0 }
189
+ : { ...menuSurfaceExit(reveal), transition: fluidCollapse }
190
+ }
191
+ transition={
192
+ reducedMotion
193
+ ? { duration: 0 }
194
+ : { ...fluidOvershoot, opacity: { duration: 0.18 } }
195
+ }
196
+ />
197
+ <motion.div
198
+ className="flex min-h-0 flex-1 flex-col overflow-y-auto"
199
+ initial={reducedMotion ? false : contentCrossfade.initial}
200
+ animate={reducedMotion ? undefined : contentCrossfade.animate}
201
+ exit={reducedMotion ? undefined : contentCrossfade.exit}
202
+ transition={reducedMotion ? { duration: 0 } : contentCrossfade.transition}
203
+ >
204
+ {children}
205
+ </motion.div>
206
+ </motion.div>
207
+ </div>
208
+ </SheetPortal>
209
+ )
210
+ }
@@ -0,0 +1,27 @@
1
+ import { useCallback, useEffect, useState } from "react"
2
+
3
+ /**
4
+ * Disclosure state that keeps the overlay mounted through its exit animation,
5
+ * mirroring `useAnchoredDisclosure`. Pair with a `useRef<HTMLElement>(null)`
6
+ * for the source.
7
+ */
8
+ export function useMorphDisclosure(disabled = false) {
9
+ const [open, setOpen] = useState(false)
10
+ const [mounted, setMounted] = useState(false)
11
+ useEffect(() => {
12
+ if (disabled) setOpen(false)
13
+ }, [disabled])
14
+ const show = useCallback(() => {
15
+ setMounted(true)
16
+ setOpen(true)
17
+ }, [])
18
+ const toggle = useCallback(() => {
19
+ setMounted(true)
20
+ setOpen((value) => !value)
21
+ }, [])
22
+ const close = useCallback(() => setOpen(false), [])
23
+ const onExited = useCallback(() => {
24
+ if (!open || disabled) setMounted(false)
25
+ }, [open, disabled])
26
+ return { open: open && !disabled, mounted, show, toggle, close, onExited }
27
+ }
@@ -1,6 +1,29 @@
1
1
  import { useId } from "react"
2
2
  import { cn } from "../../lib/class-names"
3
3
 
4
+ export type RadioGroupSize = "sm" | "md" | "lg"
5
+
6
+ const legendSizes: Record<RadioGroupSize, string> = {
7
+ sm: "mb-1.5 text-caption2 font-medium text-label-secondary",
8
+ md: "mb-2 text-footnote font-medium text-label-secondary",
9
+ lg: "mb-2.5 text-subheadline font-medium text-label-secondary",
10
+ }
11
+ const rowSizes: Record<RadioGroupSize, string> = {
12
+ sm: "flex min-h-7 items-start gap-1.5 text-footnote text-label has-disabled:opacity-40 [@media(pointer:coarse)]:min-h-10",
13
+ md: "flex min-h-8 items-start gap-2 text-subheadline text-label has-disabled:opacity-40 [@media(pointer:coarse)]:min-h-11",
14
+ lg: "flex min-h-10 items-start gap-2.5 text-body text-label has-disabled:opacity-40 [@media(pointer:coarse)]:min-h-12",
15
+ }
16
+ const radioSizes: Record<RadioGroupSize, string> = {
17
+ sm: "mt-0.5 size-3.5 shrink-0 accent-accent-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-text",
18
+ md: "mt-1 size-4 shrink-0 accent-accent-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-text",
19
+ lg: "mt-1 size-5 shrink-0 accent-accent-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-text",
20
+ }
21
+ const descriptionSizes: Record<RadioGroupSize, string> = {
22
+ sm: "block text-caption2 text-label-secondary",
23
+ md: "block text-footnote text-label-secondary",
24
+ lg: "block text-subheadline text-label-secondary",
25
+ }
26
+
4
27
  export interface RadioGroupOption<T extends string> {
5
28
  value: T
6
29
  label: string
@@ -15,6 +38,8 @@ export interface RadioGroupProps<T extends string> {
15
38
  disabled?: boolean
16
39
  name?: string
17
40
  className?: string
41
+ /** Legend, row, radio glyph and description density. @default "md" */
42
+ size?: RadioGroupSize
18
43
  }
19
44
  /** A labelled choice group using the browser's native radio keyboard behavior. */
20
45
  export function RadioGroup<T extends string>({
@@ -25,16 +50,14 @@ export function RadioGroup<T extends string>({
25
50
  disabled,
26
51
  name,
27
52
  className,
53
+ size = "md",
28
54
  }: RadioGroupProps<T>) {
29
55
  const id = useId()
30
56
  return (
31
57
  <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>
58
+ <legend className={legendSizes[size]}>{label}</legend>
33
59
  {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
- >
60
+ <label key={option.value} className={rowSizes[size]}>
38
61
  <input
39
62
  type="radio"
40
63
  name={name ?? id}
@@ -43,15 +66,12 @@ export function RadioGroup<T extends string>({
43
66
  disabled={option.disabled}
44
67
  onChange={() => onChange(option.value)}
45
68
  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"
69
+ className={radioSizes[size]}
47
70
  />
48
71
  <span>
49
72
  {option.label}
50
73
  {option.description && (
51
- <span
52
- id={`${id}-${index}`}
53
- className="block text-footnote text-label-secondary"
54
- >
74
+ <span id={`${id}-${index}`} className={descriptionSizes[size]}>
55
75
  {option.description}
56
76
  </span>
57
77
  )}
@@ -5,6 +5,29 @@ import { haptic } from "../../lib/haptics"
5
5
  import { spring } from "../../motion/transitions"
6
6
  import { Picker } from "../picker/picker"
7
7
 
8
+ export type SegmentedControlSize = "sm" | "md" | "lg"
9
+
10
+ const segmentPaddings: Record<SegmentedControlSize, string> = {
11
+ sm: "p-[2px]",
12
+ md: "p-[3px]",
13
+ lg: "p-[4px]",
14
+ }
15
+ const segmentLabelClasses: Record<SegmentedControlSize, string> = {
16
+ sm: "min-w-[52px] px-2 py-0.5 text-caption2 whitespace-nowrap",
17
+ md: "min-w-[64px] px-3 py-1 text-footnote whitespace-nowrap",
18
+ lg: "min-w-[80px] px-4 py-1.5 text-subheadline whitespace-nowrap",
19
+ }
20
+ const segmentRadius: Record<SegmentedControlSize, string> = {
21
+ sm: "rounded-sm",
22
+ md: "rounded-md",
23
+ lg: "rounded-lg",
24
+ }
25
+ const fallbackPickerSizes: Record<SegmentedControlSize, string> = {
26
+ sm: "rounded-md py-1 text-caption2 font-semibold",
27
+ md: "rounded-lg py-1.5 text-footnote font-semibold",
28
+ lg: "rounded-lg py-2 text-subheadline font-semibold",
29
+ }
30
+
8
31
  export interface SegmentedControlProps<T extends string> {
9
32
  /** Value of the selected segment. */
10
33
  value: T
@@ -16,6 +39,8 @@ export interface SegmentedControlProps<T extends string> {
16
39
  label: string
17
40
  /** Custom classes for the control container. */
18
41
  className?: string
42
+ /** Segment padding, label text scale and min-width. @default "md" */
43
+ size?: SegmentedControlSize
19
44
  }
20
45
 
21
46
  /**
@@ -30,6 +55,7 @@ export function SegmentedControl<T extends string>({
30
55
  onChange,
31
56
  label,
32
57
  className,
58
+ size = "md",
33
59
  }: SegmentedControlProps<T>) {
34
60
  const id = useId()
35
61
  const reducedMotion = useReducedMotion()
@@ -55,16 +81,25 @@ export function SegmentedControl<T extends string>({
55
81
  <div
56
82
  ref={measure}
57
83
  aria-hidden="true"
58
- className="pointer-events-none invisible absolute top-0 flex w-max p-[3px] text-footnote font-semibold"
84
+ className={cn(
85
+ "pointer-events-none invisible absolute top-0 flex w-max font-semibold",
86
+ segmentPaddings[size],
87
+ segmentLabelClasses[size].replace(/min-w-\[[^\]]+\]\s*/, "")
88
+ )}
59
89
  >
60
90
  {options.map((option) => (
61
- <span key={option.value} className="min-w-[64px] px-3 py-1 whitespace-nowrap">
91
+ <span key={option.value} className={segmentLabelClasses[size]}>
62
92
  {option.label}
63
93
  </span>
64
94
  ))}
65
95
  </div>
66
96
  {fits ? (
67
- <fieldset className="flex w-full rounded-lg bg-fill p-[3px] shadow-[inset_0_1px_2px_rgb(40_30_0/0.06)]">
97
+ <fieldset
98
+ className={cn(
99
+ "flex w-full rounded-lg bg-fill shadow-[inset_0_1px_2px_rgb(40_30_0/0.06)]",
100
+ segmentPaddings[size]
101
+ )}
102
+ >
68
103
  <legend className="sr-only">{label}</legend>
69
104
  {options.map((option) => {
70
105
  const selected = option.value === value
@@ -72,7 +107,9 @@ export function SegmentedControl<T extends string>({
72
107
  <label
73
108
  key={option.value}
74
109
  className={cn(
75
- "relative isolate min-w-[64px] flex-1 cursor-pointer rounded-md px-3 py-1 text-center text-footnote whitespace-nowrap transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent/60",
110
+ "relative isolate flex-1 cursor-pointer text-center whitespace-nowrap transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent/60",
111
+ segmentLabelClasses[size].replace("whitespace-nowrap", ""),
112
+ segmentRadius[size],
76
113
  selected
77
114
  ? "font-semibold text-label"
78
115
  : "font-medium text-label-secondary hover:text-label"
@@ -92,7 +129,10 @@ export function SegmentedControl<T extends string>({
92
129
  {selected && (
93
130
  <motion.span
94
131
  layoutId={`segment-${id}`}
95
- className="absolute inset-0 -z-10 rounded-md bg-elevated shadow-[0_0_0_0.5px_rgb(0_0_0/0.06),0_1px_2px_rgb(0_0_0/0.08),0_3px_8px_-2px_rgb(0_0_0/0.10)]"
132
+ className={cn(
133
+ "absolute inset-0 -z-10 bg-elevated shadow-[0_0_0_0.5px_rgb(0_0_0/0.06),0_1px_2px_rgb(0_0_0/0.08),0_3px_8px_-2px_rgb(0_0_0/0.10)]",
134
+ segmentRadius[size]
135
+ )}
96
136
  transition={reducedMotion ? { duration: 0 } : spring.snappy}
97
137
  />
98
138
  )}
@@ -108,7 +148,8 @@ export function SegmentedControl<T extends string>({
108
148
  options={options}
109
149
  onChange={onChange}
110
150
  searchable={false}
111
- className="rounded-lg py-1.5 text-footnote font-semibold"
151
+ className={fallbackPickerSizes[size]}
152
+ size={size === "sm" ? "sm" : size === "lg" ? "lg" : "md"}
112
153
  />
113
154
  )}
114
155
  </div>
@@ -7,6 +7,18 @@ import { SheetPortal } from "./sheet-portal"
7
7
  import { useSheetFocus } from "./use-sheet-focus"
8
8
 
9
9
  export type SheetPlacement = "adaptive" | "center"
10
+ export type SheetSize = "sm" | "md" | "lg"
11
+
12
+ const outerPaddings: Record<SheetSize, string> = {
13
+ sm: "p-4",
14
+ md: "p-6",
15
+ lg: "p-8",
16
+ }
17
+ const panelMaxWidths: Record<SheetSize, string> = {
18
+ sm: "max-w-sm",
19
+ md: "max-w-[560px]",
20
+ lg: "max-w-2xl",
21
+ }
10
22
 
11
23
  export interface SheetProps {
12
24
  /** Controls whether the sheet is presented. */
@@ -21,6 +33,8 @@ export interface SheetProps {
21
33
  labelledBy?: string
22
34
  /** `adaptive` rises from the bottom on phones; `center` always floats. @default "adaptive" */
23
35
  placement?: SheetPlacement
36
+ /** Outer padding and panel max-width. @default "md" */
37
+ size?: SheetSize
24
38
  className?: string
25
39
  children: ReactNode
26
40
  }
@@ -47,6 +61,7 @@ function OpenSheet({
47
61
  label,
48
62
  labelledBy,
49
63
  placement = "adaptive",
64
+ size = "md",
50
65
  className,
51
66
  children,
52
67
  }: Omit<SheetProps, "open">) {
@@ -59,7 +74,9 @@ function OpenSheet({
59
74
  <div
60
75
  className={cn(
61
76
  "fixed inset-0 z-[65] flex justify-center",
62
- placement === "center" ? "items-center p-6" : "items-end sm:items-center sm:p-6"
77
+ placement === "center"
78
+ ? cn("items-center", outerPaddings[size])
79
+ : cn("items-end sm:items-center", `sm:${outerPaddings[size]}`)
63
80
  )}
64
81
  style={
65
82
  viewport
@@ -91,7 +108,8 @@ function OpenSheet({
91
108
  tabIndex={-1}
92
109
  className={cn(
93
110
  "glass-menu relative flex w-full flex-col overflow-hidden",
94
- "max-h-[92%] max-w-[560px]",
111
+ "max-h-[92%]",
112
+ panelMaxWidths[size],
95
113
  placement === "center" ? "rounded-sheet" : "rounded-t-sheet sm:rounded-sheet",
96
114
  className
97
115
  )}
@@ -2,10 +2,55 @@ import { type ComponentProps, useId } from "react"
2
2
  import { cn } from "../../lib/class-names"
3
3
  import { clampRange } from "../../lib/range-value"
4
4
 
5
+ export type SliderSize = "sm" | "md" | "lg"
6
+
7
+ const labelSizes: Record<SliderSize, string> = {
8
+ sm: "text-footnote font-semibold text-label",
9
+ md: "text-subheadline font-semibold text-label",
10
+ lg: "text-body font-semibold text-label",
11
+ }
12
+ const inputHeights: Record<SliderSize, string> = {
13
+ sm: "h-9",
14
+ md: "h-11",
15
+ lg: "h-13",
16
+ }
17
+ const descriptionSizes: Record<SliderSize, string> = {
18
+ sm: "text-caption2 text-label-secondary",
19
+ md: "text-footnote text-label-secondary",
20
+ lg: "text-subheadline text-label-secondary",
21
+ }
22
+ // Track height, thumb size, and the webkit thumb's -mt offset (derived from
23
+ // (thumb - track) / 2: md (20-6)/2=7, sm (16-4)/2=6, lg (24-8)/2=8).
24
+ const webkitTrack: Record<SliderSize, string> = {
25
+ sm: "[&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-separator",
26
+ md: "[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-separator",
27
+ lg: "[&::-webkit-slider-runnable-track]:h-2 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-separator",
28
+ }
29
+ const webkitThumb: Record<SliderSize, string> = {
30
+ sm: "[&::-webkit-slider-thumb]:-mt-[6px] [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-accent [&::-webkit-slider-thumb]:bg-elevated [&::-webkit-slider-thumb]:shadow-sm",
31
+ md: "[&::-webkit-slider-thumb]:-mt-[7px] [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-accent [&::-webkit-slider-thumb]:bg-elevated [&::-webkit-slider-thumb]:shadow-sm",
32
+ lg: "[&::-webkit-slider-thumb]:-mt-[8px] [&::-webkit-slider-thumb]:size-6 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-accent [&::-webkit-slider-thumb]:bg-elevated [&::-webkit-slider-thumb]:shadow-sm",
33
+ }
34
+ const mozTrack: Record<SliderSize, string> = {
35
+ sm: "[&::-moz-range-track]:h-1 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-separator",
36
+ md: "[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-separator",
37
+ lg: "[&::-moz-range-track]:h-2 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-separator",
38
+ }
39
+ const mozProgress: Record<SliderSize, string> = {
40
+ sm: "[&::-moz-range-progress]:h-1 [&::-moz-range-progress]:rounded-full [&::-moz-range-progress]:bg-accent",
41
+ md: "[&::-moz-range-progress]:h-1.5 [&::-moz-range-progress]:rounded-full [&::-moz-range-progress]:bg-accent",
42
+ lg: "[&::-moz-range-progress]:h-2 [&::-moz-range-progress]:rounded-full [&::-moz-range-progress]:bg-accent",
43
+ }
44
+ const mozThumb: Record<SliderSize, string> = {
45
+ sm: "[&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-accent [&::-moz-range-thumb]:bg-elevated",
46
+ md: "[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-accent [&::-moz-range-thumb]:bg-elevated",
47
+ lg: "[&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-accent [&::-moz-range-thumb]:bg-elevated",
48
+ }
49
+
5
50
  export interface SliderProps
6
51
  extends Omit<
7
52
  ComponentProps<"input">,
8
- "type" | "value" | "defaultValue" | "onChange" | "min" | "max" | "step"
53
+ "type" | "value" | "defaultValue" | "onChange" | "min" | "max" | "step" | "size"
9
54
  > {
10
55
  value: number
11
56
  onChange: (value: number) => void
@@ -16,6 +61,8 @@ export interface SliderProps
16
61
  description?: string
17
62
  /** Provides a localized, spoken description of the current value. */
18
63
  formatValue?: (value: number) => string
64
+ /** Track height, thumb size and label/description text scale. @default "md" */
65
+ size?: SliderSize
19
66
  }
20
67
 
21
68
  /** Native keyboard, touch and pointer behavior with Passui's control treatment. */
@@ -28,6 +75,7 @@ export function Slider({
28
75
  step = 1,
29
76
  description,
30
77
  formatValue,
78
+ size = "md",
31
79
  id: suppliedId,
32
80
  className,
33
81
  disabled,
@@ -40,7 +88,7 @@ export function Slider({
40
88
 
41
89
  return (
42
90
  <div className={cn("flex min-w-0 flex-col gap-2", disabled && "opacity-45")}>
43
- <label htmlFor={id} className="text-subheadline font-semibold text-label">
91
+ <label htmlFor={id} className={labelSizes[size]}>
44
92
  {label}
45
93
  </label>
46
94
  <input
@@ -56,18 +104,19 @@ export function Slider({
56
104
  aria-describedby={description ? `${id}-description` : undefined}
57
105
  onChange={(event) => onChange(Number(event.currentTarget.value))}
58
106
  className={cn(
59
- "h-11 w-full min-w-0 cursor-pointer appearance-none bg-transparent accent-accent",
107
+ "w-full min-w-0 cursor-pointer appearance-none bg-transparent accent-accent",
108
+ inputHeights[size],
60
109
  "focus-visible:rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-text",
61
- "[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-separator",
62
- "[&::-webkit-slider-thumb]:-mt-[7px] [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-accent [&::-webkit-slider-thumb]:bg-elevated [&::-webkit-slider-thumb]:shadow-sm",
63
- "[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-separator",
64
- "[&::-moz-range-progress]:h-1.5 [&::-moz-range-progress]:rounded-full [&::-moz-range-progress]:bg-accent",
65
- "[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-accent [&::-moz-range-thumb]:bg-elevated",
110
+ webkitTrack[size],
111
+ webkitThumb[size],
112
+ mozTrack[size],
113
+ mozProgress[size],
114
+ mozThumb[size],
66
115
  className
67
116
  )}
68
117
  />
69
118
  {description && (
70
- <p id={`${id}-description`} className="text-footnote text-label-secondary">
119
+ <p id={`${id}-description`} className={descriptionSizes[size]}>
71
120
  {description}
72
121
  </p>
73
122
  )}