@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
|
@@ -66,6 +66,6 @@ This repo is a single, already-opinionated M3 component library, not a blank can
|
|
|
66
66
|
## References
|
|
67
67
|
|
|
68
68
|
- [`references/tokens-and-styles.md`](references/tokens-and-styles.md) — full token/utility inventory
|
|
69
|
-
- [`references/component-patterns.md`](references/component-patterns.md) — `tv()` slots/variants/compoundVariants pattern + variant decision tree
|
|
69
|
+
- [`references/component-patterns.md`](references/component-patterns.md) — `tv()` slots/variants/compoundVariants pattern + variant decision tree; search vs command palette; page-wide keyboard shortcuts
|
|
70
70
|
- [`references/motion-guide.md`](references/motion-guide.md) — applying transitions (qualities of a good transition, choosing a pattern), which animation primitive and spring to reach for, reduced-motion target behavior
|
|
71
71
|
- [`references/accessibility-checklist.md`](references/accessibility-checklist.md) — pre-delivery checklist
|
|
@@ -26,7 +26,7 @@ Only fall back to `bare` (no color/background at all) when the component supplie
|
|
|
26
26
|
|
|
27
27
|
## Reuse before you build
|
|
28
28
|
|
|
29
|
-
- **State layer / ripple** — wrap the interactive element with `Layer.svelte` (`src/lib/utils/Layer.svelte`) rather than writing hover/press opacity by hand. It listens for `.m3-layer` on its parent, already respects `prefers-reduced-motion` for the ripple, and its tint (hover 0.08, focus and pressed 0.10) matches the M3 state-layer tokens — don't retune those numbers per component.
|
|
29
|
+
- **State layer / ripple** — wrap the interactive element with `Layer.svelte` (`src/lib/utils/Layer.svelte`) rather than writing hover/press opacity by hand. It listens for `.m3-layer` on its parent, already respects `prefers-reduced-motion` for the ripple, and its tint (hover 0.08, focus and pressed 0.10) matches the M3 state-layer tokens — don't retune those numbers per component. Its tint only reacts to real `:hover`/`:focus-visible`/`:active`, and no Tailwind class can drive it (its CSS is unlayered and beats `@layer utilities`). For a highlight that follows an attribute while focus stays elsewhere (a combobox's `data-selected`/`data-highlighted` option), fill the item: `data-selected:bg-md-sys-color-on-surface/10`, as menus and `commandItem` do.
|
|
30
30
|
- **Icons** — always `Icon.svelte`, never a raw `<span class="material-symbols-...">` or an inline SVG for a Material Symbol.
|
|
31
31
|
- `name` is typed (`MaterialSymbolName`), so a typo is a type error. A `string[]` of names needs `as const` or `satisfies MaterialSymbolName[]`.
|
|
32
32
|
- Show state with `fill` (0 → 1 on the selected item; it animates), not a weight change.
|
|
@@ -71,6 +71,41 @@ See `/docs/pane` in the showcase site for the full prop reference and worked exa
|
|
|
71
71
|
|
|
72
72
|
A viewport-anchored `Rail` publishes `--md-rail-inset` on `<html>`: 0 below `md`, the collapsed 96dp on medium windows (the expanded rail is modal there and overlays), and its live width from `lg` (it pushes content, in step with its spring). `App`'s shell pads by it and `AppBar` starts at it. Never add `md:ml-24` or similar to a page, a `PaneGrid` or an app bar. A custom shell uses `ps-(--md-rail-inset)`; another fixed surface spanning the window uses `left-(--md-rail-inset)`. A rail with `anchor="parent"` renders a spacer beside itself instead, so it and its content go in a flex row.
|
|
73
73
|
|
|
74
|
+
## Search, command palette and keyboard shortcuts
|
|
75
|
+
|
|
76
|
+
Two components take a typed query over a list. Pick by what the user is looking for, not by
|
|
77
|
+
looks:
|
|
78
|
+
|
|
79
|
+
| The user wants to… | Component | Why |
|
|
80
|
+
| ------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
81
|
+
| Find **content** (records, pages, products, people) | `Search` with `results` (or search `AppBar`) | M3 search. The app filters (you render `results`), Enter with nothing highlighted calls `onsearch(query)`, the query stays in the bar |
|
|
82
|
+
| **Do** something or **go** somewhere (commands, destinations) | `CommandDialog` + `CommandItem`s | Not M3; built from M3 parts. bits-ui filters and ranks, the best match is always highlighted so Enter always runs one, the query is discarded on close |
|
|
83
|
+
| A central search entry point in a page body, not the top bar | `Search` placed in the page | The view docks where the bar sits (medium+) or goes full-screen (compact); it doesn't need an `AppBar` |
|
|
84
|
+
|
|
85
|
+
Don't make the palette the app's search: it can't search for exactly what was typed, and it only
|
|
86
|
+
filters items already rendered. Don't fill a search view with actions. Having both is normal; a
|
|
87
|
+
palette may end with a "Search for “…”" item that hands the query to search.
|
|
88
|
+
|
|
89
|
+
Page-wide keys, all built on `src/lib/utils/shortcut.ts` (`triggersShortcut`, `matchesShortcut`,
|
|
90
|
+
`ariaKeyShortcut`, `shortcutLabel`), each on by default and off with `null`:
|
|
91
|
+
|
|
92
|
+
| Key | Component | Prop |
|
|
93
|
+
| ---------------- | ------------------------- | ----------------------------- |
|
|
94
|
+
| `/` | `Search`, search `AppBar` | `shortcut` / `searchShortcut` |
|
|
95
|
+
| ⌘K / Ctrl+K | `CommandDialog` | `shortcut` |
|
|
96
|
+
| ⌘1–⌘9 / Ctrl+1–9 | `Rail` (Nth destination) | `shortcutModifier` |
|
|
97
|
+
|
|
98
|
+
- Write shortcuts like `aria-keyshortcuts`, with `Mod` for ⌘ on Apple and Ctrl elsewhere
|
|
99
|
+
(`'Mod+K'`). Never bind the Windows/Super key: the OS owns it.
|
|
100
|
+
- A new page-wide shortcut goes through `triggersShortcut(e, shortcut, ownerEl)` on
|
|
101
|
+
`<svelte:window onkeydown>`. It already skips bare keys while typing in a field, IME
|
|
102
|
+
composition, repeats, and any open modal the owner isn't inside. Set `aria-keyshortcuts` on the
|
|
103
|
+
control it activates, resolving `Mod` on the client (`isApplePlatform()` in an `$effect`) so SSR
|
|
104
|
+
and hydration agree.
|
|
105
|
+
- One owner per key per page: with two `Search` bars, pass `shortcut={null}` to all but one.
|
|
106
|
+
- Show a shortcut with `shortcutLabel()` (`⌘K` / `Ctrl+K`), e.g. in a `Kbd` or `CommandItem`'s
|
|
107
|
+
`shortcut`.
|
|
108
|
+
|
|
74
109
|
## Before exporting a new component
|
|
75
110
|
|
|
76
111
|
Follow CLAUDE.md's "Adding a New Component" steps as the mechanical checklist (create `.svelte` + `types.ts`, define `theme.ts` with `tv()`, export from the category `index.ts`, run `bun scripts/generate-components-index.ts`, add a showcase route and a docs page) — this skill governs the _design_ decisions (which variant/color/motion) that should be made before or while writing that `theme.ts`, not the export mechanics themselves.
|
|
@@ -27,12 +27,14 @@ const fromTo = (from, to) => Object.fromEntries(Object.entries(to).map(([key, va
|
|
|
27
27
|
export const presence = (isOpen, transition, onExitComplete) => (node) => {
|
|
28
28
|
let controls;
|
|
29
29
|
let started = false;
|
|
30
|
+
let settleTimer;
|
|
30
31
|
if (transition.origin)
|
|
31
32
|
node.style.transformOrigin = transition.origin;
|
|
32
33
|
$effect(() => {
|
|
33
34
|
const open = isOpen();
|
|
34
35
|
untrack(() => {
|
|
35
36
|
const { hidden, shown, exited = hidden, enter, exit } = transition;
|
|
37
|
+
clearTimeout(settleTimer);
|
|
36
38
|
if (open) {
|
|
37
39
|
controls = animate(node, started ? shown : fromTo(hidden, shown), enter);
|
|
38
40
|
}
|
|
@@ -42,11 +44,45 @@ export const presence = (isOpen, transition, onExitComplete) => (node) => {
|
|
|
42
44
|
if (controls === current)
|
|
43
45
|
onExitComplete?.();
|
|
44
46
|
});
|
|
47
|
+
settleTimer = setTimeout(() => {
|
|
48
|
+
if (controls === current)
|
|
49
|
+
settle(node);
|
|
50
|
+
}, exitDeadline(current));
|
|
45
51
|
}
|
|
46
52
|
started = true;
|
|
47
53
|
});
|
|
48
54
|
});
|
|
49
|
-
return () =>
|
|
55
|
+
return () => {
|
|
56
|
+
clearTimeout(settleTimer);
|
|
57
|
+
controls?.stop();
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
/*
|
|
61
|
+
bits-ui unmounts its content only once every animation on it has settled, and `Presence` once
|
|
62
|
+
Motion's exit resolves. On touch devices a closed menu was reported staying mounted, one per
|
|
63
|
+
open, stacking up (#53): some animation on the node never settled. So once the exit has had its
|
|
64
|
+
time, whatever is still running or paused there is finished (or cancelled, if it can't be),
|
|
65
|
+
which settles bits-ui's wait and the exit's promise.
|
|
66
|
+
*/
|
|
67
|
+
const EXIT_DEADLINE_MARGIN_MS = 150;
|
|
68
|
+
const EXIT_DEADLINE_FALLBACK_MS = 1000;
|
|
69
|
+
const exitDeadline = (controls) => {
|
|
70
|
+
const seconds = controls.duration;
|
|
71
|
+
return Number.isFinite(seconds) && seconds > 0
|
|
72
|
+
? seconds * 1000 + EXIT_DEADLINE_MARGIN_MS
|
|
73
|
+
: EXIT_DEADLINE_FALLBACK_MS;
|
|
74
|
+
};
|
|
75
|
+
const settle = (node) => {
|
|
76
|
+
for (const animation of node.getAnimations?.() ?? []) {
|
|
77
|
+
if (animation.playState === 'finished')
|
|
78
|
+
continue;
|
|
79
|
+
try {
|
|
80
|
+
animation.finish();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
animation.cancel();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
50
86
|
};
|
|
51
87
|
/**
|
|
52
88
|
* Keeps an element mounted until its exit animation finishes — the Motion replacement for a
|
|
@@ -64,7 +64,8 @@ export declare const lateral: (update: () => void | Promise<void>, { target, axi
|
|
|
64
64
|
* instead of `lateral` there.
|
|
65
65
|
*
|
|
66
66
|
* Not built into `Navbar`/`Rail`: they don't own the content region that changes, so the app wraps
|
|
67
|
-
* its own route change in this.
|
|
67
|
+
* its own route change in this. They, the AppBar and a docked Toolbar stay still and on top while
|
|
68
|
+
* it runs; give a fixed floating Toolbar or FAB the `md-vt-persist` class for the same (motion.css).
|
|
68
69
|
*
|
|
69
70
|
* The `target` region swaps its box instantly instead of sliding or resizing to the new page's:
|
|
70
71
|
* that movement would be exactly the spatial connection this pattern avoids, and after a
|
|
@@ -2,8 +2,25 @@ import { animateView } from 'motion';
|
|
|
2
2
|
import { prefersReducedMotion } from './reducedMotion.js';
|
|
3
3
|
import { springTokens, springTransition } from './spring.js';
|
|
4
4
|
const SHARED_AXIS_OFFSET_PX = 30;
|
|
5
|
+
/* While a navigation transition runs, `.md-vt-persist` chrome (Navbar, Rail, AppBar, …) gets
|
|
6
|
+
its own static layer above the page's (motion.css). Counted, since transitions queue. */
|
|
7
|
+
const NAVIGATING = 'md-navigation-transition';
|
|
8
|
+
let navigating = 0;
|
|
9
|
+
const markNavigating = (builder) => {
|
|
10
|
+
const root = document.documentElement;
|
|
11
|
+
navigating++;
|
|
12
|
+
root.classList.add(NAVIGATING);
|
|
13
|
+
new Promise((resolve, reject) => builder.then ? builder.then(resolve, reject) : resolve(undefined))
|
|
14
|
+
.then((animation) => animation?.finished)
|
|
15
|
+
.catch(() => { })
|
|
16
|
+
.finally(() => {
|
|
17
|
+
if (--navigating === 0)
|
|
18
|
+
root.classList.remove(NAVIGATING);
|
|
19
|
+
});
|
|
20
|
+
};
|
|
5
21
|
const view = (update, target, spring) => {
|
|
6
22
|
const builder = animateView(update, springTransition(spring));
|
|
23
|
+
markNavigating(builder);
|
|
7
24
|
return target ? builder.add(target) : builder;
|
|
8
25
|
};
|
|
9
26
|
/* Outgoing content clears quickly; incoming content waits a beat so the two never overlap fully.
|
|
@@ -117,7 +134,8 @@ export const lateral = (update, { target, axis = 'x', direction = 'forward', spr
|
|
|
117
134
|
* instead of `lateral` there.
|
|
118
135
|
*
|
|
119
136
|
* Not built into `Navbar`/`Rail`: they don't own the content region that changes, so the app wraps
|
|
120
|
-
* its own route change in this.
|
|
137
|
+
* its own route change in this. They, the AppBar and a docked Toolbar stay still and on top while
|
|
138
|
+
* it runs; give a fixed floating Toolbar or FAB the `md-vt-persist` class for the same (motion.css).
|
|
121
139
|
*
|
|
122
140
|
* The `target` region swaps its box instantly instead of sliding or resizing to the new page's:
|
|
123
141
|
* that movement would be exactly the spatial connection this pattern avoids, and after a
|
|
@@ -343,7 +343,10 @@ Floating action buttons (FABs) help people take primary actions.
|
|
|
343
343
|
surfaceOpen && 'invisible',
|
|
344
344
|
// In its footprint, pinned to the top trailing corner the close button shares.
|
|
345
345
|
withMenu &&
|
|
346
|
-
neverExpanded && [
|
|
346
|
+
neverExpanded && [
|
|
347
|
+
'col-start-1 row-start-1 self-start justify-self-end',
|
|
348
|
+
!open && footprint[size]
|
|
349
|
+
],
|
|
347
350
|
className
|
|
348
351
|
)
|
|
349
352
|
})}
|
|
@@ -443,7 +443,8 @@ export declare const fab: import("tailwind-variants").TVReturnType<{
|
|
|
443
443
|
};
|
|
444
444
|
/**
|
|
445
445
|
* The FAB menu's open state: the FAB becomes the round close button, label shut. Its size and
|
|
446
|
-
* corners spring to 56dp round in FAB.svelte
|
|
446
|
+
* corners spring to 56dp round in FAB.svelte; the classes hold that box once the spring
|
|
447
|
+
* settles, so the button can't fall back to its content width (#49: a 24×56 pill).
|
|
447
448
|
*/
|
|
448
449
|
menuOpen: {
|
|
449
450
|
true: {
|
|
@@ -509,7 +510,8 @@ export declare const fab: import("tailwind-variants").TVReturnType<{
|
|
|
509
510
|
};
|
|
510
511
|
/**
|
|
511
512
|
* The FAB menu's open state: the FAB becomes the round close button, label shut. Its size and
|
|
512
|
-
* corners spring to 56dp round in FAB.svelte
|
|
513
|
+
* corners spring to 56dp round in FAB.svelte; the classes hold that box once the spring
|
|
514
|
+
* settles, so the button can't fall back to its content width (#49: a 24×56 pill).
|
|
513
515
|
*/
|
|
514
516
|
menuOpen: {
|
|
515
517
|
true: {
|
|
@@ -575,7 +577,8 @@ export declare const fab: import("tailwind-variants").TVReturnType<{
|
|
|
575
577
|
};
|
|
576
578
|
/**
|
|
577
579
|
* The FAB menu's open state: the FAB becomes the round close button, label shut. Its size and
|
|
578
|
-
* corners spring to 56dp round in FAB.svelte
|
|
580
|
+
* corners spring to 56dp round in FAB.svelte; the classes hold that box once the spring
|
|
581
|
+
* settles, so the button can't fall back to its content width (#49: a 24×56 pill).
|
|
579
582
|
*/
|
|
580
583
|
menuOpen: {
|
|
581
584
|
true: {
|
|
@@ -276,11 +276,12 @@ export const fab = tv({
|
|
|
276
276
|
},
|
|
277
277
|
/**
|
|
278
278
|
* The FAB menu's open state: the FAB becomes the round close button, label shut. Its size and
|
|
279
|
-
* corners spring to 56dp round in FAB.svelte
|
|
279
|
+
* corners spring to 56dp round in FAB.svelte; the classes hold that box once the spring
|
|
280
|
+
* settles, so the button can't fall back to its content width (#49: a 24×56 pill).
|
|
280
281
|
*/
|
|
281
282
|
menuOpen: {
|
|
282
283
|
true: {
|
|
283
|
-
base: 'justify-center px-spacing-0',
|
|
284
|
+
base: 'justify-center px-spacing-0 size-spacing-700 rounded-[1.75rem]',
|
|
284
285
|
icon: 'size-spacing-250 text-[20px]',
|
|
285
286
|
labelTrack: 'grid-cols-[0fr]!'
|
|
286
287
|
},
|
|
@@ -2,7 +2,7 @@ import { tv } from '../../../utils/tv.js';
|
|
|
2
2
|
export const contextMenu = tv({
|
|
3
3
|
slots: {
|
|
4
4
|
content: `
|
|
5
|
-
z-layer-popup min-w-48 max-w-sm gap-spacing-50 overflow-y-auto rounded-lg
|
|
5
|
+
z-layer-popup outline-none min-w-48 max-w-sm gap-spacing-50 overflow-y-auto rounded-lg
|
|
6
6
|
bg-md-sys-color-surface-container-high px-spacing-100 py-spacing-50
|
|
7
7
|
shadow-elevation-3 ring-md-sys-color-outline/10
|
|
8
8
|
`,
|
|
@@ -10,14 +10,14 @@ export const contextMenu = tv({
|
|
|
10
10
|
rounded-sm relative flex w-full cursor-pointer select-none items-center gap-spacing-100
|
|
11
11
|
px-spacing-150 py-spacing-100 text-left md-sys-typescale-body-large text-md-sys-color-on-surface
|
|
12
12
|
outline-none transition-colors md-sys-motion-fast-effects
|
|
13
|
-
|
|
13
|
+
data-[highlighted]:bg-md-sys-color-on-surface/8
|
|
14
14
|
data-[disabled]:cursor-not-allowed data-[disabled]:opacity-38
|
|
15
15
|
`,
|
|
16
16
|
subTrigger: `
|
|
17
17
|
rounded-sm relative flex w-full cursor-pointer select-none items-center gap-spacing-100
|
|
18
18
|
px-spacing-150 py-spacing-100 text-left md-sys-typescale-body-large text-md-sys-color-on-surface
|
|
19
19
|
outline-none transition-colors md-sys-motion-fast-effects
|
|
20
|
-
|
|
20
|
+
data-[state=open]:bg-md-sys-color-on-surface/8
|
|
21
21
|
data-[highlighted]:bg-md-sys-color-on-surface/8
|
|
22
22
|
data-[disabled]:cursor-not-allowed data-[disabled]:opacity-38
|
|
23
23
|
`,
|
|
@@ -36,7 +36,7 @@ export const contextMenu = tv({
|
|
|
36
36
|
},
|
|
37
37
|
color: {
|
|
38
38
|
error: {
|
|
39
|
-
item: 'text-md-sys-color-error
|
|
39
|
+
item: 'text-md-sys-color-error data-[highlighted]:bg-md-sys-color-error/8',
|
|
40
40
|
icon: 'text-md-sys-color-error'
|
|
41
41
|
}
|
|
42
42
|
}
|
|
@@ -71,7 +71,7 @@ export const listItem = tv({
|
|
|
71
71
|
'cursor-pointer outline-none',
|
|
72
72
|
'hover:[--li-shape:0.75rem]',
|
|
73
73
|
'focus-visible:[--li-shape:1rem]',
|
|
74
|
-
'focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-md-sys-color-secondary',
|
|
74
|
+
'focus-visible:outline-solid focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-md-sys-color-secondary',
|
|
75
75
|
'active:[--li-shape:1rem]'
|
|
76
76
|
]
|
|
77
77
|
},
|
|
@@ -20,6 +20,7 @@ Contrast with `MenuItem` (a single interactive row inside the panel) and
|
|
|
20
20
|
import { DropdownMenu } from 'bits-ui';
|
|
21
21
|
import clsx from 'clsx';
|
|
22
22
|
import Button from '../../buttons/Button.svelte';
|
|
23
|
+
import { menu } from './theme.js';
|
|
23
24
|
import type { MenuProps } from './types.js';
|
|
24
25
|
|
|
25
26
|
let {
|
|
@@ -49,11 +50,7 @@ Contrast with `MenuItem` (a single interactive row inside the panel) and
|
|
|
49
50
|
<div {...wrapperProps} class={wrapperProps.class as any}>
|
|
50
51
|
<div
|
|
51
52
|
{...props}
|
|
52
|
-
class={clsx(
|
|
53
|
-
'bg-md-sys-color-surface-container-high shadow-elevation-3 ring-md-sys-color-outline/10 gap-spacing-50 px-spacing-100 py-spacing-50 max-w-sm min-w-48 overflow-y-auto rounded-lg',
|
|
54
|
-
props.class as any,
|
|
55
|
-
contentClass
|
|
56
|
-
)}
|
|
53
|
+
class={clsx(menu().content(), props.class as any, contentClass)}
|
|
57
54
|
{@attach presence(() => isOpen, enterExit.scale)}
|
|
58
55
|
>
|
|
59
56
|
{@render children()}
|
|
@@ -4,12 +4,14 @@ export const menu = tv({
|
|
|
4
4
|
content: `
|
|
5
5
|
bg-md-sys-color-surface-container-high shadow-elevation-3
|
|
6
6
|
ring-md-sys-color-outline/10 max-w-sm min-w-48 gap-spacing-50
|
|
7
|
-
overflow-y-auto rounded-lg px-spacing-100 py-spacing-50
|
|
7
|
+
overflow-y-auto rounded-lg px-spacing-100 py-spacing-50 outline-none
|
|
8
8
|
`,
|
|
9
|
+
// No `hover:` fill: bits-ui sets `data-highlighted` on mouse hover already,
|
|
10
|
+
// and a `:hover` tint sticks to the last-tapped row on touch screens (#48).
|
|
9
11
|
item: `
|
|
10
12
|
rounded-sm h-11 relative flex w-full cursor-pointer items-center gap-spacing-100 px-spacing-150 py-spacing-100
|
|
11
13
|
md-sys-typescale-body-medium whitespace-nowrap text-md-sys-color-on-surface
|
|
12
|
-
|
|
14
|
+
data-[highlighted]:bg-md-sys-color-on-surface/8
|
|
13
15
|
focus-visible:outline-2 focus-visible:outline-offset-2
|
|
14
16
|
focus-visible:outline-md-sys-color-primary
|
|
15
17
|
data-[disabled]:cursor-not-allowed data-[disabled]:opacity-38
|
|
@@ -218,7 +218,7 @@ export const pane = tv({
|
|
|
218
218
|
*/
|
|
219
219
|
export const paneHandle = tv({
|
|
220
220
|
slots: {
|
|
221
|
-
base: 'group relative flex shrink-0 w-spacing-150 self-stretch cursor-col-resize touch-none items-center justify-center rounded-full bg-transparent outline-none hover:bg-md-sys-color-outline/20 focus-visible:outline-2 focus-visible:outline-md-sys-color-secondary',
|
|
221
|
+
base: 'group relative flex shrink-0 w-spacing-150 self-stretch cursor-col-resize touch-none items-center justify-center rounded-full bg-transparent outline-none hover:bg-md-sys-color-outline/20 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-md-sys-color-secondary',
|
|
222
222
|
// Size is sprung inline by PaneHandle.svelte; only color transitions here.
|
|
223
223
|
grip: 'rounded-full transition-colors md-sys-motion-effects'
|
|
224
224
|
},
|
|
@@ -286,7 +286,7 @@ export const dateCalendar = tv({
|
|
|
286
286
|
cursor-pointer outline-none
|
|
287
287
|
transition-colors md-sys-motion-fast-effects
|
|
288
288
|
|
|
289
|
-
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-md-sys-color-secondary
|
|
289
|
+
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-md-sys-color-secondary
|
|
290
290
|
|
|
291
291
|
data-today:border data-today:border-md-sys-color-primary data-today:text-md-sys-color-primary
|
|
292
292
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Meta, Title, Subtitle, Canvas, Controls, ArgTypes } from '@storybook/addon-docs/blocks';
|
|
2
|
+
import * as CommandStories from './Command.stories.svelte';
|
|
3
|
+
import CommandItem from './CommandItem.svelte';
|
|
4
|
+
import CommandDialog from './CommandDialog.svelte';
|
|
5
|
+
|
|
6
|
+
<Meta of={CommandStories} />
|
|
7
|
+
|
|
8
|
+
<Title />
|
|
9
|
+
|
|
10
|
+
<Subtitle>
|
|
11
|
+
A command palette: type to filter a known set of actions and destinations, then run one.
|
|
12
|
+
</Subtitle>
|
|
13
|
+
|
|
14
|
+
`import { Command, CommandDialog, CommandInput, CommandList, CommandGroup, CommandItem, CommandEmpty, CommandSeparator } from '@noxlovette/material';`
|
|
15
|
+
|
|
16
|
+
Not an M3 component. M3 has no command palette, so this one is assembled from M3 parts: the
|
|
17
|
+
items are M3 list items, and `CommandDialog` takes the docked search view's width (360–720dp), a
|
|
18
|
+
modal dialog's scrim, extra-large shape and enter/exit motion. Filtering, ranking and keyboard
|
|
19
|
+
navigation come from bits-ui's `Command`.
|
|
20
|
+
|
|
21
|
+
<Canvas of={CommandStories.Playground} />
|
|
22
|
+
|
|
23
|
+
<Controls of={CommandStories.Playground} />
|
|
24
|
+
|
|
25
|
+
## Search or command palette?
|
|
26
|
+
|
|
27
|
+
They look alike, a field over a list, but they answer different questions. Use both in one app
|
|
28
|
+
if it has both kinds of need.
|
|
29
|
+
|
|
30
|
+
| | `Search` (bar + search view) | `CommandDialog` (palette) |
|
|
31
|
+
| --------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
32
|
+
| Answers | "Where is the thing that matches this?" | "What do I want to do, or where do I want to go?" |
|
|
33
|
+
| Items | App content: open-ended, often fetched, rich rows | A finite, known set of actions and destinations |
|
|
34
|
+
| Filtering | Yours: you render the matches in `results` | The component's: bits-ui scores and sorts the items |
|
|
35
|
+
| Enter, nothing picked | Searches for the query as typed (`onsearch`) | Can't happen: the best match is always highlighted, Enter runs it |
|
|
36
|
+
| The query | Stays in the bar; it's part of the page's state | Thrown away on close |
|
|
37
|
+
| Where it lives | On the page or in the search `AppBar`, visible | Nowhere until summoned; one per app |
|
|
38
|
+
| Page-wide key | `/` | ⌘K (Ctrl+K off Apple platforms) |
|
|
39
|
+
| M3 | [Search](https://m3.material.io/components/search/overview), spec'd with motion | None; built from M3 parts |
|
|
40
|
+
|
|
41
|
+
Rules of thumb:
|
|
42
|
+
|
|
43
|
+
- **Content goes in search, commands go in the palette.** "Invoices from March" is a search.
|
|
44
|
+
"New invoice", "Go to settings", "Toggle dark theme" are commands.
|
|
45
|
+
- **Don't use the palette as the app's search.** Its first match is always highlighted, so it
|
|
46
|
+
can't search for exactly what was typed, and its filter only sees items already rendered.
|
|
47
|
+
- **Don't use search as a launcher.** A search view's suggestions are content, and a bar
|
|
48
|
+
on every page for actions is the wrong emphasis.
|
|
49
|
+
- **Both at once is fine.** A palette can end with a "Search for “…”" item that hands the query
|
|
50
|
+
to search.
|
|
51
|
+
|
|
52
|
+
## Items
|
|
53
|
+
|
|
54
|
+
Each `CommandItem` is an M3 list item: its children are the headline, with an optional `leading`
|
|
55
|
+
icon, `supporting` second line, and `trailingText`. `shortcut` shows a keyboard shortcut at the end,
|
|
56
|
+
labelled for the platform (`⌘,` on a Mac, `Ctrl+,` elsewhere). It's only shown; bind the key
|
|
57
|
+
yourself.
|
|
58
|
+
|
|
59
|
+
```svelte
|
|
60
|
+
<CommandItem value="settings" shortcut="Mod+," onSelect={openSettings}>
|
|
61
|
+
{#snippet leading()}<Icon name="settings" />{/snippet}
|
|
62
|
+
Settings
|
|
63
|
+
</CommandItem>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The highlighted item, from the arrow keys or the pointer, takes a focused item's look: a 10%
|
|
67
|
+
on-surface fill and the 12dp shape. Focus itself stays in the input.
|
|
68
|
+
|
|
69
|
+
<ArgTypes of={CommandItem} />
|
|
70
|
+
|
|
71
|
+
## Dialog
|
|
72
|
+
|
|
73
|
+
`CommandDialog` wraps `Command` in a modal overlay opened with ⌘K from anywhere, including from
|
|
74
|
+
inside a text field. ⌘K again, Esc or a click on the scrim closes it. Close it yourself from an
|
|
75
|
+
item's `onSelect` (`bind:open`). `shortcut` changes the keys; `null` turns them off.
|
|
76
|
+
|
|
77
|
+
<Canvas of={CommandStories.Dialog} />
|
|
78
|
+
|
|
79
|
+
<ArgTypes of={CommandDialog} />
|
|
80
|
+
|
|
81
|
+
## Accessibility
|
|
82
|
+
|
|
83
|
+
- **Combobox.** The input is a `combobox` over the `listbox`, with `aria-activedescendant` on the
|
|
84
|
+
highlighted item, so screen readers follow the arrow keys while focus stays in the field.
|
|
85
|
+
- **Dialog.** `CommandDialog` is a modal dialog named by `label` (default "Command palette") and
|
|
86
|
+
keeps focus inside while open.
|
|
87
|
+
- **Shortcut.** ⌘K doesn't fire while another modal dialog is open, so it never opens on top of
|
|
88
|
+
a search view or a confirmation dialog.
|
|
89
|
+
- **Keys.** ↑/↓ move through the items, Home/End jump to the first and last, Enter runs the
|
|
90
|
+
highlighted one.
|
|
@@ -7,18 +7,27 @@
|
|
|
7
7
|
import CommandGroup from './CommandGroup.svelte';
|
|
8
8
|
import CommandItem from './CommandItem.svelte';
|
|
9
9
|
import CommandSeparator from './CommandSeparator.svelte';
|
|
10
|
+
import CommandDialog from './CommandDialog.svelte';
|
|
11
|
+
import Kbd from '../../typography/kbd/Kbd.svelte';
|
|
12
|
+
import Button from '../../buttons/Button.svelte';
|
|
13
|
+
import { shortcutLabel } from '../../../utils/shortcut.js';
|
|
10
14
|
import Icon from '../../../utils/icon/Icon.svelte';
|
|
11
|
-
import { command } from './theme.js';
|
|
12
|
-
|
|
13
|
-
const { itemIcon } = command();
|
|
14
15
|
|
|
15
16
|
const { Story } = defineMeta({
|
|
16
17
|
title: 'Forms/Command',
|
|
17
|
-
tags: ['autodocs'],
|
|
18
18
|
component: Command
|
|
19
19
|
});
|
|
20
20
|
</script>
|
|
21
21
|
|
|
22
|
+
<script lang="ts">
|
|
23
|
+
let paletteOpen = $state(false);
|
|
24
|
+
let ran = $state('');
|
|
25
|
+
const run = (name: string) => {
|
|
26
|
+
ran = name;
|
|
27
|
+
paletteOpen = false;
|
|
28
|
+
};
|
|
29
|
+
</script>
|
|
30
|
+
|
|
22
31
|
<Story name="Playground" asChild>
|
|
23
32
|
<div class="w-96">
|
|
24
33
|
<Command>
|
|
@@ -27,30 +36,30 @@
|
|
|
27
36
|
<CommandEmpty>No results found.</CommandEmpty>
|
|
28
37
|
<CommandGroup heading="Suggestions">
|
|
29
38
|
<CommandItem value="calendar">
|
|
30
|
-
<Icon name="calendar_today"
|
|
39
|
+
{#snippet leading()}<Icon name="calendar_today" />{/snippet}
|
|
31
40
|
Calendar
|
|
32
41
|
</CommandItem>
|
|
33
42
|
<CommandItem value="search-emoji">
|
|
34
|
-
<Icon name="mood"
|
|
43
|
+
{#snippet leading()}<Icon name="mood" />{/snippet}
|
|
35
44
|
Search Emoji
|
|
36
45
|
</CommandItem>
|
|
37
46
|
<CommandItem value="calculator">
|
|
38
|
-
<Icon name="calculate"
|
|
47
|
+
{#snippet leading()}<Icon name="calculate" />{/snippet}
|
|
39
48
|
Calculator
|
|
40
49
|
</CommandItem>
|
|
41
50
|
</CommandGroup>
|
|
42
51
|
<CommandSeparator />
|
|
43
52
|
<CommandGroup heading="Settings">
|
|
44
53
|
<CommandItem value="profile">
|
|
45
|
-
<Icon name="person"
|
|
54
|
+
{#snippet leading()}<Icon name="person" />{/snippet}
|
|
46
55
|
Profile
|
|
47
56
|
</CommandItem>
|
|
48
57
|
<CommandItem value="billing">
|
|
49
|
-
<Icon name="credit_card"
|
|
58
|
+
{#snippet leading()}<Icon name="credit_card" />{/snippet}
|
|
50
59
|
Billing
|
|
51
60
|
</CommandItem>
|
|
52
61
|
<CommandItem value="settings">
|
|
53
|
-
<Icon name="settings"
|
|
62
|
+
{#snippet leading()}<Icon name="settings" />{/snippet}
|
|
54
63
|
Settings
|
|
55
64
|
</CommandItem>
|
|
56
65
|
</CommandGroup>
|
|
@@ -59,6 +68,56 @@
|
|
|
59
68
|
</div>
|
|
60
69
|
</Story>
|
|
61
70
|
|
|
71
|
+
<!--
|
|
72
|
+
Press ⌘K (Ctrl+K off Apple platforms) anywhere in the story, or the button. Arrow keys move the
|
|
73
|
+
highlight, Enter runs the command, Esc or ⌘K again closes it.
|
|
74
|
+
-->
|
|
75
|
+
<Story
|
|
76
|
+
name="Dialog"
|
|
77
|
+
asChild
|
|
78
|
+
parameters={{ layout: 'fullscreen', docs: { story: { inline: false, height: '520px' } } }}
|
|
79
|
+
>
|
|
80
|
+
<div
|
|
81
|
+
class="gap-spacing-200 p-spacing-300 md-sys-typescale-body-medium text-md-sys-color-on-surface flex min-h-dvh flex-col items-start"
|
|
82
|
+
>
|
|
83
|
+
<Button variant="tonal" onclick={() => (paletteOpen = true)}>
|
|
84
|
+
Open the palette <Kbd position="relative">{shortcutLabel('Mod+K')}</Kbd>
|
|
85
|
+
</Button>
|
|
86
|
+
<p class="text-md-sys-color-on-surface-variant">{ran ? `Ran: ${ran}` : 'Nothing run yet'}</p>
|
|
87
|
+
<CommandDialog bind:open={paletteOpen}>
|
|
88
|
+
<CommandInput placeholder="Type a command or search..." />
|
|
89
|
+
<CommandList>
|
|
90
|
+
<CommandEmpty>No results found.</CommandEmpty>
|
|
91
|
+
<CommandGroup heading="Suggestions">
|
|
92
|
+
<CommandItem value="calendar" onSelect={() => run('Calendar')}>
|
|
93
|
+
{#snippet leading()}<Icon name="calendar_today" />{/snippet}
|
|
94
|
+
Calendar
|
|
95
|
+
</CommandItem>
|
|
96
|
+
<CommandItem value="calculator" onSelect={() => run('Calculator')}>
|
|
97
|
+
{#snippet leading()}<Icon name="calculate" />{/snippet}
|
|
98
|
+
Calculator
|
|
99
|
+
</CommandItem>
|
|
100
|
+
</CommandGroup>
|
|
101
|
+
<CommandSeparator />
|
|
102
|
+
<CommandGroup heading="Settings">
|
|
103
|
+
<CommandItem
|
|
104
|
+
value="profile"
|
|
105
|
+
supporting="Name, photo and email"
|
|
106
|
+
onSelect={() => run('Profile')}
|
|
107
|
+
>
|
|
108
|
+
{#snippet leading()}<Icon name="person" />{/snippet}
|
|
109
|
+
Profile
|
|
110
|
+
</CommandItem>
|
|
111
|
+
<CommandItem value="settings" shortcut="Mod+," onSelect={() => run('Settings')}>
|
|
112
|
+
{#snippet leading()}<Icon name="settings" />{/snippet}
|
|
113
|
+
Settings
|
|
114
|
+
</CommandItem>
|
|
115
|
+
</CommandGroup>
|
|
116
|
+
</CommandList>
|
|
117
|
+
</CommandDialog>
|
|
118
|
+
</div>
|
|
119
|
+
</Story>
|
|
120
|
+
|
|
62
121
|
<Story name="Empty State" asChild>
|
|
63
122
|
<div class="w-96">
|
|
64
123
|
<Command>
|
|
@@ -1,19 +1,4 @@
|
|
|
1
1
|
import Command from './Command.svelte';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
$$bindings?: Bindings;
|
|
5
|
-
} & Exports;
|
|
6
|
-
(internal: unknown, props: {
|
|
7
|
-
$$events?: Events;
|
|
8
|
-
$$slots?: Slots;
|
|
9
|
-
}): Exports & {
|
|
10
|
-
$set?: any;
|
|
11
|
-
$on?: any;
|
|
12
|
-
};
|
|
13
|
-
z_$$bindings?: Bindings;
|
|
14
|
-
}
|
|
15
|
-
declare const Command: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
|
|
16
|
-
[evt: string]: CustomEvent<any>;
|
|
17
|
-
}, {}, {}, string>;
|
|
18
|
-
type Command = InstanceType<typeof Command>;
|
|
2
|
+
declare const Command: import("svelte").Component<Record<string, never>, {}, "">;
|
|
3
|
+
type Command = ReturnType<typeof Command>;
|
|
19
4
|
export default Command;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
A command palette: a `Command` in a modal overlay, opened from anywhere with ⌘K (Ctrl+K off Apple
|
|
4
|
+
platforms). The same keys close it again, as do Esc and a click on the scrim.
|
|
5
|
+
|
|
6
|
+
Put `CommandInput`, `CommandList` and the rest inside it, as with `Command`, and close it from an
|
|
7
|
+
item's `onSelect` (`bind:open`).
|
|
8
|
+
-->
|
|
9
|
+
<script lang="ts">
|
|
10
|
+
import { Dialog } from 'bits-ui';
|
|
11
|
+
import clsx from 'clsx';
|
|
12
|
+
import { enterExit, presence } from '../../../animation/index.js';
|
|
13
|
+
import { triggersShortcut } from '../../../utils/index.js';
|
|
14
|
+
import Command from './Command.svelte';
|
|
15
|
+
import { commandDialog } from './theme.js';
|
|
16
|
+
import type { CommandDialogProps } from './types.js';
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
open = $bindable(false),
|
|
20
|
+
value = $bindable(''),
|
|
21
|
+
shortcut = 'Mod+K',
|
|
22
|
+
label = 'Command palette',
|
|
23
|
+
children,
|
|
24
|
+
class: className,
|
|
25
|
+
...restProps
|
|
26
|
+
}: CommandDialogProps = $props();
|
|
27
|
+
|
|
28
|
+
const s = commandDialog();
|
|
29
|
+
let content = $state<HTMLElement | null>(null);
|
|
30
|
+
|
|
31
|
+
function onShortcut(e: KeyboardEvent) {
|
|
32
|
+
if (!triggersShortcut(e, shortcut, open ? content : null)) return;
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
open = !open;
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<svelte:window onkeydown={onShortcut} />
|
|
39
|
+
|
|
40
|
+
<Dialog.Root bind:open>
|
|
41
|
+
<Dialog.Portal>
|
|
42
|
+
<Dialog.Overlay>
|
|
43
|
+
{#snippet child({ props, open: isOpen })}
|
|
44
|
+
<div {...props} class={s.scrim()} {@attach presence(() => isOpen, enterExit.fade)}></div>
|
|
45
|
+
{/snippet}
|
|
46
|
+
</Dialog.Overlay>
|
|
47
|
+
<Dialog.Content bind:ref={content}>
|
|
48
|
+
{#snippet child({ props, open: isOpen })}
|
|
49
|
+
<div {...props} class={s.content()} {@attach presence(() => isOpen, enterExit.dialog)}>
|
|
50
|
+
<Dialog.Title class="sr-only">{label}</Dialog.Title>
|
|
51
|
+
<Command {...restProps} bind:value class={s.command({ class: clsx(className) })}>
|
|
52
|
+
{@render children()}
|
|
53
|
+
</Command>
|
|
54
|
+
</div>
|
|
55
|
+
{/snippet}
|
|
56
|
+
</Dialog.Content>
|
|
57
|
+
</Dialog.Portal>
|
|
58
|
+
</Dialog.Root>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CommandDialogProps } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* A command palette: a `Command` in a modal overlay, opened from anywhere with ⌘K (Ctrl+K off Apple
|
|
4
|
+
* platforms). The same keys close it again, as do Esc and a click on the scrim.
|
|
5
|
+
*
|
|
6
|
+
* Put `CommandInput`, `CommandList` and the rest inside it, as with `Command`, and close it from an
|
|
7
|
+
* item's `onSelect` (`bind:open`).
|
|
8
|
+
*/
|
|
9
|
+
declare const CommandDialog: import("svelte").Component<CommandDialogProps, {}, "value" | "open">;
|
|
10
|
+
type CommandDialog = ReturnType<typeof CommandDialog>;
|
|
11
|
+
export default CommandDialog;
|