@bug-on/md3-react 2.0.2 → 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 +23 -0
- package/dist/index.css.d.ts +2 -0
- package/dist/index.d.mts +6127 -0
- package/dist/index.d.ts +6127 -69
- package/dist/index.js +2536 -665
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2443 -603
- 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 +23 -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/{dist/hooks/index.d.ts → src/hooks/index.ts} +1 -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/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/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/ui/tooltip/index.d.ts → src/ui/tooltip/index.ts} +0 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { act, renderHook } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { useSearchKeyboard } from "./use-search-keyboard";
|
|
4
|
+
|
|
5
|
+
describe("useSearchKeyboard", () => {
|
|
6
|
+
const defaultProps = {
|
|
7
|
+
active: true,
|
|
8
|
+
onActiveChange: vi.fn(),
|
|
9
|
+
onSearch: vi.fn(),
|
|
10
|
+
query: "test",
|
|
11
|
+
itemCount: 3,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
it("initializes with activeIndex -1", () => {
|
|
15
|
+
const { result } = renderHook(() => useSearchKeyboard({ ...defaultProps }));
|
|
16
|
+
expect(result.current.activeIndex).toBe(-1);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("does not handle events if not active", () => {
|
|
20
|
+
const { result } = renderHook(() =>
|
|
21
|
+
useSearchKeyboard({ ...defaultProps, active: false }),
|
|
22
|
+
);
|
|
23
|
+
const e = {
|
|
24
|
+
key: "ArrowDown",
|
|
25
|
+
preventDefault: vi.fn(),
|
|
26
|
+
} as unknown as React.KeyboardEvent;
|
|
27
|
+
act(() => {
|
|
28
|
+
result.current.handleKeyDown(e);
|
|
29
|
+
});
|
|
30
|
+
expect(e.preventDefault).not.toHaveBeenCalled();
|
|
31
|
+
expect(result.current.activeIndex).toBe(-1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("moves focus down with ArrowDown and clamps at itemCount - 1", () => {
|
|
35
|
+
const { result } = renderHook(() => useSearchKeyboard({ ...defaultProps }));
|
|
36
|
+
const e = {
|
|
37
|
+
key: "ArrowDown",
|
|
38
|
+
preventDefault: vi.fn(),
|
|
39
|
+
} as unknown as React.KeyboardEvent;
|
|
40
|
+
|
|
41
|
+
// Move from -1 to 0
|
|
42
|
+
act(() => result.current.handleKeyDown(e));
|
|
43
|
+
expect(result.current.activeIndex).toBe(0);
|
|
44
|
+
|
|
45
|
+
// Move to 1
|
|
46
|
+
act(() => result.current.handleKeyDown(e));
|
|
47
|
+
expect(result.current.activeIndex).toBe(1);
|
|
48
|
+
|
|
49
|
+
// Move to 2
|
|
50
|
+
act(() => result.current.handleKeyDown(e));
|
|
51
|
+
expect(result.current.activeIndex).toBe(2);
|
|
52
|
+
|
|
53
|
+
// Clamp at 2
|
|
54
|
+
act(() => result.current.handleKeyDown(e));
|
|
55
|
+
expect(result.current.activeIndex).toBe(2);
|
|
56
|
+
expect(e.preventDefault).toHaveBeenCalledTimes(4);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("moves focus up with ArrowUp and clamps at -1", () => {
|
|
60
|
+
const { result } = renderHook(() => useSearchKeyboard({ ...defaultProps }));
|
|
61
|
+
const eDown = {
|
|
62
|
+
key: "ArrowDown",
|
|
63
|
+
preventDefault: vi.fn(),
|
|
64
|
+
} as unknown as React.KeyboardEvent;
|
|
65
|
+
const eUp = {
|
|
66
|
+
key: "ArrowUp",
|
|
67
|
+
preventDefault: vi.fn(),
|
|
68
|
+
} as unknown as React.KeyboardEvent;
|
|
69
|
+
|
|
70
|
+
// Move down to index 1
|
|
71
|
+
act(() => {
|
|
72
|
+
result.current.handleKeyDown(eDown);
|
|
73
|
+
result.current.handleKeyDown(eDown);
|
|
74
|
+
});
|
|
75
|
+
expect(result.current.activeIndex).toBe(1);
|
|
76
|
+
|
|
77
|
+
// Move up to 0
|
|
78
|
+
act(() => result.current.handleKeyDown(eUp));
|
|
79
|
+
expect(result.current.activeIndex).toBe(0);
|
|
80
|
+
|
|
81
|
+
// Move up to -1
|
|
82
|
+
act(() => result.current.handleKeyDown(eUp));
|
|
83
|
+
expect(result.current.activeIndex).toBe(-1);
|
|
84
|
+
|
|
85
|
+
// Clamp at -1
|
|
86
|
+
act(() => result.current.handleKeyDown(eUp));
|
|
87
|
+
expect(result.current.activeIndex).toBe(-1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("calls onSearch with query when Enter is pressed without an active suggestion", () => {
|
|
91
|
+
const onSearch = vi.fn();
|
|
92
|
+
const { result } = renderHook(() =>
|
|
93
|
+
useSearchKeyboard({ ...defaultProps, onSearch }),
|
|
94
|
+
);
|
|
95
|
+
const e = {
|
|
96
|
+
key: "Enter",
|
|
97
|
+
preventDefault: vi.fn(),
|
|
98
|
+
} as unknown as React.KeyboardEvent;
|
|
99
|
+
|
|
100
|
+
act(() => result.current.handleKeyDown(e));
|
|
101
|
+
expect(e.preventDefault).toHaveBeenCalled();
|
|
102
|
+
expect(onSearch).toHaveBeenCalledWith("test");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("calls onSelectSuggestion when Enter is pressed and an item is active", () => {
|
|
106
|
+
const onSearch = vi.fn();
|
|
107
|
+
const onSelectSuggestion = vi.fn();
|
|
108
|
+
const { result } = renderHook(() =>
|
|
109
|
+
useSearchKeyboard({ ...defaultProps, onSearch, onSelectSuggestion }),
|
|
110
|
+
);
|
|
111
|
+
const eDown = {
|
|
112
|
+
key: "ArrowDown",
|
|
113
|
+
preventDefault: vi.fn(),
|
|
114
|
+
} as unknown as React.KeyboardEvent;
|
|
115
|
+
const eEnter = {
|
|
116
|
+
key: "Enter",
|
|
117
|
+
preventDefault: vi.fn(),
|
|
118
|
+
} as unknown as React.KeyboardEvent;
|
|
119
|
+
|
|
120
|
+
// Move to index 0
|
|
121
|
+
act(() => result.current.handleKeyDown(eDown));
|
|
122
|
+
expect(result.current.activeIndex).toBe(0);
|
|
123
|
+
|
|
124
|
+
// Press Enter
|
|
125
|
+
act(() => result.current.handleKeyDown(eEnter));
|
|
126
|
+
expect(onSelectSuggestion).toHaveBeenCalledWith(0);
|
|
127
|
+
expect(onSearch).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("calls onSearch instead if onSelectSuggestion is missing and Enter is pressed on active item", () => {
|
|
131
|
+
const onSearch = vi.fn();
|
|
132
|
+
const { result } = renderHook(
|
|
133
|
+
() => useSearchKeyboard({ ...defaultProps, onSearch }), // NO onSelectSuggestion
|
|
134
|
+
);
|
|
135
|
+
const eDown = {
|
|
136
|
+
key: "ArrowDown",
|
|
137
|
+
preventDefault: vi.fn(),
|
|
138
|
+
} as unknown as React.KeyboardEvent;
|
|
139
|
+
const eEnter = {
|
|
140
|
+
key: "Enter",
|
|
141
|
+
preventDefault: vi.fn(),
|
|
142
|
+
} as unknown as React.KeyboardEvent;
|
|
143
|
+
|
|
144
|
+
act(() => result.current.handleKeyDown(eDown));
|
|
145
|
+
act(() => result.current.handleKeyDown(eEnter));
|
|
146
|
+
|
|
147
|
+
expect(onSearch).toHaveBeenCalledWith("test");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("calls onActiveChange(false) when Escape is pressed", () => {
|
|
151
|
+
const onActiveChange = vi.fn();
|
|
152
|
+
const { result } = renderHook(() =>
|
|
153
|
+
useSearchKeyboard({ ...defaultProps, onActiveChange }),
|
|
154
|
+
);
|
|
155
|
+
const e = {
|
|
156
|
+
key: "Escape",
|
|
157
|
+
preventDefault: vi.fn(),
|
|
158
|
+
} as unknown as React.KeyboardEvent;
|
|
159
|
+
|
|
160
|
+
act(() => result.current.handleKeyDown(e));
|
|
161
|
+
expect(e.preventDefault).toHaveBeenCalled();
|
|
162
|
+
expect(onActiveChange).toHaveBeenCalledWith(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("resets activeIndex when query changes", () => {
|
|
166
|
+
const { result, rerender } = renderHook(
|
|
167
|
+
(props) => useSearchKeyboard(props),
|
|
168
|
+
{ initialProps: { ...defaultProps, query: "a" } },
|
|
169
|
+
);
|
|
170
|
+
const e = {
|
|
171
|
+
key: "ArrowDown",
|
|
172
|
+
preventDefault: vi.fn(),
|
|
173
|
+
} as unknown as React.KeyboardEvent;
|
|
174
|
+
|
|
175
|
+
// Move index
|
|
176
|
+
act(() => result.current.handleKeyDown(e));
|
|
177
|
+
expect(result.current.activeIndex).toBe(0);
|
|
178
|
+
|
|
179
|
+
// Change query -> should reset to -1
|
|
180
|
+
rerender({ ...defaultProps, query: "ab" });
|
|
181
|
+
expect(result.current.activeIndex).toBe(-1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("resets activeIndex when active state changes to false", () => {
|
|
185
|
+
const { result, rerender } = renderHook(
|
|
186
|
+
(props) => useSearchKeyboard(props),
|
|
187
|
+
{ initialProps: { ...defaultProps, active: true } },
|
|
188
|
+
);
|
|
189
|
+
const e = {
|
|
190
|
+
key: "ArrowDown",
|
|
191
|
+
preventDefault: vi.fn(),
|
|
192
|
+
} as unknown as React.KeyboardEvent;
|
|
193
|
+
|
|
194
|
+
// Move index
|
|
195
|
+
act(() => result.current.handleKeyDown(e));
|
|
196
|
+
expect(result.current.activeIndex).toBe(0);
|
|
197
|
+
|
|
198
|
+
// Deactivate -> should reset to -1
|
|
199
|
+
rerender({ ...defaultProps, active: false });
|
|
200
|
+
expect(result.current.activeIndex).toBe(-1);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file use-search-keyboard.ts
|
|
3
|
+
* Keyboard navigation hook for the MD3 Search component.
|
|
4
|
+
*
|
|
5
|
+
* Handles:
|
|
6
|
+
* - ArrowDown / ArrowUp → navigate through suggestions (role="option")
|
|
7
|
+
* - Enter → submit search or select active suggestion
|
|
8
|
+
* - Escape → close the SearchView
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
import type { UseSearchKeyboardReturn } from "../search.types";
|
|
13
|
+
|
|
14
|
+
interface UseSearchKeyboardOptions {
|
|
15
|
+
/** Whether the SearchView is currently open. */
|
|
16
|
+
active: boolean;
|
|
17
|
+
/** Callback to close the SearchView. */
|
|
18
|
+
onActiveChange: (active: boolean) => void;
|
|
19
|
+
/** Callback for search submission. */
|
|
20
|
+
onSearch: (query: string) => void;
|
|
21
|
+
/** Current search query. */
|
|
22
|
+
query: string;
|
|
23
|
+
/** Total number of suggestion items in the listbox. */
|
|
24
|
+
itemCount: number;
|
|
25
|
+
/** Called when user selects a specific suggestion by index. */
|
|
26
|
+
onSelectSuggestion?: (index: number) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Manages keyboard navigation for the Search component.
|
|
31
|
+
*
|
|
32
|
+
* Complies with WAI-ARIA Combobox pattern:
|
|
33
|
+
* @see https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
|
|
34
|
+
*/
|
|
35
|
+
export function useSearchKeyboard({
|
|
36
|
+
active,
|
|
37
|
+
onActiveChange,
|
|
38
|
+
onSearch,
|
|
39
|
+
query,
|
|
40
|
+
itemCount,
|
|
41
|
+
onSelectSuggestion,
|
|
42
|
+
}: UseSearchKeyboardOptions): UseSearchKeyboardReturn {
|
|
43
|
+
const [activeIndex, setActiveIndex] = React.useState(-1);
|
|
44
|
+
|
|
45
|
+
// Reset active index when SearchView closes or query changes.
|
|
46
|
+
// Done during render phase to prevent double-renders on every keystroke,
|
|
47
|
+
// which would otherwise cause severe layout thrashing.
|
|
48
|
+
const resetKeyRef = React.useRef(`${active}:${query}`);
|
|
49
|
+
const currentKey = `${active}:${query}`;
|
|
50
|
+
if (resetKeyRef.current !== currentKey) {
|
|
51
|
+
resetKeyRef.current = currentKey;
|
|
52
|
+
setActiveIndex(-1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const handleKeyDown = React.useCallback(
|
|
56
|
+
(e: React.KeyboardEvent) => {
|
|
57
|
+
if (!active) return;
|
|
58
|
+
|
|
59
|
+
switch (e.key) {
|
|
60
|
+
case "ArrowDown": {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
setActiveIndex((i) => (i < itemCount - 1 ? i + 1 : i));
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
case "ArrowUp": {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
setActiveIndex((i) => (i > -1 ? i - 1 : -1));
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case "Enter": {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
if (activeIndex >= 0 && onSelectSuggestion) {
|
|
73
|
+
onSelectSuggestion(activeIndex);
|
|
74
|
+
} else {
|
|
75
|
+
onSearch(query);
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case "Escape": {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
onActiveChange(false);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
default:
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
[
|
|
89
|
+
active,
|
|
90
|
+
activeIndex,
|
|
91
|
+
itemCount,
|
|
92
|
+
onActiveChange,
|
|
93
|
+
onSearch,
|
|
94
|
+
onSelectSuggestion,
|
|
95
|
+
query,
|
|
96
|
+
],
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const resetActiveIndex = React.useCallback(() => {
|
|
100
|
+
setActiveIndex(-1);
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
return { activeIndex, handleKeyDown, resetActiveIndex };
|
|
104
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { renderHook } from "@testing-library/react";
|
|
2
|
+
import type * as React from "react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { useSearchViewFocus } from "./use-search-view-focus";
|
|
5
|
+
|
|
6
|
+
describe("useSearchViewFocus", () => {
|
|
7
|
+
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
|
8
|
+
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
|
12
|
+
globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
|
|
13
|
+
vi.restoreAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("does not focus when active is false", () => {
|
|
17
|
+
const focusMock = vi.fn();
|
|
18
|
+
const ref = {
|
|
19
|
+
current: { focus: focusMock },
|
|
20
|
+
} as unknown as React.RefObject<HTMLInputElement>;
|
|
21
|
+
const rAFMock = vi.fn();
|
|
22
|
+
globalThis.requestAnimationFrame = rAFMock;
|
|
23
|
+
|
|
24
|
+
renderHook(() => useSearchViewFocus(ref, false));
|
|
25
|
+
|
|
26
|
+
expect(rAFMock).not.toHaveBeenCalled();
|
|
27
|
+
expect(focusMock).not.toHaveBeenCalled();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("runs inner focus call on double requestAnimationFrame when active becomes true", () => {
|
|
31
|
+
const focusMock = vi.fn();
|
|
32
|
+
const ref = {
|
|
33
|
+
current: { focus: focusMock },
|
|
34
|
+
} as unknown as React.RefObject<HTMLInputElement>;
|
|
35
|
+
|
|
36
|
+
const rafCallbacks: FrameRequestCallback[] = [];
|
|
37
|
+
globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => {
|
|
38
|
+
rafCallbacks.push(cb);
|
|
39
|
+
return rafCallbacks.length;
|
|
40
|
+
});
|
|
41
|
+
globalThis.cancelAnimationFrame = vi.fn();
|
|
42
|
+
|
|
43
|
+
const { rerender } = renderHook(
|
|
44
|
+
({ active }) => useSearchViewFocus(ref, active),
|
|
45
|
+
{
|
|
46
|
+
initialProps: { active: false },
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(globalThis.requestAnimationFrame).not.toHaveBeenCalled();
|
|
51
|
+
|
|
52
|
+
// Activate
|
|
53
|
+
rerender({ active: true });
|
|
54
|
+
|
|
55
|
+
// The hook should schedule the first rAF
|
|
56
|
+
expect(rafCallbacks.length).toBe(1);
|
|
57
|
+
|
|
58
|
+
// Fire first rAF
|
|
59
|
+
rafCallbacks[0](0);
|
|
60
|
+
|
|
61
|
+
// The hook should have scheduled the second rAF
|
|
62
|
+
expect(rafCallbacks.length).toBe(2);
|
|
63
|
+
expect(focusMock).not.toHaveBeenCalled();
|
|
64
|
+
|
|
65
|
+
// Fire second rAF
|
|
66
|
+
rafCallbacks[1](0);
|
|
67
|
+
|
|
68
|
+
// Now it should focus
|
|
69
|
+
expect(focusMock).toHaveBeenCalledTimes(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("cancels animation frames if unmounted during animation", () => {
|
|
73
|
+
const focusMock = vi.fn();
|
|
74
|
+
const ref = {
|
|
75
|
+
current: { focus: focusMock },
|
|
76
|
+
} as unknown as React.RefObject<HTMLInputElement>;
|
|
77
|
+
|
|
78
|
+
const rafCallbacks: FrameRequestCallback[] = [];
|
|
79
|
+
globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => {
|
|
80
|
+
rafCallbacks.push(cb);
|
|
81
|
+
return rafCallbacks.length;
|
|
82
|
+
});
|
|
83
|
+
globalThis.cancelAnimationFrame = vi.fn();
|
|
84
|
+
|
|
85
|
+
const { unmount } = renderHook(() => useSearchViewFocus(ref, true));
|
|
86
|
+
|
|
87
|
+
expect(rafCallbacks.length).toBe(1);
|
|
88
|
+
|
|
89
|
+
// Unmount before first rAF fires
|
|
90
|
+
unmount();
|
|
91
|
+
|
|
92
|
+
// Should have called cancelAnimationFrame with id 1
|
|
93
|
+
expect(globalThis.cancelAnimationFrame).toHaveBeenCalledWith(1);
|
|
94
|
+
expect(focusMock).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Focuses `inputRef` when `active` becomes true, using a double-rAF
|
|
5
|
+
* to wait for Framer Motion's layout animation to finish painting.
|
|
6
|
+
*/
|
|
7
|
+
export function useSearchViewFocus(
|
|
8
|
+
inputRef: React.RefObject<HTMLInputElement | null>,
|
|
9
|
+
active: boolean,
|
|
10
|
+
): void {
|
|
11
|
+
React.useEffect(() => {
|
|
12
|
+
if (!active) return;
|
|
13
|
+
let raf2: number;
|
|
14
|
+
const raf1 = requestAnimationFrame(() => {
|
|
15
|
+
raf2 = requestAnimationFrame(() => {
|
|
16
|
+
inputRef.current?.focus();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
return () => {
|
|
20
|
+
cancelAnimationFrame(raf1);
|
|
21
|
+
if (raf2) cancelAnimationFrame(raf2);
|
|
22
|
+
};
|
|
23
|
+
}, [active, inputRef]);
|
|
24
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file index.ts
|
|
3
|
+
* MD3 Expressive Search — Public API exports.
|
|
4
|
+
*
|
|
5
|
+
* Components:
|
|
6
|
+
* - Search: Orchestrator (SearchBar + SearchView)
|
|
7
|
+
* - SearchBar: Collapsed pill state (standalone use)
|
|
8
|
+
* - SearchViewDocked: Expanded docked popup (standalone use)
|
|
9
|
+
* - SearchViewFullScreen: Expanded full-screen overlay (standalone use)
|
|
10
|
+
*
|
|
11
|
+
* Hook:
|
|
12
|
+
* - useSearchKeyboard: WAI-ARIA combobox keyboard navigation
|
|
13
|
+
*
|
|
14
|
+
* Tokens:
|
|
15
|
+
* - SearchTokens: Dimensional tokens (heights, sizes)
|
|
16
|
+
* - SEARCH_COLORS: CSS custom property color references
|
|
17
|
+
* - SEARCH_TYPOGRAPHY: Typography class strings
|
|
18
|
+
* - Animation constants
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export { useSearchKeyboard } from "./hooks/use-search-keyboard";
|
|
22
|
+
// ─── Components ───────────────────────────────────────────────────────────────
|
|
23
|
+
export { Search } from "./search";
|
|
24
|
+
// ─── Tokens ───────────────────────────────────────────────────────────────────
|
|
25
|
+
export {
|
|
26
|
+
SEARCH_BAR_EXIT_SPRING,
|
|
27
|
+
SEARCH_BAR_EXPAND_SPRING,
|
|
28
|
+
SEARCH_COLORS,
|
|
29
|
+
SEARCH_DOCKED_REVEAL_SPRING,
|
|
30
|
+
SEARCH_FULLSCREEN_SPRING,
|
|
31
|
+
SEARCH_TYPOGRAPHY,
|
|
32
|
+
SearchTokens,
|
|
33
|
+
} from "./search.tokens";
|
|
34
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
35
|
+
export type {
|
|
36
|
+
SearchProps,
|
|
37
|
+
SearchStyleType,
|
|
38
|
+
SearchVariant,
|
|
39
|
+
} from "./search.types";
|
|
40
|
+
export { SearchBar } from "./search-bar";
|
|
41
|
+
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
|
42
|
+
export { useSearch } from "./search-context";
|
|
43
|
+
export { SearchViewDocked } from "./search-view-docked";
|
|
44
|
+
export { SearchViewFullScreen } from "./search-view-fullscreen";
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file search-bar.tsx
|
|
3
|
+
* MD3 Expressive SearchBar — Collapsed state.
|
|
4
|
+
*
|
|
5
|
+
* Renders a pill-shaped search bar (CornerFull, h-56px).
|
|
6
|
+
* When focused/clicked → calls onActiveChange(true) to open SearchView.
|
|
7
|
+
*
|
|
8
|
+
* Option B (MD3 morphing): SearchBar is wrapped in its own AnimatePresence
|
|
9
|
+
* with mode="popLayout". When SearchView opens, SearchBar plays an exit
|
|
10
|
+
* animation (opacity → 0, scale → 0.95) before unmounting, releasing the
|
|
11
|
+
* shared layoutId so SearchView can claim it and morph from the same origin.
|
|
12
|
+
*
|
|
13
|
+
* Role: combobox (WAI-ARIA Search Combobox pattern).
|
|
14
|
+
* @see https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { AnimatePresence, m, useReducedMotion } from "motion/react";
|
|
18
|
+
import * as React from "react";
|
|
19
|
+
import { cn } from "../../lib/utils";
|
|
20
|
+
import { AnimatedPlaceholder } from "./animated-placeholder";
|
|
21
|
+
import {
|
|
22
|
+
SEARCH_BAR_EXIT_SPRING,
|
|
23
|
+
SEARCH_BAR_EXPAND_SPRING,
|
|
24
|
+
SEARCH_COLORS,
|
|
25
|
+
SearchTokens,
|
|
26
|
+
} from "./search.tokens";
|
|
27
|
+
import type { SearchInternalProps, SearchProps } from "./search.types";
|
|
28
|
+
|
|
29
|
+
/** Default search icon (Material Symbols). */
|
|
30
|
+
function DefaultSearchIcon() {
|
|
31
|
+
return (
|
|
32
|
+
<span
|
|
33
|
+
className="material-symbols-rounded select-none leading-none"
|
|
34
|
+
style={{ fontSize: SearchTokens.iconSize }}
|
|
35
|
+
aria-hidden="true"
|
|
36
|
+
>
|
|
37
|
+
search
|
|
38
|
+
</span>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type SearchBarProps = Pick<
|
|
43
|
+
SearchProps,
|
|
44
|
+
| "query"
|
|
45
|
+
| "onQueryChange"
|
|
46
|
+
| "onSearch"
|
|
47
|
+
| "active"
|
|
48
|
+
| "onActiveChange"
|
|
49
|
+
| "leadingIcon"
|
|
50
|
+
| "trailingIcon"
|
|
51
|
+
| "placeholder"
|
|
52
|
+
| "textAlign"
|
|
53
|
+
| "className"
|
|
54
|
+
| "aria-label"
|
|
55
|
+
> &
|
|
56
|
+
SearchInternalProps & {
|
|
57
|
+
/** KeyDown handler from useSearchKeyboard. */
|
|
58
|
+
onKeyDown: (e: React.KeyboardEvent) => void;
|
|
59
|
+
/** Currently highlighted suggestion index (-1 = none). */
|
|
60
|
+
activeIndex: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* SearchBar — collapsed state of the MD3 Search component.
|
|
65
|
+
*
|
|
66
|
+
* Uses Framer Motion `layout` + shared `layoutId` to morph into
|
|
67
|
+
* SearchView when active. Wrapped in AnimatePresence with mode="popLayout"
|
|
68
|
+
* so it exits (fades/scales out) before SearchView claims the layoutId.
|
|
69
|
+
*/
|
|
70
|
+
export function SearchBar({
|
|
71
|
+
query,
|
|
72
|
+
onQueryChange,
|
|
73
|
+
onSearch,
|
|
74
|
+
active,
|
|
75
|
+
onActiveChange,
|
|
76
|
+
leadingIcon,
|
|
77
|
+
trailingIcon,
|
|
78
|
+
placeholder = "Search",
|
|
79
|
+
textAlign = "left",
|
|
80
|
+
className,
|
|
81
|
+
"aria-label": ariaLabel = "Search",
|
|
82
|
+
searchId,
|
|
83
|
+
listboxId,
|
|
84
|
+
onKeyDown,
|
|
85
|
+
activeIndex,
|
|
86
|
+
}: SearchBarProps) {
|
|
87
|
+
const shouldReduceMotion = useReducedMotion();
|
|
88
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
89
|
+
|
|
90
|
+
const prevActiveRef = React.useRef(active);
|
|
91
|
+
|
|
92
|
+
const isRestoringFocusRef = React.useRef(false);
|
|
93
|
+
|
|
94
|
+
// When SearchView opens, focus moves to SearchView's input.
|
|
95
|
+
// When SearchView closes (true → false), restore focus here.
|
|
96
|
+
React.useEffect(() => {
|
|
97
|
+
let rafId: number;
|
|
98
|
+
if (prevActiveRef.current === true && active === false) {
|
|
99
|
+
isRestoringFocusRef.current = true;
|
|
100
|
+
inputRef.current?.focus();
|
|
101
|
+
// Reset after a tick to allow the focus event to fire
|
|
102
|
+
rafId = requestAnimationFrame(() => {
|
|
103
|
+
isRestoringFocusRef.current = false;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
prevActiveRef.current = active;
|
|
107
|
+
return () => {
|
|
108
|
+
if (rafId) cancelAnimationFrame(rafId);
|
|
109
|
+
};
|
|
110
|
+
}, [active]);
|
|
111
|
+
|
|
112
|
+
const handleFocus = () => {
|
|
113
|
+
if (!active && !isRestoringFocusRef.current) {
|
|
114
|
+
onActiveChange(true);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const handleFormSubmit = (e: React.FormEvent) => {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
onSearch(query);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// aria-activedescendant points to the highlighted suggestion
|
|
124
|
+
const activeDescendant =
|
|
125
|
+
activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
/*
|
|
129
|
+
* AnimatePresence mode="popLayout":
|
|
130
|
+
* When SearchView opens (active=true), SearchBar plays its exit animation
|
|
131
|
+
* first, then unmounts — releasing the shared layoutId for SearchView to
|
|
132
|
+
* claim and morph from the pill shape.
|
|
133
|
+
*/
|
|
134
|
+
<AnimatePresence mode="popLayout">
|
|
135
|
+
{!active && (
|
|
136
|
+
<m.div
|
|
137
|
+
key={searchId}
|
|
138
|
+
layout={!shouldReduceMotion}
|
|
139
|
+
layoutId={shouldReduceMotion ? undefined : searchId}
|
|
140
|
+
transition={shouldReduceMotion ? undefined : SEARCH_BAR_EXPAND_SPRING}
|
|
141
|
+
className={cn("relative", className)}
|
|
142
|
+
style={{ height: SearchTokens.heights.bar }}
|
|
143
|
+
initial={shouldReduceMotion ? false : { opacity: 0, scale: 0.95 }}
|
|
144
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
145
|
+
exit={
|
|
146
|
+
shouldReduceMotion
|
|
147
|
+
? {}
|
|
148
|
+
: { opacity: 0, scale: 0.95, transition: SEARCH_BAR_EXIT_SPRING }
|
|
149
|
+
}
|
|
150
|
+
>
|
|
151
|
+
{/* Background layer — rounded-full per SearchBarTokens.ContainerShape */}
|
|
152
|
+
<div
|
|
153
|
+
className="absolute inset-0 rounded-full"
|
|
154
|
+
style={{ backgroundColor: SEARCH_COLORS.container }}
|
|
155
|
+
aria-hidden="true"
|
|
156
|
+
/>
|
|
157
|
+
|
|
158
|
+
{/* <search> is the semantic element for role="search" */}
|
|
159
|
+
<search
|
|
160
|
+
aria-label={ariaLabel}
|
|
161
|
+
className="relative flex h-full items-center gap-2 rounded-full px-4"
|
|
162
|
+
>
|
|
163
|
+
<form className="contents" onSubmit={handleFormSubmit}>
|
|
164
|
+
<span
|
|
165
|
+
className="flex shrink-0 items-center justify-center"
|
|
166
|
+
style={{ color: SEARCH_COLORS.leadingIcon }}
|
|
167
|
+
aria-hidden="true"
|
|
168
|
+
>
|
|
169
|
+
{leadingIcon ?? <DefaultSearchIcon />}
|
|
170
|
+
</span>
|
|
171
|
+
|
|
172
|
+
{/* AnimatedPlaceholder wraps the input to provide a smooth
|
|
173
|
+
translateX animation from textAlign → left on focus. */}
|
|
174
|
+
<AnimatedPlaceholder
|
|
175
|
+
text={placeholder}
|
|
176
|
+
textAlign={textAlign}
|
|
177
|
+
visible={!query}
|
|
178
|
+
focused={active}
|
|
179
|
+
>
|
|
180
|
+
{/* role="combobox" per WAI-ARIA combobox pattern */}
|
|
181
|
+
<input
|
|
182
|
+
ref={inputRef}
|
|
183
|
+
id={searchId}
|
|
184
|
+
type="search"
|
|
185
|
+
role="combobox"
|
|
186
|
+
aria-expanded={active}
|
|
187
|
+
aria-controls={listboxId}
|
|
188
|
+
aria-autocomplete="list"
|
|
189
|
+
aria-activedescendant={activeDescendant}
|
|
190
|
+
aria-label={placeholder}
|
|
191
|
+
value={query}
|
|
192
|
+
placeholder={placeholder}
|
|
193
|
+
className={cn(
|
|
194
|
+
"w-full bg-transparent border-none outline-none",
|
|
195
|
+
"text-[16px] leading-6 font-normal tracking-[0.5px]",
|
|
196
|
+
"placeholder:text-transparent",
|
|
197
|
+
)}
|
|
198
|
+
style={{ color: SEARCH_COLORS.inputText }}
|
|
199
|
+
onFocus={handleFocus}
|
|
200
|
+
onChange={(e) => onQueryChange(e.target.value)}
|
|
201
|
+
onKeyDown={onKeyDown}
|
|
202
|
+
/>
|
|
203
|
+
</AnimatedPlaceholder>
|
|
204
|
+
|
|
205
|
+
{trailingIcon && (
|
|
206
|
+
<span
|
|
207
|
+
className="flex shrink-0 items-center justify-center"
|
|
208
|
+
style={{ color: SEARCH_COLORS.trailingIcon }}
|
|
209
|
+
aria-hidden="true"
|
|
210
|
+
>
|
|
211
|
+
{trailingIcon}
|
|
212
|
+
</span>
|
|
213
|
+
)}
|
|
214
|
+
</form>
|
|
215
|
+
</search>
|
|
216
|
+
</m.div>
|
|
217
|
+
)}
|
|
218
|
+
</AnimatePresence>
|
|
219
|
+
);
|
|
220
|
+
}
|