@noxlovette/material 0.7.1 → 0.8.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.
Files changed (29) hide show
  1. package/claude-skill/material-design/references/motion-guide.md +9 -9
  2. package/dist/animation/containerTransform.d.ts +0 -27
  3. package/dist/animation/containerTransform.js +40 -9
  4. package/dist/components/containers/popover/theme.d.ts +3 -3
  5. package/dist/components/forms/checkbox/Checkbox.svelte +1 -1
  6. package/dist/components/forms/checkbox/theme.d.ts +0 -12
  7. package/dist/components/forms/checkbox/theme.js +2 -7
  8. package/dist/components/forms/search/Search.mdx +76 -8
  9. package/dist/components/forms/search/Search.stories.svelte +107 -0
  10. package/dist/components/forms/search/Search.stories.svelte.d.ts +2 -17
  11. package/dist/components/forms/search/Search.svelte +55 -2
  12. package/dist/components/forms/search/Search.svelte.d.ts +4 -1
  13. package/dist/components/forms/search/SearchView.svelte +269 -0
  14. package/dist/components/forms/search/SearchView.svelte.d.ts +21 -0
  15. package/dist/components/forms/search/index.d.ts +1 -0
  16. package/dist/components/forms/search/index.js +1 -0
  17. package/dist/components/forms/search/theme.d.ts +68 -1
  18. package/dist/components/forms/search/theme.js +91 -2
  19. package/dist/components/forms/search/types.d.ts +75 -2
  20. package/dist/components/nav/appbar/AppBar.mdx +5 -1
  21. package/dist/components/nav/appbar/AppBar.stories.svelte +38 -0
  22. package/dist/components/nav/appbar/AppBar.svelte +47 -2
  23. package/dist/components/nav/appbar/AppBar.svelte.d.ts +3 -2
  24. package/dist/components/nav/appbar/theme.js +1 -1
  25. package/dist/components/nav/appbar/types.d.ts +18 -1
  26. package/dist/components/table/theme.js +3 -1
  27. package/dist/styles/components.css +6 -0
  28. package/dist/styles/motion.css +35 -0
  29. package/package.json +1 -1
@@ -58,7 +58,7 @@ Spatial springs overshoot by design (fast spatial most, at damping ratio 0.6); e
58
58
  | ----------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
