@noxlovette/material 0.8.3 → 0.9.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.
Files changed (53) hide show
  1. package/claude-skill/material-design/SKILL.md +1 -1
  2. package/claude-skill/material-design/references/component-patterns.md +36 -1
  3. package/dist/animation/presence.svelte.js +37 -1
  4. package/dist/animation/sharedAxisTransition.d.ts +2 -1
  5. package/dist/animation/sharedAxisTransition.js +19 -1
  6. package/dist/components/buttons/FAB.svelte +4 -1
  7. package/dist/components/buttons/theme.d.ts +6 -3
  8. package/dist/components/buttons/theme.js +3 -2
  9. package/dist/components/containers/context-menu/theme.js +4 -4
  10. package/dist/components/containers/list/theme.js +1 -1
  11. package/dist/components/containers/menu/Menu.svelte +2 -5
  12. package/dist/components/containers/menu/theme.js +4 -2
  13. package/dist/components/containers/pane/theme.js +1 -1
  14. package/dist/components/date/theme.js +1 -1
  15. package/dist/components/forms/command/Command.mdx +90 -0
  16. package/dist/components/forms/command/Command.stories.svelte +69 -10
  17. package/dist/components/forms/command/Command.stories.svelte.d.ts +2 -17
  18. package/dist/components/forms/command/CommandDialog.svelte +58 -0
  19. package/dist/components/forms/command/CommandDialog.svelte.d.ts +11 -0
  20. package/dist/components/forms/command/CommandItem.svelte +48 -5
  21. package/dist/components/forms/command/CommandItem.svelte.d.ts +4 -0
  22. package/dist/components/forms/command/index.d.ts +1 -0
  23. package/dist/components/forms/command/index.js +1 -0
  24. package/dist/components/forms/command/theme.d.ts +371 -6
  25. package/dist/components/forms/command/theme.js +32 -1
  26. package/dist/components/forms/command/types.d.ts +27 -1
  27. package/dist/components/forms/search/Search.mdx +9 -0
  28. package/dist/components/forms/search/Search.stories.svelte +14 -2
  29. package/dist/components/forms/search/Search.svelte +41 -9
  30. package/dist/components/forms/search/Search.svelte.d.ts +3 -0
  31. package/dist/components/forms/search/SearchView.svelte +57 -6
  32. package/dist/components/forms/search/SearchView.svelte.d.ts +4 -2
  33. package/dist/components/forms/search/theme.js +4 -2
  34. package/dist/components/forms/search/types.d.ts +14 -1
  35. package/dist/components/forms/slider/theme.js +1 -1
  36. package/dist/components/nav/appbar/AppBar.stories.svelte +30 -2
  37. package/dist/components/nav/appbar/AppBar.svelte +40 -8
  38. package/dist/components/nav/appbar/theme.js +1 -1
  39. package/dist/components/nav/appbar/types.d.ts +14 -0
  40. package/dist/components/nav/navbar/theme.js +1 -1
  41. package/dist/components/nav/rail/Rail.mdx +6 -0
  42. package/dist/components/nav/rail/Rail.svelte +55 -1
  43. package/dist/components/nav/rail/Rail.svelte.d.ts +3 -0
  44. package/dist/components/nav/rail/theme.js +1 -1
  45. package/dist/components/nav/rail/types.d.ts +7 -0
  46. package/dist/components/toolbar/theme.js +4 -1
  47. package/dist/styles/motion.css +26 -0
  48. package/dist/utils/Layer.svelte +3 -1
  49. package/dist/utils/index.d.ts +1 -0
  50. package/dist/utils/index.js +1 -0
  51. package/dist/utils/shortcut.d.ts +26 -0
  52. package/dist/utils/shortcut.js +84 -0
  53. package/package.json +1 -1
@@ -7,13 +7,16 @@ Search bars allow users to enter a query to find specific information within an
7
7
  Give it `results` and the bar opens a search view (`SearchView`) when clicked or typed in:
8
8
  full-screen on compact windows, docked from medium up, with a container transform between them.
9
9
 
10
+ `/` anywhere on the page (outside a text field) jumps to the bar, or opens its view; `shortcut`
11
+ changes the key, `null` turns it off.
12
+
10
13
  @see https://m3.material.io/components/search/specs
11
14
  -->
12
15
  <script lang="ts">
13
16
  import { search } from './theme.js';
14
17
  import clsx from 'clsx';
15
18
  import type { SearchProps } from './types.js';
16
- import { Icon } from '../../../utils/index.js';
19
+ import { Icon, ariaKeyShortcut, isApplePlatform, triggersShortcut } from '../../../utils/index.js';
17
20
  import ButtonIcon from '../../buttons/ButtonIcon.svelte';
18
21
  import SearchView from './SearchView.svelte';
19
22
 
