@passui/react 0.1.45 → 0.1.46

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 (140) hide show
  1. package/README.md +57 -46
  2. package/dist/types/src/components/accordion/accordion.d.ts +1 -1
  3. package/dist/types/src/components/breadcrumb/breadcrumb.d.ts +1 -1
  4. package/dist/types/src/components/inline-disclosure/inline-disclosure.d.ts +1 -1
  5. package/dist/types/src/components/popover/anchored-surface.d.ts +5 -1
  6. package/dist/types/src/components/popover/index.d.ts +41 -0
  7. package/dist/types/src/components/popover/popover-content.d.ts +32 -0
  8. package/dist/types/src/components/popover/popover-root.d.ts +22 -0
  9. package/dist/types/src/components/popover/popover-trigger.d.ts +23 -0
  10. package/dist/types/src/components/stepper/rolling-digit.d.ts +1 -1
  11. package/dist/types/src/components/table/table.d.ts +1 -1
  12. package/dist/types/src/components/view-controller/view-controller.d.ts +2 -2
  13. package/dist/types/src/index.d.ts +1 -1
  14. package/dist/types/src/machines/popover.machine.d.ts +30 -0
  15. package/package.json +307 -307
  16. package/src/appearance/index.ts +2 -2
  17. package/src/appearance/platform.tsx +76 -60
  18. package/src/appearance/theme.tsx +409 -377
  19. package/src/components/accordion/accordion.tsx +58 -49
  20. package/src/components/ambient/ambient.tsx +25 -25
  21. package/src/components/ambient/index.ts +1 -1
  22. package/src/components/aspect-ratio/aspect-ratio.tsx +21 -16
  23. package/src/components/breadcrumb/breadcrumb.tsx +63 -57
  24. package/src/components/button/button-styles.ts +39 -32
  25. package/src/components/button/button.tsx +91 -84
  26. package/src/components/button/icon-button.tsx +27 -25
  27. package/src/components/button/pending-spinner.tsx +8 -8
  28. package/src/components/button/use-pending-indicator.ts +11 -11
  29. package/src/components/chip/chip.tsx +35 -30
  30. package/src/components/code-block/code-block.tsx +40 -39
  31. package/src/components/combo-box/combo-box.tsx +5 -5
  32. package/src/components/command/command.tsx +124 -94
  33. package/src/components/compact-date-picker/calendar-month.ts +75 -53
  34. package/src/components/compact-date-picker/compact-date-picker.tsx +308 -289
  35. package/src/components/compact-time-picker/compact-time-picker.tsx +288 -281
  36. package/src/components/compact-time-picker/time-value.ts +32 -29
  37. package/src/components/content-unavailable-view/content-unavailable-view.tsx +64 -48
  38. package/src/components/content-view/content-view.tsx +168 -159
  39. package/src/components/content-view/index.ts +1 -1
  40. package/src/components/context-menu/conceal-menu-source.ts +25 -25
  41. package/src/components/context-menu/context-menu-host.tsx +439 -402
  42. package/src/components/context-menu/context-menu-store.ts +125 -123
  43. package/src/components/context-menu/menu-navigation.ts +25 -18
  44. package/src/components/context-menu/menu-position.ts +33 -29
  45. package/src/components/context-menu/menu-reveal.ts +32 -32
  46. package/src/components/context-menu/use-context-menu.ts +63 -56
  47. package/src/components/conversation/conversation.tsx +265 -263
  48. package/src/components/conversation/index.ts +1 -1
  49. package/src/components/date-picker/date-picker.tsx +95 -94
  50. package/src/components/date-picker/date-value.ts +20 -10
  51. package/src/components/dialog/dialog-host.tsx +105 -103
  52. package/src/components/dialog/dialog-store.ts +37 -36
  53. package/src/components/dialog/dialog.tsx +192 -179
  54. package/src/components/dialog/index.ts +1 -1
  55. package/src/components/disclosure-group/disclosure-group.tsx +93 -86
  56. package/src/components/divider/divider.tsx +24 -22
  57. package/src/components/inline-disclosure/inline-disclosure.tsx +40 -36
  58. package/src/components/keyboard-shortcut/keyboard-shortcut.tsx +23 -19
  59. package/src/components/labeled-content/labeled-content.tsx +68 -68
  60. package/src/components/locale-switcher/index.ts +1 -1
  61. package/src/components/locale-switcher/locale-switcher.tsx +279 -251
  62. package/src/components/menu/menu.tsx +75 -58
  63. package/src/components/morph/index.ts +2 -2
  64. package/src/components/morph/morph.tsx +227 -201
  65. package/src/components/morph/use-morph-disclosure.ts +19 -19
  66. package/src/components/one-time-code/one-time-code.tsx +216 -200
  67. package/src/components/picker/picker-trigger.ts +16 -13
  68. package/src/components/picker/picker.tsx +371 -345
  69. package/src/components/popover/anchored-surface.tsx +253 -229
  70. package/src/components/popover/index.ts +50 -0
  71. package/src/components/popover/popover-content.tsx +334 -0
  72. package/src/components/popover/popover-fit.ts +58 -53
  73. package/src/components/popover/popover-root.tsx +97 -0
  74. package/src/components/popover/popover-trigger.tsx +71 -0
  75. package/src/components/profile-image/profile-image.tsx +80 -64
  76. package/src/components/progress-view/progress-view.tsx +61 -59
  77. package/src/components/progress-view/use-liquid-progress.ts +38 -38
  78. package/src/components/radio-group/radio-group.tsx +71 -68
  79. package/src/components/search-field/expanding-search-field.tsx +191 -177
  80. package/src/components/search-field/search-field.tsx +59 -50
  81. package/src/components/search-list/search-list.tsx +246 -236
  82. package/src/components/section/section.tsx +58 -58
  83. package/src/components/secure-field/secure-field.tsx +3 -3
  84. package/src/components/segmented-control/segmented-control.tsx +136 -136
  85. package/src/components/sheet/modal-environment.ts +71 -64
  86. package/src/components/sheet/sheet-portal.tsx +58 -53
  87. package/src/components/sheet/sheet.tsx +146 -141
  88. package/src/components/sheet/use-sheet-focus.ts +97 -82
  89. package/src/components/sidebar/sidebar.tsx +78 -78
  90. package/src/components/skeleton/skeleton.tsx +30 -21
  91. package/src/components/slider/slider.tsx +113 -104
  92. package/src/components/slosh-slider/liquid-step.ts +32 -31
  93. package/src/components/slosh-slider/slosh-slider.tsx +265 -228
  94. package/src/components/stat-tile/index.ts +1 -1
  95. package/src/components/stat-tile/stat-tile.tsx +79 -68
  96. package/src/components/status-indicator/status-indicator.tsx +40 -37
  97. package/src/components/stepper/rolling-digit.tsx +82 -68
  98. package/src/components/stepper/stepper.tsx +122 -113
  99. package/src/components/swatch-picker/swatch-picker.tsx +120 -120
  100. package/src/components/tab-bar/tab-bar.tsx +149 -148
  101. package/src/components/tab-view/tab-view.tsx +140 -137
  102. package/src/components/table/table.tsx +117 -98
  103. package/src/components/text-editor/text-editor.tsx +34 -29
  104. package/src/components/text-field/text-field.tsx +182 -180
  105. package/src/components/toast/toast-store.ts +127 -115
  106. package/src/components/toast/toaster.tsx +361 -347
  107. package/src/components/toggle/toggle.tsx +297 -282
  108. package/src/components/tooltip/tooltip-host.tsx +157 -150
  109. package/src/components/view-controller/index.ts +1 -1
  110. package/src/components/view-controller/view-controller.tsx +64 -53
  111. package/src/components/wake-slider/wake-slider.tsx +220 -205
  112. package/src/hooks/scroll/minimize-decision.ts +11 -7
  113. package/src/hooks/use-anchored-disclosure.ts +19 -19
  114. package/src/hooks/use-anchored-placement.ts +29 -25
  115. package/src/hooks/use-dismiss.ts +33 -29
  116. package/src/hooks/use-floating-dismiss.ts +24 -22
  117. package/src/hooks/use-media-query.ts +67 -60
  118. package/src/hooks/use-minimize-on-scroll.ts +69 -60
  119. package/src/hooks/use-visual-viewport.ts +63 -63
  120. package/src/hooks/viewport/types.ts +16 -16
  121. package/src/hooks/viewport/viewport-metrics.ts +16 -10
  122. package/src/icons/icons.tsx +440 -328
  123. package/src/index.ts +91 -91
  124. package/src/lib/anchored-placement.ts +70 -65
  125. package/src/lib/class-names.ts +14 -13
  126. package/src/lib/clipboard.ts +8 -7
  127. package/src/lib/date.ts +61 -60
  128. package/src/lib/floating-layers.ts +39 -24
  129. package/src/lib/haptics.ts +10 -10
  130. package/src/lib/language.ts +9 -9
  131. package/src/lib/range-value.ts +27 -21
  132. package/src/lib/screen-edges.ts +33 -30
  133. package/src/lib/shapes.ts +38 -34
  134. package/src/machines/popover.machine.ts +51 -0
  135. package/src/motion/transitions.ts +46 -43
  136. package/src/styles/code-block.css +69 -43
  137. package/src/styles/motion-controls.css +393 -191
  138. package/src/tokens.ts +1 -1
  139. package/dist/types/src/components/popover/popover.d.ts +0 -21
  140. package/src/components/popover/popover.tsx +0 -334
