@bug-on/md3-react 2.0.3 → 3.0.1
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 +42 -0
- package/CHANGELOG.md +69 -0
- package/dist/index.css +178 -0
- package/dist/index.css.d.ts +2 -0
- package/dist/index.d.mts +6135 -0
- package/dist/index.d.ts +6135 -71
- package/dist/index.js +1688 -631
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1600 -564
- 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/plugin.d.mts +1 -0
- package/dist/plugin.d.ts +1 -0
- package/dist/plugin.js +13 -0
- package/dist/plugin.js.map +1 -0
- package/dist/plugin.mjs +3 -0
- package/dist/plugin.mjs.map +1 -0
- package/dist/typography.css.d.ts +2 -0
- package/package.json +28 -19
- package/scripts/copy-assets.js +115 -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 +195 -0
- package/src/lib/utils.ts +6 -0
- package/src/plugin.ts +12 -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 +306 -0
- package/src/ui/button.tsx +665 -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 +607 -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 +135 -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 +215 -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,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file search.test.tsx
|
|
3
|
+
* Unit tests for the MD3 Expressive Search component system.
|
|
4
|
+
*
|
|
5
|
+
* Tests cover:
|
|
6
|
+
* - SearchBar: render, focus → open, A11y attributes
|
|
7
|
+
* - SearchViewDocked: render when active, keyboard (Arrow, Enter, Escape)
|
|
8
|
+
* - SearchViewFullScreen: portal render, dialog role, close behavior
|
|
9
|
+
* - useSearchKeyboard: isolated hook behavior
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
13
|
+
import { describe, expect, it, vi } from "vitest";
|
|
14
|
+
import { Search } from "./search";
|
|
15
|
+
import { SearchBar } from "./search-bar";
|
|
16
|
+
|
|
17
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function makeBaseProps(overrides = {}) {
|
|
20
|
+
return {
|
|
21
|
+
query: "",
|
|
22
|
+
onQueryChange: vi.fn(),
|
|
23
|
+
onSearch: vi.fn(),
|
|
24
|
+
active: false,
|
|
25
|
+
onActiveChange: vi.fn(),
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── SearchBar ──────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
describe("SearchBar", () => {
|
|
33
|
+
it("renders the search input with placeholder", () => {
|
|
34
|
+
render(<Search {...makeBaseProps()} placeholder="Search messages" />);
|
|
35
|
+
expect(screen.getByPlaceholderText("Search messages")).toBeInTheDocument();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("has role='combobox' on the input", () => {
|
|
39
|
+
render(<Search {...makeBaseProps()} />);
|
|
40
|
+
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("input has aria-expanded=false when inactive", () => {
|
|
44
|
+
render(<Search {...makeBaseProps({ active: false })} />);
|
|
45
|
+
const input = screen.getByRole("combobox");
|
|
46
|
+
expect(input).toHaveAttribute("aria-expanded", "false");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("calls onActiveChange(true) when input is focused", () => {
|
|
50
|
+
const onActiveChange = vi.fn();
|
|
51
|
+
render(<Search {...makeBaseProps({ onActiveChange })} />);
|
|
52
|
+
const input = screen.getByRole("combobox");
|
|
53
|
+
fireEvent.focus(input);
|
|
54
|
+
expect(onActiveChange).toHaveBeenCalledWith(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("calls onQueryChange when user types", () => {
|
|
58
|
+
const onQueryChange = vi.fn();
|
|
59
|
+
render(<Search {...makeBaseProps({ onQueryChange })} />);
|
|
60
|
+
const input = screen.getByRole("combobox");
|
|
61
|
+
fireEvent.change(input, { target: { value: "hello" } });
|
|
62
|
+
expect(onQueryChange).toHaveBeenCalledWith("hello");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ─── SearchViewDocked ────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
describe("SearchViewDocked", () => {
|
|
69
|
+
it("does not render listbox when inactive", () => {
|
|
70
|
+
render(
|
|
71
|
+
<Search {...makeBaseProps({ active: false })} variant="docked">
|
|
72
|
+
<div role="option" tabIndex={-1}>
|
|
73
|
+
Result 1
|
|
74
|
+
</div>
|
|
75
|
+
</Search>,
|
|
76
|
+
);
|
|
77
|
+
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("renders listbox when active", () => {
|
|
81
|
+
render(
|
|
82
|
+
<Search {...makeBaseProps({ active: true })} variant="docked">
|
|
83
|
+
<div role="option" tabIndex={-1}>
|
|
84
|
+
Result 1
|
|
85
|
+
</div>
|
|
86
|
+
</Search>,
|
|
87
|
+
);
|
|
88
|
+
expect(screen.getByRole("listbox")).toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("shows child results in the listbox", () => {
|
|
92
|
+
render(
|
|
93
|
+
<Search {...makeBaseProps({ active: true })} variant="docked">
|
|
94
|
+
<div role="option" tabIndex={-1} data-testid="result-item">
|
|
95
|
+
Result A
|
|
96
|
+
</div>
|
|
97
|
+
</Search>,
|
|
98
|
+
);
|
|
99
|
+
expect(screen.getByTestId("result-item")).toBeInTheDocument();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("calls onActiveChange(false) on Escape key", () => {
|
|
103
|
+
const onActiveChange = vi.fn();
|
|
104
|
+
render(
|
|
105
|
+
<Search
|
|
106
|
+
{...makeBaseProps({ active: true, onActiveChange })}
|
|
107
|
+
variant="docked"
|
|
108
|
+
/>,
|
|
109
|
+
);
|
|
110
|
+
// Use the visible combobox input in the docked view
|
|
111
|
+
const inputs = screen.getAllByRole("combobox");
|
|
112
|
+
// The docked view renders a second input (id ending in "-view")
|
|
113
|
+
const viewInput = inputs.find((el) => el.id.endsWith("-view")) ?? inputs[0];
|
|
114
|
+
fireEvent.keyDown(viewInput, { key: "Escape" });
|
|
115
|
+
expect(onActiveChange).toHaveBeenCalledWith(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("calls onSearch on Enter key", () => {
|
|
119
|
+
const onSearch = vi.fn();
|
|
120
|
+
render(
|
|
121
|
+
<Search
|
|
122
|
+
{...makeBaseProps({ active: true, query: "test", onSearch })}
|
|
123
|
+
variant="docked"
|
|
124
|
+
/>,
|
|
125
|
+
);
|
|
126
|
+
const inputs = screen.getAllByRole("combobox");
|
|
127
|
+
const viewInput = inputs.find((el) => el.id.endsWith("-view")) ?? inputs[0];
|
|
128
|
+
fireEvent.keyDown(viewInput, { key: "Enter" });
|
|
129
|
+
expect(onSearch).toHaveBeenCalledWith("test");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("renders divider when styleType='divided'", () => {
|
|
133
|
+
render(
|
|
134
|
+
<Search
|
|
135
|
+
{...makeBaseProps({ active: true })}
|
|
136
|
+
variant="docked"
|
|
137
|
+
styleType="divided"
|
|
138
|
+
>
|
|
139
|
+
<div role="option" tabIndex={-1}>
|
|
140
|
+
Result
|
|
141
|
+
</div>
|
|
142
|
+
</Search>,
|
|
143
|
+
);
|
|
144
|
+
expect(document.querySelector("hr")).toBeInTheDocument();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("does not render divider when styleType='contained'", () => {
|
|
148
|
+
render(
|
|
149
|
+
<Search
|
|
150
|
+
{...makeBaseProps({ active: true })}
|
|
151
|
+
variant="docked"
|
|
152
|
+
styleType="contained"
|
|
153
|
+
>
|
|
154
|
+
<div role="option" tabIndex={-1}>
|
|
155
|
+
Result
|
|
156
|
+
</div>
|
|
157
|
+
</Search>,
|
|
158
|
+
);
|
|
159
|
+
expect(document.querySelector("hr")).not.toBeInTheDocument();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ─── SearchViewFullScreen ────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
describe("SearchViewFullScreen", () => {
|
|
166
|
+
it("renders a dialog when active", () => {
|
|
167
|
+
render(
|
|
168
|
+
<Search {...makeBaseProps({ active: true })} variant="fullscreen" />,
|
|
169
|
+
);
|
|
170
|
+
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("has aria-modal=true on the dialog", () => {
|
|
174
|
+
render(
|
|
175
|
+
<Search {...makeBaseProps({ active: true })} variant="fullscreen" />,
|
|
176
|
+
);
|
|
177
|
+
expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("does not render dialog when inactive", () => {
|
|
181
|
+
render(
|
|
182
|
+
<Search {...makeBaseProps({ active: false })} variant="fullscreen" />,
|
|
183
|
+
);
|
|
184
|
+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("calls onActiveChange(false) when close button is clicked", () => {
|
|
188
|
+
const onActiveChange = vi.fn();
|
|
189
|
+
render(
|
|
190
|
+
<Search
|
|
191
|
+
{...makeBaseProps({ active: true, onActiveChange })}
|
|
192
|
+
variant="fullscreen"
|
|
193
|
+
/>,
|
|
194
|
+
);
|
|
195
|
+
fireEvent.click(screen.getByLabelText("Close search"));
|
|
196
|
+
expect(onActiveChange).toHaveBeenCalledWith(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("clears query when clear button is clicked", () => {
|
|
200
|
+
const onQueryChange = vi.fn();
|
|
201
|
+
render(
|
|
202
|
+
<Search
|
|
203
|
+
{...makeBaseProps({ active: true, query: "hello", onQueryChange })}
|
|
204
|
+
variant="fullscreen"
|
|
205
|
+
/>,
|
|
206
|
+
);
|
|
207
|
+
fireEvent.click(screen.getByLabelText("Clear search"));
|
|
208
|
+
expect(onQueryChange).toHaveBeenCalledWith("");
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ─── SearchBar standalone ────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
describe("SearchBar (standalone)", () => {
|
|
215
|
+
it("renders with all required ARIA on combobox", () => {
|
|
216
|
+
render(
|
|
217
|
+
<SearchBar
|
|
218
|
+
query=""
|
|
219
|
+
onQueryChange={vi.fn()}
|
|
220
|
+
onSearch={vi.fn()}
|
|
221
|
+
active={false}
|
|
222
|
+
onActiveChange={vi.fn()}
|
|
223
|
+
searchId="test-bar"
|
|
224
|
+
listboxId="test-listbox"
|
|
225
|
+
onKeyDown={vi.fn()}
|
|
226
|
+
activeIndex={-1}
|
|
227
|
+
/>,
|
|
228
|
+
);
|
|
229
|
+
const input = screen.getByRole("combobox");
|
|
230
|
+
expect(input).toHaveAttribute("aria-controls", "test-listbox");
|
|
231
|
+
expect(input).toHaveAttribute("aria-autocomplete", "list");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file search.tokens.ts
|
|
3
|
+
* MD3 Expressive Search — Design tokens ported from:
|
|
4
|
+
* - SearchBarTokens.kt (v0_210)
|
|
5
|
+
* - SearchViewTokens.kt (v0_210)
|
|
6
|
+
*
|
|
7
|
+
* All dimensional values are in px (1dp = 1px on web).
|
|
8
|
+
* Colors reference CSS custom properties — do NOT hardcode hex.
|
|
9
|
+
* @see docs/m3/search/SearchBarTokens.kt
|
|
10
|
+
* @see docs/m3/search/SearchViewTokens.kt
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ─── Dimensional Tokens ───────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Height and shape tokens for Search variants.
|
|
17
|
+
* Maps directly from MD3 Kotlin token files.
|
|
18
|
+
*/
|
|
19
|
+
export const SearchTokens = {
|
|
20
|
+
// ── Heights ─────────────────────────────────────────────────────────────
|
|
21
|
+
heights: {
|
|
22
|
+
/** SearchBarTokens.ContainerHeight = 56dp */
|
|
23
|
+
bar: 56,
|
|
24
|
+
/** SearchViewTokens.DockedHeaderContainerHeight = 56dp */
|
|
25
|
+
dockedHeader: 56,
|
|
26
|
+
/** SearchViewTokens.FullScreenHeaderContainerHeight = 72dp */
|
|
27
|
+
fullScreenHeader: 72,
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
// ── Avatar ────────────────────────────────────────────────────────────
|
|
31
|
+
/** SearchBarTokens.AvatarSize = 30dp */
|
|
32
|
+
avatarSize: 30,
|
|
33
|
+
|
|
34
|
+
// ── Icon ───────────────────────────────────────────────────────────────
|
|
35
|
+
/** Standard icon size for leading/trailing icons. */
|
|
36
|
+
iconSize: 20,
|
|
37
|
+
/** Touch target for interactive icons per MD3 a11y spec. */
|
|
38
|
+
iconTouchTarget: 48,
|
|
39
|
+
|
|
40
|
+
// ── Gap ────────────────────────────────────────────────────────────────
|
|
41
|
+
/** Gap between SearchBar and results list when hasGap=true. */
|
|
42
|
+
dropdownGap: 2,
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
// ─── Color Tokens ─────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* CSS custom property references for Search colors.
|
|
49
|
+
* Maps to --md-sys-color-* tokens in the MD3 theme system.
|
|
50
|
+
*
|
|
51
|
+
* SearchBarTokens.kt:
|
|
52
|
+
* - ContainerColor → SurfaceContainerHigh
|
|
53
|
+
* - LeadingIconColor → OnSurface
|
|
54
|
+
* - TrailingIconColor → OnSurfaceVariant
|
|
55
|
+
* - InputTextColor → OnSurface
|
|
56
|
+
* - SupportingTextColor → OnSurfaceVariant (placeholder)
|
|
57
|
+
*
|
|
58
|
+
* SearchViewTokens.kt:
|
|
59
|
+
* - ContainerColor → SurfaceContainerHigh
|
|
60
|
+
* - DividerColor → Outline
|
|
61
|
+
*/
|
|
62
|
+
export const SEARCH_COLORS = {
|
|
63
|
+
/** SearchBarTokens.ContainerColor → surface-container-high */
|
|
64
|
+
container: "var(--md-sys-color-surface-container-high)",
|
|
65
|
+
/** SearchBarTokens.LeadingIconColor → on-surface */
|
|
66
|
+
leadingIcon: "var(--md-sys-color-on-surface)",
|
|
67
|
+
/** SearchBarTokens.TrailingIconColor → on-surface-variant */
|
|
68
|
+
trailingIcon: "var(--md-sys-color-on-surface-variant)",
|
|
69
|
+
/** SearchBarTokens.InputTextColor → on-surface */
|
|
70
|
+
inputText: "var(--md-sys-color-on-surface)",
|
|
71
|
+
/** SearchBarTokens.SupportingTextColor → on-surface-variant (placeholder) */
|
|
72
|
+
supportingText: "var(--md-sys-color-on-surface-variant)",
|
|
73
|
+
/** SearchViewTokens.DividerColor → outline */
|
|
74
|
+
divider: "var(--md-sys-color-outline)",
|
|
75
|
+
/** Focus indicator → secondary */
|
|
76
|
+
focusIndicator: "var(--md-sys-color-secondary)",
|
|
77
|
+
} as const;
|
|
78
|
+
|
|
79
|
+
// ─── Typography Tokens ────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* SearchBarTokens.InputTextFont = BodyLarge (16sp / 24sp line-height).
|
|
83
|
+
* SearchBarTokens.SupportingTextFont = BodyLarge.
|
|
84
|
+
*/
|
|
85
|
+
export const SEARCH_TYPOGRAPHY = {
|
|
86
|
+
/** BodyLarge — used for input text and placeholder. */
|
|
87
|
+
bodyLarge: "text-[16px] leading-6 font-normal tracking-[0.5px]",
|
|
88
|
+
} as const;
|
|
89
|
+
|
|
90
|
+
// ─── Animation Constants ──────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Spring animation for SearchBar width expand (inactive → active).
|
|
94
|
+
* Matches MD3 FastSpatial motion scheme.
|
|
95
|
+
*/
|
|
96
|
+
export const SEARCH_BAR_EXPAND_SPRING = {
|
|
97
|
+
type: "spring" as const,
|
|
98
|
+
stiffness: 380,
|
|
99
|
+
damping: 38,
|
|
100
|
+
mass: 1,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Spring animation for Docked SearchView dropdown reveal (slide + fade).
|
|
105
|
+
* Offset Y: -8px on enter, opacity 0→1.
|
|
106
|
+
*/
|
|
107
|
+
export const SEARCH_DOCKED_REVEAL_SPRING = {
|
|
108
|
+
type: "spring" as const,
|
|
109
|
+
stiffness: 400,
|
|
110
|
+
damping: 35,
|
|
111
|
+
mass: 0.8,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Spring animation for FullScreen SearchView shape morphing.
|
|
116
|
+
* Lower stiffness + mass gives a smoother pill→fullscreen morph.
|
|
117
|
+
*/
|
|
118
|
+
export const SEARCH_FULLSCREEN_SPRING = {
|
|
119
|
+
type: "spring" as const,
|
|
120
|
+
stiffness: 300,
|
|
121
|
+
damping: 30,
|
|
122
|
+
mass: 0.9,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Exit transition for SearchBar when mode="popLayout" is used.
|
|
127
|
+
* Fast fade-out so SearchView can claim the layoutId quickly.
|
|
128
|
+
*/
|
|
129
|
+
export const SEARCH_BAR_EXIT_SPRING = {
|
|
130
|
+
type: "spring" as const,
|
|
131
|
+
stiffness: 500,
|
|
132
|
+
damping: 40,
|
|
133
|
+
mass: 0.6,
|
|
134
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file search.tsx
|
|
3
|
+
* MD3 Expressive Search — Orchestrator component.
|
|
4
|
+
*
|
|
5
|
+
* Composes SearchBar (collapsed) + SearchView (expanded) into a single
|
|
6
|
+
* developer-facing API. Routes to the correct SearchView variant based on props.
|
|
7
|
+
*
|
|
8
|
+
* Developer usage:
|
|
9
|
+
* ```tsx
|
|
10
|
+
* const [query, setQuery] = useState("");
|
|
11
|
+
* const [active, setActive] = useState(false);
|
|
12
|
+
*
|
|
13
|
+
* <Search
|
|
14
|
+
* query={query}
|
|
15
|
+
* onQueryChange={setQuery}
|
|
16
|
+
* onSearch={(q) => doSearch(q)}
|
|
17
|
+
* active={active}
|
|
18
|
+
* onActiveChange={setActive}
|
|
19
|
+
* variant="docked"
|
|
20
|
+
* styleType="contained"
|
|
21
|
+
* >
|
|
22
|
+
* {results.map((r) => (
|
|
23
|
+
* <div key={r.id} id={`${YOUR_LISTBOX_ID}-option-0`} role="option">
|
|
24
|
+
* {r.label}
|
|
25
|
+
* </div>
|
|
26
|
+
* ))}
|
|
27
|
+
* </Search>
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { m } from "motion/react";
|
|
32
|
+
import * as React from "react";
|
|
33
|
+
import { useSearchKeyboard } from "./hooks/use-search-keyboard";
|
|
34
|
+
import type { SearchProps } from "./search.types";
|
|
35
|
+
import { SearchBar } from "./search-bar";
|
|
36
|
+
import { SearchProvider, useSearch } from "./search-context";
|
|
37
|
+
import { SearchViewDocked } from "./search-view-docked";
|
|
38
|
+
import { SearchViewFullScreen } from "./search-view-fullscreen";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* MD3 Expressive Search — Orchestrator component.
|
|
42
|
+
*
|
|
43
|
+
* Renders a SearchBar (collapsed pill) and the appropriate SearchView
|
|
44
|
+
* (docked popup or fullscreen overlay) based on `variant`.
|
|
45
|
+
*
|
|
46
|
+
* The component is fully controlled:
|
|
47
|
+
* - `active` / `onActiveChange` manage open/close state.
|
|
48
|
+
* - `query` / `onQueryChange` manage input value.
|
|
49
|
+
*
|
|
50
|
+
* Shared `searchId` (React.useId) links SearchBar and SearchView via
|
|
51
|
+
* Framer Motion `layoutId` for seamless animated transitions.
|
|
52
|
+
*/
|
|
53
|
+
function SearchComponent({
|
|
54
|
+
query,
|
|
55
|
+
onQueryChange,
|
|
56
|
+
onSearch,
|
|
57
|
+
active,
|
|
58
|
+
onActiveChange,
|
|
59
|
+
variant = "docked",
|
|
60
|
+
styleType = "contained",
|
|
61
|
+
hasGap = false,
|
|
62
|
+
leadingIcon,
|
|
63
|
+
trailingIcon,
|
|
64
|
+
placeholder = "Search",
|
|
65
|
+
textAlign = "left",
|
|
66
|
+
children,
|
|
67
|
+
id,
|
|
68
|
+
"aria-label": ariaLabel = "Search",
|
|
69
|
+
className,
|
|
70
|
+
viewClassName,
|
|
71
|
+
}: SearchProps) {
|
|
72
|
+
const generatedId = React.useId();
|
|
73
|
+
const searchId = id ?? generatedId;
|
|
74
|
+
const listboxId = `${searchId}-listbox`;
|
|
75
|
+
|
|
76
|
+
const itemCount = React.Children.count(children);
|
|
77
|
+
|
|
78
|
+
const { activeIndex, handleKeyDown } = useSearchKeyboard({
|
|
79
|
+
active,
|
|
80
|
+
onActiveChange,
|
|
81
|
+
onSearch,
|
|
82
|
+
query,
|
|
83
|
+
itemCount,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const sharedProps = {
|
|
87
|
+
query,
|
|
88
|
+
onQueryChange,
|
|
89
|
+
onSearch,
|
|
90
|
+
active,
|
|
91
|
+
onActiveChange,
|
|
92
|
+
leadingIcon,
|
|
93
|
+
trailingIcon,
|
|
94
|
+
placeholder,
|
|
95
|
+
textAlign,
|
|
96
|
+
"aria-label": ariaLabel,
|
|
97
|
+
searchId,
|
|
98
|
+
listboxId,
|
|
99
|
+
onKeyDown: handleKeyDown,
|
|
100
|
+
activeIndex,
|
|
101
|
+
} as const;
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<SearchProvider value={{ activeIndex, listboxId }}>
|
|
105
|
+
<m.div className={className} style={{ minHeight: 56 }}>
|
|
106
|
+
<SearchBar {...sharedProps} />
|
|
107
|
+
{variant === "fullscreen" ? (
|
|
108
|
+
<SearchViewFullScreen
|
|
109
|
+
{...sharedProps}
|
|
110
|
+
styleType={styleType}
|
|
111
|
+
viewClassName={viewClassName}
|
|
112
|
+
>
|
|
113
|
+
{children}
|
|
114
|
+
</SearchViewFullScreen>
|
|
115
|
+
) : (
|
|
116
|
+
<SearchViewDocked
|
|
117
|
+
{...sharedProps}
|
|
118
|
+
styleType={styleType}
|
|
119
|
+
hasGap={hasGap}
|
|
120
|
+
viewClassName={viewClassName}
|
|
121
|
+
>
|
|
122
|
+
{children}
|
|
123
|
+
</SearchViewDocked>
|
|
124
|
+
)}
|
|
125
|
+
</m.div>
|
|
126
|
+
</SearchProvider>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** MD3 Expressive Search component with `Search.useSearch` context accessor. */
|
|
131
|
+
export const Search = Object.assign(SearchComponent, { useSearch });
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file search.types.ts
|
|
3
|
+
* MD3 Expressive Search — TypeScript prop definitions.
|
|
4
|
+
* Spec: https://m3.material.io/components/search/overview
|
|
5
|
+
* Reference: docs/m3/search/SearchBar.kt (MD3 Expressive)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type * as React from "react";
|
|
9
|
+
|
|
10
|
+
// ─── Variant & Style ──────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Display variant for the expanded SearchView.
|
|
14
|
+
*
|
|
15
|
+
* - `docked`: Popup dropdown below the SearchBar. For medium/large screens.
|
|
16
|
+
* - `fullscreen`: Full-screen dialog overlay. For compact/mobile screens.
|
|
17
|
+
*/
|
|
18
|
+
export type SearchVariant = "docked" | "fullscreen";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Visual style type for the SearchView.
|
|
22
|
+
*
|
|
23
|
+
* - `contained`: No divider between the input area and results.
|
|
24
|
+
* The container background is preserved continuously (recommended).
|
|
25
|
+
* - `divided`: A HorizontalDivider separates the input area from results.
|
|
26
|
+
*/
|
|
27
|
+
export type SearchStyleType = "contained" | "divided";
|
|
28
|
+
|
|
29
|
+
// ─── Internal Shared Props ────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Internal props shared between SearchBar and SearchView sub-components.
|
|
33
|
+
* Not part of the public API.
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
export interface SearchInternalProps {
|
|
37
|
+
/** Unique ID generated by useId(), used as Framer Motion layoutId. */
|
|
38
|
+
searchId: string;
|
|
39
|
+
/** Unique ID for the results listbox, used for aria-controls. */
|
|
40
|
+
listboxId: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Public API ────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Props for the `<Search>` component (orchestrator).
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```tsx
|
|
50
|
+
* const [query, setQuery] = useState("");
|
|
51
|
+
* const [active, setActive] = useState(false);
|
|
52
|
+
*
|
|
53
|
+
* <Search
|
|
54
|
+
* query={query}
|
|
55
|
+
* onQueryChange={setQuery}
|
|
56
|
+
* onSearch={(q) => console.log("search:", q)}
|
|
57
|
+
* active={active}
|
|
58
|
+
* onActiveChange={setActive}
|
|
59
|
+
* variant="docked"
|
|
60
|
+
* styleType="contained"
|
|
61
|
+
* >
|
|
62
|
+
* <SearchResultsList />
|
|
63
|
+
* </Search>
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export interface SearchProps {
|
|
67
|
+
// ── State ─────────────────────────────────────────────────────────────
|
|
68
|
+
/** Current search query value (controlled). */
|
|
69
|
+
query: string;
|
|
70
|
+
/** Called when user types in the search input. */
|
|
71
|
+
onQueryChange: (query: string) => void;
|
|
72
|
+
/** Called when user submits search (Enter key or suggestion click). */
|
|
73
|
+
onSearch: (query: string) => void;
|
|
74
|
+
/** Whether the SearchView is expanded/open (controlled). */
|
|
75
|
+
active: boolean;
|
|
76
|
+
/** Called when the Search open/close state should change. */
|
|
77
|
+
onActiveChange: (active: boolean) => void;
|
|
78
|
+
|
|
79
|
+
// ── Variants ──────────────────────────────────────────────────────────
|
|
80
|
+
/**
|
|
81
|
+
* Display variant for the expanded state.
|
|
82
|
+
* @default "docked"
|
|
83
|
+
*/
|
|
84
|
+
variant?: SearchVariant;
|
|
85
|
+
/**
|
|
86
|
+
* Visual style for the SearchView container.
|
|
87
|
+
* @default "contained"
|
|
88
|
+
*/
|
|
89
|
+
styleType?: SearchStyleType;
|
|
90
|
+
/**
|
|
91
|
+
* Whether to add a 2dp gap between the input header and the results list.
|
|
92
|
+
* Only applies when `variant="docked"`.
|
|
93
|
+
* @default false
|
|
94
|
+
*/
|
|
95
|
+
hasGap?: boolean;
|
|
96
|
+
|
|
97
|
+
// ── Content ───────────────────────────────────────────────────────────
|
|
98
|
+
/**
|
|
99
|
+
* Icon rendered at the leading edge of the search input.
|
|
100
|
+
* Defaults to a built-in search icon.
|
|
101
|
+
*/
|
|
102
|
+
leadingIcon?: React.ReactNode;
|
|
103
|
+
/**
|
|
104
|
+
* Icon or action rendered at the trailing edge of the search input.
|
|
105
|
+
* Common examples: mic, camera, QR code.
|
|
106
|
+
*/
|
|
107
|
+
trailingIcon?: React.ReactNode;
|
|
108
|
+
/** Placeholder text shown when the input is empty. @default "Search" */
|
|
109
|
+
placeholder?: string;
|
|
110
|
+
/**
|
|
111
|
+
* Align the placeholder text to left, center, or right.
|
|
112
|
+
* Typed text will always remain left-aligned.
|
|
113
|
+
* @default "left"
|
|
114
|
+
*/
|
|
115
|
+
textAlign?: "left" | "center" | "right";
|
|
116
|
+
/**
|
|
117
|
+
* Search results or suggestions rendered inside the SearchView.
|
|
118
|
+
* Use `role="option"` on each item for accessibility.
|
|
119
|
+
*/
|
|
120
|
+
children?: React.ReactNode;
|
|
121
|
+
|
|
122
|
+
// ── Accessibility ──────────────────────────────────────────────────────
|
|
123
|
+
/**
|
|
124
|
+
* Override the auto-generated input element ID.
|
|
125
|
+
* Auto-generated via React.useId() if not provided.
|
|
126
|
+
*/
|
|
127
|
+
id?: string;
|
|
128
|
+
/**
|
|
129
|
+
* Accessible label for the search landmark.
|
|
130
|
+
* @default "Search"
|
|
131
|
+
*/
|
|
132
|
+
"aria-label"?: string;
|
|
133
|
+
|
|
134
|
+
// ── Styling ────────────────────────────────────────────────────────────
|
|
135
|
+
/** Additional CSS classes for the SearchBar root element. */
|
|
136
|
+
className?: string;
|
|
137
|
+
/** Additional CSS classes for the SearchView container. */
|
|
138
|
+
viewClassName?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Hook Return ───────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Return type for `useSearchKeyboard`.
|
|
145
|
+
* @internal
|
|
146
|
+
*/
|
|
147
|
+
export interface UseSearchKeyboardReturn {
|
|
148
|
+
/** Currently highlighted suggestion index. -1 = none. */
|
|
149
|
+
activeIndex: number;
|
|
150
|
+
/** KeyDown handler — attach to the search input. */
|
|
151
|
+
handleKeyDown: (e: React.KeyboardEvent) => void;
|
|
152
|
+
/** Reset activeIndex (e.g., when query changes). */
|
|
153
|
+
resetActiveIndex: () => void;
|
|
154
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import { IconButton } from "../icon-button";
|
|
3
|
+
import { SEARCH_COLORS } from "./search.tokens";
|
|
4
|
+
|
|
5
|
+
interface TrailingActionProps {
|
|
6
|
+
query: string;
|
|
7
|
+
trailingIcon?: React.ReactNode;
|
|
8
|
+
onClear: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Clear button when query is non-empty, otherwise the trailing icon slot. */
|
|
12
|
+
export function TrailingAction({
|
|
13
|
+
query,
|
|
14
|
+
trailingIcon,
|
|
15
|
+
onClear,
|
|
16
|
+
}: TrailingActionProps) {
|
|
17
|
+
if (query) {
|
|
18
|
+
return (
|
|
19
|
+
<IconButton
|
|
20
|
+
size="sm"
|
|
21
|
+
style={{ color: SEARCH_COLORS.trailingIcon }}
|
|
22
|
+
aria-label="Clear search"
|
|
23
|
+
onClick={onClear}
|
|
24
|
+
>
|
|
25
|
+
<span
|
|
26
|
+
className="material-symbols-rounded select-none leading-none"
|
|
27
|
+
style={{ fontSize: 20 }}
|
|
28
|
+
aria-hidden="true"
|
|
29
|
+
>
|
|
30
|
+
close
|
|
31
|
+
</span>
|
|
32
|
+
</IconButton>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (trailingIcon) {
|
|
37
|
+
return (
|
|
38
|
+
<span
|
|
39
|
+
className="flex shrink-0 items-center justify-center"
|
|
40
|
+
style={{ color: SEARCH_COLORS.trailingIcon }}
|
|
41
|
+
aria-hidden="true"
|
|
42
|
+
>
|
|
43
|
+
{trailingIcon}
|
|
44
|
+
</span>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
}
|