@@ -30,6 +33,8 @@ full-screen on compact windows, docked from medium up, with a container transfor
30
33
  clearLabel = 'Clear search',
31
34
  open = $bindable(false),
32
35
  results,
36
+ shortcut = '/',
37
+ onsearch,
33
38
  layout,
34
39
  backLabel,
35
40
  resultsLabel,
@@ -44,7 +49,7 @@ full-screen on compact windows, docked from medium up, with a container transfor
44
49
 
45
50
  let bar = $state<HTMLElement>();
46
51
 
47
- // With a search view, clicking the bar, typing in it or pressing ↓ opens the view. Not focus:
52
+ // With a search view, clicking the bar or typing in it opens the view. Not focus:
48
53
  // closing the view hands focus back here, which must not reopen it.
49
54
  const opener = $derived(
50
55
  results
@@ -58,18 +63,40 @@ full-screen on compact windows, docked from medium up, with a container transfor
58
63
  oninput: (e: Event & { currentTarget: HTMLInputElement }) => {
59
64
  restProps.oninput?.(e);
60
65
  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
66
  }
69
67
  }
70
68
  : {}
71
69
  );
72
70
 
71
+ // ↓ opens the view; Enter searches for the query as typed.
72
+ function onkeydown(e: KeyboardEvent & { currentTarget: HTMLInputElement }) {
73
+ restProps.onkeydown?.(e);
74
+ if (e.defaultPrevented) return;
75
+ if (e.key === 'ArrowDown' && results) {
76
+ e.preventDefault();
77
+ open = true;
78
+ } else if (e.key === 'Enter' && onsearch && value?.trim()) {
79
+ e.preventDefault();
80
+ onsearch(value);
81
+ }
82
+ }
83
+
84
+ // `Mod` resolves on the client, so SSR and the first render agree.
85
+ let apple = $state(false);
86
+ $effect(() => {
87
+ apple = isApplePlatform();
88
+ });
89
+
90
+ function onShortcut(e: KeyboardEvent) {
91
+ if (!triggersShortcut(e, shortcut, bar)) return;
92
+ e.preventDefault();
93
+ if (results) open = true;
94
+ else {
95
+ elementRef?.focus();
96
+ elementRef?.select();
97
+ }
98
+ }
99
+
73
100
  const showClear = $derived(!!trailingIconProps && !!value);
74
101
 
75
102
  const s = $derived(
@@ -80,6 +107,8 @@ full-screen on compact windows, docked from medium up, with a container transfor
80
107
  );
81
108
  </script>
82
109
 
110
+ <svelte:window onkeydown={onShortcut} />
111
+
83
112
  <label for={id} class={s.base({ class: clsx(className) })} bind:this={bar}>
84
113
  {#if leading}
85
114
  <span class={s.leading()}>{@render leading()}</span>
@@ -89,7 +118,9 @@ full-screen on compact windows, docked from medium up, with a container transfor
89
118
  <input
90
119
  {...restProps}
91
120
  {...opener}
121
+ {onkeydown}
92
122
  {id}
123
+ aria-keyshortcuts={shortcut ? ariaKeyShortcut(shortcut, apple) : undefined}
93
124
  placeholder={placeholder ?? undefined}
94
125
  bind:this={elementRef}
95
126
  bind:value
@@ -124,5 +155,6 @@ full-screen on compact windows, docked from medium up, with a container transfor
124
155
  {resultsLabel}
125
156
  {clearLabel}
126
157
  {trailing}
158
+ {onsearch}
127
159
  />
128
160
  {/if}
@@ -7,6 +7,9 @@ import type { SearchProps } from './types.js';
7
7
  * Give it `results` and the bar opens a search view (`SearchView`) when clicked or typed in:
8
8
  * full-screen on compact windows, docked from medium up, with a container transform between them.
9
9
  *
10
+ * `/` anywhere on the page (outside a text field) jumps to the bar, or opens its view; `shortcut`
11
+ * changes the key, `null` turns it off.
12
+ *
10
13
  * @see https://m3.material.io/components/search/specs
11
14
  */
12
15
  declare const Search: import("svelte").Component<SearchProps, {}, "value" | "open" | "elementRef">;
@@ -9,10 +9,12 @@ open a view from a bar of your own, passing that bar as `anchor`.
9
9
  from 24dp to 12dp) with results in a container 2dp below. `layout` takes one per window tier and
10
10
  defaults to full-screen on compact windows, docked from medium up. Switched in CSS.
11
11
  - **Motion.** Opening and closing are an M3 container transform between the bar and the view, on
12
- the `spatial` spring; a crossfade under reduced motion.
12
+ the `spatial` spring; a crossfade under reduced motion. Following a result link closes it at
13
+ once, leaving the route change to the app's own transition.
13
14
  - **Behavior.** A bits-ui `Dialog`: focus stays in the view, the page doesn't scroll, and Esc, the
