@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.
- package/claude-skill/material-design/SKILL.md +1 -1
- package/claude-skill/material-design/references/component-patterns.md +36 -1
- package/dist/animation/presence.svelte.js +37 -1
- package/dist/animation/sharedAxisTransition.d.ts +2 -1
- package/dist/animation/sharedAxisTransition.js +19 -1
- package/dist/components/buttons/FAB.svelte +4 -1
- package/dist/components/buttons/theme.d.ts +6 -3
- package/dist/components/buttons/theme.js +3 -2
- package/dist/components/containers/context-menu/theme.js +4 -4
- package/dist/components/containers/list/theme.js +1 -1
- package/dist/components/containers/menu/Menu.svelte +2 -5
- package/dist/components/containers/menu/theme.js +4 -2
- package/dist/components/containers/pane/theme.js +1 -1
- package/dist/components/date/theme.js +1 -1
- package/dist/components/forms/command/Command.mdx +90 -0
- package/dist/components/forms/command/Command.stories.svelte +69 -10
- package/dist/components/forms/command/Command.stories.svelte.d.ts +2 -17
- package/dist/components/forms/command/CommandDialog.svelte +58 -0
- package/dist/components/forms/command/CommandDialog.svelte.d.ts +11 -0
- package/dist/components/forms/command/CommandItem.svelte +48 -5
- package/dist/components/forms/command/CommandItem.svelte.d.ts +4 -0
- package/dist/components/forms/command/index.d.ts +1 -0
- package/dist/components/forms/command/index.js +1 -0
- package/dist/components/forms/command/theme.d.ts +371 -6
- package/dist/components/forms/command/theme.js +32 -1
- package/dist/components/forms/command/types.d.ts +27 -1
- package/dist/components/forms/search/Search.mdx +9 -0
- package/dist/components/forms/search/Search.stories.svelte +14 -2
- package/dist/components/forms/search/Search.svelte +41 -9
- package/dist/components/forms/search/Search.svelte.d.ts +3 -0
- package/dist/components/forms/search/SearchView.svelte +57 -6
- package/dist/components/forms/search/SearchView.svelte.d.ts +4 -2
- package/dist/components/forms/search/theme.js +4 -2
- package/dist/components/forms/search/types.d.ts +14 -1
- package/dist/components/forms/slider/theme.js +1 -1
- package/dist/components/nav/appbar/AppBar.stories.svelte +30 -2
- package/dist/components/nav/appbar/AppBar.svelte +40 -8
- package/dist/components/nav/appbar/theme.js +1 -1
- package/dist/components/nav/appbar/types.d.ts +14 -0
- package/dist/components/nav/navbar/theme.js +1 -1
- package/dist/components/nav/rail/Rail.mdx +6 -0
- package/dist/components/nav/rail/Rail.svelte +55 -1
- package/dist/components/nav/rail/Rail.svelte.d.ts +3 -0
- package/dist/components/nav/rail/theme.js +1 -1
- package/dist/components/nav/rail/types.d.ts +7 -0
- package/dist/components/toolbar/theme.js +4 -1
- package/dist/styles/motion.css +26 -0
- package/dist/utils/Layer.svelte +3 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/shortcut.d.ts +26 -0
- package/dist/utils/shortcut.js +84 -0
- 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
|
|
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)
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
<!--
|
|
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
|
|
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:
|
|
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
|
|
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.
|