@@ -1,67 +1,67 @@
1
- import { motion, useReducedMotion } from "motion/react"
2
- import { Fragment, type ReactNode, useId } from "react"
3
- import { useMinimizeOnScroll } from "../../hooks/use-minimize-on-scroll"
4
- import { SearchIcon } from "../../icons/icons"
5
- import { cn } from "../../lib/class-names"
6
- import { spring } from "../../motion/transitions"
1
+ import { motion, useReducedMotion } from "motion/react";
2
+ import { Fragment, type ReactNode, useId } from "react";
3
+ import { useMinimizeOnScroll } from "../../hooks/use-minimize-on-scroll";
4
+ import { SearchIcon } from "../../icons/icons";
5
+ import { cn } from "../../lib/class-names";
6
+ import { spring } from "../../motion/transitions";
7
7
 
8
8
  export interface TabBarTab<Id extends string = string> {
9
- id: Id
10
- label: string
11
- icon: ReactNode
9
+ id: Id;
10
+ label: string;
11
+ icon: ReactNode;
12
12
  }
13
13
 
14
14
  /** What a tab's link receives; spread it onto the router's own link. */
15
15
  export interface TabBarLinkProps {
16
- className: string
17
- "aria-current": "page" | undefined
18
- onClick: () => void
19
- children: ReactNode
16
+ className: string;
17
+ "aria-current": "page" | undefined;
18
+ onClick: () => void;
19
+ children: ReactNode;
20
20
  }