14
15
  back button or a click outside closes it. The field is a combobox over the `results` listbox:
15
- arrow keys move through the `role="option"` items and Enter picks one.
16
+ arrow keys move through the `role="option"` items and Enter picks one. Enter with no item
17
+ highlighted searches for the query as typed (`onsearch`).
16
18
 
17
19
  @see https://m3.material.io/components/search/specs
18
20
  -->
@@ -39,7 +41,8 @@ open a view from a bar of your own, passing that bar as `anchor`.
39
41
  clearLabel = 'Clear search',
40
42
  trailing,
41
43
  inputProps,
42
- inputRef = $bindable()
44
+ inputRef = $bindable(),
45
+ onsearch
43
46
  }: SearchViewProps = $props();
44
47
 
45
48
  const listboxId = `${uid}-listbox`;
@@ -56,11 +59,19 @@ open a view from a bar of your own, passing that bar as `anchor`.
56
59
  let shown = $state(untrack(() => open));
57
60
  let view = $state<HTMLElement>();
58
61
  let busy = false;
62
+ // Closing because a result link was followed: no transform, and focus doesn't go back to the bar.
63
+ let leaving = false;
64
+
65
+ const detached = (target: HTMLElement | string | undefined) =>
66
+ !target || (typeof target !== 'string' && !target.isConnected);
59
67
 
60
68
  const sync = () => {
61
69
  if (busy || open === shown) return;
62
70
  const opening = open;
63
- if (opening) measure();
71
+ if (opening) {
72
+ leaving = false;
73
+ measure();
74
+ }
64
75
  const update = async () => {
65
76
  // The view takes the bar's place: hidden, the bar isn't left behind in the page's snapshot
66
77
  // (a second, static bar under the morph). Shown again first on close, so focus can return.
@@ -75,8 +86,18 @@ open a view from a bar of your own, passing that bar as `anchor`.
75
86
  busy = false;
76
87
  sync();
77
88
  };
78
- if (!from || !to) update().then(settle, settle);
79
- else containerTransform(update, { from, to }).then(settle, settle);
89
+ // Without both ends on the page (the bar unmounted by a navigation) there is nothing to morph
90
+ // between; a transform that can't start must still close the view, or the Dialog keeps the
91
+ // page locked (#51).
92
+ if ((leaving && !opening) || detached(from) || detached(to)) {
93
+ update().then(settle, settle);
94
+ return;
95
+ }
96
+ try {
97
+ containerTransform(update, { from: from!, to: to! }).then(settle, settle);
98
+ } catch {
99
+ update().then(settle, settle);
100
+ }
80
101
  };
81
102
 
82
103
  $effect(() => {
@@ -167,6 +188,9 @@ open a view from a bar of your own, passing that bar as `anchor`.
167
188
  } else if (e.key === 'Enter' && active?.isConnected) {
168
189
  e.preventDefault();
169
190
  active.click();
191
+ } else if (e.key === 'Enter' && onsearch && value?.trim()) {
192
+ e.preventDefault();
193
+ onsearch(value);
170
194
  }
171
195
  }
172
196
 
@@ -180,10 +204,37 @@ open a view from a bar of your own, passing that bar as `anchor`.
180
204
  // Back to the bar's field. The bar opens on click or typing, never on focus, so this is safe.
181
205
  function focusBar(e: Event) {
182
206
  e.preventDefault();
207
+ // Following a result: the page is changing, and focusing a field would raise the keyboard.
208
+ if (leaving) {
209
+ leaving = false;
210
+ return;
211
+ }
183
212
  const field = anchor instanceof HTMLInputElement ? anchor : anchor?.querySelector('input');
184
213
  field?.focus();
185
214
  }
186
215
 
216
+ /*
217
+ Picking a result that is a link navigates away, so the view closes at once instead of morphing
218
+ back into a bar that's about to leave. A morph there would run alongside the route's own view
219
+ transition and, if the bar's page unmounts, never finish (#51). The click still reaches the
220
+ link, since the view unmounts only after this event. New-tab and download clicks keep it open.
221
+ */
222
+ $effect(() => {
223
+ const el = resultsEl;
224
+ if (!el) return;
225
+ const onclick = (e: MouseEvent) => {
226
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
227
+ return;
228
+ const link = (e.target as Element | null)?.closest?.('a[href]');
229
+ if (!(link instanceof HTMLAnchorElement) || !el.contains(link)) return;
230
+ if ((link.target && link.target !== '_self') || link.hasAttribute('download')) return;
231
+ leaving = true;
232
+ open = false;
233
+ };
234
+ el.addEventListener('click', onclick);
235
+ return () => el.removeEventListener('click', onclick);
236
+ });
237
+
187
238
  function clear() {
188
239
  value = '';
189
240
  inputRef?.focus();
@@ -9,10 +9,12 @@ import type { SearchViewProps } from './types.js';
9
9
  * from 24dp to 12dp) with results in a container 2dp below. `layout` takes one per window tier and
