@passui/react 0.1.36 → 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.
@@ -1,4 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
+ import { type DisclosureGroupSize } from "../disclosure-group/disclosure-group";
3
+ export type AccordionSize = DisclosureGroupSize;
2
4
  export interface AccordionItem {
3
5
  /** Stable value used to identify the expanded item. */
4
6
  value: string;
@@ -13,7 +15,9 @@ export interface AccordionProps {
13
15
  value: string | null;
14
16
  /** Selecting the open item reports `null`, so every item can collapse. */
15
17
  onValueChange: (value: string | null) => void;
18
+ /** Density passed through to each DisclosureGroup. @default "md" */
19
+ size?: AccordionSize;
16
20
  className?: string;
17
21
  }
18
22
  /** A single-open, collapsible accordion with labelled animated content regions. */
19
- export declare function Accordion({ items, value, onValueChange, className }: AccordionProps): import("react").JSX.Element;
23
+ export declare function Accordion({ items, value, onValueChange, size, className }: AccordionProps): import("react").JSX.Element;
@@ -1,3 +1,4 @@
1
+ import { type TextFieldSize } from "../text-field/text-field";
1
2
  type DatePickerProps = {
2
3
  value: string;
3
4
  onChange: (value: string) => void;
@@ -10,7 +11,9 @@ type DatePickerProps = {
10
11
  };
11
12
  locale: string;
12
13
  max?: string;
14
+ /** Field scale passed to each TextField; matches TextField's xs|sm|md|lg. @default "md" */
15
+ size?: TextFieldSize;
13
16
  };
14
17
  /** Segmented date entry suited to birthdays, without a native calendar dialog. */
15
- export declare function DatePicker({ value, onChange, label, labels, locale, max }: DatePickerProps): import("react").JSX.Element;
18
+ export declare function DatePicker({ value, onChange, label, labels, locale, max, size, }: DatePickerProps): import("react").JSX.Element;
16
19
  export {};
@@ -1,3 +1,4 @@
1
+ export type SegmentedControlSize = "sm" | "md" | "lg";
1
2
  export interface SegmentedControlProps<T extends string> {
2
3
  /** Value of the selected segment. */
3
4
  value: T;
@@ -12,6 +13,8 @@ export interface SegmentedControlProps<T extends string> {
12
13
  label: string;
13
14
  /** Custom classes for the control container. */
14
15
  className?: string;
16
+ /** Segment padding, label text scale and min-width. @default "md" */
17
+ size?: SegmentedControlSize;
15
18
  }
16
19
  /**
17
20
  * Mutually exclusive choices with a sliding selection, as on Apple
@@ -19,4 +22,4 @@ export interface SegmentedControlProps<T extends string> {
19
22
  * translation, a narrow pane) it becomes a pop-up button instead of
20
23
  * squeezing or spilling its text.
21
24
  */
22
- export declare function SegmentedControl<T extends string>({ value, options, onChange, label, className, }: SegmentedControlProps<T>): import("react").JSX.Element;
25
+ export declare function SegmentedControl<T extends string>({ value, options, onChange, label, className, size, }: SegmentedControlProps<T>): import("react").JSX.Element;
@@ -1,5 +1,6 @@
1
1
  import { type ComponentProps } from "react";
2
- export interface SliderProps extends Omit<ComponentProps<"input">, "type" | "value" | "defaultValue" | "onChange" | "min" | "max" | "step"> {
2
+ export type SliderSize = "sm" | "md" | "lg";
3
+ export interface SliderProps extends Omit<ComponentProps<"input">, "type" | "value" | "defaultValue" | "onChange" | "min" | "max" | "step" | "size"> {
3
4
  value: number;
4
5
  onChange: (value: number) => void;
5
6
  label: string;
@@ -9,6 +10,8 @@ export interface SliderProps extends Omit<ComponentProps<"input">, "type" | "val
9
10
  description?: string;
10
11
  /** Provides a localized, spoken description of the current value. */
11
12
  formatValue?: (value: number) => string;
13
+ /** Track height, thumb size and label/description text scale. @default "md" */
14
+ size?: SliderSize;
12
15
  }
13
16
  /** Native keyboard, touch and pointer behavior with Passui's control treatment. */
14
- export declare function Slider({ value, onChange, label, min, max, step, description, formatValue, id: suppliedId, className, disabled, ...props }: SliderProps): import("react").JSX.Element;
17
+ export declare function Slider({ value, onChange, label, min, max, step, description, formatValue, size, id: suppliedId, className, disabled, ...props }: SliderProps): import("react").JSX.Element;
@@ -1,3 +1,4 @@
1
+ export type StepperSize = "sm" | "md" | "lg";
1
2
  export interface StepperProps {
2
3
  value: number;
3
4
  onChange: (value: number) => void;
@@ -14,6 +15,8 @@ export interface StepperProps {
14
15
  className?: string;
15
16
  /** Formats the displayed value. */
16
17
  formatValue?: (value: number) => string;
18
+ /** Label, output and +/- button scale. @default "md" */
19
+ size?: StepperSize;
17
20
  }
18
21
  /** Discrete increments with disabled boundary actions and a spoken value. */
19
- export declare function Stepper({ value, onChange, label, labels, min, max, step, disabled, className, formatValue, }: StepperProps): import("react").JSX.Element;
22
+ export declare function Stepper({ value, onChange, label, labels, min, max, step, disabled, className, formatValue, size, }: StepperProps): import("react").JSX.Element;
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
2
  export type TabViewAlign = "start" | "center";
3
+ export type TabViewSize = "sm" | "md" | "lg";
3
4
  export interface TabViewProps<Value extends string> {
4
5
  /** Available tabs, in keyboard navigation order. */
5
6
  options: readonly {
@@ -18,7 +19,9 @@ export interface TabViewProps<Value extends string> {
18
19
  label: string;
19
20
  /** Renders the selected tab's panel. */
20
21
  children: (value: Value) => ReactNode;
22
+ /** Tablist height, tab text scale and underline offset. @default "md" */
23
+ size?: TabViewSize;
21
24
  className?: string;
22
25
  }
23
26
  /** Content tabs with native buttons, roving focus and automatic keyboard activation. */
24
- export declare function TabView<Value extends string>({ options, defaultValue, value: controlledValue, onValueChange, align, label, children, className, }: TabViewProps<Value>): import("react").JSX.Element | null;
27
+ export declare function TabView<Value extends string>({ options, defaultValue, value: controlledValue, onValueChange, align, label, children, size, className, }: TabViewProps<Value>): import("react").JSX.Element | null;
@@ -1,4 +1,5 @@
1
1
  export type ToggleStyle = "switch" | "checkbox";
2
+ export type ToggleSize = "sm" | "md" | "lg";
2
3
  export interface ToggleProps {
3
4
  /** Current on or off state. */
4
5
  checked: boolean;
@@ -13,7 +14,9 @@ export interface ToggleProps {
13
14
  mixed?: boolean;
14
15
  /** Keeps the accessible name while hiding the visible label. @default false */
15
16
  labelHidden?: boolean;
17
+ /** Switch track size, thumb travel, checkbox box and label text scale. @default "md" */
18
+ size?: ToggleSize;
16
19
  /** Custom classes for the toggle container. */
17
20
  className?: string;
18
21
  }
19
- export declare function Toggle({ checked, onChange, label, disabled, style, mixed, labelHidden, className, }: ToggleProps): import("react").JSX.Element;
22
+ export declare function Toggle({ checked, onChange, label, disabled, style, mixed, labelHidden, size, className, }: ToggleProps): import("react").JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passui/react",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
4
4
  "description": "Passui: native React controls, design tokens, presentation and appearance.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,7 +1,15 @@
1
1
  import type { ReactNode } from "react"
2
- import { DisclosureGroup } from "../disclosure-group/disclosure-group"
2
+ import { DisclosureGroup, type DisclosureGroupSize } from "../disclosure-group/disclosure-group"
3
3
  import { cn } from "../../lib/class-names"
4
4
 
5
+ export type AccordionSize = DisclosureGroupSize
6
+
7
+ const containerRadius: Record<AccordionSize, string> = {
8
+ sm: "rounded-xl",
9
+ md: "rounded-2xl",
10
+ lg: "rounded-3xl",
11
+ }
12
+
5
13
  export interface AccordionItem {
6
14
  /** Stable value used to identify the expanded item. */
7
15
  value: string
@@ -17,15 +25,19 @@ export interface AccordionProps {
17
25
  value: string | null
18
26
  /** Selecting the open item reports `null`, so every item can collapse. */
19
27
  onValueChange: (value: string | null) => void
28
+ /** Density passed through to each DisclosureGroup. @default "md" */
29
+ size?: AccordionSize
20
30
  className?: string
21
31
  }
22
32
 
23
33
  /** A single-open, collapsible accordion with labelled animated content regions. */
24
- export function Accordion({ items, value, onValueChange, className }: AccordionProps) {
34
+ export function Accordion({ items, value, onValueChange, size = "md", className }: AccordionProps) {
25
35
  return (
26
36
  <div
27
37
  className={cn(
28
- "flex w-full min-w-0 flex-col overflow-hidden rounded-2xl border border-separator/60 bg-elevated",
38
+ "flex w-full min-w-0 flex-col overflow-hidden",
39
+ containerRadius[size],
40
+ "border border-separator/60 bg-elevated",
29
41
  className
30
42
  )}
31
43
  >
@@ -36,6 +48,7 @@ export function Accordion({ items, value, onValueChange, className }: AccordionP
36
48
  description={item.description}
37
49
  open={value === item.value}
38
50
  disabled={item.disabled}
51
+ size={size}
39
52
  triggerClassName="rounded-none"
40
53
  onOpenChange={(open) => onValueChange(open ? item.value : null)}
41
54
  className={cn(index > 0 && "border-t border-separator/60")}
@@ -1,7 +1,27 @@
1
1
  import { useState } from "react"
2
- import { TextField } from "../text-field/text-field"
2
+ import { TextField, type TextFieldSize } from "../text-field/text-field"
3
+ import { cn } from "../../lib/class-names"
3
4
  import { dateValue } from "./date-value"
4
5
 
6
+ const legendText: Record<TextFieldSize, string> = {
7
+ xs: "mb-2 text-caption font-medium",
8
+ sm: "mb-2 text-footnote font-medium",
9
+ md: "mb-3 text-subheadline font-medium",
10
+ lg: "mb-3 text-body font-medium",
11
+ }
12
+ const fieldsetGap: Record<TextFieldSize, string> = {
13
+ xs: "gap-2",
14
+ sm: "gap-2",
15
+ md: "gap-3",
16
+ lg: "gap-4",
17
+ }
18
+ const errorText: Record<TextFieldSize, string> = {
19
+ xs: "text-caption2 text-danger",
20
+ sm: "text-caption text-danger",
21
+ md: "text-footnote text-danger",
22
+ lg: "text-subheadline text-danger",
23
+ }
24
+
5
25
  type DatePickerProps = {
6
26
  value: string
7
27
  onChange: (value: string) => void
@@ -14,10 +34,20 @@ type DatePickerProps = {
14
34
  }
15
35
  locale: string
16
36
  max?: string
37
+ /** Field scale passed to each TextField; matches TextField's xs|sm|md|lg. @default "md" */
38
+ size?: TextFieldSize
17
39
  }
18
40
 
19
41
  /** Segmented date entry suited to birthdays, without a native calendar dialog. */
20
- export function DatePicker({ value, onChange, label, labels, locale, max }: DatePickerProps) {
42
+ export function DatePicker({
43
+ value,
44
+ onChange,
45
+ label,
46
+ labels,
47
+ locale,
48
+ max,
49
+ size = "md",
50
+ }: DatePickerProps) {
21
51
  const [year, month, day] = value.split("-")
22
52
  const [parts, setParts] = useState({
23
53
  year: year ?? "",
@@ -41,9 +71,9 @@ export function DatePicker({ value, onChange, label, labels, locale, max }: Date
41
71
  } => part.type === "year" || part.type === "month" || part.type === "day"
42
72
  )
43
73
  return (
44
- <fieldset className="flex min-w-0 flex-col gap-3">
45
- <legend className="mb-3 text-subheadline font-medium">{label}</legend>
46
- <div className="grid grid-cols-3 gap-3">
74
+ <fieldset className={cn("flex min-w-0 flex-col", fieldsetGap[size])}>
75
+ <legend className={legendText[size]}>{label}</legend>
76
+ <div className={cn("grid grid-cols-3", fieldsetGap[size])}>
47
77
  {order.map(({ type }) => (
48
78
  <TextField
49
79
  key={type}
@@ -52,6 +82,7 @@ export function DatePicker({ value, onChange, label, labels, locale, max }: Date
52
82
  autoComplete={`bday-${type}`}
53
83
  maxLength={type === "year" ? 4 : 2}
54
84
  value={parts[type]}
85
+ size={size}
55
86
  aria-invalid={invalid || undefined}
56
87
  onChange={(event) => {
57
88
  const next = {
@@ -65,7 +96,7 @@ export function DatePicker({ value, onChange, label, labels, locale, max }: Date
65
96
  ))}
66
97
  </div>
67
98
  {invalid && (
68
- <p role="status" className="text-footnote text-danger">
99
+ <p role="status" className={errorText[size]}>
69
100
  {labels.invalid}
70
101
  </p>
71
102
  )}
@@ -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>
@@ -76,7 +76,7 @@ function OpenSheet({
76
76
  "fixed inset-0 z-[65] flex justify-center",
77
77
  placement === "center"
78
78
  ? cn("items-center", outerPaddings[size])
79
- : cn("items-end sm:items-center", "sm:" + outerPaddings[size])
79
+ : cn("items-end sm:items-center", `sm:${outerPaddings[size]}`)
80
80
  )}
81
81
  style={
82
82
  viewport
@@ -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
  )}
@@ -1,9 +1,36 @@
1
1
  import { useId } from "react"
2
- import { Button } from "../button/button"
2
+ import { Button, type ButtonSize } from "../button/button"
3
3
  import { cn } from "../../lib/class-names"
4
4
  import { clampRange, stepRange } from "../../lib/range-value"
5
5
  import { RollingDigit } from "./rolling-digit"
6
6
 
7
+ export type StepperSize = "sm" | "md" | "lg"
8
+
9
+ const labelText: Record<StepperSize, string> = {
10
+ sm: "min-w-0 text-footnote text-label",
11
+ md: "min-w-0 text-subheadline text-label",
12
+ lg: "min-w-0 text-body text-label",
13
+ }
14
+ const outputText: Record<StepperSize, string> = {
15
+ sm: "text-footnote tabular-nums text-label",
16
+ md: "text-subheadline tabular-nums text-label",
17
+ lg: "text-body tabular-nums text-label",
18
+ }
19
+ const buttonSizes: Record<StepperSize, { size: ButtonSize; cls: string }> = {
20
+ sm: {
21
+ size: "icon-sm",
22
+ cls: "size-7 rounded-none text-subheadline text-accent-text [@media(pointer:coarse)]:size-10",
23
+ },
24
+ md: {
25
+ size: "icon",
26
+ cls: "size-8 rounded-none text-title3 text-accent-text [@media(pointer:coarse)]:size-11",
27
+ },
28
+ lg: {
29
+ size: "icon-lg",
30
+ cls: "size-9 rounded-none text-title2 text-accent-text [@media(pointer:coarse)]:size-12",
31
+ },
32
+ }
33
+
7
34
  export interface StepperProps {
8
35
  value: number
9
36
  onChange: (value: number) => void
@@ -17,6 +44,8 @@ export interface StepperProps {
17
44
  className?: string
18
45
  /** Formats the displayed value. */
19
46
  formatValue?: (value: number) => string
47
+ /** Label, output and +/- button scale. @default "md" */
48
+ size?: StepperSize
20
49
  }
21
50
 
22
51
  /** Discrete increments with disabled boundary actions and a spoken value. */
@@ -31,6 +60,7 @@ export function Stepper({
31
60
  disabled = false,
32
61
  className,
33
62
  formatValue,
63
+ size = "md",
34
64
  }: StepperProps) {
35
65
  const id = useId()
36
66
  const upper = Math.max(min, max)
@@ -43,15 +73,11 @@ export function Stepper({
43
73
 
44
74
  return (
45
75
  <div className={cn("flex min-w-0 items-center justify-between gap-3", className)}>
46
- <span id={id} className="min-w-0 text-subheadline text-label">
76
+ <span id={id} className={labelText[size]}>
47
77
  {label}
48
78
  </span>
49
79
  <div className="flex shrink-0 items-center gap-3">
50
- <output
51
- aria-live="polite"
52
- aria-labelledby={id}
53
- className="text-subheadline tabular-nums text-label"
54
- >
80
+ <output aria-live="polite" aria-labelledby={id} className={outputText[size]}>
55
81
  <span aria-hidden="true" className="inline-flex items-center">
56
82
  {Array.from(displayed).map((character, index) => {
57
83
  if (!/[0-9]/.test(character))
@@ -73,24 +99,24 @@ export function Stepper({
73
99
  <Button
74
100
  type="button"
75
101
  variant="ghost"
76
- size="icon"
102
+ size={buttonSizes[size].size}
77
103
  aria-label={labels.decrement}
78
104
  aria-describedby={id}
79
105
  disabled={disabled || decrement === current}
80
106
  onClick={() => onChange(decrement)}
81
- className="size-8 rounded-none text-title3 text-accent-text [@media(pointer:coarse)]:size-11"
107
+ className={buttonSizes[size].cls}
82
108
  >
83
109
  −
84
110
  </Button>
85
111
  <Button
86
112
  type="button"
87
113
  variant="ghost"
88
- size="icon"
114
+ size={buttonSizes[size].size}
89
115
  aria-label={labels.increment}
90
116
  aria-describedby={id}
91
117
  disabled={disabled || increment === current}
92
118
  onClick={() => onChange(increment)}
93
- className="size-8 rounded-none text-title3 text-accent-text [@media(pointer:coarse)]:size-11"
119
+ className={buttonSizes[size].cls}
94
120
  >
95
121
  +
96
122
  </Button>
@@ -2,6 +2,28 @@ import { useId, useRef, useState, type ReactNode } from "react"
2
2
  import { cn } from "../../lib/class-names"
3
3
 
4
4
  export type TabViewAlign = "start" | "center"
5
+ export type TabViewSize = "sm" | "md" | "lg"
6
+
7
+ const containerGaps: Record<TabViewSize, string> = {
8
+ sm: "gap-4",
9
+ md: "gap-5",
10
+ lg: "gap-6",
11
+ }
12
+ const tablistClasses: Record<TabViewSize, string> = {
13
+ sm: "inline-flex h-7 w-fit max-w-full items-center gap-4 p-[2px]",
14
+ md: "inline-flex h-8 w-fit max-w-full items-center gap-5 p-[3px]",
15
+ lg: "inline-flex h-10 w-fit max-w-full items-center gap-6 p-[4px]",
16
+ }
17
+ const tabText: Record<TabViewSize, string> = {
18
+ sm: "text-footnote",
19
+ md: "text-subheadline",
20
+ lg: "text-body",
21
+ }
22
+ const underlineOffset: Record<TabViewSize, string> = {
23
+ sm: "after:bottom-[-4px] after:h-0.5",
24
+ md: "after:bottom-[-5px] after:h-0.5",
25
+ lg: "after:bottom-[-6px] after:h-1",
26
+ }
5
27
 
6
28
  export interface TabViewProps<Value extends string> {
7
29
  /** Available tabs, in keyboard navigation order. */
@@ -17,6 +39,8 @@ export interface TabViewProps<Value extends string> {
17
39
  label: string
18
40
  /** Renders the selected tab's panel. */
19
41
  children: (value: Value) => ReactNode
42
+ /** Tablist height, tab text scale and underline offset. @default "md" */
43
+ size?: TabViewSize
20
44
  className?: string
21
45
  }
22
46
 
@@ -29,6 +53,7 @@ export function TabView<Value extends string>({
29
53
  align = "center",
30
54
  label,
31
55
  children,
56
+ size = "md",
32
57
  className,
33
58
  }: TabViewProps<Value>) {
34
59
  const id = useId()
@@ -44,12 +69,12 @@ export function TabView<Value extends string>({
44
69
  if (value === undefined) return null
45
70
 
46
71
  return (
47
- <div className={cn("flex w-full flex-col gap-5", className)}>
72
+ <div className={cn("flex w-full flex-col", containerGaps[size], className)}>
48
73
  <div
49
74
  role="tablist"
50
75
  aria-label={label}
51
76
  className={cn(
52
- "inline-flex h-8 w-fit max-w-full items-center gap-5 p-[3px]",
77
+ tablistClasses[size],
53
78
  align === "start" ? "self-start" : "mx-auto justify-center"
54
79
  )}
55
80
  >
@@ -96,9 +121,11 @@ export function TabView<Value extends string>({
96
121
  }
97
122
  }}
98
123
  className={cn(
99
- "relative inline-flex h-[calc(100%-1px)] shrink-0 items-center justify-center border border-transparent p-0 text-subheadline font-medium whitespace-nowrap",
124
+ "relative inline-flex h-[calc(100%-1px)] shrink-0 items-center justify-center border border-transparent p-0 font-medium whitespace-nowrap",
125
+ tabText[size],
100
126
  "focus-visible:outline-2 focus-visible:outline-offset-2",
101
- "after:absolute after:inset-x-0 after:bottom-[-5px] after:h-0.5 after:bg-current",
127
+ "after:absolute after:inset-x-0 after:bg-current",
128
+ underlineOffset[size],
102
129
  option.value === value
103
130
  ? "after:opacity-100"
104
131
  : "opacity-60 hover:opacity-100 after:opacity-0"
@@ -13,6 +13,74 @@ import { haptic } from "../../lib/haptics"
13
13
  import { spring } from "../../motion/transitions"
14
14
 
15
15
  export type ToggleStyle = "switch" | "checkbox"
16
+ export type ToggleSize = "sm" | "md" | "lg"
17
+
18
+ const labelText: Record<ToggleSize, string> = {
19
+ sm: "text-footnote",
20
+ md: "text-subheadline",
21
+ lg: "text-body",
22
+ }
23
+ const labelGap: Record<ToggleSize, string> = {
24
+ sm: "gap-2",
25
+ md: "gap-3",
26
+ lg: "gap-3.5",
27
+ }
28
+ const checkboxBox: Record<ToggleSize, string> = {
29
+ sm: "size-4 rounded-sm",
30
+ md: "size-5 rounded-sm",
31
+ lg: "size-6 rounded-md",
32
+ }
33
+ const checkIconSize: Record<ToggleSize, number> = {
34
+ sm: 12,
35
+ md: 14,
36
+ lg: 16,
37
+ }
38
+ const mixedBar: Record<ToggleSize, string> = {
39
+ sm: "h-0.5 w-2",
40
+ md: "h-0.5 w-2.5",
41
+ lg: "h-0.5 w-3",
42
+ }
43
+
44
+ // Switch physics: travel = track_width - thumb_width - 2*inset.
45
+ // md: 50 - 26 - 2 = 22 ✓ sm: 44 - 22 - 2 = 20 ✓ lg: 56 - 30 - 2 = 24 ✓
46
+ type SwitchGeo = {
47
+ travel: number
48
+ inset: number
49
+ dropOffset: number
50
+ track: string
51
+ thumb: string
52
+ drop: string
53
+ dropTop: string
54
+ }
55
+ const switchGeo: Record<ToggleSize, SwitchGeo> = {
56
+ sm: {
57
+ travel: 20,
58
+ inset: 2,
59
+ dropOffset: 4,
60
+ track: "h-[26px] w-[44px]",
61
+ thumb: "size-[22px]",
62
+ drop: "size-3.5",
63
+ dropTop: "top-[6px]",
64
+ },
65
+ md: {
66
+ travel: 22,
67
+ inset: 2,
68
+ dropOffset: 5,
69
+ track: "h-[30px] w-[50px]",
70
+ thumb: "size-[26px]",
71
+ drop: "size-4",
72
+ dropTop: "top-[7px]",
73
+ },
74
+ lg: {
75
+ travel: 24,
76
+ inset: 2,
77
+ dropOffset: 6,
78
+ track: "h-[34px] w-[56px]",
79
+ thumb: "size-[30px]",
80
+ drop: "size-5",
81
+ dropTop: "top-[8px]",
82
+ },
83
+ }
16
84
 
17
85
  export interface ToggleProps {
18
86
  /** Current on or off state. */
@@ -28,6 +96,8 @@ export interface ToggleProps {
28
96
  mixed?: boolean
29
97
  /** Keeps the accessible name while hiding the visible label. @default false */
30
98
  labelHidden?: boolean
99
+ /** Switch track size, thumb travel, checkbox box and label text scale. @default "md" */
100
+ size?: ToggleSize
31
101
  /** Custom classes for the toggle container. */
32
102
  className?: string
33
103
  }
@@ -40,6 +110,7 @@ export function Toggle({
40
110
  style = "switch",
41
111
  mixed = false,
42
112
  labelHidden = false,
113
+ size = "md",
43
114
  className,
44
115
  }: ToggleProps) {
45
116
  const input = useRef<HTMLInputElement>(null)
@@ -50,12 +121,14 @@ export function Toggle({
50
121
  return (
51
122
  <label
52
123
  className={cn(
53
- "flex cursor-pointer items-center gap-3 text-subheadline",
124
+ "flex cursor-pointer items-center",
125
+ labelGap[size],
126
+ labelText[size],
54
127
  disabled && "cursor-not-allowed opacity-40",
55
128
  className
56
129
  )}
57
130
  >
58
- <span className="relative inline-flex size-5 shrink-0">
131
+ <span className={cn("relative inline-flex shrink-0", checkboxBox[size])}>
59
132
  <input
60
133
  ref={input}
61
134
  type="checkbox"
@@ -64,19 +137,23 @@ export function Toggle({
64
137
  disabled={disabled}
65
138
  onChange={(event) => onChange(event.target.checked)}
66
139
  className={cn(
67
- "peer size-5 cursor-inherit appearance-none rounded-sm border border-separator/80 bg-elevated transition-colors checked:border-transparent checked:bg-accent indeterminate:border-transparent indeterminate:bg-accent motion-reduce:transition-none",
140
+ "peer cursor-inherit appearance-none border border-separator/80 bg-elevated transition-colors checked:border-transparent checked:bg-accent indeterminate:border-transparent indeterminate:bg-accent motion-reduce:transition-none",
141
+ checkboxBox[size],
68
142
  "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-text"
69
143
  )}
70
144
  />
71
145
  {mixed ? (
72
146
  <span
73
147
  aria-hidden="true"
74
- className="pointer-events-none absolute inset-0 m-auto h-0.5 w-2.5 rounded-full bg-on-accent"
148
+ className={cn(
149
+ "pointer-events-none absolute inset-0 m-auto rounded-full bg-on-accent",
150
+ mixedBar[size]
151
+ )}
75
152
  />
76
153
  ) : (
77
154
  <CheckIcon
78
155
  aria-hidden="true"
79
- size={14}
156
+ size={checkIconSize[size]}
80
157
  strokeWidth={2.5}
81
158
  className="pointer-events-none absolute inset-0 m-auto text-on-accent opacity-0 peer-checked:opacity-100"
82
159
  />
@@ -92,6 +169,7 @@ export function Toggle({
92
169
  onChange={onChange}
93
170
  label={label}
94
171
  disabled={disabled}
172
+ size={size}
95
173
  className={className}
96
174
  />
97
175
  )
@@ -102,30 +180,32 @@ function SwitchToggle({
102
180
  onChange,
103
181
  label,
104
182
  disabled,
183
+ size = "md",
105
184
  className,
106
- }: Pick<ToggleProps, "checked" | "onChange" | "label" | "disabled" | "className">) {
185
+ }: Pick<ToggleProps, "checked" | "onChange" | "label" | "disabled" | "size" | "className">) {
107
186
  const reducedMotion = useReducedMotion()
108
187
  const id = `toggle-goo-${useId().replace(/:/g, "")}`
109
- const x = useMotionValue(checked ? 22 : 2)
110
- // One position owns the thumb. The drop follows that position, not a delayed target.
188
+ const geo = switchGeo[size]
189
+ const on = geo.travel
190
+ const off = geo.inset
191
+ const x = useMotionValue(checked ? on : off)
111
192
  const chase = useSpring(x, spring.snappy)
112
- const dropX = useTransform(chase, (value) => value + 5)
193
+ const dropX = useTransform(chase, (value) => value + geo.dropOffset)
113
194
  const gesture = useRef({ start: 0, x: 0, moved: false, skipClick: false, held: false })
114
195
  useEffect(() => {
115
196
  if (reducedMotion) {
116
- x.set(checked ? 22 : 2)
197
+ x.set(checked ? on : off)
117
198
  return
118
199
  }
119
- const animation = animate(x, checked ? 22 : 2, spring.snappy)
200
+ const animation = animate(x, checked ? on : off, spring.snappy)
120
201
  return () => animation.stop()
121
- }, [checked, reducedMotion, x])
202
+ }, [checked, reducedMotion, x, on, off])
122
203
  const choose = (next: boolean) => {
123
204
  if (disabled) return
124
205
  haptic("selection")
125
206
  if (next !== checked) onChange(next)
126
- // Releasing on the current side still needs a settling spring.
127
- if (reducedMotion) x.set(next ? 22 : 2)
128
- else animate(x, next ? 22 : 2, spring.snappy)
207
+ if (reducedMotion) x.set(next ? on : off)
208
+ else animate(x, next ? on : off, spring.snappy)
129
209
  }
130
210
  return (
131
211
  <button
@@ -158,7 +238,7 @@ function SwitchToggle({
158
238
  const direction = getComputedStyle(event.currentTarget).direction === "rtl" ? -1 : 1
159
239
  const delta = (event.clientX - drag.start) * direction
160
240
  if (Math.abs(delta) > 3) drag.moved = true
161
- if (drag.moved) x.set(Math.max(2, Math.min(22, drag.x + delta)))
241
+ if (drag.moved) x.set(Math.max(off, Math.min(on, drag.x + delta)))
162
242
  }}
163
243
  onPointerUp={() => {
164
244
  const drag = gesture.current
@@ -166,7 +246,7 @@ function SwitchToggle({
166
246
  drag.held = false
167
247
  if (!drag.moved) return
168
248
  drag.skipClick = true
169
- choose(x.get() >= 12)
249
+ choose(x.get() >= (on + off) / 2)
170
250
  }}
171
251
  onPointerCancel={() => {
172
252
  gesture.current.held = false
@@ -174,7 +254,8 @@ function SwitchToggle({
174
254
  choose(checked)
175
255
  }}
176
256
  className={cn(
177
- "relative h-[30px] w-[50px] shrink-0 touch-pan-y rounded-full transition-colors duration-normal motion-reduce:transition-none",
257
+ "relative shrink-0 touch-pan-y rounded-full transition-colors duration-normal motion-reduce:transition-none",
258
+ geo.track,
178
259
  "shadow-[inset_0_1px_2px_rgb(0_0_0/0.10)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:opacity-45",
179
260
  checked ? "bg-accent" : "bg-separator/80",
180
261
  className
@@ -206,12 +287,16 @@ function SwitchToggle({
206
287
  >
207
288
  {!reducedMotion && (
208
289
  <motion.span
209
- className="absolute top-[7px] left-0 size-4 rounded-full bg-white"
290
+ className={cn(
291
+ "absolute left-0 rounded-full bg-white",
292
+ geo.dropTop,
293
+ geo.drop
294
+ )}
210
295
  style={{ x: dropX }}
211
296
  />
212
297
  )}
213
298
  <motion.span
214
- className="absolute top-0.5 left-0 size-[26px] rounded-full bg-white"
299
+ className={cn("absolute top-0.5 left-0 rounded-full bg-white", geo.thumb)}
215
300
  style={{ x }}
216
301
  />
217
302
  </span>