@passui/react 0.1.44 → 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.
- package/README.md +57 -46
- package/dist/types/src/components/accordion/accordion.d.ts +1 -1
- package/dist/types/src/components/breadcrumb/breadcrumb.d.ts +1 -1
- package/dist/types/src/components/inline-disclosure/inline-disclosure.d.ts +1 -1
- package/dist/types/src/components/popover/anchored-surface.d.ts +5 -1
- package/dist/types/src/components/popover/index.d.ts +41 -0
- package/dist/types/src/components/popover/popover-content.d.ts +32 -0
- package/dist/types/src/components/popover/popover-root.d.ts +22 -0
- package/dist/types/src/components/popover/popover-trigger.d.ts +23 -0
- package/dist/types/src/components/stepper/rolling-digit.d.ts +1 -1
- package/dist/types/src/components/table/table.d.ts +1 -1
- package/dist/types/src/components/view-controller/view-controller.d.ts +2 -2
- package/dist/types/src/index.d.ts +1 -1
- package/dist/types/src/machines/popover.machine.d.ts +30 -0
- package/package.json +307 -307
- package/src/appearance/index.ts +2 -2
- package/src/appearance/platform.tsx +76 -60
- package/src/appearance/theme.tsx +409 -377
- package/src/components/accordion/accordion.tsx +58 -49
- package/src/components/ambient/ambient.tsx +25 -25
- package/src/components/ambient/index.ts +1 -1
- package/src/components/aspect-ratio/aspect-ratio.tsx +21 -16
- package/src/components/breadcrumb/breadcrumb.tsx +63 -57
- package/src/components/button/button-styles.ts +39 -32
- package/src/components/button/button.tsx +91 -84
- package/src/components/button/icon-button.tsx +27 -25
- package/src/components/button/pending-spinner.tsx +8 -8
- package/src/components/button/use-pending-indicator.ts +11 -11
- package/src/components/chip/chip.tsx +35 -30
- package/src/components/code-block/code-block.tsx +40 -39
- package/src/components/combo-box/combo-box.tsx +5 -5
- package/src/components/command/command.tsx +124 -94
- package/src/components/compact-date-picker/calendar-month.ts +75 -53
- package/src/components/compact-date-picker/compact-date-picker.tsx +308 -289
- package/src/components/compact-time-picker/compact-time-picker.tsx +288 -281
- package/src/components/compact-time-picker/time-value.ts +32 -29
- package/src/components/content-unavailable-view/content-unavailable-view.tsx +64 -48
- package/src/components/content-view/content-view.tsx +168 -159
- package/src/components/content-view/index.ts +1 -1
- package/src/components/context-menu/conceal-menu-source.ts +25 -25
- package/src/components/context-menu/context-menu-host.tsx +439 -398
- package/src/components/context-menu/context-menu-store.ts +125 -123
- package/src/components/context-menu/menu-navigation.ts +25 -18
- package/src/components/context-menu/menu-position.ts +33 -29
- package/src/components/context-menu/menu-reveal.ts +32 -32
- package/src/components/context-menu/use-context-menu.ts +63 -56
- package/src/components/conversation/conversation.tsx +265 -263
- package/src/components/conversation/index.ts +1 -1
- package/src/components/date-picker/date-picker.tsx +95 -94
- package/src/components/date-picker/date-value.ts +20 -10
- package/src/components/dialog/dialog-host.tsx +105 -103
- package/src/components/dialog/dialog-store.ts +37 -36
- package/src/components/dialog/dialog.tsx +192 -179
- package/src/components/dialog/index.ts +1 -1
- package/src/components/disclosure-group/disclosure-group.tsx +93 -86
- package/src/components/divider/divider.tsx +24 -22
- package/src/components/inline-disclosure/inline-disclosure.tsx +40 -36
- package/src/components/keyboard-shortcut/keyboard-shortcut.tsx +23 -19
- package/src/components/labeled-content/labeled-content.tsx +68 -68
- package/src/components/locale-switcher/index.ts +1 -1
- package/src/components/locale-switcher/locale-switcher.tsx +279 -251
- package/src/components/menu/menu.tsx +75 -58
- package/src/components/morph/index.ts +2 -2
- package/src/components/morph/morph.tsx +227 -201
- package/src/components/morph/use-morph-disclosure.ts +19 -19
- package/src/components/one-time-code/one-time-code.tsx +216 -200
- package/src/components/picker/picker-trigger.ts +16 -13
- package/src/components/picker/picker.tsx +371 -345
- package/src/components/popover/anchored-surface.tsx +253 -229
- package/src/components/popover/index.ts +50 -0
- package/src/components/popover/popover-content.tsx +334 -0
- package/src/components/popover/popover-fit.ts +58 -53
- package/src/components/popover/popover-root.tsx +97 -0
- package/src/components/popover/popover-trigger.tsx +71 -0
- package/src/components/profile-image/profile-image.tsx +80 -64
- package/src/components/progress-view/progress-view.tsx +61 -59
- package/src/components/progress-view/use-liquid-progress.ts +38 -38
- package/src/components/radio-group/radio-group.tsx +71 -68
- package/src/components/search-field/expanding-search-field.tsx +191 -177
- package/src/components/search-field/search-field.tsx +59 -50
- package/src/components/search-list/search-list.tsx +246 -236
- package/src/components/section/section.tsx +58 -58
- package/src/components/secure-field/secure-field.tsx +3 -3
- package/src/components/segmented-control/segmented-control.tsx +136 -136
- package/src/components/sheet/modal-environment.ts +71 -64
- package/src/components/sheet/sheet-portal.tsx +58 -53
- package/src/components/sheet/sheet.tsx +146 -141
- package/src/components/sheet/use-sheet-focus.ts +97 -82
- package/src/components/sidebar/sidebar.tsx +78 -78
- package/src/components/skeleton/skeleton.tsx +30 -21
- package/src/components/slider/slider.tsx +113 -104
- package/src/components/slosh-slider/liquid-step.ts +32 -31
- package/src/components/slosh-slider/slosh-slider.tsx +265 -228
- package/src/components/stat-tile/index.ts +1 -1
- package/src/components/stat-tile/stat-tile.tsx +79 -68
- package/src/components/status-indicator/status-indicator.tsx +40 -37
- package/src/components/stepper/rolling-digit.tsx +82 -68
- package/src/components/stepper/stepper.tsx +122 -113
- package/src/components/swatch-picker/swatch-picker.tsx +120 -120
- package/src/components/tab-bar/tab-bar.tsx +149 -148
- package/src/components/tab-view/tab-view.tsx +140 -137
- package/src/components/table/table.tsx +117 -98
- package/src/components/text-editor/text-editor.tsx +34 -29
- package/src/components/text-field/text-field.tsx +182 -180
- package/src/components/toast/toast-store.ts +127 -115
- package/src/components/toast/toaster.tsx +361 -347
- package/src/components/toggle/toggle.tsx +297 -282
- package/src/components/tooltip/tooltip-host.tsx +157 -150
- package/src/components/view-controller/index.ts +1 -1
- package/src/components/view-controller/view-controller.tsx +64 -53
- package/src/components/wake-slider/wake-slider.tsx +220 -205
- package/src/hooks/scroll/minimize-decision.ts +11 -7
- package/src/hooks/use-anchored-disclosure.ts +19 -19
- package/src/hooks/use-anchored-placement.ts +29 -25
- package/src/hooks/use-dismiss.ts +33 -29
- package/src/hooks/use-floating-dismiss.ts +24 -22
- package/src/hooks/use-media-query.ts +67 -60
- package/src/hooks/use-minimize-on-scroll.ts +69 -60
- package/src/hooks/use-visual-viewport.ts +63 -63
- package/src/hooks/viewport/types.ts +16 -16
- package/src/hooks/viewport/viewport-metrics.ts +16 -10
- package/src/icons/icons.tsx +440 -328
- package/src/index.ts +91 -91
- package/src/lib/anchored-placement.ts +70 -65
- package/src/lib/class-names.ts +14 -13
- package/src/lib/clipboard.ts +8 -7
- package/src/lib/date.ts +61 -60
- package/src/lib/floating-layers.ts +39 -24
- package/src/lib/haptics.ts +10 -10
- package/src/lib/language.ts +9 -9
- package/src/lib/range-value.ts +27 -21
- package/src/lib/screen-edges.ts +33 -30
- package/src/lib/shapes.ts +38 -34
- package/src/machines/popover.machine.ts +51 -0
- package/src/motion/transitions.ts +46 -43
- package/src/styles/code-block.css +69 -43
- package/src/styles/motion-controls.css +393 -191
- package/src/tokens.ts +1 -1
- package/dist/types/src/components/popover/popover.d.ts +0 -21
- package/src/components/popover/popover.tsx +0 -322
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
26
|
+
sm: "h-9",
|
|
27
|
+
md: "h-11",
|
|
28
|
+
lg: "h-13",
|
|
29
|
+
};
|
|
30
30
|
const tabMinimizedWidths: Record<TabBarSize, string> = {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
31
|
+
sm: "w-9",
|
|
32
|
+
md: "w-11",
|
|
33
|
+
lg: "w-13",
|
|
34
|
+
};
|
|
35
35
|
const tabText: Record<TabBarSize, string> = {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
36
|
+
sm: "text-caption",
|
|
37
|
+
md: "text-caption2",
|
|
38
|
+
lg: "text-subheadline",
|
|
39
|
+
};
|
|
40
40
|
const searchButtonSizes: Record<TabBarSize, string> = {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
41
|
+
sm: "size-10",
|
|
42
|
+
md: "size-12",
|
|
43
|
+
lg: "size-14",
|
|
44
|
+
};
|
|
45
45
|
const searchIconSizes: Record<TabBarSize, number> = {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
46
|
+
sm: 17,
|
|
47
|
+
md: 19,
|
|
48
|
+
lg: 21,
|
|
49
|
+
};
|
|
50
50
|
|
|
51
51
|
export interface TabBarProps<Tab extends TabBarTab> {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
77
|
+
tabs,
|
|
78
|
+
active,
|
|
79
|
+
renderLink,
|
|
80
|
+
search,
|
|
81
|
+
label,
|
|
82
|
+
className,
|
|
83
|
+
size = "md",
|
|
84
|
+
minimizeBehavior = "platform",
|
|
85
85
|
}: TabBarProps<Tab>) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
8
|
+
sm: "gap-4",
|
|
9
|
+
md: "gap-5",
|
|
10
|
+
lg: "gap-6",
|
|
11
|
+
};
|
|
12
12
|
const tablistClasses: Record<TabViewSize, string> = {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
18
|
+
sm: "text-footnote",
|
|
19
|
+
md: "text-subheadline",
|
|
20
|
+
lg: "text-body",
|
|
21
|
+
};
|
|
22
22
|
const underlineOffset: Record<TabViewSize, string> = {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
}
|