10
10
  * defaults to full-screen on compact windows, docked from medium up. Switched in CSS.
11
11
  * - **Motion.** Opening and closing are an M3 container transform between the bar and the view, on
12
- * the `spatial` spring; a crossfade under reduced motion.
12
+ * the `spatial` spring; a crossfade under reduced motion. Following a result link closes it at
13
+ * once, leaving the route change to the app's own transition.
13
14
  * - **Behavior.** A bits-ui `Dialog`: focus stays in the view, the page doesn't scroll, and Esc, the
14
15
  * back button or a click outside closes it. The field is a combobox over the `results` listbox:
15
- * arrow keys move through the `role="option"` items and Enter picks one.
16
+ * arrow keys move through the `role="option"` items and Enter picks one. Enter with no item
17
+ * highlighted searches for the query as typed (`onsearch`).
16
18
  *
17
19
  * @see https://m3.material.io/components/search/specs
18
20
  */
@@ -35,8 +35,10 @@ export const searchView = tv({
35
35
  input: 'md-sys-typescale-body-large text-md-sys-color-on-surface placeholder:text-md-sys-color-on-surface-variant ms-spacing-50 me-spacing-50 w-full min-w-spacing-0 bg-transparent outline-none [&::-webkit-search-cancel-button]:hidden',
36
36
  trailing: 'text-md-sys-color-on-surface-variant flex shrink-0 items-center',
37
37
  // Options sit on the view's container colour, not the list's own surface. The one the arrow
38
- // keys are on gets the M3 focus indicator, drawn inside the item.
39
- results: 'min-h-spacing-0 overflow-y-auto overscroll-contain [&_[role=option]:not([aria-disabled=true])]:bg-transparent [&_[data-highlighted]]:outline-3 [&_[data-highlighted]]:-outline-offset-3 [&_[data-highlighted]]:outline-md-sys-color-secondary'
38
+ // keys are on gets the M3 focus indicator, drawn inside the item, and a focused item's shape.
39
+ // `outline-solid` is load-bearing: ListItem's `outline-none` sets --tw-outline-style to none,
40
+ // which `outline-3` reads, so without it the ring has a width and colour but no style.
41
+ results: 'min-h-spacing-0 overflow-y-auto overscroll-contain [&_[role=option]:not([aria-disabled=true])]:bg-transparent [&_[data-highlighted]]:outline-solid [&_[data-highlighted]]:outline-3 [&_[data-highlighted]]:-outline-offset-3 [&_[data-highlighted]]:outline-md-sys-color-secondary [&_[data-highlighted]]:[--li-shape:1rem]'
40
42
  },
41
43
  variants: {
42
44
  hasTrailing: { true: '', false: { input: 'me-spacing-150' } }
@@ -1,4 +1,4 @@
1
- import type { IconProps } from '../../../utils/index.js';
1
+ import type { IconProps, Shortcut } from '../../../utils/index.js';
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLInputAttributes } from 'svelte/elements';
4
4
  import type { Responsive } from '../../containers/pane/theme.js';
@@ -42,6 +42,12 @@ export interface SearchViewOptions {
42
42
  backLabel?: string;
43
43
  /** Accessible name of the search view and its suggestion list. Defaults to the placeholder. */
44
44
  resultsLabel?: string;
45
+ /**
46
+ * Called with the query when it's submitted: Enter in the bar, or Enter in the search view
47
+ * with no suggestion highlighted. Not called for an empty query. The view stays open; set
48
+ * `open` to false to close it, e.g. when navigating to a results page.
49
+ */
50
+ onsearch?: (query: string) => void;
45
51
  }
46
52
  /**
47
53
  * Props for the SearchView component.
@@ -110,4 +116,11 @@ export interface SearchProps extends Omit<HTMLInputAttributes, 'size' | 'results
110
116
  * Accessible label for the clear button.
111
117
  */
112
118
  clearLabel?: string;
119
+ /**
120
+ * Page-wide key that focuses the bar, or opens its search view when it has `results`. A key
121
+ * without ⌘/Ctrl/Alt doesn't fire while typing in a field. With several bars on a page, the
122
+ * first one mounted takes it. `null` turns it off.
123
+ * @default '/'
124
+ */
125
+ shortcut?: Shortcut | null;
113
126
  }
