@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,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file text-field.types.ts
|
|
3
|
+
* TypeScript interfaces and types for TextField MD3 Expressive component.
|
|
4
|
+
* @see https://m3.material.io/components/text-fields/overview
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type * as React from "react";
|
|
8
|
+
|
|
9
|
+
// ─── Variant & Input Types ───────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export type TextFieldVariant = "filled" | "outlined";
|
|
12
|
+
|
|
13
|
+
export type TextFieldInputType =
|
|
14
|
+
| "text"
|
|
15
|
+
| "email"
|
|
16
|
+
| "number"
|
|
17
|
+
| "password"
|
|
18
|
+
| "search"
|
|
19
|
+
| "tel"
|
|
20
|
+
| "url"
|
|
21
|
+
| "textarea";
|
|
22
|
+
|
|
23
|
+
export type TextFieldTrailingIconMode =
|
|
24
|
+
| "none"
|
|
25
|
+
| "clear"
|
|
26
|
+
| "password-toggle"
|
|
27
|
+
| "custom";
|
|
28
|
+
|
|
29
|
+
// ─── Imperative Handle ───────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Imperative handle exposed via forwardRef.
|
|
33
|
+
* Allows parent components to programmatically control the text field.
|
|
34
|
+
*/
|
|
35
|
+
export interface TextFieldHandle {
|
|
36
|
+
/** Focuses the input element. */
|
|
37
|
+
focus(): void;
|
|
38
|
+
/** Blurs the input element. */
|
|
39
|
+
blur(): void;
|
|
40
|
+
/** Selects all text in the input. */
|
|
41
|
+
select(): void;
|
|
42
|
+
/** Clears the current value and fires onChange with empty string. */
|
|
43
|
+
clear(): void;
|
|
44
|
+
/** Sets a custom validation message on the native input. */
|
|
45
|
+
setCustomValidity(message: string): void;
|
|
46
|
+
/** Returns true if the input is valid. Does not show validation UI. */
|
|
47
|
+
checkValidity(): boolean;
|
|
48
|
+
/** Returns true if the input is valid. Shows validation UI if invalid. */
|
|
49
|
+
reportValidity(): boolean;
|
|
50
|
+
/** Returns the current value string. */
|
|
51
|
+
getValue(): string;
|
|
52
|
+
/** Returns the underlying input or textarea element. */
|
|
53
|
+
getInputElement(): HTMLInputElement | HTMLTextAreaElement | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Main Props ──────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export interface TextFieldProps {
|
|
59
|
+
// ── Core ──────────────────────────────────────────────────────────────────
|
|
60
|
+
/** Filled or outlined variant. @default 'filled' */
|
|
61
|
+
variant?: TextFieldVariant;
|
|
62
|
+
/** Floating label text. Also used as accessible name when no aria-label is set. */
|
|
63
|
+
label?: string;
|
|
64
|
+
/** Controlled value. Use with onChange for controlled mode. */
|
|
65
|
+
value?: string;
|
|
66
|
+
/** Initial value for uncontrolled mode. */
|
|
67
|
+
defaultValue?: string;
|
|
68
|
+
/** Fires when value changes. Receives new value string and native event. */
|
|
69
|
+
onChange?: (
|
|
70
|
+
value: string,
|
|
71
|
+
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
|
72
|
+
) => void;
|
|
73
|
+
|
|
74
|
+
// ── Input config ──────────────────────────────────────────────────────────
|
|
75
|
+
/** Input type. Use 'textarea' for multi-line input. @default 'text' */
|
|
76
|
+
type?: TextFieldInputType;
|
|
77
|
+
/** Placeholder text — shown only when no label OR label is floated. */
|
|
78
|
+
placeholder?: string;
|
|
79
|
+
name?: string;
|
|
80
|
+
id?: string;
|
|
81
|
+
autoComplete?: string;
|
|
82
|
+
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"];
|
|
83
|
+
/** Number of rows for textarea type. @default 2 */
|
|
84
|
+
rows?: number;
|
|
85
|
+
/** Number of columns for textarea type. @default 20 */
|
|
86
|
+
cols?: number;
|
|
87
|
+
/** Whether the textarea should automatically resize to fit its content. @default false */
|
|
88
|
+
autoResize?: boolean;
|
|
89
|
+
/** Maximum number of rows when autoResize is true. */
|
|
90
|
+
maxRows?: number;
|
|
91
|
+
/** CSS direction override for input text. */
|
|
92
|
+
textDirection?: "ltr" | "rtl" | "";
|
|
93
|
+
|
|
94
|
+
// ── Validation ────────────────────────────────────────────────────────────
|
|
95
|
+
/** Marks field as required. Shows asterisk on label. */
|
|
96
|
+
required?: boolean;
|
|
97
|
+
/** Hides the asterisk even when required=true. */
|
|
98
|
+
noAsterisk?: boolean;
|
|
99
|
+
/** Manual error override — forces error visual state. */
|
|
100
|
+
error?: boolean;
|
|
101
|
+
/** Error message shown below the field (replaces supportingText). */
|
|
102
|
+
errorText?: string;
|
|
103
|
+
minLength?: number;
|
|
104
|
+
/** When set, enables character counter display. */
|
|
105
|
+
maxLength?: number;
|
|
106
|
+
min?: string;
|
|
107
|
+
max?: string;
|
|
108
|
+
step?: string;
|
|
109
|
+
pattern?: string;
|
|
110
|
+
/** For type="email" — allows multiple email addresses. */
|
|
111
|
+
multiple?: boolean;
|
|
112
|
+
|
|
113
|
+
// ── Supporting text ───────────────────────────────────────────────────────
|
|
114
|
+
/** Helper text shown below the field. Replaced by errorText when in error state. */
|
|
115
|
+
supportingText?: string;
|
|
116
|
+
|
|
117
|
+
// ── Decorators ────────────────────────────────────────────────────────────
|
|
118
|
+
/** Text displayed before the input value (e.g., "$"). */
|
|
119
|
+
prefixText?: string;
|
|
120
|
+
/** Text displayed after the input value (e.g., ".00"). */
|
|
121
|
+
suffixText?: string;
|
|
122
|
+
/** Icon node for the leading slot. Should be 24×24px. */
|
|
123
|
+
leadingIcon?: React.ReactNode;
|
|
124
|
+
/** Custom trailing icon node. Used when trailingIconMode='custom'. */
|
|
125
|
+
trailingIcon?: React.ReactNode;
|
|
126
|
+
|
|
127
|
+
// ── Trailing icon mode ────────────────────────────────────────────────────
|
|
128
|
+
/**
|
|
129
|
+
* Built-in trailing icon behavior.
|
|
130
|
+
* - 'none' — no trailing icon
|
|
131
|
+
* - 'clear' — ✕ button, clears value when clicked
|
|
132
|
+
* - 'password-toggle' — eye icon, toggles password visibility
|
|
133
|
+
* - 'custom' — uses trailingIcon prop
|
|
134
|
+
* @default 'none'
|
|
135
|
+
*/
|
|
136
|
+
trailingIconMode?: TextFieldTrailingIconMode;
|
|
137
|
+
|
|
138
|
+
// ── States ────────────────────────────────────────────────────────────────
|
|
139
|
+
disabled?: boolean;
|
|
140
|
+
readOnly?: boolean;
|
|
141
|
+
/** Hides spinner arrows on type="number". */
|
|
142
|
+
noSpinner?: boolean;
|
|
143
|
+
|
|
144
|
+
// ── Form integration ──────────────────────────────────────────────────────
|
|
145
|
+
form?: string;
|
|
146
|
+
|
|
147
|
+
// ── Accessibility ─────────────────────────────────────────────────────────
|
|
148
|
+
"aria-label"?: string;
|
|
149
|
+
"aria-describedby"?: string;
|
|
150
|
+
"aria-labelledby"?: string;
|
|
151
|
+
|
|
152
|
+
// ── Layout ────────────────────────────────────────────────────────────────
|
|
153
|
+
/** Extra class applied to the root wrapper element. */
|
|
154
|
+
className?: string;
|
|
155
|
+
/** Makes the component fill its container width. */
|
|
156
|
+
fullWidth?: boolean;
|
|
157
|
+
/** Dense variant — reduced height (48px instead of 56px). */
|
|
158
|
+
dense?: boolean;
|
|
159
|
+
|
|
160
|
+
// ── Callbacks ─────────────────────────────────────────────────────────────
|
|
161
|
+
onFocus?: React.FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>;
|
|
162
|
+
onBlur?: React.FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>;
|
|
163
|
+
onKeyDown?: React.KeyboardEventHandler<
|
|
164
|
+
HTMLInputElement | HTMLTextAreaElement
|
|
165
|
+
>;
|
|
166
|
+
onKeyUp?: React.KeyboardEventHandler<HTMLInputElement | HTMLTextAreaElement>;
|
|
167
|
+
|
|
168
|
+
// ── Ref ───────────────────────────────────────────────────────────────────
|
|
169
|
+
ref?: React.Ref<TextFieldHandle>;
|
|
170
|
+
|
|
171
|
+
// ── ScrollArea ────────────────────────────────────────────────────────────
|
|
172
|
+
/**
|
|
173
|
+
* Controls when the scrollbars are visible when type="textarea".
|
|
174
|
+
* - `hover`: Show on hover (default)
|
|
175
|
+
* - `scroll`: Show only while scrolling
|
|
176
|
+
* - `always`: Always visible
|
|
177
|
+
* - `none`: Never visible
|
|
178
|
+
*/
|
|
179
|
+
scrollAreaType?: "hover" | "scroll" | "always" | "none";
|
|
180
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { domMax, LazyMotion } from "motion/react";
|
|
4
|
+
import {
|
|
5
|
+
createContext,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
useContext,
|
|
8
|
+
useEffect,
|
|
9
|
+
useMemo,
|
|
10
|
+
useState,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { applyTheme, type ThemeMode } from "../../lib/theme-utils";
|
|
13
|
+
import {
|
|
14
|
+
SnackbarContext,
|
|
15
|
+
SnackbarHost,
|
|
16
|
+
useSnackbarState,
|
|
17
|
+
} from "../snackbar/snackbar";
|
|
18
|
+
import { Typography, TypographyContext } from "../typography/typography";
|
|
19
|
+
import {
|
|
20
|
+
type FontVariationAxes,
|
|
21
|
+
TypographyTokens,
|
|
22
|
+
} from "../typography/typography-tokens";
|
|
23
|
+
|
|
24
|
+
// ─── Theme Context ─────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
interface ThemeContextValue {
|
|
27
|
+
sourceColor: string;
|
|
28
|
+
setSourceColor: (color: string) => void;
|
|
29
|
+
mode: ThemeMode;
|
|
30
|
+
setMode: (mode: ThemeMode) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
|
34
|
+
|
|
35
|
+
const STORAGE_KEY_COLOR = "md3-source-color";
|
|
36
|
+
const STORAGE_KEY_MODE = "md3-theme-mode";
|
|
37
|
+
|
|
38
|
+
// ─── Singleton defaults (computed once) ───────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const defaultTokens = new TypographyTokens();
|
|
41
|
+
const defaultTypography = new Typography(defaultTokens);
|
|
42
|
+
|
|
43
|
+
// ─── MD3ThemeProvider ──────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export interface MD3ThemeProviderProps {
|
|
46
|
+
children: ReactNode;
|
|
47
|
+
// ── Theme ─────────────────────────────────────────────────────────────────
|
|
48
|
+
sourceColor?: string;
|
|
49
|
+
defaultMode?: ThemeMode;
|
|
50
|
+
persistToLocalStorage?: boolean;
|
|
51
|
+
// ── Typography ────────────────────────────────────────────────────────────
|
|
52
|
+
/**
|
|
53
|
+
* A fully custom `Typography` instance.
|
|
54
|
+
* When provided, `fontFamily` and `fontVariationAxes` are ignored.
|
|
55
|
+
*/
|
|
56
|
+
typography?: Typography;
|
|
57
|
+
/**
|
|
58
|
+
* Override the CSS `font-family` for all typography styles.
|
|
59
|
+
* Ignored when `typography` prop is provided.
|
|
60
|
+
* @example "'Inter', sans-serif"
|
|
61
|
+
*/
|
|
62
|
+
fontFamily?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Variable font axes applied globally via `font-variation-settings`.
|
|
65
|
+
* Merged on top of defaults (`ROND: 100`). Ignored when `typography` is provided.
|
|
66
|
+
* @example { ROND: 50 }
|
|
67
|
+
*/
|
|
68
|
+
fontVariationAxes?: FontVariationAxes;
|
|
69
|
+
// ── Snackbar ──────────────────────────────────────────────────────────────
|
|
70
|
+
/**
|
|
71
|
+
* When `true`, mounts `SnackbarHost` inside the provider and exposes
|
|
72
|
+
* `useSnackbar()` to all descendants — no separate `<SnackbarProvider>` needed.
|
|
73
|
+
*
|
|
74
|
+
* Opt-in, default `false`. For advanced usage (e.g., scoped snackbars or
|
|
75
|
+
* custom host positioning), keep this `false` and use `<SnackbarProvider>`
|
|
76
|
+
* or `<SnackbarHost>` directly.
|
|
77
|
+
*
|
|
78
|
+
* @default false
|
|
79
|
+
*/
|
|
80
|
+
enableSnackbar?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function MD3ThemeProvider({
|
|
84
|
+
children,
|
|
85
|
+
sourceColor: initialSourceColor = "#6750A4",
|
|
86
|
+
defaultMode = "light",
|
|
87
|
+
persistToLocalStorage = false,
|
|
88
|
+
typography: typographyProp,
|
|
89
|
+
fontFamily,
|
|
90
|
+
fontVariationAxes,
|
|
91
|
+
enableSnackbar = false,
|
|
92
|
+
}: MD3ThemeProviderProps) {
|
|
93
|
+
// ── Theme state ──────────────────────────────────────────────────────────
|
|
94
|
+
const [sourceColor, setSourceColor] = useState(initialSourceColor);
|
|
95
|
+
const [mode, setMode] = useState<ThemeMode>(defaultMode);
|
|
96
|
+
const [isHydrated, setIsHydrated] = useState(!persistToLocalStorage);
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!persistToLocalStorage) return;
|
|
100
|
+
|
|
101
|
+
const savedColor = localStorage.getItem(STORAGE_KEY_COLOR);
|
|
102
|
+
const savedMode = localStorage.getItem(
|
|
103
|
+
STORAGE_KEY_MODE,
|
|
104
|
+
) as ThemeMode | null;
|
|
105
|
+
|
|
106
|
+
if (savedColor) setSourceColor(savedColor);
|
|
107
|
+
if (savedMode === "light" || savedMode === "dark") setMode(savedMode);
|
|
108
|
+
|
|
109
|
+
setIsHydrated(true);
|
|
110
|
+
}, [persistToLocalStorage]);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!isHydrated) return;
|
|
114
|
+
|
|
115
|
+
applyTheme(sourceColor, mode);
|
|
116
|
+
|
|
117
|
+
if (persistToLocalStorage) {
|
|
118
|
+
localStorage.setItem(STORAGE_KEY_COLOR, sourceColor);
|
|
119
|
+
localStorage.setItem(STORAGE_KEY_MODE, mode);
|
|
120
|
+
}
|
|
121
|
+
}, [sourceColor, mode, persistToLocalStorage, isHydrated]);
|
|
122
|
+
|
|
123
|
+
const themeValue = useMemo<ThemeContextValue>(
|
|
124
|
+
() => ({ sourceColor, setSourceColor, mode, setMode }),
|
|
125
|
+
[sourceColor, mode],
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// ── Typography value ─────────────────────────────────────────────────────
|
|
129
|
+
const typographyValue = useMemo<Typography>(() => {
|
|
130
|
+
if (typographyProp) return typographyProp;
|
|
131
|
+
if (fontFamily ?? fontVariationAxes) {
|
|
132
|
+
return new Typography(
|
|
133
|
+
new TypographyTokens({ fontFamily, fontVariationAxes }),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return defaultTypography;
|
|
137
|
+
}, [typographyProp, fontFamily, fontVariationAxes]);
|
|
138
|
+
|
|
139
|
+
// ── Snackbar — mounted as isolated subtree so hook only allocates when needed
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<LazyMotion features={domMax}>
|
|
143
|
+
<ThemeContext.Provider value={themeValue}>
|
|
144
|
+
<TypographyContext.Provider value={typographyValue}>
|
|
145
|
+
{enableSnackbar ? (
|
|
146
|
+
<SnackbarMountedProvider>{children}</SnackbarMountedProvider>
|
|
147
|
+
) : (
|
|
148
|
+
children
|
|
149
|
+
)}
|
|
150
|
+
</TypographyContext.Provider>
|
|
151
|
+
</ThemeContext.Provider>
|
|
152
|
+
</LazyMotion>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── SnackbarMountedProvider ──────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Internal helper component — renders only when `enableSnackbar={true}`.
|
|
160
|
+
* Isolates `useSnackbarState` so the hook is never allocated unnecessarily.
|
|
161
|
+
*/
|
|
162
|
+
function SnackbarMountedProvider({ children }: { children: ReactNode }) {
|
|
163
|
+
const state = useSnackbarState();
|
|
164
|
+
const contextValue = useMemo(
|
|
165
|
+
() => ({ showSnackbar: state.showSnackbar }),
|
|
166
|
+
[state.showSnackbar],
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<SnackbarContext.Provider value={contextValue}>
|
|
171
|
+
{children}
|
|
172
|
+
<SnackbarHost state={state} />
|
|
173
|
+
</SnackbarContext.Provider>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Hooks ────────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
export function useTheme(): ThemeContextValue {
|
|
180
|
+
const context = useContext(ThemeContext);
|
|
181
|
+
if (!context) {
|
|
182
|
+
throw new Error("useTheme must be used within <MD3ThemeProvider>.");
|
|
183
|
+
}
|
|
184
|
+
return context;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function useThemeMode(): Pick<ThemeContextValue, "mode" | "setMode"> {
|
|
188
|
+
const { mode, setMode } = useTheme();
|
|
189
|
+
return { mode, setMode };
|
|
190
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
act,
|
|
3
|
+
fireEvent,
|
|
4
|
+
render,
|
|
5
|
+
screen,
|
|
6
|
+
waitFor,
|
|
7
|
+
} from "@testing-library/react";
|
|
8
|
+
import { beforeEach, describe, expect, it, type Mock, vi } from "vitest";
|
|
9
|
+
|
|
10
|
+
// Mock implementation
|
|
11
|
+
let mockObserve: Mock<(element: Element) => void>;
|
|
12
|
+
let mockDisconnect: Mock<() => void>;
|
|
13
|
+
let observerCallback: IntersectionObserverCallback | undefined;
|
|
14
|
+
|
|
15
|
+
class MockObserver implements IntersectionObserver {
|
|
16
|
+
readonly root: Element | null = null;
|
|
17
|
+
readonly rootMargin: string = "";
|
|
18
|
+
readonly thresholds: ReadonlyArray<number> = [];
|
|
19
|
+
|
|
20
|
+
constructor(callback: IntersectionObserverCallback) {
|
|
21
|
+
observerCallback = callback;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
observe = (element: Element) => mockObserve(element);
|
|
25
|
+
disconnect = () => mockDisconnect();
|
|
26
|
+
unobserve = vi.fn();
|
|
27
|
+
takeRecords = vi.fn(() => []);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
vi.stubGlobal("IntersectionObserver", MockObserver);
|
|
31
|
+
|
|
32
|
+
import { TableOfContents } from "./toc";
|
|
33
|
+
|
|
34
|
+
const mockItems = [
|
|
35
|
+
{ id: "section-1", label: "Section 1" },
|
|
36
|
+
{ id: "section-2", label: "Section 2" },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
describe("TableOfContents", () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
vi.clearAllMocks();
|
|
42
|
+
mockObserve = vi.fn();
|
|
43
|
+
mockDisconnect = vi.fn();
|
|
44
|
+
window.HTMLElement.prototype.scrollIntoView = vi.fn();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("renders correctly", () => {
|
|
48
|
+
render(<TableOfContents items={mockItems} />);
|
|
49
|
+
expect(screen.getByText("On this page")).toBeInTheDocument();
|
|
50
|
+
expect(screen.getByText("Section 1")).toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("handles active state change", async () => {
|
|
54
|
+
render(
|
|
55
|
+
<>
|
|
56
|
+
<div id="section-1">Content 1</div>
|
|
57
|
+
<div id="section-2">Content 2</div>
|
|
58
|
+
<TableOfContents items={mockItems} />
|
|
59
|
+
</>,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const firstLink = screen.getByText("Section 1");
|
|
63
|
+
|
|
64
|
+
// Trigger observer inside act to handle state update
|
|
65
|
+
if (observerCallback) {
|
|
66
|
+
await act(async () => {
|
|
67
|
+
const mockEntry = {
|
|
68
|
+
isIntersecting: true,
|
|
69
|
+
target: { id: "section-1" } as unknown as Element,
|
|
70
|
+
time: Date.now(),
|
|
71
|
+
intersectionRatio: 1,
|
|
72
|
+
boundingClientRect: {} as DOMRectReadOnly,
|
|
73
|
+
intersectionRect: {} as DOMRectReadOnly,
|
|
74
|
+
rootBounds: null,
|
|
75
|
+
} as IntersectionObserverEntry;
|
|
76
|
+
|
|
77
|
+
observerCallback?.([mockEntry], {} as IntersectionObserver);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await waitFor(() => {
|
|
82
|
+
expect(firstLink).toHaveClass("text-m3-primary");
|
|
83
|
+
expect(firstLink).toHaveClass("font-bold");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("handles click navigation", () => {
|
|
88
|
+
render(
|
|
89
|
+
<>
|
|
90
|
+
<div id="section-1">Content 1</div>
|
|
91
|
+
<TableOfContents items={mockItems} />
|
|
92
|
+
</>,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const firstLink = screen.getByText("Section 1");
|
|
96
|
+
const target = document.getElementById("section-1");
|
|
97
|
+
|
|
98
|
+
if (target) {
|
|
99
|
+
const scrollSpy = vi.fn();
|
|
100
|
+
target.scrollIntoView = scrollSpy;
|
|
101
|
+
fireEvent.click(firstLink);
|
|
102
|
+
expect(scrollSpy).toHaveBeenCalledWith({
|
|
103
|
+
behavior: "smooth",
|
|
104
|
+
block: "start",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
package/src/ui/toc.tsx
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file toc.tsx
|
|
3
|
+
*
|
|
4
|
+
* Table of Contents component for long-form documentation pages.
|
|
5
|
+
*
|
|
6
|
+
* Uses `IntersectionObserver` to track which section heading is currently in the
|
|
7
|
+
* viewport and highlights the corresponding link. Smooth-scrolls to the target
|
|
8
|
+
* when a link is clicked (respecting the browser's `prefers-reduced-motion`
|
|
9
|
+
* media query via the native `scrollIntoView` API).
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* - SSR-safe: `IntersectionObserver` usage is guarded by a `typeof` check.
|
|
13
|
+
* - The observer `rootMargin` is tuned for documentation layout with a fixed header
|
|
14
|
+
* (~100px) and early deactivation (~80% from bottom) so the active item changes
|
|
15
|
+
* before the section scrolls off-screen.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type React from "react";
|
|
19
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
20
|
+
import { cn } from "../lib/utils";
|
|
21
|
+
import { ScrollArea, type ScrollAreaProps } from "./scroll-area";
|
|
22
|
+
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
// Types
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A single entry in the Table of Contents.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* const items: ToCItem[] = [
|
|
33
|
+
* { id: "installation", label: "Installation" },
|
|
34
|
+
* { id: "usage", label: "Usage" },
|
|
35
|
+
* ];
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export interface ToCItem {
|
|
39
|
+
/** The DOM `id` attribute of the corresponding section heading. */
|
|
40
|
+
id: string;
|
|
41
|
+
/** Human-readable label shown in the ToC. */
|
|
42
|
+
label: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Props for the `TableOfContents` component.
|
|
47
|
+
*/
|
|
48
|
+
export interface TableOfContentsProps {
|
|
49
|
+
/**
|
|
50
|
+
* Ordered list of section items to display.
|
|
51
|
+
* Each item must have a matching DOM element with the same `id`.
|
|
52
|
+
*/
|
|
53
|
+
items: ToCItem[];
|
|
54
|
+
/**
|
|
55
|
+
* Additional CSS classes applied to the root `<nav>` element.
|
|
56
|
+
* Use this to control positioning (e.g. sticky, fixed) from the consumer.
|
|
57
|
+
*/
|
|
58
|
+
className?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Configuration for the internal ScrollArea.
|
|
61
|
+
* @default { type: "hover" }
|
|
62
|
+
*/
|
|
63
|
+
scrollAreaProps?: Omit<ScrollAreaProps, "children">;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
// Component
|
|
68
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Table of Contents sidebar component.
|
|
72
|
+
*
|
|
73
|
+
* Renders a `<nav>` sidebar with links to page sections. Tracks the active
|
|
74
|
+
* section using `IntersectionObserver` and applies active styles to the
|
|
75
|
+
* current link.
|
|
76
|
+
*
|
|
77
|
+
* @remarks
|
|
78
|
+
* - Visible only on `xl` screens (`hidden xl:block` — sticky sidebar).
|
|
79
|
+
* - The `aria-current` attribute is set on the active link for screen readers.
|
|
80
|
+
* - Click scroll is smooth: `scrollIntoView({ behavior: "smooth" })`.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```tsx
|
|
84
|
+
* const toc: ToCItem[] = [
|
|
85
|
+
* { id: "overview", label: "Overview" },
|
|
86
|
+
* { id: "props", label: "Props" },
|
|
87
|
+
* { id: "examples", label: "Examples" },
|
|
88
|
+
* ];
|
|
89
|
+
*
|
|
90
|
+
* <TableOfContents items={toc} />
|
|
91
|
+
* ```
|
|
92
|
+
*
|
|
93
|
+
* @see https://m3.material.io/foundations/content-design/navigation
|
|
94
|
+
*/
|
|
95
|
+
export function TableOfContents({
|
|
96
|
+
items,
|
|
97
|
+
className,
|
|
98
|
+
scrollAreaProps,
|
|
99
|
+
}: TableOfContentsProps) {
|
|
100
|
+
const [activeId, setActiveId] = useState("");
|
|
101
|
+
|
|
102
|
+
// Stabilize dependency — re-subscribe only when the item IDs actually change.
|
|
103
|
+
const itemIds = useMemo(() => items.map((i) => i.id), [items]);
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
// SSR guard: IntersectionObserver is not available in Node.js.
|
|
107
|
+
if (typeof IntersectionObserver === "undefined") return;
|
|
108
|
+
|
|
109
|
+
const observer = new IntersectionObserver(
|
|
110
|
+
(entries) => {
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
if (entry.isIntersecting) setActiveId(entry.target.id);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
// rootMargin: top offset ~100px (fixed header), bottom -80% (early switch)
|
|
116
|
+
{ rootMargin: "-100px 0% -80% 0%" },
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
for (const id of itemIds) {
|
|
120
|
+
const el = document.getElementById(id);
|
|
121
|
+
if (el) observer.observe(el);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return () => observer.disconnect();
|
|
125
|
+
}, [itemIds]);
|
|
126
|
+
|
|
127
|
+
const handleClick = useCallback(
|
|
128
|
+
(e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
document
|
|
131
|
+
.getElementById(id)
|
|
132
|
+
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
133
|
+
},
|
|
134
|
+
[],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<nav
|
|
139
|
+
aria-label="On this page"
|
|
140
|
+
className={cn("pl-6 flex flex-col h-full", className)}
|
|
141
|
+
>
|
|
142
|
+
<h4 className="text-xs font-bold text-m3-on-surface-variant uppercase tracking-widest mb-4 sm:hidden lg:block">
|
|
143
|
+
On this page
|
|
144
|
+
</h4>
|
|
145
|
+
<ScrollArea
|
|
146
|
+
type="hover"
|
|
147
|
+
{...scrollAreaProps}
|
|
148
|
+
className={cn("flex-1 min-h-0", scrollAreaProps?.className)}
|
|
149
|
+
>
|
|
150
|
+
<ul className="space-y-4 pr-4">
|
|
151
|
+
{items.map((item) => (
|
|
152
|
+
<li key={item.id}>
|
|
153
|
+
<a
|
|
154
|
+
href={`#${item.id}`}
|
|
155
|
+
onClick={(e) => handleClick(e, item.id)}
|
|
156
|
+
aria-current={activeId === item.id ? "true" : undefined}
|
|
157
|
+
className={cn(
|
|
158
|
+
"text-sm transition-colors hover:text-m3-primary block",
|
|
159
|
+
activeId === item.id
|
|
160
|
+
? "text-m3-primary font-bold"
|
|
161
|
+
: "text-m3-on-surface-variant font-medium",
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
164
|
+
{item.label}
|
|
165
|
+
</a>
|
|
166
|
+
</li>
|
|
167
|
+
))}
|
|
168
|
+
</ul>
|
|
169
|
+
</ScrollArea>
|
|
170
|
+
</nav>
|
|
171
|
+
);
|
|
172
|
+
}
|