@bug-on/md3-react 2.0.3 → 3.0.0
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/.turbo/turbo-build.log +33 -0
- package/CHANGELOG.md +55 -0
- package/dist/index.css.d.ts +2 -0
- package/dist/index.d.mts +6127 -0
- package/dist/index.d.ts +6127 -71
- package/dist/index.js +1653 -614
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1566 -547
- package/dist/index.mjs.map +1 -1
- package/dist/material-symbols-cdn.css.d.ts +2 -0
- package/dist/material-symbols-self-hosted.css.d.ts +2 -0
- package/dist/typography.css.d.ts +2 -0
- package/package.json +22 -19
- package/scripts/copy-assets.js +82 -0
- package/src/assets/fonts/GoogleSansFlex-VariableFont.woff2 +0 -0
- package/src/assets/fonts/MaterialSymbolsOutlined-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
- package/src/assets/fonts/MaterialSymbolsRounded-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
- package/src/assets/fonts/MaterialSymbolsSharp-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
- package/src/assets/loading-indicator.svg +19 -0
- package/src/assets/material-symbols-cdn.css +65 -0
- package/src/assets/material-symbols-self-hosted.css +90 -0
- package/src/css.d.ts +20 -0
- package/src/hooks/useClickOutside.ts +37 -0
- package/src/hooks/useMediaQuery.ts +28 -0
- package/src/hooks/useRipple.ts +88 -0
- package/src/index.css +23 -0
- package/src/index.ts +349 -0
- package/src/lib/material-symbols-preconnect.tsx +82 -0
- package/src/lib/theme-utils.ts +180 -0
- package/src/lib/utils.ts +6 -0
- package/src/test/button.test.tsx +59 -0
- package/src/test/icon.test.tsx +91 -0
- package/src/test/loading-indicator.test.tsx +128 -0
- package/src/test/progress-indicator.test.tsx +306 -0
- package/src/test/setup.ts +80 -0
- package/src/test/typography.test.tsx +206 -0
- package/src/types/index.ts +7 -0
- package/src/types/md3.ts +31 -0
- package/src/ui/Text.tsx +60 -0
- package/src/ui/__snapshots__/divider.test.tsx.snap +63 -0
- package/src/ui/app-bar/app-bar-column.tsx +99 -0
- package/src/ui/app-bar/app-bar-item-button.tsx +71 -0
- package/src/ui/app-bar/app-bar-items.test.tsx +89 -0
- package/src/ui/app-bar/app-bar-overflow-indicator.tsx +108 -0
- package/src/ui/app-bar/app-bar-row.tsx +104 -0
- package/src/ui/app-bar/app-bar.test.tsx +87 -0
- package/src/ui/app-bar/app-bar.tokens.ts +223 -0
- package/src/ui/app-bar/app-bar.types.ts +441 -0
- package/src/ui/app-bar/bottom-app-bar.test.tsx +42 -0
- package/src/ui/app-bar/bottom-app-bar.tsx +84 -0
- package/src/ui/app-bar/docked-toolbar.test.tsx +34 -0
- package/src/ui/app-bar/docked-toolbar.tsx +54 -0
- package/src/ui/app-bar/flexible-app-bar.test.tsx +75 -0
- package/src/ui/app-bar/hooks/use-app-bar-scroll.ts +110 -0
- package/src/ui/app-bar/hooks/use-flexible-app-bar.ts +123 -0
- package/{dist/ui/app-bar/index.d.ts → src/ui/app-bar/index.ts} +35 -2
- package/src/ui/app-bar/large-flexible-app-bar.tsx +165 -0
- package/src/ui/app-bar/medium-flexible-app-bar.tsx +167 -0
- package/src/ui/app-bar/search-app-bar.test.tsx +49 -0
- package/src/ui/app-bar/search-app-bar.tsx +176 -0
- package/src/ui/app-bar/search-view.tsx +227 -0
- package/src/ui/app-bar/small-app-bar.test.tsx +48 -0
- package/src/ui/app-bar/small-app-bar.tsx +203 -0
- package/src/ui/badge.test.tsx +345 -0
- package/src/ui/badge.tsx +282 -0
- package/src/ui/button-group.test.tsx +71 -0
- package/src/ui/button-group.tsx +350 -0
- package/src/ui/button.test.tsx +297 -0
- package/src/ui/button.tsx +669 -0
- package/src/ui/card.test.tsx +187 -0
- package/src/ui/card.tsx +259 -0
- package/src/ui/checkbox.test.tsx +423 -0
- package/src/ui/checkbox.tsx +525 -0
- package/src/ui/chip.test.tsx +292 -0
- package/src/ui/chip.tsx +548 -0
- package/src/ui/code-block.tsx +219 -0
- package/src/ui/dialog.test.tsx +300 -0
- package/src/ui/dialog.tsx +384 -0
- package/src/ui/divider.test.tsx +314 -0
- package/src/ui/divider.tsx +412 -0
- package/src/ui/drawer.tsx +240 -0
- package/src/ui/fab-menu.test.tsx +494 -0
- package/src/ui/fab-menu.tsx +739 -0
- package/src/ui/fab.test.tsx +232 -0
- package/src/ui/fab.tsx +505 -0
- package/src/ui/icon-button.test.tsx +515 -0
- package/src/ui/icon-button.tsx +525 -0
- package/src/ui/icon.test.tsx +197 -0
- package/src/ui/icon.tsx +179 -0
- package/src/ui/loading-indicator.test.tsx +73 -0
- package/src/ui/loading-indicator.tsx +312 -0
- package/src/ui/menu/context-menu.tsx +275 -0
- package/src/ui/menu/index.ts +77 -0
- package/src/ui/menu/menu-animations.ts +102 -0
- package/src/ui/menu/menu-context.tsx +99 -0
- package/src/ui/menu/menu-divider.tsx +47 -0
- package/src/ui/menu/menu-group.tsx +200 -0
- package/src/ui/menu/menu-item.tsx +294 -0
- package/src/ui/menu/menu-tokens.ts +208 -0
- package/src/ui/menu/menu-types.ts +313 -0
- package/src/ui/menu/menu.test.tsx +624 -0
- package/src/ui/menu/menu.tsx +289 -0
- package/src/ui/menu/sub-menu.tsx +223 -0
- package/src/ui/menu/vertical-menu.tsx +382 -0
- package/src/ui/navigation-rail.test.tsx +404 -0
- package/src/ui/navigation-rail.tsx +604 -0
- package/src/ui/progress-indicator/circular.tsx +248 -0
- package/src/ui/progress-indicator/hooks.ts +51 -0
- package/{dist/ui/progress-indicator/index.d.ts → src/ui/progress-indicator/index.tsx} +20 -2
- package/src/ui/progress-indicator/linear-flat.tsx +83 -0
- package/src/ui/progress-indicator/linear-wavy.tsx +243 -0
- package/src/ui/progress-indicator/linear.tsx +143 -0
- package/src/ui/progress-indicator/types.ts +158 -0
- package/src/ui/progress-indicator/utils.ts +73 -0
- package/src/ui/radio-button.test.tsx +407 -0
- package/src/ui/radio-button.tsx +551 -0
- package/src/ui/ripple.test.tsx +72 -0
- package/src/ui/ripple.tsx +234 -0
- package/src/ui/scroll-area.test.tsx +58 -0
- package/src/ui/scroll-area.tsx +139 -0
- package/src/ui/search/animated-placeholder.tsx +145 -0
- package/src/ui/search/hooks/use-search-keyboard.test.ts +202 -0
- package/src/ui/search/hooks/use-search-keyboard.ts +104 -0
- package/src/ui/search/hooks/use-search-view-focus.test.ts +96 -0
- package/src/ui/search/hooks/use-search-view-focus.ts +24 -0
- package/src/ui/search/index.ts +44 -0
- package/src/ui/search/search-bar.tsx +220 -0
- package/src/ui/search/search-context.tsx +42 -0
- package/src/ui/search/search-view-docked.tsx +194 -0
- package/src/ui/search/search-view-fullscreen.tsx +247 -0
- package/src/ui/search/search.test.tsx +233 -0
- package/src/ui/search/search.tokens.ts +134 -0
- package/src/ui/search/search.tsx +131 -0
- package/src/ui/search/search.types.ts +154 -0
- package/src/ui/search/trailing-action.tsx +49 -0
- package/src/ui/shared/constants.ts +122 -0
- package/{dist/ui/shared/touch-target.d.ts → src/ui/shared/touch-target.tsx} +13 -1
- package/src/ui/slider/hooks/useSliderMath.ts +195 -0
- package/{dist/ui/slider/index.d.ts → src/ui/slider/index.ts} +12 -1
- package/src/ui/slider/range-slider.tsx +561 -0
- package/src/ui/slider/slider-thumb.tsx +379 -0
- package/src/ui/slider/slider-track.tsx +912 -0
- package/src/ui/slider/slider.tokens.ts +189 -0
- package/src/ui/slider/slider.tsx +259 -0
- package/src/ui/slider/slider.types.ts +288 -0
- package/src/ui/snackbar/index.ts +20 -0
- package/src/ui/snackbar/snackbar.test.tsx +338 -0
- package/src/ui/snackbar/snackbar.tsx +476 -0
- package/{dist/ui/switch/index.d.ts → src/ui/switch/index.ts} +1 -0
- package/src/ui/switch/switch.stories.tsx +309 -0
- package/src/ui/switch/switch.test.tsx +243 -0
- package/src/ui/switch/switch.tokens.ts +89 -0
- package/src/ui/switch/switch.tsx +504 -0
- package/src/ui/switch/switch.types.ts +62 -0
- package/{dist/ui/tabs/index.d.ts → src/ui/tabs/index.ts} +8 -1
- package/src/ui/tabs/tab.tsx +407 -0
- package/src/ui/tabs/tabs-content.tsx +89 -0
- package/src/ui/tabs/tabs-list.tsx +146 -0
- package/src/ui/tabs/tabs.test.tsx +290 -0
- package/src/ui/tabs/tabs.tokens.ts +121 -0
- package/src/ui/tabs/tabs.tsx +229 -0
- package/src/ui/tabs/tabs.types.ts +185 -0
- package/{dist/ui/text-field/index.d.ts → src/ui/text-field/index.ts} +8 -1
- package/src/ui/text-field/subcomponents/active-indicator.tsx +67 -0
- package/src/ui/text-field/subcomponents/floating-label.tsx +161 -0
- package/src/ui/text-field/subcomponents/leading-icon.tsx +46 -0
- package/src/ui/text-field/subcomponents/outline-container.tsx +170 -0
- package/src/ui/text-field/subcomponents/prefix-suffix.tsx +59 -0
- package/src/ui/text-field/subcomponents/supporting-text.tsx +145 -0
- package/src/ui/text-field/subcomponents/trailing-icon.tsx +199 -0
- package/src/ui/text-field/text-field.test.tsx +454 -0
- package/src/ui/text-field/text-field.tokens.ts +104 -0
- package/src/ui/text-field/text-field.tsx +548 -0
- package/src/ui/text-field/text-field.types.ts +180 -0
- package/src/ui/theme-provider/index.tsx +190 -0
- package/src/ui/toc.test.tsx +108 -0
- package/src/ui/toc.tsx +172 -0
- package/src/ui/tooltip/plain-tooltip.tsx +63 -0
- package/src/ui/tooltip/rich-tooltip.tsx +94 -0
- package/src/ui/tooltip/tooltip-box.tsx +266 -0
- package/src/ui/tooltip/tooltip-caret-shape.tsx +68 -0
- package/src/ui/tooltip/tooltip.tokens.ts +26 -0
- package/src/ui/tooltip/tooltip.types.ts +70 -0
- package/src/ui/tooltip/use-tooltip-position.ts +208 -0
- package/src/ui/tooltip/use-tooltip-state.ts +41 -0
- package/src/ui/typography/__tests__/typography.test.tsx +170 -0
- package/{dist/ui/typography/index.d.ts → src/ui/typography/index.ts} +21 -3
- package/src/ui/typography/type-scale-tokens.ts +205 -0
- package/src/ui/typography/typography-key-tokens.ts +43 -0
- package/src/ui/typography/typography-tokens.ts +360 -0
- package/src/ui/typography/typography.css +22 -0
- package/src/ui/typography/typography.tsx +559 -0
- package/test-render.tsx +4 -0
- package/test-shadow.html +26 -0
- package/test_output.txt +164 -0
- package/test_output_v2.txt +5 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.json +18 -0
- package/tsup.config.ts +20 -0
- package/vitest.config.ts +11 -0
- package/dist/hooks/useClickOutside.d.ts +0 -8
- package/dist/hooks/useMediaQuery.d.ts +0 -11
- package/dist/hooks/useRipple.d.ts +0 -26
- package/dist/lib/material-symbols-preconnect.d.ts +0 -42
- package/dist/lib/theme-utils.d.ts +0 -63
- package/dist/lib/utils.d.ts +0 -2
- package/dist/types/index.d.ts +0 -1
- package/dist/types/md3.d.ts +0 -14
- package/dist/ui/app-bar/app-bar-column.d.ts +0 -28
- package/dist/ui/app-bar/app-bar-item-button.d.ts +0 -16
- package/dist/ui/app-bar/app-bar-overflow-indicator.d.ts +0 -18
- package/dist/ui/app-bar/app-bar-row.d.ts +0 -36
- package/dist/ui/app-bar/app-bar.tokens.d.ts +0 -184
- package/dist/ui/app-bar/app-bar.types.d.ts +0 -392
- package/dist/ui/app-bar/bottom-app-bar.d.ts +0 -31
- package/dist/ui/app-bar/docked-toolbar.d.ts +0 -25
- package/dist/ui/app-bar/hooks/use-app-bar-scroll.d.ts +0 -42
- package/dist/ui/app-bar/hooks/use-flexible-app-bar.d.ts +0 -37
- package/dist/ui/app-bar/large-flexible-app-bar.d.ts +0 -26
- package/dist/ui/app-bar/medium-flexible-app-bar.d.ts +0 -28
- package/dist/ui/app-bar/search-app-bar.d.ts +0 -43
- package/dist/ui/app-bar/search-view.d.ts +0 -54
- package/dist/ui/app-bar/small-app-bar.d.ts +0 -37
- package/dist/ui/badge.d.ts +0 -125
- package/dist/ui/button-group.d.ts +0 -59
- package/dist/ui/button.d.ts +0 -148
- package/dist/ui/card.d.ts +0 -62
- package/dist/ui/checkbox.d.ts +0 -82
- package/dist/ui/chip.d.ts +0 -110
- package/dist/ui/code-block.d.ts +0 -14
- package/dist/ui/dialog.d.ts +0 -111
- package/dist/ui/divider.d.ts +0 -164
- package/dist/ui/drawer.d.ts +0 -39
- package/dist/ui/dropdown.d.ts +0 -29
- package/dist/ui/fab-menu.d.ts +0 -204
- package/dist/ui/fab.d.ts +0 -162
- package/dist/ui/icon-button.d.ts +0 -131
- package/dist/ui/icon.d.ts +0 -88
- package/dist/ui/loading-indicator.d.ts +0 -42
- package/dist/ui/navigation-rail.d.ts +0 -29
- package/dist/ui/progress-indicator/circular.d.ts +0 -3
- package/dist/ui/progress-indicator/hooks.d.ts +0 -3
- package/dist/ui/progress-indicator/linear-flat.d.ts +0 -10
- package/dist/ui/progress-indicator/linear-wavy.d.ts +0 -18
- package/dist/ui/progress-indicator/linear.d.ts +0 -3
- package/dist/ui/progress-indicator/types.d.ts +0 -151
- package/dist/ui/progress-indicator/utils.d.ts +0 -3
- package/dist/ui/radio-button.d.ts +0 -106
- package/dist/ui/ripple.d.ts +0 -126
- package/dist/ui/scroll-area.d.ts +0 -27
- package/dist/ui/search/animated-placeholder.d.ts +0 -54
- package/dist/ui/search/hooks/use-search-keyboard.d.ts +0 -32
- package/dist/ui/search/hooks/use-search-view-focus.d.ts +0 -6
- package/dist/ui/search/index.d.ts +0 -27
- package/dist/ui/search/search-bar.d.ts +0 -32
- package/dist/ui/search/search-context.d.ts +0 -24
- package/dist/ui/search/search-view-docked.d.ts +0 -25
- package/dist/ui/search/search-view-fullscreen.d.ts +0 -36
- package/dist/ui/search/search.d.ts +0 -50
- package/dist/ui/search/search.tokens.d.ts +0 -112
- package/dist/ui/search/search.types.d.ts +0 -131
- package/dist/ui/search/trailing-action.d.ts +0 -9
- package/dist/ui/shared/constants.d.ts +0 -86
- package/dist/ui/slider/hooks/useSliderMath.d.ts +0 -101
- package/dist/ui/slider/range-slider.d.ts +0 -47
- package/dist/ui/slider/slider-thumb.d.ts +0 -33
- package/dist/ui/slider/slider-track.d.ts +0 -25
- package/dist/ui/slider/slider.d.ts +0 -60
- package/dist/ui/slider/slider.tokens.d.ts +0 -151
- package/dist/ui/slider/slider.types.d.ts +0 -259
- package/dist/ui/snackbar/index.d.ts +0 -6
- package/dist/ui/snackbar/snackbar.d.ts +0 -197
- package/dist/ui/switch/switch.d.ts +0 -30
- package/dist/ui/switch/switch.stories.d.ts +0 -48
- package/dist/ui/switch/switch.tokens.d.ts +0 -67
- package/dist/ui/switch/switch.types.d.ts +0 -59
- package/dist/ui/tabs/tab.d.ts +0 -43
- package/dist/ui/tabs/tabs-content.d.ts +0 -36
- package/dist/ui/tabs/tabs-list.d.ts +0 -40
- package/dist/ui/tabs/tabs.d.ts +0 -60
- package/dist/ui/tabs/tabs.tokens.d.ts +0 -94
- package/dist/ui/tabs/tabs.types.d.ts +0 -172
- package/dist/ui/text-field/subcomponents/active-indicator.d.ts +0 -24
- package/dist/ui/text-field/subcomponents/floating-label.d.ts +0 -43
- package/dist/ui/text-field/subcomponents/leading-icon.d.ts +0 -23
- package/dist/ui/text-field/subcomponents/outline-container.d.ts +0 -42
- package/dist/ui/text-field/subcomponents/prefix-suffix.d.ts +0 -24
- package/dist/ui/text-field/subcomponents/supporting-text.d.ts +0 -37
- package/dist/ui/text-field/subcomponents/trailing-icon.d.ts +0 -41
- package/dist/ui/text-field/text-field.d.ts +0 -49
- package/dist/ui/text-field/text-field.tokens.d.ts +0 -76
- package/dist/ui/text-field/text-field.types.d.ts +0 -126
- package/dist/ui/theme-provider/index.d.ts +0 -48
- package/dist/ui/toc.d.ts +0 -80
- package/dist/ui/tooltip/plain-tooltip.d.ts +0 -2
- package/dist/ui/tooltip/rich-tooltip.d.ts +0 -2
- package/dist/ui/tooltip/tooltip-box.d.ts +0 -2
- package/dist/ui/tooltip/tooltip-caret-shape.d.ts +0 -9
- package/dist/ui/tooltip/tooltip.tokens.d.ts +0 -26
- package/dist/ui/tooltip/tooltip.types.d.ts +0 -56
- package/dist/ui/tooltip/use-tooltip-position.d.ts +0 -8
- package/dist/ui/tooltip/use-tooltip-state.d.ts +0 -2
- package/dist/ui/typography/type-scale-tokens.d.ts +0 -162
- package/dist/ui/typography/typography-key-tokens.d.ts +0 -40
- package/dist/ui/typography/typography-tokens.d.ts +0 -220
- package/dist/ui/typography/typography.d.ts +0 -265
- /package/{dist/hooks/index.d.ts → src/hooks/index.ts} +0 -0
- /package/{dist/ui/tooltip/index.d.ts → src/ui/tooltip/index.ts} +0 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file tab.tsx
|
|
3
|
+
* MD3 Expressive Tab — Individual tab button with Framer Motion indicator.
|
|
4
|
+
*
|
|
5
|
+
* Design decisions:
|
|
6
|
+
* 1. PRIMARY indicator nested inside content wrapper → width = content width (not full button).
|
|
7
|
+
* 2. SECONDARY indicator outside content wrapper → `inset-x-0` = full button width.
|
|
8
|
+
* 3. ROVING TABINDEX (WAI-ARIA): only focused tab has tabIndex=0; ArrowKey moves focus, Enter/Space selects.
|
|
9
|
+
* 4. DISABLED tabs are skipped in ArrowKey navigation.
|
|
10
|
+
* 5. RTL: ArrowLeft/Right directions are swapped when `direction: rtl` is detected.
|
|
11
|
+
* 6. INLINE ICON: icon beside label, height stays 48dp (stacked = 64dp).
|
|
12
|
+
* 7. AUTO-ACTIVATE: when parent `<Tabs autoActivate>`, ArrowKey also selects.
|
|
13
|
+
*
|
|
14
|
+
* @see https://m3.material.io/components/tabs/overview
|
|
15
|
+
* @see https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { m, useReducedMotion } from "motion/react";
|
|
19
|
+
import * as React from "react";
|
|
20
|
+
import { cn } from "../../lib/utils";
|
|
21
|
+
import { BadgedBox } from "../badge";
|
|
22
|
+
import { useTabsContext, useTabsListContext } from "./tabs";
|
|
23
|
+
import {
|
|
24
|
+
TABS_COLOR_TRANSITION,
|
|
25
|
+
TABS_INDICATOR_SPRING,
|
|
26
|
+
TabsColors,
|
|
27
|
+
TabsTokens,
|
|
28
|
+
} from "./tabs.tokens";
|
|
29
|
+
import type { TabProps } from "./tabs.types";
|
|
30
|
+
|
|
31
|
+
// ─── Constants ──────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/** Minimum indicator width per MD3 spec (24dp). */
|
|
34
|
+
const INDICATOR_MIN_WIDTH = 24;
|
|
35
|
+
|
|
36
|
+
// ─── Tab ───────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const TabComponent = React.forwardRef<HTMLButtonElement, TabProps>(
|
|
39
|
+
(
|
|
40
|
+
{
|
|
41
|
+
value,
|
|
42
|
+
icon,
|
|
43
|
+
inlineIcon = false,
|
|
44
|
+
disabled = false,
|
|
45
|
+
badge,
|
|
46
|
+
className,
|
|
47
|
+
children,
|
|
48
|
+
},
|
|
49
|
+
ref,
|
|
50
|
+
) => {
|
|
51
|
+
const {
|
|
52
|
+
value: selectedValue,
|
|
53
|
+
onValueChange,
|
|
54
|
+
focusedValue,
|
|
55
|
+
setFocusedValue,
|
|
56
|
+
tabValues,
|
|
57
|
+
registerTab,
|
|
58
|
+
unregisterTab,
|
|
59
|
+
layoutGroupId,
|
|
60
|
+
disabledValues,
|
|
61
|
+
markTabDisabled,
|
|
62
|
+
autoActivate,
|
|
63
|
+
} = useTabsContext();
|
|
64
|
+
|
|
65
|
+
const { variant, scrollable } = useTabsListContext();
|
|
66
|
+
|
|
67
|
+
const prefersReduced = useReducedMotion() ?? false;
|
|
68
|
+
|
|
69
|
+
const isActive = selectedValue === value;
|
|
70
|
+
const isFocused = focusedValue === value;
|
|
71
|
+
const hasIcon = icon != null;
|
|
72
|
+
const isStackedIcon = hasIcon && !inlineIcon;
|
|
73
|
+
|
|
74
|
+
// ── Refs ───────────────────────────────────────────────────────────────
|
|
75
|
+
const buttonRef = React.useRef<HTMLButtonElement | null>(null);
|
|
76
|
+
const isFirstMount = React.useRef(true);
|
|
77
|
+
|
|
78
|
+
// Merge forwarded ref with internal ref
|
|
79
|
+
const mergedRef = React.useCallback(
|
|
80
|
+
(node: HTMLButtonElement | null) => {
|
|
81
|
+
buttonRef.current = node;
|
|
82
|
+
if (typeof ref === "function") ref(node);
|
|
83
|
+
else if (ref) ref.current = node;
|
|
84
|
+
},
|
|
85
|
+
[ref],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// ── Register/unregister with parent context on mount/unmount ──────────
|
|
89
|
+
React.useEffect(() => {
|
|
90
|
+
registerTab(value);
|
|
91
|
+
return () => unregisterTab(value);
|
|
92
|
+
}, [value, registerTab, unregisterTab]);
|
|
93
|
+
|
|
94
|
+
// ── Sync disabled state with parent context ────────────────────────────
|
|
95
|
+
React.useEffect(() => {
|
|
96
|
+
markTabDisabled(value, disabled);
|
|
97
|
+
return () => markTabDisabled(value, false);
|
|
98
|
+
}, [value, disabled, markTabDisabled]);
|
|
99
|
+
|
|
100
|
+
// ── Keyboard navigation ────────────────────────────────────────────────
|
|
101
|
+
const handleKeyDown = React.useCallback(
|
|
102
|
+
(e: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
103
|
+
const isRtl = buttonRef.current
|
|
104
|
+
? getComputedStyle(buttonRef.current).direction === "rtl"
|
|
105
|
+
: false;
|
|
106
|
+
|
|
107
|
+
const enabledValues = tabValues.filter((v) => !disabledValues.has(v));
|
|
108
|
+
const currentIndex = enabledValues.indexOf(value);
|
|
109
|
+
|
|
110
|
+
switch (e.key) {
|
|
111
|
+
case "ArrowRight":
|
|
112
|
+
case "ArrowLeft": {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
const goForward = isRtl
|
|
115
|
+
? e.key === "ArrowLeft"
|
|
116
|
+
: e.key === "ArrowRight";
|
|
117
|
+
const nextIndex = goForward
|
|
118
|
+
? (currentIndex + 1) % enabledValues.length
|
|
119
|
+
: (currentIndex - 1 + enabledValues.length) %
|
|
120
|
+
enabledValues.length;
|
|
121
|
+
const nextValue = enabledValues[nextIndex];
|
|
122
|
+
if (nextValue) {
|
|
123
|
+
setFocusedValue(nextValue);
|
|
124
|
+
if (autoActivate) onValueChange(nextValue);
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case "Home": {
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
const firstValue = enabledValues[0];
|
|
131
|
+
if (firstValue) {
|
|
132
|
+
setFocusedValue(firstValue);
|
|
133
|
+
if (autoActivate) onValueChange(firstValue);
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case "End": {
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
const lastValue = enabledValues[enabledValues.length - 1];
|
|
140
|
+
if (lastValue) {
|
|
141
|
+
setFocusedValue(lastValue);
|
|
142
|
+
if (autoActivate) onValueChange(lastValue);
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
case "Enter":
|
|
147
|
+
case " ": {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
if (!disabled) onValueChange(value);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
[
|
|
155
|
+
tabValues,
|
|
156
|
+
disabledValues,
|
|
157
|
+
value,
|
|
158
|
+
disabled,
|
|
159
|
+
setFocusedValue,
|
|
160
|
+
onValueChange,
|
|
161
|
+
autoActivate,
|
|
162
|
+
],
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Focus DOM node when focusedValue changes via keyboard (skip initial mount)
|
|
166
|
+
React.useEffect(() => {
|
|
167
|
+
if (isFirstMount.current) {
|
|
168
|
+
isFirstMount.current = false;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (isFocused && buttonRef.current) {
|
|
172
|
+
buttonRef.current.focus({ preventScroll: true });
|
|
173
|
+
}
|
|
174
|
+
}, [isFocused]);
|
|
175
|
+
|
|
176
|
+
// ── Auto-scroll active tab into view (scrollable mode) ─────────────────
|
|
177
|
+
// Horizontally scrolls the nearest overflow-x container to reveal the
|
|
178
|
+
// active tab. Uses scrollTo (not scrollIntoView) to avoid vertical page jumps.
|
|
179
|
+
React.useEffect(() => {
|
|
180
|
+
if (!isActive || !scrollable || !buttonRef.current) return;
|
|
181
|
+
|
|
182
|
+
const btn = buttonRef.current;
|
|
183
|
+
let container: HTMLElement | null = btn.parentElement;
|
|
184
|
+
while (container) {
|
|
185
|
+
const { overflowX } = getComputedStyle(container);
|
|
186
|
+
if (overflowX === "auto" || overflowX === "scroll") break;
|
|
187
|
+
container = container.parentElement;
|
|
188
|
+
}
|
|
189
|
+
if (!container) return;
|
|
190
|
+
|
|
191
|
+
const btnRect = btn.getBoundingClientRect();
|
|
192
|
+
const containerRect = container.getBoundingClientRect();
|
|
193
|
+
const overflowLeft = containerRect.left - btnRect.left;
|
|
194
|
+
const overflowRight = btnRect.right - containerRect.right;
|
|
195
|
+
|
|
196
|
+
if (overflowLeft > 0) {
|
|
197
|
+
container.scrollTo({
|
|
198
|
+
left: container.scrollLeft - overflowLeft,
|
|
199
|
+
behavior: "smooth",
|
|
200
|
+
});
|
|
201
|
+
} else if (overflowRight > 0) {
|
|
202
|
+
container.scrollTo({
|
|
203
|
+
left: container.scrollLeft + overflowRight,
|
|
204
|
+
behavior: "smooth",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}, [isActive, scrollable]);
|
|
208
|
+
|
|
209
|
+
// ── Derived tokens ─────────────────────────────────────────────────────
|
|
210
|
+
const containerHeight = isStackedIcon
|
|
211
|
+
? TabsTokens.containerHeightWithIcon
|
|
212
|
+
: TabsTokens.containerHeight;
|
|
213
|
+
|
|
214
|
+
const activeColor =
|
|
215
|
+
variant === "primary"
|
|
216
|
+
? TabsColors.primaryActiveText
|
|
217
|
+
: TabsColors.secondaryActiveText;
|
|
218
|
+
|
|
219
|
+
const inactiveColor =
|
|
220
|
+
variant === "primary"
|
|
221
|
+
? TabsColors.primaryInactiveText
|
|
222
|
+
: TabsColors.secondaryInactiveText;
|
|
223
|
+
|
|
224
|
+
const indicatorColor =
|
|
225
|
+
variant === "primary"
|
|
226
|
+
? TabsColors.primaryIndicator
|
|
227
|
+
: TabsColors.secondaryIndicator;
|
|
228
|
+
|
|
229
|
+
const indicatorLayoutId = `${layoutGroupId}-indicator`;
|
|
230
|
+
|
|
231
|
+
const colorTransition = prefersReduced
|
|
232
|
+
? { duration: 0 }
|
|
233
|
+
: TABS_COLOR_TRANSITION;
|
|
234
|
+
const springTransition = prefersReduced
|
|
235
|
+
? { duration: 0 }
|
|
236
|
+
: TABS_INDICATOR_SPRING;
|
|
237
|
+
|
|
238
|
+
// ── IDs for ARIA wiring ────────────────────────────────────────────────
|
|
239
|
+
const tabId = `${layoutGroupId}-tab-${value}`;
|
|
240
|
+
const panelId = `${layoutGroupId}-panel-${value}`;
|
|
241
|
+
|
|
242
|
+
// ── Content wrapper layout ─────────────────────────────────────────────
|
|
243
|
+
// inlineIcon → flex-row; stacked icon → flex-col gap-0.5; text only → flex-col
|
|
244
|
+
const contentFlexClass = inlineIcon
|
|
245
|
+
? "flex-row gap-2"
|
|
246
|
+
: isStackedIcon
|
|
247
|
+
? "flex-col gap-0.5"
|
|
248
|
+
: "flex-col gap-0";
|
|
249
|
+
|
|
250
|
+
// Badge placement
|
|
251
|
+
const shouldWrapIconWithBadge = isStackedIcon && badge != null;
|
|
252
|
+
const shouldAppendInlineBadge = !isStackedIcon && badge != null;
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<button
|
|
256
|
+
ref={mergedRef}
|
|
257
|
+
id={tabId}
|
|
258
|
+
type="button"
|
|
259
|
+
role="tab"
|
|
260
|
+
aria-selected={isActive}
|
|
261
|
+
aria-controls={panelId}
|
|
262
|
+
aria-disabled={disabled || undefined}
|
|
263
|
+
disabled={disabled}
|
|
264
|
+
tabIndex={isFocused ? 0 : -1}
|
|
265
|
+
onClick={() => {
|
|
266
|
+
if (!disabled) {
|
|
267
|
+
onValueChange(value);
|
|
268
|
+
setFocusedValue(value);
|
|
269
|
+
}
|
|
270
|
+
}}
|
|
271
|
+
onFocus={() => setFocusedValue(value)}
|
|
272
|
+
onKeyDown={handleKeyDown}
|
|
273
|
+
className={cn(
|
|
274
|
+
"relative inline-flex items-center justify-center",
|
|
275
|
+
"cursor-pointer select-none",
|
|
276
|
+
scrollable ? "shrink-0" : "flex-1",
|
|
277
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2",
|
|
278
|
+
"focus-visible:outline-(--md-sys-color-secondary)",
|
|
279
|
+
"focus-visible:rounded-lg",
|
|
280
|
+
"rounded-none",
|
|
281
|
+
disabled && "pointer-events-none opacity-[0.38]",
|
|
282
|
+
className,
|
|
283
|
+
)}
|
|
284
|
+
style={{
|
|
285
|
+
height: containerHeight,
|
|
286
|
+
zIndex: isActive ? 1 : 0,
|
|
287
|
+
...(scrollable && { minWidth: TabsTokens.scrollableMinTabWidth }),
|
|
288
|
+
}}
|
|
289
|
+
>
|
|
290
|
+
{/*
|
|
291
|
+
* Content wrapper — PRIMARY INDICATOR TECHNIQUE:
|
|
292
|
+
* Indicator lives inside this wrapper → width matches content (not button).
|
|
293
|
+
* inlineIcon: flex-row places icon beside label, height stays 48dp.
|
|
294
|
+
*/}
|
|
295
|
+
<m.div
|
|
296
|
+
className={cn(
|
|
297
|
+
"relative flex h-full items-center justify-center",
|
|
298
|
+
contentFlexClass,
|
|
299
|
+
)}
|
|
300
|
+
animate={{ color: isActive ? activeColor : inactiveColor }}
|
|
301
|
+
transition={colorTransition}
|
|
302
|
+
>
|
|
303
|
+
{/* Icon (optional) — 24dp per MD3 token */}
|
|
304
|
+
{hasIcon && (
|
|
305
|
+
<span
|
|
306
|
+
aria-hidden={!shouldWrapIconWithBadge ? "true" : undefined}
|
|
307
|
+
className={cn("flex shrink-0 items-center justify-center")}
|
|
308
|
+
style={{
|
|
309
|
+
width: TabsTokens.iconSize,
|
|
310
|
+
height: TabsTokens.iconSize,
|
|
311
|
+
}}
|
|
312
|
+
>
|
|
313
|
+
{shouldWrapIconWithBadge ? (
|
|
314
|
+
<BadgedBox badge={badge}>
|
|
315
|
+
<span aria-hidden="true">{icon}</span>
|
|
316
|
+
</BadgedBox>
|
|
317
|
+
) : (
|
|
318
|
+
<span className="size-full" aria-hidden="true">
|
|
319
|
+
{icon}
|
|
320
|
+
</span>
|
|
321
|
+
)}
|
|
322
|
+
</span>
|
|
323
|
+
)}
|
|
324
|
+
|
|
325
|
+
{/* Label text — TitleSmall per MD3 typography token */}
|
|
326
|
+
<span className="text-title-sm font-medium whitespace-nowrap">
|
|
327
|
+
{children}
|
|
328
|
+
</span>
|
|
329
|
+
|
|
330
|
+
{/* Inline Badge */}
|
|
331
|
+
{shouldAppendInlineBadge && (
|
|
332
|
+
<span className="ml-1 flex items-center justify-center">
|
|
333
|
+
{badge}
|
|
334
|
+
</span>
|
|
335
|
+
)}
|
|
336
|
+
|
|
337
|
+
{/*
|
|
338
|
+
* PRIMARY INDICATOR
|
|
339
|
+
* Inside content wrapper → width matches content.
|
|
340
|
+
* `layoutId` enables shared layout animation across tabs.
|
|
341
|
+
*/}
|
|
342
|
+
{variant === "primary" && isActive && (
|
|
343
|
+
<m.div
|
|
344
|
+
layoutId={indicatorLayoutId}
|
|
345
|
+
aria-hidden="true"
|
|
346
|
+
className="absolute bottom-0 left-1/2 -translate-x-1/2"
|
|
347
|
+
style={{
|
|
348
|
+
height: TabsTokens.primaryIndicatorHeight,
|
|
349
|
+
minWidth: INDICATOR_MIN_WIDTH,
|
|
350
|
+
width: "100%",
|
|
351
|
+
borderRadius: TabsTokens.indicatorBorderRadius,
|
|
352
|
+
backgroundColor: indicatorColor,
|
|
353
|
+
}}
|
|
354
|
+
transition={springTransition}
|
|
355
|
+
/>
|
|
356
|
+
)}
|
|
357
|
+
</m.div>
|
|
358
|
+
|
|
359
|
+
{/*
|
|
360
|
+
* SECONDARY INDICATOR
|
|
361
|
+
* Outside content wrapper → `inset-x-0` = full button width.
|
|
362
|
+
*/}
|
|
363
|
+
{variant === "secondary" && isActive && (
|
|
364
|
+
<m.div
|
|
365
|
+
layoutId={indicatorLayoutId}
|
|
366
|
+
aria-hidden="true"
|
|
367
|
+
className="absolute bottom-0 inset-x-0"
|
|
368
|
+
style={{
|
|
369
|
+
height: TabsTokens.secondaryIndicatorHeight,
|
|
370
|
+
borderRadius: TabsTokens.indicatorBorderRadius,
|
|
371
|
+
backgroundColor: indicatorColor,
|
|
372
|
+
}}
|
|
373
|
+
transition={springTransition}
|
|
374
|
+
/>
|
|
375
|
+
)}
|
|
376
|
+
</button>
|
|
377
|
+
);
|
|
378
|
+
},
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
TabComponent.displayName = "Tab";
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* MD3 Expressive Tab component — individual tab button.
|
|
385
|
+
*
|
|
386
|
+
* Must be a direct child of `<TabsList>`. Implements WAI-ARIA Tabs pattern
|
|
387
|
+
* with roving tabindex keyboard navigation.
|
|
388
|
+
*
|
|
389
|
+
* - **Primary variant**: indicator width = content (text + icon) width.
|
|
390
|
+
* - **Secondary variant**: indicator width = full button hit area.
|
|
391
|
+
* - **Disabled**: Skipped entirely in ArrowKey navigation (cannot be focused).
|
|
392
|
+
* - **inlineIcon**: Icon beside (not above) label; height stays 48dp.
|
|
393
|
+
* - Framer Motion `layoutId` animates indicator with spring physics.
|
|
394
|
+
* - ArrowLeft/Right respect RTL direction automatically.
|
|
395
|
+
*
|
|
396
|
+
* @example
|
|
397
|
+
* ```tsx
|
|
398
|
+
* <Tab value="flights" icon={<Icon name="flight" />}>Flights</Tab>
|
|
399
|
+
* <Tab value="trips">Trips</Tab>
|
|
400
|
+
* <Tab value="explore" disabled>Explore</Tab>
|
|
401
|
+
* <Tab value="hotels" icon={<Icon name="hotel" />} inlineIcon>Hotels</Tab>
|
|
402
|
+
* ```
|
|
403
|
+
*
|
|
404
|
+
* @see https://m3.material.io/components/tabs/overview
|
|
405
|
+
* @see https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
|
|
406
|
+
*/
|
|
407
|
+
export const Tab = React.memo(TabComponent);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file tabs-content.tsx
|
|
3
|
+
* MD3 Expressive TabsContent — Animated panel component.
|
|
4
|
+
*
|
|
5
|
+
* Implements WAI-ARIA tabpanel role with:
|
|
6
|
+
* - AnimatePresence for fade transition on tab switch
|
|
7
|
+
* - Proper aria-labelledby pointing to the associated <Tab>
|
|
8
|
+
* - tabIndex=0 so keyboard users can Tab from the tablist into the panel
|
|
9
|
+
* - Hidden panels are removed from the DOM (not just visually hidden)
|
|
10
|
+
* to prevent screen readers from reading inactive content
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { AnimatePresence, m, useReducedMotion } from "motion/react";
|
|
14
|
+
import * as React from "react";
|
|
15
|
+
import { cn } from "../../lib/utils";
|
|
16
|
+
import { useTabsContext } from "./tabs";
|
|
17
|
+
import { TABS_CONTENT_TRANSITION } from "./tabs.tokens";
|
|
18
|
+
import type { TabsContentProps } from "./tabs.types";
|
|
19
|
+
|
|
20
|
+
// ─── TabsContent ───────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const TabsContentComponent = React.forwardRef<HTMLDivElement, TabsContentProps>(
|
|
23
|
+
({ value, className, children }, ref) => {
|
|
24
|
+
const { value: selectedValue, layoutGroupId } = useTabsContext();
|
|
25
|
+
const isActive = selectedValue === value;
|
|
26
|
+
const prefersReduced = useReducedMotion() ?? false;
|
|
27
|
+
|
|
28
|
+
// ARIA wiring: panel is labelled by its corresponding <Tab> button
|
|
29
|
+
const tabId = `${layoutGroupId}-tab-${value}`;
|
|
30
|
+
const panelId = `${layoutGroupId}-panel-${value}`;
|
|
31
|
+
|
|
32
|
+
const contentTransition = prefersReduced
|
|
33
|
+
? { duration: 0 }
|
|
34
|
+
: TABS_CONTENT_TRANSITION;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<AnimatePresence mode="popLayout" initial={false}>
|
|
38
|
+
{isActive && (
|
|
39
|
+
<m.div
|
|
40
|
+
ref={ref}
|
|
41
|
+
key={value}
|
|
42
|
+
id={panelId}
|
|
43
|
+
role="tabpanel"
|
|
44
|
+
aria-labelledby={tabId}
|
|
45
|
+
tabIndex={0}
|
|
46
|
+
className={cn(
|
|
47
|
+
"focus:outline-none w-full",
|
|
48
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2",
|
|
49
|
+
"focus-visible:outline-(--md-sys-color-secondary)",
|
|
50
|
+
className,
|
|
51
|
+
)}
|
|
52
|
+
initial={{ opacity: 0 }}
|
|
53
|
+
animate={{ opacity: 1 }}
|
|
54
|
+
exit={{ opacity: 0 }}
|
|
55
|
+
transition={contentTransition}
|
|
56
|
+
>
|
|
57
|
+
{children}
|
|
58
|
+
</m.div>
|
|
59
|
+
)}
|
|
60
|
+
</AnimatePresence>
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
TabsContentComponent.displayName = "TabsContent";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* MD3 Expressive TabsContent panel component.
|
|
69
|
+
*
|
|
70
|
+
* Each panel corresponds to a `<Tab>` with the same `value`.
|
|
71
|
+
* Only the active panel is rendered in the DOM — inactive panels
|
|
72
|
+
* are fully unmounted (not `display: none`) to prevent screen readers
|
|
73
|
+
* from reading hidden content.
|
|
74
|
+
*
|
|
75
|
+
* Fade animation is applied on both enter and exit via Framer Motion
|
|
76
|
+
* `AnimatePresence`. We use `mode="popLayout"` to prevent height layout shifting
|
|
77
|
+
* during tab transitions. Animation is automatically disabled when the user
|
|
78
|
+
* has enabled `prefers-reduced-motion`.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```tsx
|
|
82
|
+
* <TabsContent value="flights">
|
|
83
|
+
* <p>Available flights...</p>
|
|
84
|
+
* </TabsContent>
|
|
85
|
+
* ```
|
|
86
|
+
*
|
|
87
|
+
* @see https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
|
|
88
|
+
*/
|
|
89
|
+
export const TabsContent = React.memo(TabsContentComponent);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file tabs-list.tsx
|
|
3
|
+
* MD3 Expressive TabsList — Container component for tab buttons.
|
|
4
|
+
*
|
|
5
|
+
* Responsibilities:
|
|
6
|
+
* - Applies variant (primary/secondary) layout and styling
|
|
7
|
+
* - Manages horizontal scroll for scrollable mode (52px edge padding per MD3)
|
|
8
|
+
* - Renders the bottom divider for secondary variant
|
|
9
|
+
* - Scopes Framer Motion LayoutGroup so indicators animate correctly
|
|
10
|
+
* when multiple <Tabs> instances are on the same page
|
|
11
|
+
* - Restores focus to activeTab when keyboard focus leaves the tablist
|
|
12
|
+
* (matches Google's `focusout` handler on <md-tabs>)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { LayoutGroup } from "motion/react";
|
|
16
|
+
import * as React from "react";
|
|
17
|
+
import { cn } from "../../lib/utils";
|
|
18
|
+
import { TabsListContext, useTabsContext } from "./tabs";
|
|
19
|
+
import { TabsTokens } from "./tabs.tokens";
|
|
20
|
+
import type { TabsListProps } from "./tabs.types";
|
|
21
|
+
|
|
22
|
+
// ─── TabsList ──────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const TabsListComponent = React.forwardRef<HTMLDivElement, TabsListProps>(
|
|
25
|
+
(
|
|
26
|
+
{
|
|
27
|
+
variant,
|
|
28
|
+
scrollable = false,
|
|
29
|
+
backgroundColor,
|
|
30
|
+
children,
|
|
31
|
+
className,
|
|
32
|
+
"aria-label": ariaLabel,
|
|
33
|
+
},
|
|
34
|
+
ref,
|
|
35
|
+
) => {
|
|
36
|
+
const { layoutGroupId, value, setFocusedValue } = useTabsContext();
|
|
37
|
+
|
|
38
|
+
// Unique layout group ID scoped to this TabsList instance.
|
|
39
|
+
const listLayoutId = `${layoutGroupId}-list`;
|
|
40
|
+
|
|
41
|
+
// ── TabsListContext: provide variant + scrollable to children ──────────
|
|
42
|
+
const listContextValue = React.useMemo(
|
|
43
|
+
() => ({ variant, scrollable }),
|
|
44
|
+
[variant, scrollable],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// ── Background color ───────────────────────────────────────────────────
|
|
48
|
+
const bgColor = backgroundColor ?? "var(--md-sys-color-surface)";
|
|
49
|
+
|
|
50
|
+
// ── Focusout handler — restore roving focus to active tab ──────────────
|
|
51
|
+
// When keyboard focus leaves the tablist entirely (e.g. user presses Tab
|
|
52
|
+
// to move to the panel), reset `focusedValue` back to the selected tab.
|
|
53
|
+
// This ensures the next time the user Tabs back into the tablist, focus
|
|
54
|
+
// lands on the active tab — not the last arrow-key-focused tab.
|
|
55
|
+
// Mirrors Google's `handleFocusout` in tabs.ts:
|
|
56
|
+
// "restore focus to selected item when blurring the tab bar"
|
|
57
|
+
const handleBlur = React.useCallback(
|
|
58
|
+
(e: React.FocusEvent<HTMLDivElement>) => {
|
|
59
|
+
// `relatedTarget` is the element receiving focus.
|
|
60
|
+
// If it's still inside the tablist, this is an internal focus move
|
|
61
|
+
// (e.g. clicking another tab) — don't restore.
|
|
62
|
+
const listEl = e.currentTarget;
|
|
63
|
+
if (listEl.contains(e.relatedTarget as Node | null)) return;
|
|
64
|
+
|
|
65
|
+
// Focus left the tablist — restore focusedValue to the active tab.
|
|
66
|
+
setFocusedValue(value);
|
|
67
|
+
},
|
|
68
|
+
[value, setFocusedValue],
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<TabsListContext.Provider value={listContextValue}>
|
|
73
|
+
{/* LayoutGroup scopes shared layout animation so indicators from different Tabs instances don't bleed into each other. */}
|
|
74
|
+
<LayoutGroup id={listLayoutId}>
|
|
75
|
+
{/* Outer wrapper: positioning context for the secondary divider */}
|
|
76
|
+
<div
|
|
77
|
+
ref={ref}
|
|
78
|
+
className={cn("relative w-full", className)}
|
|
79
|
+
style={{ backgroundColor: bgColor }}
|
|
80
|
+
>
|
|
81
|
+
<div
|
|
82
|
+
role="tablist"
|
|
83
|
+
aria-label={ariaLabel}
|
|
84
|
+
onBlur={handleBlur}
|
|
85
|
+
className={cn(
|
|
86
|
+
"flex flex-row items-stretch",
|
|
87
|
+
scrollable &&
|
|
88
|
+
"overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden",
|
|
89
|
+
)}
|
|
90
|
+
style={
|
|
91
|
+
scrollable
|
|
92
|
+
? {
|
|
93
|
+
paddingLeft: TabsTokens.scrollableEdgePadding,
|
|
94
|
+
paddingRight: TabsTokens.scrollableEdgePadding,
|
|
95
|
+
}
|
|
96
|
+
: undefined
|
|
97
|
+
}
|
|
98
|
+
>
|
|
99
|
+
{children}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{/* Secondary variant: bottom divider — absolute so it doesn't affect tab layout flow */}
|
|
103
|
+
{variant === "secondary" && (
|
|
104
|
+
<div
|
|
105
|
+
aria-hidden="true"
|
|
106
|
+
className="absolute bottom-0 left-0 right-0"
|
|
107
|
+
style={{
|
|
108
|
+
height: TabsTokens.dividerHeight,
|
|
109
|
+
backgroundColor: "var(--md-sys-color-surface-variant)",
|
|
110
|
+
}}
|
|
111
|
+
/>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
</LayoutGroup>
|
|
115
|
+
</TabsListContext.Provider>
|
|
116
|
+
);
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
TabsListComponent.displayName = "TabsList";
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* MD3 Expressive TabsList container component.
|
|
124
|
+
*
|
|
125
|
+
* Renders a horizontal row of `<Tab>` components with MD3-compliant
|
|
126
|
+
* layout (fixed or scrollable) and variant styling (primary or secondary).
|
|
127
|
+
*
|
|
128
|
+
* - **Primary**: Tabs divide available width equally, indicator width = content width.
|
|
129
|
+
* - **Secondary**: Tabs divide equally + full-width indicator + bottom divider line.
|
|
130
|
+
* - **Scrollable**: Tabs have min-width (90px), scroll horizontally with 52px edge padding.
|
|
131
|
+
* - **Focusout**: When focus leaves the tablist, roving focus resets to the active tab.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```tsx
|
|
135
|
+
* <TabsList variant="primary" scrollable={false}>
|
|
136
|
+
* <Tab value="tab1">Tab 1</Tab>
|
|
137
|
+
* <Tab value="tab2">Tab 2</Tab>
|
|
138
|
+
* </TabsList>
|
|
139
|
+
*
|
|
140
|
+
* <TabsList variant="secondary" scrollable={true} aria-label="Content sections">
|
|
141
|
+
* <Tab value="a">Alpha</Tab>
|
|
142
|
+
* <Tab value="b">Beta</Tab>
|
|
143
|
+
* </TabsList>
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
export const TabsList = React.memo(TabsListComponent);
|