21
21
 
22
- export type TabBarMinimizeBehavior = "platform" | "scroll" | "never"
23
- export type TabBarSize = "sm" | "md" | "lg"
22
+ export type TabBarMinimizeBehavior = "platform" | "scroll" | "never";
23
+ export type TabBarSize = "sm" | "md" | "lg";
24
24
 
25
25
  const tabHeights: Record<TabBarSize, string> = {
26
- sm: "h-9",
27
- md: "h-11",
28
- lg: "h-13",
29
- }
26
+ sm: "h-9",
27
+ md: "h-11",
28
+ lg: "h-13",
29
+ };
30
30
  const tabMinimizedWidths: Record<TabBarSize, string> = {
31
- sm: "w-9",
32
- md: "w-11",
33
- lg: "w-13",
34
- }
31
+ sm: "w-9",
32
+ md: "w-11",
33
+ lg: "w-13",
34
+ };
35
35
  const tabText: Record<TabBarSize, string> = {
36
- sm: "text-caption",
37
- md: "text-caption2",
38
- lg: "text-subheadline",
39
- }
36
+ sm: "text-caption",
37
+ md: "text-caption2",
38
+ lg: "text-subheadline",
39
+ };
40
40
  const searchButtonSizes: Record<TabBarSize, string> = {
41
- sm: "size-10",
42
- md: "size-12",
43
- lg: "size-14",
44
- }
41
+ sm: "size-10",
42
+ md: "size-12",
43
+ lg: "size-14",
44
+ };
45
45
  const searchIconSizes: Record<TabBarSize, number> = {
46
- sm: 17,
47
- md: 19,
48
- lg: 21,
49
- }
46
+ sm: 17,
47
+ md: 19,
48
+ lg: 21,
49
+ };
50
50
 