@@ -23,7 +23,7 @@ export const slider = tv({
23
23
  iconOnInactive: 'text-md-sys-color-on-secondary-container',
24
24
  handle: `
25
25
  absolute rounded-full bg-md-sys-color-primary outline-none
26
- focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-md-sys-color-secondary
26
+ focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-md-sys-color-secondary
27
27
  group-data-[disabled]:bg-md-sys-color-on-surface/38
28
28
  `,
29
29
  value: `
@@ -36,6 +36,7 @@
36
36
  const products = ['Headphones', 'Keyboard', 'Laptop stand', 'Monitor', 'Mouse', 'Webcam'];
37
37
  let query = $state('');
38
38
  let searchOpen = $state(false);
39
+ let searched = $state('');
39
40
  const found = $derived(products.filter((p) => p.toLowerCase().includes(query.toLowerCase())));
40
41
  </script>
41
42
 
@@ -132,13 +133,26 @@
132
133
  </AppBar>
133
134
  </Story>
134
135
 
135
- <!-- Selecting the search field opens the search view: full-screen here, docked from medium up. -->
136
+ <!--
137
+ Selecting the search field (or / anywhere) opens the search view: full-screen here, docked from
138
+ medium up. Enter with no suggestion highlighted searches for what was typed (onsearch).
139
+ -->
136
140
  <Story
137
141
  name="Search view"
138
142
  asChild
139
143
  parameters={{ docs: { story: { inline: false, height: '560px' } } }}
140
144
  >
141
- <AppBar search="Search products" title="Products" bind:query bind:searchOpen ghost>
145
+ <AppBar
146
+ search="Search products"
147
+ title="Products"
148
+ bind:query
149
+ bind:searchOpen
150
+ ghost
151
+ onsearch={(q) => {
152
+ searched = q;
153
+ searchOpen = false;
154
+ }}
155
+ >
142
156
  {#snippet leading()}
143
157
  <ButtonIcon variant="standard" iconProps={{ name: 'menu' }} aria-label="Menu" />
144
158
  {/snippet}
@@ -146,6 +160,14 @@
146
160
  <ButtonIcon variant="standard" iconProps={{ name: 'mic' }} aria-label="Voice search" />
147
161
  {/snippet}
148
162
  {#snippet searchResults(listbox)}
163
+ {#if !found.length}
164
+ <p
165
+ class="md-sys-typescale-body-medium text-md-sys-color-on-surface-variant p-spacing-200"
166
+ role="status"
167
+ >
168
+ No products match “{query}”. Press Enter to search anyway.
169
+ </p>
170
+ {/if}
149
171
  <List {...listbox}>
150
172
  {#each found as product (product)}
151
173
  <ListItem
@@ -154,6 +176,7 @@
154
176
  headline={product}
155
177
  onclick={() => {
156
178
  query = product;
179
+ searched = product;
157
180
  searchOpen = false;
158
181
  }}
159
182
  />
@@ -161,6 +184,11 @@
161
184
  </List>
162
185
  {/snippet}
163
186
  </AppBar>
187
+ <p
188
+ class="md-sys-typescale-body-medium text-md-sys-color-on-surface-variant pt-spacing-900 ps-spacing-200"
189
+ >
190
+ {searched ? `Searched for “${searched}”` : 'Nothing searched yet'}
191
+ </p>
164
192
  </Story>
165
193
 
166
194
  <Story name="Scroll container" asChild>
@@ -30,6 +30,7 @@ A `ButtonIcon` anywhere inside it defaults to `variant="standard"`, per M3; pass
30
30
  import ButtonIcon from '../../buttons/ButtonIcon.svelte';
31
31
  import { setButtonIconVariant } from '../../buttons/context.js';
32
32
  import SearchView from '../../forms/search/SearchView.svelte';
33
+ import { ariaKeyShortcut, isApplePlatform, triggersShortcut } from '../../../utils/index.js';
33
34
 
34
35
  let {
35
36
  children,
@@ -52,6 +53,8 @@ A `ButtonIcon` anywhere inside it defaults to `variant="standard"`, per M3; pass
52
53
  searchResults,
53
54
  searchOpen = $bindable(false),
54
55
  searchLayout,
56
+ searchShortcut = '/',
57
+ onsearch,
55
58
  scrollContainer,
56
59
  ghost = false,
57
60
  ...rest
@@ -77,7 +80,25 @@ A `ButtonIcon` anywhere inside it defaults to `variant="standard"`, per M3; pass
77
80
 
78
81
  let searchBar = $state<HTMLElement>();
79
82
 
80
- // Same as Search: click, typing or ↓ opens the view; focus alone doesn't.
83
+ // Same as Search: `/` jumps to the field, or opens the view.
84
+ let apple = $state(false);
85
+ $effect(() => {
86
+ apple = isApplePlatform();
87
+ });
88
+ const shortcut = $derived(isSearch ? searchShortcut : null);
89
+
90
+ function onShortcut(e: KeyboardEvent) {
91
+ if (!triggersShortcut(e, shortcut, searchBar)) return;
92
+ e.preventDefault();
93
+ if (searchResults) searchOpen = true;
94
+ else {
95
+ const field = searchBar?.querySelector('input');
96
+ field?.focus();
97
+ field?.select();
98
+ }
99
+ }
100
+
101
+ // Same as Search: click or typing opens the view; focus alone doesn't.
81
102
  const opener = $derived(
82
103
  searchResults
83
104
  ? {
@@ -90,17 +111,23 @@ A `ButtonIcon` anywhere inside it defaults to `variant="standard"`, per M3; pass
90
111
  oninput: (e: Event & { currentTarget: HTMLInputElement }) => {
91
112
  searchProps?.oninput?.(e);
92
113
  if (!e.defaultPrevented) searchOpen = true;
93
- },
94
- onkeydown: (e: KeyboardEvent & { currentTarget: HTMLInputElement }) => {
95
- searchProps?.onkeydown?.(e);
96
- if (!e.defaultPrevented && e.key === 'ArrowDown') {
97
- e.preventDefault();
98
- searchOpen = true;
99
- }
100
114
  }
101
115
  }
102
116
  : {}
103
117
  );
118
+
119
+ // ↓ opens the view; Enter searches for the query as typed.
120
+ function onSearchKeydown(e: KeyboardEvent & { currentTarget: HTMLInputElement }) {
121
+ searchProps?.onkeydown?.(e);
122
+ if (e.defaultPrevented) return;
123
+ if (e.key === 'ArrowDown' && searchResults) {
124
+ e.preventDefault();
125
+ searchOpen = true;
126
+ } else if (e.key === 'Enter' && onsearch && query?.trim()) {
127
+ e.preventDefault();
128
+ onsearch(query);
129
+ }
130
+ }
104
131
  const noLeading = $derived(!leading && !showBack);
105
132
 
106
133
  const s = $derived(
@@ -131,6 +158,8 @@ A `ButtonIcon` anywhere inside it defaults to `variant="standard"`, per M3; pass
131
158
  });
132
159
  </script>
133
160
 
161
+ <svelte:window onkeydown={onShortcut} />
162
+
134
163
  <nav {...rest} class={s.base({ class: clsx(className) })} {@attach trackHeight}>
135
164
  <div class={s.row({ class: clsx(sized.row, rowClass) })}>
136
165
  <div class={s.leading()}>
@@ -155,7 +184,9 @@ A `ButtonIcon` anywhere inside it defaults to `variant="standard"`, per M3; pass
155
184
  type="search"
156
185
  {...searchProps}
157
186
  {...opener}
187
+ onkeydown={onSearchKeydown}
158
188
  placeholder={search}
189
+ aria-keyshortcuts={shortcut ? ariaKeyShortcut(shortcut, apple) : undefined}
159
190
  aria-label={searchProps?.['aria-label'] ?? search}
160
191
  bind:value={query}
161
192
  class={s.searchInput({ class: clsx(searchProps?.class) })}
@@ -198,6 +229,7 @@ A `ButtonIcon` anywhere inside it defaults to `variant="standard"`, per M3; pass
198
229
  layout={searchLayout}
199
230
  placeholder={search}
200
231
  trailing={searchTrailing}
232
+ {onsearch}
201
233
  />
202
234
  {/if}
203
235
 
@@ -122,7 +122,7 @@ export function appbarSize(size, hasSubtitle) {
122
122
  export const appbar = tv({
123
123
  slots: {
124
124
  // Starts beside a viewport-anchored Rail (styles/components.css), which runs full height.
125
- base: 'fixed top-spacing-0 left-(--md-rail-inset) right-spacing-0 flex flex-col z-layer-bar transition-colors md-sys-motion-effects',
125
+ base: 'md-vt-persist fixed top-spacing-0 left-(--md-rail-inset) right-spacing-0 flex flex-col z-layer-bar transition-colors md-sys-motion-effects',
126
126
  row: 'grid w-full items-center px-spacing-50',
127
127
  childrenRow: 'w-full px-spacing-100 pb-spacing-100',
128
128
  ghost: 'w-full shrink-0 pointer-events-none',
@@ -1,3 +1,4 @@
1
+ import type { Shortcut } from '../../../utils/index.js';
1
2
  import type { Snippet } from 'svelte';
2
3
  import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements';
3
4
  import type { Responsive } from '../../containers/pane/theme.js';
@@ -47,6 +48,8 @@ export type TitleAppBarProps = AppBarBaseProps & {
47
48
  searchResults?: never;
48
49
  searchOpen?: never;
49
50
  searchLayout?: never;
51
+ searchShortcut?: never;
52
+ onsearch?: never;
50
53
  };
51
54
  /** A search app bar: a search field in place of the title, always 64dp. */
52
55
  export type SearchAppBarProps = AppBarBaseProps & {
@@ -73,6 +76,17 @@ export type SearchAppBarProps = AppBarBaseProps & {
73
76
  * @default { small: 'fullScreen', medium: 'docked' }
74
77
  */
75
78
  searchLayout?: Responsive<SearchLayout>;
79
+ /**
80
+ * Page-wide key that focuses the field, or opens the search view with `searchResults`. See
81
+ * `Search`'s `shortcut`. `null` turns it off.
82
+ * @default '/'
83
+ */
84
+ searchShortcut?: Shortcut | null;
85
+ /**
86
+ * Called with the query when it's submitted: Enter in the field, or Enter in the search view
87
+ * with no suggestion highlighted. See `Search`'s `onsearch`.
88
+ */
89
+ onsearch?: (query: string) => void;
76
90
  size?: never;
77
91
  };
78
92
  /**
@@ -1,7 +1,7 @@
1
1
  import { tv } from '../../../utils/tv.js';
2
2
  export const navbar = tv({
3
3
  slots: {
4
- base: 'shadow-elevation-2 bg-md-sys-color-surface-container z-layer-bar flex h-20 fixed bottom-spacing-0 w-full left-spacing-0 justify-around py-spacing-150 md:hidden',
4
+ base: 'md-vt-persist shadow-elevation-2 bg-md-sys-color-surface-container z-layer-bar flex h-20 fixed bottom-spacing-0 w-full left-spacing-0 justify-around py-spacing-150 md:hidden',
5
5
  items: 'flex justify-around w-full',
6
6
  fab: 'bottom-24 right-spacing-200 absolute',
7
7
  ghost: 'h-20 w-full shrink-0 md:hidden pointer-events-none'
@@ -113,3 +113,9 @@ uses `ps-(--md-rail-inset)` / `left-(--md-rail-inset)`.
113
113
  - **Keyboard.** Every item is a link reachable with Tab; the focus ring goes around the
114
114
  indicator. The menu toggle is a real button with `aria-expanded`, and Escape closes the modal
115
115
  rail.
116
+ - **Destination shortcuts.** ⌘1–⌘9 (Ctrl+1–9 off Apple platforms) go to the first nine
117
+ destinations, exactly as clicking them would; each link carries `aria-keyshortcuts`. A disabled
118
+ destination keeps its number and does nothing. `shortcutModifier` changes the modifier
119
+ (`'Alt'`, `'Alt+Shift'`…), `null` turns them off. The Windows/Super key isn't offered: the OS
120
+ owns Win+1–9. In a normal browser tab, ⌘/Ctrl+1–9 may also be the browser's own tab switch, which a page can't
121
+ always override; they're dependable in an installed PWA or a desktop shell (Tauri, Electron).
@@ -8,6 +8,9 @@ the content aside (standard); on medium windows it opens over it, above a scrim
8
8
  closes on a scrim click, Escape, or picking a destination. Below `md` it's hidden: mount a
9
9
  `Navbar` for small windows.
10
10
 
11
+ ⌘1–⌘9 (Ctrl+1–9 off Apple platforms) go to the first nine destinations; `shortcutModifier`
12
+ changes the modifier, `null` turns them off.
13
+
11
14
  @see https://m3.material.io/components/navigation-rail/specs
12
15
  -->
13
16
  <script lang="ts">
@@ -20,6 +23,12 @@ closes on a scrim click, Escape, or picking a destination. Below `md` it's hidde
20
23
  import { rail } from './theme';
21
24
  import { NavigationMenu } from 'bits-ui';
22
25
  import { railStore } from './railStore.svelte.js';
26
+ import {
27
+ ariaKeyShortcut,
28
+ isApplePlatform,
29
+ matchesShortcut,
30
+ triggersShortcut
31
+ } from '../../../utils/index.js';
23
32
 
24
33
  let {
25
34
  children,
@@ -31,6 +40,7 @@ closes on a scrim click, Escape, or picking a destination. Below `md` it's hidde
31
40
  railTop = 0,
32
41
  expandLabel = 'Expand navigation',
33
42
  collapseLabel = 'Collapse navigation',
43
+ shortcutModifier = 'Mod',
34
44
  class: className,
35
45
  showHelp: _showHelp,
36
46
  withNavbar: _withNavbar,
@@ -155,9 +165,48 @@ closes on a scrim click, Escape, or picking a destination. Below `md` it's hidde
155
165
  function onKeydown(event: KeyboardEvent) {
156
166
  if (event.key === 'Escape' && expanded && isModal()) {
157
167
  collapsed = true;
168
+ return;
158
169
  }
170
+ goToDestination(event);
171
+ }
172
+
173
+ /*
174
+ Destination shortcuts: modifier+N clicks the Nth link, so it navigates exactly as a click
175
+ does (SvelteKit's router, a consumer's onclick, closing the modal rail). A disabled
176
+ destination keeps its number and does nothing.
177
+ */
178
+ let navEl = $state<HTMLElement | null>(null);
179
+ const destinations = () => [...(navEl?.querySelectorAll<HTMLAnchorElement>('a') ?? [])];
180
+
181
+ function goToDestination(event: KeyboardEvent) {
182
+ if (!shortcutModifier || !/^Digit[1-9]$/.test(event.code)) return;
183
+ const n = Number(event.code.slice(-1));
184
+ const shortcut = `${shortcutModifier}+${n}`;
185
+ if (!matchesShortcut(event, shortcut) || !triggersShortcut(event, shortcut, railEl)) return;
186
+ const link = destinations()[n - 1];
187
+ if (!link) return;
188
+ event.preventDefault();
189
+ if (link.getAttribute('aria-disabled') !== 'true') link.click();
159
190
  }
160
191
 
192
+ // Advertise each destination's shortcut; kept in step as destinations come and go.
193
+ $effect(() => {
194
+ const nav = navEl;
195
+ const modifier = shortcutModifier;
196
+ if (!nav) return;
197
+ const apple = isApplePlatform();
198
+ const label = () =>
199
+ destinations().forEach((a, i) => {
200
+ if (modifier && i < 9)
201
+ a.setAttribute('aria-keyshortcuts', ariaKeyShortcut(`${modifier}+${i + 1}`, apple));
202
+ else a.removeAttribute('aria-keyshortcuts');
203
+ });
204
+ label();
205
+ const observer = new MutationObserver(label);
206
+ observer.observe(nav, { childList: true, subtree: true });
207
+ return () => observer.disconnect();
208
+ });
209
+
161
210
  // Picking a destination closes the modal rail; the standard one stays as it is.
162
211
  function onNavClick(event: MouseEvent) {
163
212
  if (expanded && isModal() && (event.target as Element).closest('a[href]')) {
@@ -212,7 +261,12 @@ closes on a scrim click, Escape, or picking a destination. Below `md` it's hidde
212
261
  {/if}
213
262
 
214
263
  <!-- The click is delegated from the links inside; Enter on a link fires it too. -->
215
- <NavigationMenu.Root orientation="vertical" class={styles.nav()} onclick={onNavClick}>
264
+ <NavigationMenu.Root
265
+ bind:ref={navEl}
266
+ orientation="vertical"
267
+ class={styles.nav()}
268
+ onclick={onNavClick}
269
+ >
216
270
  <NavigationMenu.List class={styles.items()}>
217
271
  {@render children?.()}
218
272
  </NavigationMenu.List>
@@ -8,6 +8,9 @@ import type { RailProps } from './types';
8
8
  * closes on a scrim click, Escape, or picking a destination. Below `md` it's hidden: mount a
9
9
  * `Navbar` for small windows.
10
10
  *
11
+ * ⌘1–⌘9 (Ctrl+1–9 off Apple platforms) go to the first nine destinations; `shortcutModifier`
12
+ * changes the modifier, `null` turns them off.
13
+ *
11
14
  * @see https://m3.material.io/components/navigation-rail/specs
12
15
  */
13
16
  declare const Rail: import("svelte").Component<RailProps, {}, "collapsed">;
@@ -34,7 +34,7 @@ export const rail = tv({
34
34
  variants: {
35
35
  anchor: {
36
36
  viewport: {
37
- base: 'fixed top-[var(--rail-top,0px)] bottom-spacing-0 left-spacing-0',
37
+ base: 'md-vt-persist fixed top-[var(--rail-top,0px)] bottom-spacing-0 left-spacing-0',
38
38
  scrim: 'fixed'
39
39
  },
40
40
  parent: {
@@ -45,6 +45,13 @@ export type RailProps = RailVariants & HTMLAttributes<HTMLDivElement> & {
45
45
  expandLabel?: string;
46
46
  /** Tooltip and accessible name of the toggle while expanded. Default 'Collapse navigation'. */
47
47
  collapseLabel?: string;
48
+ /**
49
+ * Modifier for the destination shortcuts: it plus 1–9 goes to the Nth destination. `Mod` is
50
+ * ⌘ on Apple platforms and Ctrl elsewhere; combine with `+`, e.g. `'Alt+Shift'`. The Windows
51
+ * and Super keys belong to the OS there, so they aren't used. `null` turns them off.
52
+ * @default 'Mod'
53
+ */
54
+ shortcutModifier?: string | null;
48
55
  /**
49
56
  * @deprecated No effect: Rail no longer renders a navigation bar below `md`. Mount a `Navbar`
50
57
  * next to it for small windows.