@noxlovette/material 0.8.2 → 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.
Files changed (40) 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/containerTransform.js +21 -3
  4. package/dist/components/containers/list/theme.js +1 -1
  5. package/dist/components/containers/pane/theme.js +1 -1
  6. package/dist/components/date/theme.js +1 -1
  7. package/dist/components/forms/command/Command.mdx +90 -0
  8. package/dist/components/forms/command/Command.stories.svelte +69 -10
  9. package/dist/components/forms/command/Command.stories.svelte.d.ts +2 -17
  10. package/dist/components/forms/command/CommandDialog.svelte +58 -0
  11. package/dist/components/forms/command/CommandDialog.svelte.d.ts +11 -0
  12. package/dist/components/forms/command/CommandItem.svelte +48 -5
  13. package/dist/components/forms/command/CommandItem.svelte.d.ts +4 -0
  14. package/dist/components/forms/command/index.d.ts +1 -0
  15. package/dist/components/forms/command/index.js +1 -0
  16. package/dist/components/forms/command/theme.d.ts +371 -6
  17. package/dist/components/forms/command/theme.js +32 -1
  18. package/dist/components/forms/command/types.d.ts +27 -1
  19. package/dist/components/forms/search/Search.mdx +9 -0
  20. package/dist/components/forms/search/Search.stories.svelte +14 -2
  21. package/dist/components/forms/search/Search.svelte +41 -9
  22. package/dist/components/forms/search/Search.svelte.d.ts +3 -0
  23. package/dist/components/forms/search/SearchView.svelte +7 -2
  24. package/dist/components/forms/search/SearchView.svelte.d.ts +2 -1
  25. package/dist/components/forms/search/theme.js +4 -2
  26. package/dist/components/forms/search/types.d.ts +14 -1
  27. package/dist/components/forms/slider/theme.js +1 -1
  28. package/dist/components/nav/appbar/AppBar.stories.svelte +30 -2
  29. package/dist/components/nav/appbar/AppBar.svelte +40 -8
  30. package/dist/components/nav/appbar/types.d.ts +14 -0
  31. package/dist/components/nav/rail/Rail.mdx +6 -0
  32. package/dist/components/nav/rail/Rail.svelte +55 -1
  33. package/dist/components/nav/rail/Rail.svelte.d.ts +3 -0
  34. package/dist/components/nav/rail/types.d.ts +7 -0
  35. package/dist/styles/motion.css +6 -1
  36. package/dist/utils/index.d.ts +1 -0
  37. package/dist/utils/index.js +1 -0
  38. package/dist/utils/shortcut.d.ts +26 -0
  39. package/dist/utils/shortcut.js +84 -0
  40. 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
- 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
 
@@ -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 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">;
@@ -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.
@@ -362,7 +362,12 @@
362
362
  animation-name: none !important;
363
363
  }
364
364
 
365
- /* Filled with the destination's own background while it morphs (set by containerTransform). */
365
+ /*
366
+ Filled with the destination's own background while it morphs, and elevated with its shadow
367
+ (or the source's), which Motion's crop clip would otherwise cut out of the snapshots. Both set
368
+ by containerTransform.
369
+ */
366
370
  ::view-transition-group(.md-container-transform) {
367
371
  background-color: var(--md-container-transform-color, transparent);
372
+ box-shadow: var(--md-container-transform-shadow, none);
368
373
  }
@@ -1,4 +1,5 @@
1
1
  export * from './icon/index.js';
2
+ export * from './shortcut.js';
2
3
  export * from './theme.svelte.js';
3
4
  export * from './types.js';
4
5
  export * from './ui.js';
@@ -1,4 +1,5 @@
1
1
  export * from './icon/index.js';
2
+ export * from './shortcut.js';
2
3
  export * from './theme.svelte.js';
3
4
  export * from './types.js';
4
5
  export * from './ui.js';
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noxlovette/material",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "A Material Design 3 component library for Svelte, built on Bits UI.",
6
6
  "keywords": [