51
51
  export interface TabBarProps<Tab extends TabBarTab> {
52
- /** `platform` follows the platform, `scroll` always minimizes, `never` stays expanded. @default "platform" */
53
- minimizeBehavior?: TabBarMinimizeBehavior
54
- tabs: readonly Tab[]
55
- /** Active tab ID, or null for a destination outside the bar. */
56
- active: Tab["id"] | null
57
- /** Renders your router's link with the supplied appearance and accessibility props. */
58
- renderLink: (tab: Tab, props: TabBarLinkProps) => ReactNode
59
- search?: { label: string; onPress: () => void }
60
- /** Accessible name for the navigation. */
61
- label: string
62
- /** Tab height, label text, minimized width and search button diameter. @default "md" */
63
- size?: TabBarSize
64
- className?: string
52
+ /** `platform` follows the platform, `scroll` always minimizes, `never` stays expanded. @default "platform" */
53
+ minimizeBehavior?: TabBarMinimizeBehavior;
54
+ tabs: readonly Tab[];
55
+ /** Active tab ID, or null for a destination outside the bar. */
56
+ active: Tab["id"] | null;
57
+ /** Renders your router's link with the supplied appearance and accessibility props. */
58
+ renderLink: (tab: Tab, props: TabBarLinkProps) => ReactNode;
59
+ search?: { label: string; onPress: () => void };
60
+ /** Accessible name for the navigation. */
61
+ label: string;
62
+ /** Tab height, label text, minimized width and search button diameter. @default "md" */
63
+ size?: TabBarSize;
64
+ className?: string;
65
65
  }
66
66
 
67
67
  /**
@@ -74,108 +74,109 @@ export interface TabBarProps<Tab extends TabBarTab> {
74
74
  * Links stay the app's own (`renderLink`), so the bar works with any router.
75
75
  */
