@noxlovette/material 0.8.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude-skill/material-design/SKILL.md +1 -1
- package/claude-skill/material-design/references/component-patterns.md +36 -1
- package/dist/components/containers/list/theme.js +1 -1
- 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 +7 -2
- package/dist/components/forms/search/SearchView.svelte.d.ts +2 -1
- 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/types.d.ts +14 -0
- 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/types.d.ts +7 -0
- 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
|
@@ -12,7 +12,8 @@ import type { SearchViewProps } from './types.js';
|
|
|
12
12
|
* the `spatial` spring; a crossfade under reduced motion.
|
|
13
13
|
* - **Behavior.** A bits-ui `Dialog`: focus stays in the view, the page doesn't scroll, and Esc, the
|
|
14
14
|
* 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.
|
|
15
|
+
* arrow keys move through the `role="option"` items and Enter picks one. Enter with no item
|
|
16
|
+
* highlighted searches for the query as typed (`onsearch`).
|
|
16
17
|
*
|
|
17
18
|
* @see https://m3.material.io/components/search/specs
|
|
18
19
|
*/
|
|
@@ -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
|
|
|
@@ -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
|
/**
|
|
@@ -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">;
|
|
@@ -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.
|
package/dist/utils/index.d.ts
CHANGED
package/dist/utils/index.js
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A keyboard shortcut, written as `aria-keyshortcuts` writes one: modifiers, then the key, joined
|
|
3
|
+
* by `+`. `'/'`, `'Mod+K'`, `'Alt+Shift+P'`. `Mod` is ⌘ on Apple platforms and Ctrl elsewhere,
|
|
4
|
+
* the key each platform's apps use for their own shortcuts.
|
|
5
|
+
*/
|
|
6
|
+
export type Shortcut = string;
|
|
7
|
+
/** Whether this is an Apple platform, where `Mod` is ⌘. False during SSR. */
|
|
8
|
+
export declare function isApplePlatform(): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Whether a keydown is this shortcut. Modifiers must match exactly. Digits match by physical key,
|
|
11
|
+
* since Alt+1 types `¡` on a Mac; letters match by the character typed, falling back to the
|
|
12
|
+
* physical key on non-Latin layouts, so ⌘K still works with a Cyrillic layout on.
|
|
13
|
+
*/
|
|
14
|
+
export declare function matchesShortcut(e: KeyboardEvent, shortcut: Shortcut, apple?: boolean): boolean;
|
|
15
|
+
/** Whether a keydown's target takes text, where a bare `/` is typing, not a shortcut. */
|
|
16
|
+
export declare function isEditableTarget(target: EventTarget | null): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Whether a page-wide shortcut should fire for this keydown: it matches, nothing handled the key
|
|
19
|
+
* first, no IME is composing, a shortcut without ⌘/Ctrl/Alt isn't being typed into a field, and
|
|
20
|
+
* no modal dialog other than the one holding `owner` is open.
|
|
21
|
+
*/
|
|
22
|
+
export declare function triggersShortcut(e: KeyboardEvent, shortcut: Shortcut | null | undefined, owner?: Element | null): boolean;
|
|
23
|
+
/** The shortcut for `aria-keyshortcuts`, with `Mod` resolved for this platform. */
|
|
24
|
+
export declare function ariaKeyShortcut(shortcut: Shortcut, apple?: boolean): string;
|
|
25
|
+
/** The shortcut as people read it on this platform: `⌘K` on a Mac, `Ctrl+K` elsewhere. */
|
|
26
|
+
export declare function shortcutLabel(shortcut: Shortcut, apple?: boolean): string;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** Whether this is an Apple platform, where `Mod` is ⌘. False during SSR. */
|
|
2
|
+
export function isApplePlatform() {
|
|
3
|
+
if (typeof navigator === 'undefined')
|
|
4
|
+
return false;
|
|
5
|
+
const platform = navigator.userAgentData?.platform ??
|
|
6
|
+
navigator.platform;
|
|
7
|
+
return /mac|iphone|ipad|ipod/i.test(platform ?? '');
|
|
8
|
+
}
|
|
9
|
+
function parse(shortcut, apple) {
|
|
10
|
+
const parts = shortcut.split('+');
|
|
11
|
+
const key = parts.pop() ?? '';
|
|
12
|
+
const modifiers = new Set(parts.map((m) => (m === 'Mod' ? (apple ? 'Meta' : 'Control') : m)));
|
|
13
|
+
return { key, modifiers };
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Whether a keydown is this shortcut. Modifiers must match exactly. Digits match by physical key,
|
|
17
|
+
* since Alt+1 types `¡` on a Mac; letters match by the character typed, falling back to the
|
|
18
|
+
* physical key on non-Latin layouts, so ⌘K still works with a Cyrillic layout on.
|
|
19
|
+
*/
|
|
20
|
+
export function matchesShortcut(e, shortcut, apple = isApplePlatform()) {
|
|
21
|
+
const { key, modifiers } = parse(shortcut, apple);
|
|
22
|
+
if (e.metaKey !== modifiers.has('Meta') ||
|
|
23
|
+
e.ctrlKey !== modifiers.has('Control') ||
|
|
24
|
+
e.altKey !== modifiers.has('Alt'))
|
|
25
|
+
return false;
|
|
26
|
+
const alphanumeric = /^[a-z0-9]$/i.test(key);
|
|
27
|
+
// Shift is part of typing a symbol like `?`, so it's only checked for letters and digits.
|
|
28
|
+
if (modifiers.has('Shift') ? !e.shiftKey : alphanumeric && e.shiftKey)
|
|
29
|
+
return false;
|
|
30
|
+
if (/^[0-9]$/.test(key))
|
|
31
|
+
return e.code === `Digit${key}` || e.code === `Numpad${key}`;
|
|
32
|
+
if (/^[a-z]$/i.test(key)) {
|
|
33
|
+
return /^[a-z]$/i.test(e.key)
|
|
34
|
+
? e.key.toLowerCase() === key.toLowerCase()
|
|
35
|
+
: e.code === `Key${key.toUpperCase()}`;
|
|
36
|
+
}
|
|
37
|
+
return e.key === key;
|
|
38
|
+
}
|
|
39
|
+
/** Whether a keydown's target takes text, where a bare `/` is typing, not a shortcut. */
|
|
40
|
+
export function isEditableTarget(target) {
|
|
41
|
+
if (!(target instanceof HTMLElement))
|
|
42
|
+
return false;
|
|
43
|
+
if (target.isContentEditable || target instanceof HTMLTextAreaElement)
|
|
44
|
+
return true;
|
|
45
|
+
if (target instanceof HTMLSelectElement)
|
|
46
|
+
return true;
|
|
47
|
+
return (target instanceof HTMLInputElement &&
|
|
48
|
+
!/^(button|checkbox|color|file|hidden|image|radio|range|reset|submit)$/.test(target.type));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Whether a page-wide shortcut should fire for this keydown: it matches, nothing handled the key
|
|
52
|
+
* first, no IME is composing, a shortcut without ⌘/Ctrl/Alt isn't being typed into a field, and
|
|
53
|
+
* no modal dialog other than the one holding `owner` is open.
|
|
54
|
+
*/
|
|
55
|
+
export function triggersShortcut(e, shortcut, owner) {
|
|
56
|
+
if (!shortcut || e.defaultPrevented || e.isComposing || e.repeat)
|
|
57
|
+
return false;
|
|
58
|
+
if (!matchesShortcut(e, shortcut))
|
|
59
|
+
return false;
|
|
60
|
+
if (!/(^|\+)(Mod|Meta|Control|Alt)\+/.test(shortcut) && isEditableTarget(e.target))
|
|
61
|
+
return false;
|
|
62
|
+
const modal = document.querySelector('[aria-modal="true"]');
|
|
63
|
+
return !modal || (!!owner && modal.contains(owner));
|
|
64
|
+
}
|
|
65
|
+
/** The shortcut for `aria-keyshortcuts`, with `Mod` resolved for this platform. */
|
|
66
|
+
export function ariaKeyShortcut(shortcut, apple = isApplePlatform()) {
|
|
67
|
+
const { key, modifiers } = parse(shortcut, apple);
|
|
68
|
+
return [...modifiers, key.length === 1 ? key.toUpperCase() : key].join('+');
|
|
69
|
+
}
|
|
70
|
+
const SYMBOLS = { Meta: '⌘', Control: '⌃', Alt: '⌥', Shift: '⇧' };
|
|
71
|
+
const NAMES = {
|
|
72
|
+
Meta: 'Win',
|
|
73
|
+
Control: 'Ctrl',
|
|
74
|
+
Alt: 'Alt',
|
|
75
|
+
Shift: 'Shift'
|
|
76
|
+
};
|
|
77
|
+
/** The shortcut as people read it on this platform: `⌘K` on a Mac, `Ctrl+K` elsewhere. */
|
|
78
|
+
export function shortcutLabel(shortcut, apple = isApplePlatform()) {
|
|
79
|
+
const { key, modifiers } = parse(shortcut, apple);
|
|
80
|
+
const k = key.length === 1 ? key.toUpperCase() : key;
|
|
81
|
+
return apple
|
|
82
|
+
? [...modifiers].map((m) => SYMBOLS[m]).join('') + k
|
|
83
|
+
: [...[...modifiers].map((m) => NAMES[m]), k].join('+');
|
|
84
|
+
}
|