59
59
  | **Enter and exit** — a surface appears/leaves within the screen | `presence(() => open, enterExit.<preset>)` | Attachment. Presets: `fade`, `scale` (menus/popovers/tooltips — sets `transform-origin` to bits-ui's anchor side, so never add an `origin-*` class), `slideUp` (snackbar), `dialog`, `sideSheet`, `bottomSheet`. Interruptible: reopening mid-exit retargets from the current value with velocity. |
60
60
  | Same, outside bits-ui (element must stay mounted during its exit) | `new Presence(() => open)` | `{#if p.mounted}<div {@attach p.attach(enterExit.x)}>`. Construct during component init. Used by Snackbar, SideSheet, BottomSheet. |
61
- | **Container transform** — card → detail, FAB → sheet | `containerTransform(update, { from, to })` | Motion `animateView()` (View Transition API). `update` swaps the DOM (`async () => { open = true; await tick(); }`); `to` may be a selector for an element that only exists after the update. |
61
+ | **Container transform** — card → detail, search bar → view | `containerTransform(update, { from, to })` | Motion `animateView()` (View Transition API). `update` swaps the DOM (`async () => { open = true; await tick(); }`); `to` may be a selector for an element that only exists after the update. |
62
62
  | **Forward and backward** — hierarchy levels, wizard steps | `sharedAxis(update, { target, axis, direction })` | `axis: 'x' \| 'y' \| 'z'`, `direction: 'forward' \| 'backward'`. `target` is the persistent region whose content changes; omit for the whole page. |
63
63
  | **Lateral** — peer screens (tabs, carousels) | `lateral(update, { target, direction, axis })` | Edge-to-edge slide, no fade, along the axis the peers are laid out on: `axis: 'y'` for vertical tabs, a vertical carousel or a top-to-bottom stepper (`forward` = next, pushing up from below). Never on a vertical nav list: that's a drawer, so top level. |
64
64
  | **Top level** — unrelated destinations (navigation bar) | `fadeThrough(update, { target })` | Old fades out, new fades in scaling from 92%. The `target` region swaps its box instantly (no slide or resize, so a scroll reset doesn't glide). In SvelteKit, call it from `onNavigate` in the root layout, skip hash-only changes, and fade only the region whose content changed: `main` between rail/navbar destinations, a section's content pane between pages of a nav that stays on screen (the showcase site's root layout does both). |
@@ -71,14 +71,14 @@ Spatial springs overshoot by design (fast spatial most, at damping ratio 0.6); e
71
71
 
72
72
  A pattern belongs in a component only when that component owns both states of the change. When the content that changes lives outside the component (a route, an app screen), the app applies the pattern, not the library. Keep this table in sync when a component gains or loses one of these patterns; each primitive's JSDoc in `src/lib/animation/` repeats its own row.
73
73
 
74
- | Pattern | Built into (must use it) | Left to the app |
75
- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
76
- | **Enter and exit** | Dialogue, BottomSheet, SideSheet, Menu, MenuSub, ContextMenu, Popover, Tooltip, LinkPreview, Select, FABMenu, SplitButton, Snackbar, DateField/DateRangeField/TimeField popups | — |
77
- | **Lateral** | TabHolder (switching content panels); DateField/DateRangeField (changing the visible month, via `date/calendarMotion.ts`) | Navigation (`href`) tabs: the route change is the app's |
78
- | **Container transform** | FAB → its `surface` (`FAB.svelte`: a clip-path and colour morph on Motion, not `containerTransform`) | Card → detail, Carousel item → detail. `Search` has no search view to expand into; add it here if one is built |
79
- | **Forward and backward** | — | Hierarchy levels, wizard steps |
80
- | **Top level** | — | Page changes from `Navbar`/`Rail`: they don't own the content region |
81
- | **Skeleton loaders** | — | Placeholders for data the app loads |
74
+ | Pattern | Built into (must use it) | Left to the app |
75
+ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
76
+ | **Enter and exit** | Dialogue, BottomSheet, SideSheet, Menu, MenuSub, ContextMenu, Popover, Tooltip, LinkPreview, Select, FABMenu, SplitButton, Snackbar, DateField/DateRangeField/TimeField popups | — |
77
+ | **Lateral** | TabHolder (switching content panels); DateField/DateRangeField (changing the visible month, via `date/calendarMotion.ts`) | Navigation (`href`) tabs: the route change is the app's |
78
+ | **Container transform** | FAB → its `surface` (`FAB.svelte`: a clip-path and colour morph on Motion, not `containerTransform`); `Search` / search `AppBar` bar → `SearchView` (`containerTransform`, both ways) | Card → detail, Carousel item → detail |
79
+ | **Forward and backward** | — | Hierarchy levels, wizard steps |
80
+ | **Top level** | — | Page changes from `Navbar`/`Rail`: they don't own the content region |
81
+ | **Skeleton loaders** | — | Placeholders for data the app loads |
82
82
 
83
83
  A new component that mounts a surface must use enter/exit; one that pages between peer views (a stepper of equal steps) must use lateral. `Carousel` is not a pager: its motion is the scroll itself, items resizing through keylines. Opening a tapped item into its detail is a container transform the app applies.
84
84
 
@@ -8,31 +8,4 @@ export interface ContainerTransformOptions {
8
8
  /** Spring for the bounds/shape morph. `slowSpatial` suits full-screen expansions. */
9
9
  spring?: SpringToken;
10
10
  }
11
- /**
12
- * M3 container transform: one container morphs its bounds, shape and color into another while
13
- * the outgoing content fades out and the incoming content fades in on top.
14
- * https://m3.material.io/styles/motion/transitions/transition-patterns#container-transform
15
- *
16
- * The most dramatic pattern (https://m3.material.io/styles/motion/transitions/applying-transitions).
17
- * Use it for hero moments, shallow expand → collapse hierarchies and seamless element-to-element
18
- * connections. Don't use it in deep hierarchies or utility-focused navigation, where it becomes
19
- * excessive. Use `sharedAxis` there. Keep `spring` at `spatial`/`slowSpatial`, never the bouncy
20
- * `fastSpatial`.
21
- *
22
- * Built on Motion's `animateView()` (View Transition API), so `from` and `to` never have to be in
23
- * the DOM at the same time — `update` swaps one for the other. Browsers without the API just run
24
- * `update`. A second call while one is running is queued, not interrupted.
25
- *
26
- * Not built into any component. FAB → sheet is the FAB's own morph (a clip-path on Motion, in
27
- * `FAB.svelte`), because M3 keeps that container opaque and changes its colour, which a
28
- * snapshot cross-fade can't. Card → detail is the app's own navigation, and `Search` is a plain
29
- * field with no search view to expand into.
30
- *
31
- * ```ts
32
- * containerTransform(
33
- * async () => { expanded = true; await tick(); },
34
- * { from: cardEl, to: '[data-detail]' }
35
- * );
36
- * ```
37
- */
38
11
  export declare const containerTransform: (update: () => void | Promise<void>, { from, to, spring }: ContainerTransformOptions) => import("motion-dom").ViewTransitionBuilder;
@@ -2,8 +2,9 @@ import { animateView } from 'motion';
2
2
  import { prefersReducedMotion } from './reducedMotion.js';
3
3
  import { springTokens, springTransition } from './spring.js';
4
4
  /**
5
- * M3 container transform: one container morphs its bounds, shape and color into another while
6
- * the outgoing content fades out and the incoming content fades in on top.
5
+ * M3 container transform: one container morphs its bounds, shape and color into another. The
6
+ * incoming state is drawn underneath at full opacity from the start and the outgoing one fades
7
+ * out on top of it, so the morph ends exactly as the page looks and never shows through.
7
8
  * https://m3.material.io/styles/motion/transitions/transition-patterns#container-transform
8
9
  *
9
10
  * The most dramatic pattern (https://m3.material.io/styles/motion/transitions/applying-transitions).
@@ -16,10 +17,10 @@ import { springTokens, springTransition } from './spring.js';
16
17
  * the DOM at the same time — `update` swaps one for the other. Browsers without the API just run
17
18
  * `update`. A second call while one is running is queued, not interrupted.
18
19
  *
19
- * Not built into any component. FAB → sheet is the FAB's own morph (a clip-path on Motion, in
20
- * `FAB.svelte`), because M3 keeps that container opaque and changes its colour, which a
21
- * snapshot cross-fade can't. Card → detail is the app's own navigation, and `Search` is a plain
22
- * field with no search view to expand into.
20
+ * Built into `SearchView`: the search bar (`Search`, or a search `AppBar`) grows into the search
21
+ * view and back. FAB → sheet is the FAB's own morph (a clip-path on Motion, in `FAB.svelte`),
22
+ * because M3 keeps that container opaque and changes its colour, which a snapshot cross-fade
23
+ * can't. Card → detail is the app's own navigation.
23
24
  *
24
25
  * ```ts
25
26
  * containerTransform(
@@ -28,12 +29,42 @@ import { springTokens, springTransition } from './spring.js';
28
29
  * );
29
30
  * ```
30
31
  */
32
+ const FILL = '--md-container-transform-color';
33
+ const resolve = (target) => typeof target === 'string'
34
+ ? document.querySelector(target)
35
+ : target instanceof Element
36
+ ? target
37
+ : null;
38
+ const TRANSPARENT = new Set(['transparent', 'rgba(0, 0, 0, 0)']);
39
+ /*
40
+ The destination's own background, if it has one. A container made of separate surfaces on a
41
+ transparent wrapper (the docked search view's bar and results) gets no fill: its opaque
42
+ incoming snapshot already covers what it should, and a flat fill would paper over the gaps
43
+ between its surfaces until the transition ends, then pop.
44
+ */
45
+ const fillOf = (target) => {
46
+ const colour = target ? getComputedStyle(target).backgroundColor : '';
47
+ return colour && !TRANSPARENT.has(colour) ? colour : 'transparent';
48
+ };
31
49
  export const containerTransform = (update, { from, to, spring = springTokens.spatial }) => {
32
- const builder = animateView(update, springTransition(spring)).add(from, to);
50
+ // The incoming snapshot is opaque wherever the destination is, and the outgoing one fades off
51
+ // it (motion.css layers them), so nothing behind shows through. The fill (motion.css) covers
52
+ // the rest of a destination with its own background, e.g. a full-screen view's area below the
53
+ // incoming snapshot's top as the bar grows. Only the transition's group reads the property,
54
+ // and it's rewritten before each new snapshot, so it's left in place afterwards.
55
+ const root = document.documentElement;
56
+ const updateAndFill = async () => {
57
+ await update();
58
+ root.style.setProperty(FILL, fillOf(resolve(to)));
59
+ };
60
+ // The class lets motion.css keep both snapshots at their width, clipped by the container.
61
+ const builder = animateView(updateAndFill, springTransition(spring))
62
+ .add(from, to)
63
+ .class('md-container-transform');
33
64
  // Reduced motion: the container doesn't grow; its two states just crossfade in place.
34
65
  if (prefersReducedMotion())
35
66
  builder.layout({ duration: 0 });
36
67
  return builder
37
- .old({ opacity: [1, 0] }, springTransition(springTokens.fastEffects))
38
- .new({ opacity: [0, 1] }, { ...springTransition(springTokens.effects), delay: 0.05 });
68
+ .old({ opacity: [1, 0] }, springTransition(springTokens.effects))
69
+ .new({ opacity: [1, 1] }, springTransition(springTokens.effects));
39
70
  };
@@ -3,20 +3,20 @@ export type PopoverVariants = VariantProps<typeof popover>;
3
3
  export declare const popover: import("tailwind-variants").TVReturnType<{
4
4
  [key: string]: {
5
5
  [key: string]: import("tailwind-variants").ClassValue | {
6
- title?: import("tailwind-variants").ClassValue;
7
6
  base?: import("tailwind-variants").ClassValue;
8
7
  body?: import("tailwind-variants").ClassValue;
9
8
  header?: import("tailwind-variants").ClassValue;
9
+ title?: import("tailwind-variants").ClassValue;
10
10
  close?: import("tailwind-variants").ClassValue;
11
11
  };
12
12
  };
13
13
  } | {
14
14
  [x: string]: {
15
15
  [x: string]: import("tailwind-variants").ClassValue | {
16
- title?: import("tailwind-variants").ClassValue;
17
16
  base?: import("tailwind-variants").ClassValue;
18
17
  body?: import("tailwind-variants").ClassValue;
19
18
  header?: import("tailwind-variants").ClassValue;
19
+ title?: import("tailwind-variants").ClassValue;
20
20
  close?: import("tailwind-variants").ClassValue;
21
21
  };
22
22
  };
@@ -29,10 +29,10 @@ export declare const popover: import("tailwind-variants").TVReturnType<{
29
29
  }, undefined, {
30
30
  [key: string]: {
31
31
  [key: string]: import("tailwind-variants").ClassValue | {
32
- title?: import("tailwind-variants").ClassValue;
33
32
  base?: import("tailwind-variants").ClassValue;
34
33
  body?: import("tailwind-variants").ClassValue;
35
34
  header?: import("tailwind-variants").ClassValue;
35
+ title?: import("tailwind-variants").ClassValue;
36
36
  close?: import("tailwind-variants").ClassValue;
37
37
  };
38
38
  };
@@ -28,7 +28,7 @@ Checkboxes let users select one or more items from a list, or turn an item on or
28
28
  const cls = $derived(checkbox({ state, error, disabled }));
29
29
  </script>
30
30
 
31
- <div class="space-x-spacing-150 flex cursor-pointer items-center">
31
+ <div class="gap-spacing-50 flex cursor-pointer items-center">
32
32
  <Checkbox.Root
33
33
  bind:checked
34
34
  bind:indeterminate
@@ -21,10 +21,6 @@ export declare const checkbox: import("tailwind-variants").TVReturnType<{
21
21
  supporting: string;
22
22
  };
23
23
  };
24
- align: {
25
- start: "items-start";
26
- center: "items-center";
27
- };
28
24
  disabled: {
29
25
  true: {
30
26
  root: string;
@@ -66,10 +62,6 @@ export declare const checkbox: import("tailwind-variants").TVReturnType<{
66
62
  supporting: string;
67
63
  };
68
64
  };
69
- align: {
70
- start: "items-start";
71
- center: "items-center";
72
- };
73
65
  disabled: {
74
66
  true: {
75
67
  root: string;
@@ -111,10 +103,6 @@ export declare const checkbox: import("tailwind-variants").TVReturnType<{
111
103
  supporting: string;
112
104
  };
113
105
  };
114
- align: {
115
- start: "items-start";
116
- center: "items-center";
117
- };
118
106
  disabled: {
119
107
  true: {
120
108
  root: string;
@@ -1,7 +1,7 @@
1
1
  import { tv } from '../../../utils/tv.js';
2
2
  export const checkbox = tv({
3
3
  slots: {
4
- root: 'group inline-flex min-h-spacing-500 select-none items-start gap-spacing-150 text-md-sys-color-on-surface cursor-pointer',
4
+ root: 'group inline-flex size-spacing-500 shrink-0 select-none items-center justify-center cursor-pointer',
5
5
  container: 'relative inline-flex size-[18px] shrink-0',
6
6
  control: 'layer-container absolute -inset-[11px] rounded-full text-md-sys-color-on-surface-variant state-layer before:rounded-full group-focus-visible:outline group-focus-visible:outline-3 group-focus-visible:outline-offset-2 group-focus-visible:outline-md-sys-color-secondary transition-colors md-sys-motion-fast-effects',
7
7
  box: 'absolute inset-[11px] rounded-[4px] border-2 border-current bg-md-sys-color-surface transition-colors md-sys-motion-fast-effects',
@@ -31,10 +31,6 @@ export const checkbox = tv({
31
31
  supporting: 'text-md-sys-color-error'
32
32
  }
33
33
  },
34
- align: {
35
- start: 'items-start',
36
- center: 'items-center'
37
- },
38
34
  disabled: {
39
35
  true: {
40
36
  root: 'cursor-not-allowed',
@@ -84,7 +80,6 @@ export const checkbox = tv({
84
80
  }
85
81
  ],
86
82
  defaultVariants: {
87
- state: 'unchecked',
88
- align: 'start'
83
+ state: 'unchecked'
89
84
  }
90
85
  });
@@ -5,21 +5,22 @@ import * as SearchStories from './Search.stories.svelte';
5
5
 
6
6
  <Title />
7
7
 
8
- <Subtitle>The search bar lets users enter a query to find information in an app.</Subtitle>
8
+ <Subtitle>
9
+ Search lets users enter a query and pick from suggestions and results in a search view.
10
+ </Subtitle>
9
11
 
10
12
  [M3 spec](https://m3.material.io/components/search/specs) ·
11
- `import { Search } from '@noxlovette/material';`
13
+ `import { Search, SearchView } from '@noxlovette/material';`
12
14
 
13
15
  <Canvas of={SearchStories.Playground} />
14
16
 
15
17
  <Controls of={SearchStories.Playground} />
16
18
 
17
- ## Search bar, not search view
19
+ ## Search bar
18
20
 
19
- This is M3's search bar: a 56dp, fully rounded `surface-container-high` field with a leading
20
- search icon. It's a different thing from the full-screen search view, and from a filled text
21
- field. Once there's a value, a clear button appears at the end. Other native `<input>` attributes
22
- pass straight through to the input.
21
+ M3's search bar is a 56dp, fully rounded `surface-container-high` field with a leading search
22
+ icon. It's a different thing from a filled text field. Once there's a value, a clear button
23
+ appears at the end. Other native `<input>` attributes pass straight through to the input.
23
24
 
24
25
  ```svelte
25
26
  <Search placeholder="Search" bind:value={query} />
@@ -27,7 +28,65 @@ pass straight through to the input.
27
28
 
28
29
  <Canvas of={SearchStories.WithActions} />
29
30
 
30
- For a search field inside a top app bar, use `AppBar`'s `search` prop instead.
31
+ For a search field inside a top app bar, use `AppBar`'s `search` prop instead. It opens the same
32
+ search view when you give it `searchResults`.
33
+
34
+ ## Search view
35
+
36
+ M3 treats search as a bar plus a view. Pass `results` and clicking or typing in the bar opens the
37
+ search view, where the suggestions and results go. The view is empty until you fill it: spread the
38
+ snippet's argument onto a `List`, and give each item `role="option"`.
39
+
40
+ ```svelte
41
+ <Search placeholder="Search" bind:value={query} bind:open results={suggestions} />
42
+
43
+ {#snippet suggestions(listbox)}
44
+ <List {...listbox}>
45
+ {#each matches as item (item)}
46
+ <ListItem role="option" asChild headline={item} onclick={() => pick(item)} />
47
+ {/each}
48
+ </List>
49
+ {/snippet}
50
+ ```
51
+
52
+ Picking a result is up to you: set the value and `open = false` in the item's `onclick`.
53
+
54
+ <Canvas of={SearchStories.SearchView} />
55
+
56
+ ### Layouts
57
+
58
+ `layout` takes `'fullScreen' | 'docked'`, one value or one per window tier. The default,
59
+ `{ small: 'fullScreen', medium: 'docked' }`, follows M3: full-screen on compact windows, docked
60
+ from medium up. The switch is made in CSS, so there's no flash on load.
61
+
62
+ | Layout | Container | Bar |
63
+ | ----------- | ---------------------------------------------------------------- | ----------------------------------------------------------------- |
64
+ | Full-screen | The whole window, `surface-container-low`, 0 radius | 56dp, circular, `surface-container-high`, 12dp from the sides |
65
+ | Docked | Results 2dp below the bar, 12dp radius, `surface-container-high` | The search bar widened in place: its margins go from 24dp to 12dp |
66
+
67
+ The docked view is 360–720dp wide and 240dp to ⅔ of the window tall. In both layouts the bar's
68
+ leading icon becomes a back button, a clear button appears once there's a query, and `trailing`
69
+ actions (a mic, say) stay in the bar.
70
+
71
+ <Canvas of={SearchStories.FullScreen} />
72
+
73
+ <Canvas of={SearchStories.Docked} />
74
+
75
+ ### Motion
76
+
77
+ Opening is an M3 [container transform](https://m3.material.io/styles/motion/transitions/transition-patterns#container-transform):
78
+ the bar grows into the view on the `spatial` spring. Back, Esc and a click outside reverse it.
79
+ Under reduced motion the two crossfade in place. Browsers without the View Transition API switch
80
+ straight to the view.
81
+
82
+ ### Your own bar
83
+
84
+ `SearchView` is exported for bars you build yourself. Pass the bar element as `anchor`, and open
85
+ the view on click or typing, not on focus: closing hands focus back to the bar.
86
+
87
+ ```svelte
88
+ <SearchView bind:open bind:value anchor={barEl} results={suggestions} />
89
+ ```
31
90
 
32
91
  ## Accessibility
33
92
 
@@ -36,3 +95,12 @@ For a search field inside a top app bar, use `AppBar`'s `search` prop instead.
36
95
  - **Clear button.** A 48dp `type="button"` labelled by `clearLabel` (default "Clear search"). It
37
96
  never submits a form and returns focus to the input.
38
97
  - **Focus.** Keyboard focus draws the M3 focus indicator around the bar.
98
+ - **Opening.** With `results`, the bar's input has `aria-haspopup="dialog"` and `aria-expanded`.
99
+ Click, typing or ↓ opens the view; focus alone doesn't.
100
+ - **Search view.** A bits-ui `Dialog`: focus moves into the view's field and stays in the view,
101
+ the page doesn't scroll, and Esc or the back button (`backLabel`, default "Back") closes it and
102
+ returns focus to the bar. The view is named by `resultsLabel` (default: the placeholder).
103
+ - **Suggestions.** The view's field is a `combobox` over the `listbox` you render. ↑/↓ move
104
+ through the `role="option"` items (wrapping, skipping disabled ones) via
105
+ `aria-activedescendant`, so focus stays in the field while typing; Enter picks the highlighted
106
+ one. The highlighted item gets the M3 focus indicator.
@@ -2,6 +2,10 @@
2
2
  import { defineMeta } from '@storybook/addon-svelte-csf';
3
3
  import Search from './Search.svelte';
4
4
  import ButtonIcon from '../../buttons/ButtonIcon.svelte';
5
+ import List from '../../containers/list/List.svelte';
6
+ import ListItem from '../../containers/list/ListItem.svelte';
7
+ import { Icon } from '../../../utils/index.js';
8
+ import type { SearchResultsProps } from './types.js';
5
9
 
6
10
  const { Story } = defineMeta({
7
11
  title: 'Forms/Search',
@@ -17,6 +21,51 @@
17
21
  });
18
22
  </script>
19
23
 
24
+ <script lang="ts">
25
+ const recent = ['Material Design 3', 'Container transform', 'Search view specs'];
26
+ const topics = [
27
+ 'Buttons',
28
+ 'Cards',
29
+ 'Chips',
30
+ 'Dialogs',
31
+ 'Lists',
32
+ 'Menus',
33
+ 'Navigation rail',
34
+ 'Search',
35
+ 'Sheets',
36
+ 'Sliders',
37
+ 'Snackbar',
38
+ 'Tabs',
39
+ 'Text fields'
40
+ ];
41
+
42
+ let query = $state('');
43
+ let open = $state(false);
44
+ let picked = $state('');
45
+
46
+ const matches = $derived(
47
+ query ? topics.filter((t) => t.toLowerCase().includes(query.toLowerCase())) : recent
48
+ );
49
+
50
+ const pick = (item: string) => {
51
+ picked = item;
52
+ query = item;
53
+ open = false;
54
+ };
55
+ </script>
56
+
57
+ {#snippet suggestions(listbox: SearchResultsProps)}
58
+ <List {...listbox}>
59
+ {#each matches as item (item)}
60
+ <ListItem role="option" asChild headline={item} onclick={() => pick(item)}>
61
+ {#snippet leading()}
62
+ <Icon name={query ? 'search' : 'history'} size="sm" />
63
+ {/snippet}
64
+ </ListItem>
65
+ {/each}
66
+ </List>
67
+ {/snippet}
68
+
20
69
  <Story name="Playground">
21
70
  {#snippet template(args)}
22
71
  <div class="p-spacing-300 max-w-md">
@@ -49,3 +98,61 @@
49
98
  <Search value="query" leadingIconProps={null} placeholder="No leading icon" />
50
99
  </div>
51
100
  </Story>
101
+
102
+ <!--
103
+ Click or type in the bar. The view is full-screen below the medium window class and docked from
104
+ it up: resize the viewport to see both. Arrow keys move through the suggestions; Enter picks one.
105
+ -->
106
+ <Story
107
+ name="Search view"
108
+ asChild
109
+ parameters={{
110
+ layout: 'fullscreen',
111
+ docs: { story: { inline: false, height: '560px' } }
112
+ }}
113
+ >
114
+ <div class="gap-spacing-200 p-spacing-300 flex min-h-dvh flex-col items-center">
115
+ <Search placeholder="Search components" bind:value={query} bind:open results={suggestions}>
116
+ {#snippet trailing()}
117
+ <ButtonIcon variant="standard" iconProps={{ name: 'mic' }} aria-label="Voice search" />
118
+ {/snippet}
119
+ </Search>
120
+ <p class="md-sys-typescale-body-medium text-md-sys-color-on-surface-variant">
121
+ {picked ? `Picked: ${picked}` : 'Nothing picked yet'}
122
+ </p>
123
+ </div>
124
+ </Story>
125
+
126
+ <Story
127
+ name="Full-screen"
128
+ asChild
129
+ parameters={{
130
+ layout: 'fullscreen',
131
+ viewport: { defaultViewport: 'mobile1' },
132
+ docs: { story: { inline: false, height: '560px' } }
133
+ }}
134
+ >
135
+ <div class="p-spacing-300 flex min-h-dvh flex-col items-center">
136
+ <Search
137
+ placeholder="Search components"
138
+ bind:value={query}
139
+ layout="fullScreen"
140
+ results={suggestions}
141
+ />
142
+ </div>
143
+ </Story>
144
+
145
+ <Story
146
+ name="Docked"
147
+ asChild
148
+ parameters={{ layout: 'fullscreen', docs: { story: { inline: false, height: '560px' } } }}
149
+ >
150
+ <div class="p-spacing-300 flex min-h-dvh flex-col items-center">
151
+ <Search
152
+ placeholder="Search components"
153
+ bind:value={query}
154
+ layout="docked"
155
+ results={suggestions}
156
+ />
157
+ </div>
158
+ </Story>
@@ -1,19 +1,4 @@
1
1
  import Search from './Search.svelte';
2
- interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
3
- new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
4
- $$bindings?: Bindings;
5
- } & Exports;
6
- (internal: unknown, props: {
7
- $$events?: Events;
8
- $$slots?: Slots;
9
- }): Exports & {
10
- $set?: any;
11
- $on?: any;
12
- };
13
- z_$$bindings?: Bindings;
14
- }
15
- declare const Search: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
16
- [evt: string]: CustomEvent<any>;
17
- }, {}, {}, string>;
18
- type Search = InstanceType<typeof Search>;
2
+ declare const Search: import("svelte").Component<Record<string, never>, {}, "">;
3
+ type Search = ReturnType<typeof Search>;
19
4
  export default Search;
@@ -4,6 +4,9 @@ Material 3 Search Bar (contained, M3 Expressive).
4
4
 
5
5
  Search bars allow users to enter a query to find specific information within an app.
6
6
 
7
+ Give it `results` and the bar opens a search view (`SearchView`) when clicked or typed in:
8
+ full-screen on compact windows, docked from medium up, with a container transform between them.
9
+
7
10
  @see https://m3.material.io/components/search/specs
8
11
  -->
9
12
  <script lang="ts">
@@ -12,6 +15,7 @@ Search bars allow users to enter a query to find specific information within an
12
15
  import type { SearchProps } from './types.js';
13
16
  import { Icon } from '../../../utils/index.js';
14
17
  import ButtonIcon from '../../buttons/ButtonIcon.svelte';
18
+ import SearchView from './SearchView.svelte';
15
19
 
16
20
  const uid = $props.id();
17
21
 
@@ -24,6 +28,11 @@ Search bars allow users to enter a query to find specific information within an
24
28
  trailingIconProps = { name: 'close' },
25
29
  leadingIconProps = { name: 'search' },
26
30
  clearLabel = 'Clear search',
31
+ open = $bindable(false),
32
+ results,
33
+ layout,
34
+ backLabel,
35
+ resultsLabel,
27
36
  class: className,
28
37
  id = uid,
29
38
  trailingClick = () => {
@@ -33,6 +42,34 @@ Search bars allow users to enter a query to find specific information within an
33
42
  ...restProps
34
43
  }: SearchProps = $props();
35
44
 
45
+ let bar = $state<HTMLElement>();
46
+
47
+ // With a search view, clicking the bar, typing in it or pressing ↓ opens the view. Not focus:
48
+ // closing the view hands focus back here, which must not reopen it.
49
+ const opener = $derived(
50
+ results
51
+ ? {
52
+ 'aria-haspopup': 'dialog' as const,
53
+ 'aria-expanded': open,
54
+ onclick: (e: MouseEvent & { currentTarget: HTMLInputElement }) => {
55
+ restProps.onclick?.(e);
56
+ if (!e.defaultPrevented) open = true;
57
+ },
58
+ oninput: (e: Event & { currentTarget: HTMLInputElement }) => {
59
+ restProps.oninput?.(e);
60
+ if (!e.defaultPrevented) open = true;
61
+ },
62
+ onkeydown: (e: KeyboardEvent & { currentTarget: HTMLInputElement }) => {
63
+ restProps.onkeydown?.(e);
64
+ if (!e.defaultPrevented && e.key === 'ArrowDown') {
65
+ e.preventDefault();
66
+ open = true;
67
+ }
68
+ }
69
+ }
70
+ : {}
71
+ );
72
+
36
73
  const showClear = $derived(!!trailingIconProps && !!value);
37
74
 
38
75
  const s = $derived(
@@ -43,7 +80,7 @@ Search bars allow users to enter a query to find specific information within an
43
80
  );
44
81
  </script>
45
82
 
46
- <label for={id} class={s.base({ class: clsx(className) })}>
83
+ <label for={id} class={s.base({ class: clsx(className) })} bind:this={bar}>
47
84
  {#if leading}
48
85
  <span class={s.leading()}>{@render leading()}</span>
49
86
  {:else if leadingIconProps}
@@ -51,8 +88,9 @@ Search bars allow users to enter a query to find specific information within an
51
88
  {/if}
52
89
  <input
53
90
  {...restProps}
91
+ {...opener}
54
92
  {id}
55
- {placeholder}
93
+ placeholder={placeholder ?? undefined}
56
94
  bind:this={elementRef}
57
95
  bind:value
58
96
  type="search"
@@ -73,3 +111,18 @@ Search bars allow users to enter a query to find specific information within an
73
111
  </span>
74
112
  {/if}
75
113
  </label>
114
+
115
+ {#if results}
116
+ <SearchView
117
+ bind:open
118
+ bind:value
119
+ anchor={bar}
120
+ {results}
121
+ {layout}
122
+ placeholder={placeholder ?? undefined}
123
+ {backLabel}
124
+ {resultsLabel}
125
+ {clearLabel}
126
+ {trailing}
127
+ />
128
+ {/if}
@@ -4,8 +4,11 @@ import type { SearchProps } from './types.js';
4
4
  *
5
5
  * Search bars allow users to enter a query to find specific information within an app.
6
6
  *
7
+ * Give it `results` and the bar opens a search view (`SearchView`) when clicked or typed in:
8
+ * full-screen on compact windows, docked from medium up, with a container transform between them.
9
+ *
7
10
  * @see https://m3.material.io/components/search/specs
8
11
  */
9
- declare const Search: import("svelte").Component<SearchProps, {}, "value" | "elementRef">;
12
+ declare const Search: import("svelte").Component<SearchProps, {}, "value" | "open" | "elementRef">;
10
13
  type Search = ReturnType<typeof Search>;
11
14
  export default Search;