76
76
  export function TabBar<Tab extends TabBarTab>({
77
- tabs,
78
- active,
79
- renderLink,
80
- search,
81
- label,
82
- className,
83
- size = "md",
84
- minimizeBehavior = "platform",
77
+ tabs,
78
+ active,
79
+ renderLink,
80
+ search,
81
+ label,
82
+ className,
83
+ size = "md",
84
+ minimizeBehavior = "platform",
85
85
  }: TabBarProps<Tab>) {
86
- // Scopes the sliding selection to this bar, so two bars on one
87
- // screen (a docs page, a split view) never trade their highlight.
88
- const selectionId = `${useId()}-tab-selection`
89
- const reducedMotion = useReducedMotion()
90
- const [scrolledAway, setScrolledAway] = useMinimizeOnScroll()
91
- // Platform behavior keeps Android expanded; apps can opt into scroll minimization.
92
- const minimized =
93
- scrolledAway &&
94
- active !== null &&
95
- minimizeBehavior !== "never" &&
96
- (minimizeBehavior === "scroll" || !onAndroid())
97
- return (
98
- <nav
99
- aria-label={label}
100
- className={cn(
101
- "fixed inset-x-4 bottom-[max(14px,env(safe-area-inset-bottom))] z-30 flex items-center gap-3",
102
- className
103
- )}
104
- >
105
- <motion.div
106
- layout={!reducedMotion}
107
- transition={reducedMotion ? { duration: 0 } : spring.smooth}
108
- // A tap on the shrunken capsule brings every tab back.
109
- onClickCapture={(event) => {
110
- if (!minimized) return
111
- event.preventDefault()
112
- event.stopPropagation()
113
- setScrolledAway(false)
114
- }}
115
- className={cn(
116
- "glass flex min-w-0 items-center rounded-full p-0.5",
117
- minimized ? "grow-0" : "grow"
118
- )}
119
- >
120
- {tabs.map((tab) => {
121
- const current = tab.id === active
122
- if (minimized && !current) return null
123
- return (
124
- <Fragment key={tab.id}>
125
- {renderLink(tab, {
126
- "aria-current": current ? "page" : undefined,
127
- // Switching tabs is silent, as on Apple and Android: no haptic.
128
- onClick: () => {},
129
- className: cn(
130
- "relative isolate flex min-w-0 flex-col items-center justify-center gap-0.5 rounded-full font-semibold tracking-[0.01em] no-underline transition-[color,transform] duration-fast active:scale-[0.94]",
131
- tabHeights[size],
132
- tabText[size],
133
- minimized ? tabMinimizedWidths[size] : "flex-1 px-1",
134
- current ? "text-accent-text" : "text-label-secondary"
135
- ),
136
- children: (
137
- <>
138
- {current && (
139
- <motion.span
140
- layoutId={selectionId}
141
- className="glass-lens absolute inset-0 -z-10 rounded-full"
142
- transition={
143
- reducedMotion ? { duration: 0 } : spring.snappy
144
- }
145
- />
146
- )}
147
- {tab.icon}
148
- <span
149
- className={
150
- minimized ? "sr-only" : "max-w-full truncate"
151
- }
152
- >
153
- {tab.label}
154
- </span>
155
- </>
156
- ),
157
- })}
158
- </Fragment>
159
- )
160
- })}
161
- </motion.div>
162
- {search && (
163
- <button
164
- type="button"
165
- aria-label={search.label}
166
- onClick={search.onPress}
167
- className={cn(
168
- "glass ms-auto flex shrink-0 items-center justify-center rounded-full text-label transition-transform duration-fast active:scale-[0.92]",
169
- searchButtonSizes[size]
170
- )}
171
- >
172
- <SearchIcon size={searchIconSizes[size]} strokeWidth={2} />
173
- </button>
174
- )}
175
- </nav>
176
- )
86
+ // Scopes the sliding selection to this bar, so two bars on one
87
+ // screen (a docs page, a split view) never trade their highlight.
88
+ const selectionId = `${useId()}-tab-selection`;
89
+ const reducedMotion = useReducedMotion();
90
+ const [scrolledAway, setScrolledAway] = useMinimizeOnScroll();
91
+ // Platform behavior keeps Android expanded; apps can opt into scroll minimization.
92
+ const minimized =
93
+ scrolledAway &&
94
+ active !== null &&
95
+ minimizeBehavior !== "never" &&
96
+ (minimizeBehavior === "scroll" || !onAndroid());
97
+ return (
98
+ <nav
99
+ aria-label={label}
100
+ className={cn(
101
+ "fixed inset-x-4 bottom-[max(14px,env(safe-area-inset-bottom))] z-30 flex items-center gap-3",
102
+ className,
103
+ )}
104
+ >
105
+ <motion.div
106
+ layout={!reducedMotion}
107
+ transition={reducedMotion ? { duration: 0 } : spring.smooth}
108
+ // A tap on the shrunken capsule brings every tab back.
109
+ onClickCapture={(event) => {
110
+ if (!minimized) return;
111
+ event.preventDefault();
112
+ event.stopPropagation();
113
+ setScrolledAway(false);
114
+ }}
115
+ className={cn(
116
+ "glass flex min-w-0 items-center rounded-full p-0.5",
117
+ minimized ? "grow-0" : "grow",
118
+ )}
119
+ >
120
+ {tabs.map((tab) => {
121
+ const current = tab.id === active;
122
+ if (minimized && !current) return null;
123
+ return (
124
+ <Fragment key={tab.id}>
125
+ {renderLink(tab, {
126
+ "aria-current": current ? "page" : undefined,
127
+ // Switching tabs is silent, as on Apple and Android: no haptic.
128
+ onClick: () => {},
129
+ className: cn(
130
+ "duration-fast relative isolate flex min-w-0 flex-col items-center justify-center gap-0.5 rounded-full font-semibold tracking-[0.01em] no-underline transition-[color,transform] active:scale-[0.94]",
131
+ tabHeights[size],
132
+ tabText[size],
133
+ minimized ? tabMinimizedWidths[size] : "flex-1 px-1",
134
+ current ? "text-accent-text" : "text-label-secondary",
135
+ ),
136
+ children: (
137
+ <>
138
+ {current && (
139
+ <motion.span
140
+ layoutId={selectionId}
141
+ className="glass-lens absolute inset-0 -z-10 rounded-full"
142
+ transition={
143
+ reducedMotion ? { duration: 0 } : spring.snappy
144
+ }
145
+ />
146
+ )}
147
+ {tab.icon}
148
+ <span
149
+ className={minimized ? "sr-only" : "max-w-full truncate"}
150
+ >
151
+ {tab.label}
152
+ </span>
153
+ </>
154
+ ),
155
+ })}
156
+ </Fragment>
157
+ );
158
+ })}
159
+ </motion.div>
160
+ {search && (
161
+ <button
162
+ type="button"
163
+ aria-label={search.label}
164
+ onClick={search.onPress}
165
+ className={cn(
166
+ "glass text-label duration-fast ms-auto flex shrink-0 items-center justify-center rounded-full transition-transform active:scale-[0.92]",
167
+ searchButtonSizes[size],
168
+ )}
169
+ >
170
+ <SearchIcon size={searchIconSizes[size]} strokeWidth={2} />
171
+ </button>
172
+ )}
173
+ </nav>
174
+ );
177
175
  }
178
176
 
179
177
  function onAndroid() {
180
- return typeof document !== "undefined" && document.documentElement.dataset.os === "android"
178
+ return (
179
+ typeof document !== "undefined" &&
180
+ document.documentElement.dataset.os === "android"
181
+ );
181
182
  }
@@ -1,152 +1,155 @@
1
- import { useId, useRef, useState, type ReactNode } from "react"
2
- import { cn } from "../../lib/class-names"
1
+ import { useId, useRef, useState, type ReactNode } from "react";
2
+ import { cn } from "../../lib/class-names";
3
3
 
4
- export type TabViewAlign = "start" | "center"
5
- export type TabViewSize = "sm" | "md" | "lg"
4
+ export type TabViewAlign = "start" | "center";
5
+ export type TabViewSize = "sm" | "md" | "lg";
6
6
 
7
7
  const containerGaps: Record<TabViewSize, string> = {
8
- sm: "gap-4",
9
- md: "gap-5",
10
- lg: "gap-6",
11
- }
8
+ sm: "gap-4",
9
+ md: "gap-5",
10
+ lg: "gap-6",
11
+ };
12
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
- }
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
17
  const tabText: Record<TabViewSize, string> = {
18
- sm: "text-footnote",
19
- md: "text-subheadline",
20
- lg: "text-body",
21
- }
18
+ sm: "text-footnote",
19
+ md: "text-subheadline",
20
+ lg: "text-body",
21
+ };
22
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
- }
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
+ };
27
27
 
28
28
  export interface TabViewProps<Value extends string> {
29
- /** Available tabs, in keyboard navigation order. */
30
- options: readonly { value: Value; label: string; disabled?: boolean }[]
31
- /** Initial selection when value is not controlled. */
32
- defaultValue: Value
33
- /** Controlled selection; omit to keep selection inside TabView. */
34
- value?: Value
35
- onValueChange?: (value: Value) => void
36
- /** Tab alignment: `start` or `center`. @default "center" */
37
- align?: TabViewAlign
38
- /** Accessible name for the tab list. */
39
- label: string
40
- /** Renders the selected tab's panel. */
41
- children: (value: Value) => ReactNode
42
- /** Tablist height, tab text scale and underline offset. @default "md" */
43
- size?: TabViewSize
44
- className?: string
29
+ /** Available tabs, in keyboard navigation order. */
30
+ options: readonly { value: Value; label: string; disabled?: boolean }[];
31
+ /** Initial selection when value is not controlled. */
32
+ defaultValue: Value;
33
+ /** Controlled selection; omit to keep selection inside TabView. */
34
+ value?: Value;
35
+ onValueChange?: (value: Value) => void;
36
+ /** Tab alignment: `start` or `center`. @default "center" */
37
+ align?: TabViewAlign;
38
+ /** Accessible name for the tab list. */
39
+ label: string;
40
+ /** Renders the selected tab's panel. */
41
+ children: (value: Value) => ReactNode;
42
+ /** Tablist height, tab text scale and underline offset. @default "md" */
43
+ size?: TabViewSize;
44
+ className?: string;
45
45
  }
46
46
 
47
47
  /** Content tabs with native buttons, roving focus and automatic keyboard activation. */
48
48
  export function TabView<Value extends string>({
49
- options,
50
- defaultValue,
51
- value: controlledValue,
52
- onValueChange,
53
- align = "center",
54
- label,
55
- children,
56
- size = "md",
57
- className,
49
+ options,
50
+ defaultValue,
51
+ value: controlledValue,
52
+ onValueChange,
53
+ align = "center",
54
+ label,
55
+ children,
56
+ size = "md",
57
+ className,
58
58
  }: TabViewProps<Value>) {
59
- const id = useId()
60
- const buttons = useRef<(HTMLButtonElement | null)[]>([])
61
- const [selected, setSelected] = useState(defaultValue)
62
- const current = controlledValue ?? selected
63
- function select(next: Value) {
64
- if (controlledValue === undefined) setSelected(next)
65
- onValueChange?.(next)
66
- }
67
- const enabled = options.filter((option) => !option.disabled)
68
- const value = enabled.some((option) => option.value === current) ? current : enabled[0]?.value
69
- if (value === undefined) return null
59
+ const id = useId();
60
+ const buttons = useRef<(HTMLButtonElement | null)[]>([]);
61
+ const [selected, setSelected] = useState(defaultValue);
62
+ const current = controlledValue ?? selected;
63
+ function select(next: Value) {
64
+ if (controlledValue === undefined) setSelected(next);
65
+ onValueChange?.(next);
66
+ }
67
+ const enabled = options.filter((option) => !option.disabled);
68
+ const value = enabled.some((option) => option.value === current)
69
+ ? current
70
+ : enabled[0]?.value;
71
+ if (value === undefined) return null;
70
72
 
71
- return (
72
- <div className={cn("flex w-full flex-col", containerGaps[size], className)}>
73
- <div
74
- role="tablist"
75
- aria-label={label}
76
- className={cn(
77
- tablistClasses[size],
78
- align === "start" ? "self-start" : "mx-auto justify-center"
79
- )}
80
- >
81
- {options.map((option, index) => (
82
- <button
83
- key={option.value}
84
- type="button"
85
- role="tab"
86
- id={`${id}-tab-${index}`}
87
- aria-controls={`${id}-panel-${index}`}
88
- aria-selected={option.value === value}
89
- aria-disabled={option.disabled || undefined}
90
- disabled={option.disabled}
91
- tabIndex={option.value === value ? 0 : -1}
92
- ref={(element) => {
93
- buttons.current[index] = element
94
- }}
95
- onClick={() => select(option.value)}
96
- onKeyDown={(event) => {
97
- const rtl = getComputedStyle(event.currentTarget).direction === "rtl"
98
- let next: number
99
- let step: number
100
- if (event.key === "Home") {
101
- next = -1
102
- step = 1
103
- } else if (event.key === "End") {
104
- next = options.length
105
- step = -1
106
- } else if (event.key === "ArrowRight") {
107
- step = rtl ? -1 : 1
108
- next = index
109
- } else if (event.key === "ArrowLeft") {
110
- step = rtl ? 1 : -1
111
- next = index
112
- } else return
113
- event.preventDefault()
114
- for (let offset = 0; offset < options.length; offset++) {
115
- next = (next + step + options.length) % options.length
116
- const option = options[next]
117
- if (!option || option.disabled) continue
118
- select(option.value)
119
- buttons.current[next]?.focus()
120
- break
121
- }
122
- }}
123
- className={cn(
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],
126
- "focus-visible:outline-2 focus-visible:outline-offset-2",
127
- "after:absolute after:inset-x-0 after:bg-current",
128
- underlineOffset[size],
129
- option.value === value
130
- ? "after:opacity-100"
131
- : "opacity-60 hover:opacity-100 after:opacity-0"
132
- )}
133
- >
134
- {option.label}
135
- </button>
136
- ))}
137
- </div>
138
- {options.map((option, index) => (
139
- <div
140
- key={option.value}
141
- role="tabpanel"
142
- id={`${id}-panel-${index}`}
143
- aria-labelledby={`${id}-tab-${index}`}
144
- hidden={option.value !== value}
145
- className="w-full outline-offset-4"
146
- >
147
- {option.value === value && children(option.value)}
148
- </div>
149
- ))}
150
- </div>
151
- )
73
+ return (
74
+ <div className={cn("flex w-full flex-col", containerGaps[size], className)}>
75
+ <div
76
+ role="tablist"
77
+ aria-label={label}
78
+ className={cn(
79
+ tablistClasses[size],
80
+ align === "start" ? "self-start" : "mx-auto justify-center",
81
+ )}
82
+ >
83
+ {options.map((option, index) => (
84
+ <button
85
+ key={option.value}
86
+ type="button"
87
+ role="tab"
88
+ id={`${id}-tab-${index}`}
89
+ aria-controls={`${id}-panel-${index}`}
90
+ aria-selected={option.value === value}
91
+ aria-disabled={option.disabled || undefined}
92
+ disabled={option.disabled}
93
+ tabIndex={option.value === value ? 0 : -1}
94
+ ref={(element) => {
95
+ buttons.current[index] = element;
96
+ }}
97
+ onClick={() => select(option.value)}
98
+ onKeyDown={(event) => {
99
+ const rtl =
100
+ getComputedStyle(event.currentTarget).direction === "rtl";
101
+ let next: number;
102
+ let step: number;
103
+ if (event.key === "Home") {
104
+ next = -1;
105
+ step = 1;
106
+ } else if (event.key === "End") {
107
+ next = options.length;
108
+ step = -1;
109
+ } else if (event.key === "ArrowRight") {
110
+ step = rtl ? -1 : 1;
111
+ next = index;
112
+ } else if (event.key === "ArrowLeft") {
113
+ step = rtl ? 1 : -1;
114
+ next = index;
115
+ } else return;
116
+ event.preventDefault();
117
+ for (let offset = 0; offset < options.length; offset++) {
118
+ next = (next + step + options.length) % options.length;
119
+ const option = options[next];
120
+ if (!option || option.disabled) continue;
121
+ select(option.value);
122
+ buttons.current[next]?.focus();
123
+ break;
124
+ }
125
+ }}
126
+ className={cn(
127
+ "relative inline-flex h-[calc(100%-1px)] shrink-0 items-center justify-center border border-transparent p-0 font-medium whitespace-nowrap",
128
+ tabText[size],
129
+ "focus-visible:outline-2 focus-visible:outline-offset-2",
130
+ "after:absolute after:inset-x-0 after:bg-current",
131
+ underlineOffset[size],
132
+ option.value === value
133
+ ? "after:opacity-100"
134
+ : "opacity-60 after:opacity-0 hover:opacity-100",
135
+ )}
136
+ >
137
+ {option.label}
138
+ </button>
139
+ ))}
140
+ </div>
141
+ {options.map((option, index) => (
142
+ <div
143
+ key={option.value}
144
+ role="tabpanel"
145
+ id={`${id}-panel-${index}`}
146
+ aria-labelledby={`${id}-tab-${index}`}
147
+ hidden={option.value !== value}
148
+ className="w-full outline-offset-4"
149
+ >
150
+ {option.value === value && children(option.value)}
151
+ </div>
152
+ ))}
153
+ </div>
154
+ );
152
155
  }