@noxlovette/material 0.7.1 → 0.8.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/references/motion-guide.md +9 -9
- package/dist/animation/containerTransform.d.ts +0 -27
- package/dist/animation/containerTransform.js +40 -9
- package/dist/components/containers/popover/theme.d.ts +3 -3
- package/dist/components/forms/checkbox/Checkbox.svelte +1 -1
- package/dist/components/forms/checkbox/theme.d.ts +0 -12
- package/dist/components/forms/checkbox/theme.js +2 -7
- package/dist/components/forms/search/Search.mdx +76 -8
- package/dist/components/forms/search/Search.stories.svelte +107 -0
- package/dist/components/forms/search/Search.stories.svelte.d.ts +2 -17
- package/dist/components/forms/search/Search.svelte +55 -2
- package/dist/components/forms/search/Search.svelte.d.ts +4 -1
- package/dist/components/forms/search/SearchView.svelte +269 -0
- package/dist/components/forms/search/SearchView.svelte.d.ts +21 -0
- package/dist/components/forms/search/index.d.ts +1 -0
- package/dist/components/forms/search/index.js +1 -0
- package/dist/components/forms/search/theme.d.ts +68 -1
- package/dist/components/forms/search/theme.js +91 -2
- package/dist/components/forms/search/types.d.ts +75 -2
- package/dist/components/nav/appbar/AppBar.mdx +5 -1
- package/dist/components/nav/appbar/AppBar.stories.svelte +38 -0
- package/dist/components/nav/appbar/AppBar.svelte +47 -2
- package/dist/components/nav/appbar/AppBar.svelte.d.ts +3 -2
- package/dist/components/nav/appbar/theme.js +1 -1
- package/dist/components/nav/appbar/types.d.ts +18 -1
- package/dist/components/table/theme.js +3 -1
- package/dist/components/time/TimeField.svelte +1 -4
- package/dist/styles/components.css +6 -0
- package/dist/styles/icon-font.css +17 -0
- package/dist/styles/motion.css +35 -0
- package/dist/utils/icon/MaterialSymbolsProvider.svelte +7 -1
- package/dist/utils/icon/MaterialSymbolsProvider.svelte.d.ts +3 -0
- package/dist/utils/icon/types.d.ts +11 -0
- package/package.json +2 -1
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
Material 3 search view (contained, M3 Expressive): where a search bar's suggestions and results go.
|
|
4
|
+
|
|
5
|
+
`Search` and the search `AppBar` render it for you when given `results`; use it directly only to
|
|
6
|
+
open a view from a bar of your own, passing that bar as `anchor`.
|
|
7
|
+
|
|
8
|
+
- **Layouts.** `fullScreen` covers the window; `docked` widens the bar in place (its margins go
|
|
9
|
+
from 24dp to 12dp) with results in a container 2dp below. `layout` takes one per window tier and
|
|
10
|
+
defaults to full-screen on compact windows, docked from medium up. Switched in CSS.
|
|
11
|
+
- **Motion.** Opening and closing are an M3 container transform between the bar and the view, on
|
|
12
|
+
the `spatial` spring; a crossfade under reduced motion.
|
|
13
|
+
- **Behavior.** A bits-ui `Dialog`: focus stays in the view, the page doesn't scroll, and Esc, the
|
|
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.
|
|
16
|
+
|
|
17
|
+
@see https://m3.material.io/components/search/specs
|
|
18
|
+
-->
|
|
19
|
+
<script lang="ts">
|
|
20
|
+
import { tick, untrack } from 'svelte';
|
|
21
|
+
import { Dialog } from 'bits-ui';
|
|
22
|
+
import clsx from 'clsx';
|
|
23
|
+
import { containerTransform } from '../../../animation/index.js';
|
|
24
|
+
import ButtonIcon from '../../buttons/ButtonIcon.svelte';
|
|
25
|
+
import { defaultSearchLayout, searchView, searchViewLayout } from './theme.js';
|
|
26
|
+
import type { SearchViewProps } from './types.js';
|
|
27
|
+
|
|
28
|
+
const uid = $props.id();
|
|
29
|
+
|
|
30
|
+
let {
|
|
31
|
+
open = $bindable(false),
|
|
32
|
+
value = $bindable(),
|
|
33
|
+
anchor,
|
|
34
|
+
results,
|
|
35
|
+
layout = defaultSearchLayout,
|
|
36
|
+
placeholder = 'Search',
|
|
37
|
+
resultsLabel,
|
|
38
|
+
backLabel = 'Back',
|
|
39
|
+
clearLabel = 'Clear search',
|
|
40
|
+
trailing,
|
|
41
|
+
inputProps,
|
|
42
|
+
inputRef = $bindable()
|
|
43
|
+
}: SearchViewProps = $props();
|
|
44
|
+
|
|
45
|
+
const listboxId = `${uid}-listbox`;
|
|
46
|
+
const label = $derived(resultsLabel ?? placeholder ?? 'Search');
|
|
47
|
+
const s = $derived(searchView({ hasTrailing: !!value || !!trailing }));
|
|
48
|
+
const placed = $derived(searchViewLayout(layout));
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
`open` is what was asked for; `shown` is what's on screen. The container transform flips
|
|
52
|
+
`shown` inside its update, so the View Transition API snapshots the bar before and the view
|
|
53
|
+
after (or the reverse). One transform at a time: a request made mid-flight is picked up when
|
|
54
|
+
the running one settles, so a quick open-close never strands the view.
|
|
55
|
+
*/
|
|
56
|
+
let shown = $state(untrack(() => open));
|
|
57
|
+
let view = $state<HTMLElement>();
|
|
58
|
+
let busy = false;
|
|
59
|
+
|
|
60
|
+
const sync = () => {
|
|
61
|
+
if (busy || open === shown) return;
|
|
62
|
+
const opening = open;
|
|
63
|
+
if (opening) measure();
|
|
64
|
+
const update = async () => {
|
|
65
|
+
// The view takes the bar's place: hidden, the bar isn't left behind in the page's snapshot
|
|
66
|
+
// (a second, static bar under the morph). Shown again first on close, so focus can return.
|
|
67
|
+
hideAnchor(opening);
|
|
68
|
+
shown = opening;
|
|
69
|
+
await tick();
|
|
70
|
+
};
|
|
71
|
+
const from = opening ? anchor : view;
|
|
72
|
+
const to = opening ? `[data-search-view="${uid}"]` : anchor;
|
|
73
|
+
busy = true;
|
|
74
|
+
const settle = () => {
|
|
75
|
+
busy = false;
|
|
76
|
+
sync();
|
|
77
|
+
};
|
|
78
|
+
if (!from || !to) update().then(settle, settle);
|
|
79
|
+
else containerTransform(update, { from, to }).then(settle, settle);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
$effect(() => {
|
|
83
|
+
void open;
|
|
84
|
+
untrack(sync);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const hideAnchor = (hidden: boolean) => {
|
|
88
|
+
if (anchor) anchor.style.visibility = hidden ? 'hidden' : '';
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Covers a view that starts open, and a bar swapped while it's open.
|
|
92
|
+
$effect(() => {
|
|
93
|
+
if (!anchor) return;
|
|
94
|
+
hideAnchor(untrack(() => shown));
|
|
95
|
+
return () => hideAnchor(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/*
|
|
99
|
+
Docked: the view's bar is the search bar widened by the 12dp contained margin on each side,
|
|
100
|
+
held to 360–720dp and to the window; results may grow to ⅔ of the window, stopping 16dp
|
|
101
|
+
above its bottom edge. Read in px from the bar and the --md-comp-search-view-* tokens.
|
|
102
|
+
*/
|
|
103
|
+
let pos = $state({ top: 0, left: 0, width: 0, room: 0 });
|
|
104
|
+
|
|
105
|
+
function measure() {
|
|
106
|
+
if (!anchor) return;
|
|
107
|
+
const r = anchor.getBoundingClientRect();
|
|
108
|
+
const root = getComputedStyle(document.documentElement);
|
|
109
|
+
const rem = parseFloat(root.fontSize) || 16;
|
|
110
|
+
const token = (name: string, fallback: number) =>
|
|
111
|
+
parseFloat(
|
|
112
|
+
root.getPropertyValue(`--md-comp-search-view-contained-docked-container-width-${name}`)
|
|
113
|
+
) * rem || fallback;
|
|
114
|
+
const margin = 0.75 * rem;
|
|
115
|
+
const width = Math.min(
|
|
116
|
+
Math.max(r.width + 2 * margin, token('minimum', 0)),
|
|
117
|
+
token('maximum', Infinity),
|
|
118
|
+
innerWidth
|
|
119
|
+
);
|
|
120
|
+
const left = Math.max(0, Math.min(r.left - margin, innerWidth - width));
|
|
121
|
+
pos = { top: r.top, left, width, room: innerHeight - r.top - rem };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
$effect(() => {
|
|
125
|
+
if (!shown) return;
|
|
126
|
+
addEventListener('resize', measure);
|
|
127
|
+
return () => removeEventListener('resize', measure);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/* Combobox: the input keeps focus; aria-activedescendant points at the highlighted option. */
|
|
131
|
+
let resultsEl = $state<HTMLElement>();
|
|
132
|
+
let active = $state<HTMLElement | null>(null);
|
|
133
|
+
let optionIds = 0;
|
|
134
|
+
|
|
135
|
+
const options = () =>
|
|
136
|
+
[...(resultsEl?.querySelectorAll<HTMLElement>('[role="option"]') ?? [])].filter(
|
|
137
|
+
(o) => o.getAttribute('aria-disabled') !== 'true' && !o.hasAttribute('disabled')
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const highlight = (el: HTMLElement | null) => {
|
|
141
|
+
active?.removeAttribute('data-highlighted');
|
|
142
|
+
if (el) {
|
|
143
|
+
el.id ||= `${uid}-option-${optionIds++}`;
|
|
144
|
+
el.setAttribute('data-highlighted', '');
|
|
145
|
+
el.scrollIntoView?.({ block: 'nearest' });
|
|
146
|
+
}
|
|
147
|
+
active = el;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// A new query means new results: start over from the field.
|
|
151
|
+
$effect(() => {
|
|
152
|
+
void value;
|
|
153
|
+
void shown;
|
|
154
|
+
untrack(() => highlight(null));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
function onkeydown(e: KeyboardEvent & { currentTarget: HTMLInputElement }) {
|
|
158
|
+
inputProps?.onkeydown?.(e);
|
|
159
|
+
if (e.defaultPrevented) return;
|
|
160
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
161
|
+
const list = options();
|
|
162
|
+
if (!list.length) return;
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
const i = active ? list.indexOf(active) : -1;
|
|
165
|
+
const next = e.key === 'ArrowDown' ? (i + 1) % list.length : i <= 0 ? list.length - 1 : i - 1;
|
|
166
|
+
highlight(list[next]);
|
|
167
|
+
} else if (e.key === 'Enter' && active?.isConnected) {
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
active.click();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function focusInput(e: Event) {
|
|
174
|
+
e.preventDefault();
|
|
175
|
+
const end = inputRef?.value.length ?? 0;
|
|
176
|
+
inputRef?.focus();
|
|
177
|
+
inputRef?.setSelectionRange(end, end);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Back to the bar's field. The bar opens on click or typing, never on focus, so this is safe.
|
|
181
|
+
function focusBar(e: Event) {
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
const field = anchor instanceof HTMLInputElement ? anchor : anchor?.querySelector('input');
|
|
184
|
+
field?.focus();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function clear() {
|
|
188
|
+
value = '';
|
|
189
|
+
inputRef?.focus();
|
|
190
|
+
}
|
|
191
|
+
</script>
|
|
192
|
+
|
|
193
|
+
<!--
|
|
194
|
+
forceMount + {#if shown}: the view mounts and unmounts in the same tick as `shown`, inside the
|
|
195
|
+
transform's update. bits-ui's own presence would unmount a frame later, after the snapshot.
|
|
196
|
+
-->
|
|
197
|
+
<Dialog.Root
|
|
198
|
+
bind:open={
|
|
199
|
+
() => shown,
|
|
200
|
+
(next) => {
|
|
201
|
+
if (!next) open = false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
>
|
|
205
|
+
<Dialog.Portal>
|
|
206
|
+
<Dialog.Content forceMount onOpenAutoFocus={focusInput} onCloseAutoFocus={focusBar}>
|
|
207
|
+
{#snippet child({ props })}
|
|
208
|
+
{#if shown}
|
|
209
|
+
<div
|
|
210
|
+
{...props}
|
|
211
|
+
bind:this={view}
|
|
212
|
+
data-search-view={uid}
|
|
213
|
+
class={s.base({ class: placed.base })}
|
|
214
|
+
style:--sv-top="{pos.top}px"
|
|
215
|
+
style:--sv-left="{pos.left}px"
|
|
216
|
+
style:--sv-width="{pos.width}px"
|
|
217
|
+
style:--sv-max-height="min(var(--md-comp-search-view-contained-docked-container-height-maximum),
|
|
218
|
+
{pos.room}px)"
|
|
219
|
+
>
|
|
220
|
+
<Dialog.Title class="sr-only">{label}</Dialog.Title>
|
|
221
|
+
<div class={s.bar()}>
|
|
222
|
+
<span class={s.leading()}>
|
|
223
|
+
<ButtonIcon
|
|
224
|
+
variant="standard"
|
|
225
|
+
type="button"
|
|
226
|
+
iconProps={{ name: 'arrow_back' }}
|
|
227
|
+
aria-label={backLabel}
|
|
228
|
+
onclick={() => (open = false)}
|
|
229
|
+
/>
|
|
230
|
+
</span>
|
|
231
|
+
<input
|
|
232
|
+
{...inputProps}
|
|
233
|
+
bind:this={inputRef}
|
|
234
|
+
bind:value
|
|
235
|
+
type="search"
|
|
236
|
+
role="combobox"
|
|
237
|
+
aria-autocomplete="list"
|
|
238
|
+
aria-expanded="true"
|
|
239
|
+
aria-controls={listboxId}
|
|
240
|
+
aria-activedescendant={active?.id}
|
|
241
|
+
aria-label={inputProps?.['aria-label'] ?? placeholder}
|
|
242
|
+
{placeholder}
|
|
243
|
+
class={s.input({ class: clsx(inputProps?.class) })}
|
|
244
|
+
{onkeydown}
|
|
245
|
+
/>
|
|
246
|
+
{#if value || trailing}
|
|
247
|
+
<span class={s.trailing()}>
|
|
248
|
+
{#if value}
|
|
249
|
+
<ButtonIcon
|
|
250
|
+
variant="standard"
|
|
251
|
+
type="button"
|
|
252
|
+
iconProps={{ name: 'close' }}
|
|
253
|
+
aria-label={clearLabel}
|
|
254
|
+
onclick={clear}
|
|
255
|
+
/>
|
|
256
|
+
{/if}
|
|
257
|
+
{@render trailing?.()}
|
|
258
|
+
</span>
|
|
259
|
+
{/if}
|
|
260
|
+
</div>
|
|
261
|
+
<div bind:this={resultsEl} class={s.results({ class: placed.results })}>
|
|
262
|
+
{@render results({ id: listboxId, role: 'listbox', 'aria-label': label })}
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
{/if}
|
|
266
|
+
{/snippet}
|
|
267
|
+
</Dialog.Content>
|
|
268
|
+
</Dialog.Portal>
|
|
269
|
+
</Dialog.Root>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SearchViewProps } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Material 3 search view (contained, M3 Expressive): where a search bar's suggestions and results go.
|
|
4
|
+
*
|
|
5
|
+
* `Search` and the search `AppBar` render it for you when given `results`; use it directly only to
|
|
6
|
+
* open a view from a bar of your own, passing that bar as `anchor`.
|
|
7
|
+
*
|
|
8
|
+
* - **Layouts.** `fullScreen` covers the window; `docked` widens the bar in place (its margins go
|
|
9
|
+
* from 24dp to 12dp) with results in a container 2dp below. `layout` takes one per window tier and
|
|
10
|
+
* defaults to full-screen on compact windows, docked from medium up. Switched in CSS.
|
|
11
|
+
* - **Motion.** Opening and closing are an M3 container transform between the bar and the view, on
|
|
12
|
+
* the `spatial` spring; a crossfade under reduced motion.
|
|
13
|
+
* - **Behavior.** A bits-ui `Dialog`: focus stays in the view, the page doesn't scroll, and Esc, the
|
|
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.
|
|
16
|
+
*
|
|
17
|
+
* @see https://m3.material.io/components/search/specs
|
|
18
|
+
*/
|
|
19
|
+
declare const SearchView: import("svelte").Component<SearchViewProps, {}, "value" | "open" | "inputRef">;
|
|
20
|
+
type SearchView = ReturnType<typeof SearchView>;
|
|
21
|
+
export default SearchView;
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { type VariantProps } from '../../../utils/tv.js';
|
|
2
|
+
import type { Responsive } from '../../containers/pane/theme.js';
|
|
3
|
+
import type { SearchLayout } from './types.js';
|
|
2
4
|
export type SearchVariants = VariantProps<typeof search>;
|
|
3
5
|
/**
|
|
4
6
|
* M3 Expressive contained search bar (https://m3.material.io/components/search/specs):
|
|
5
7
|
* 56dp, circular, surface-container-high, flat. 48dp leading/trailing targets at a 4dp inset,
|
|
6
8
|
* 4dp between the leading element and the label, 0 between trailing actions; 16dp at an edge
|
|
7
|
-
* with no action.
|
|
9
|
+
* with no action. Circular as `rounded-xl` (28dp, half the height), not `rounded-full`: the
|
|
10
|
+
* search view's container transform interpolates the corner radius, and an infinite one balloons
|
|
11
|
+
* the bar into a circle mid-morph. The focus ring is dropped while a view transition runs: it's
|
|
12
|
+
* outside the bar's box, and a snapshot of it would trail the morph as a stray arc. Hover 8% / pressed 10% on-surface; focus 3dp secondary outline, 2dp offset.
|
|
8
13
|
*/
|
|
9
14
|
export declare const search: import("tailwind-variants").TVReturnType<{
|
|
10
15
|
hasLeading: {
|
|
@@ -64,3 +69,65 @@ export declare const search: import("tailwind-variants").TVReturnType<{
|
|
|
64
69
|
input: string;
|
|
65
70
|
trailing: string;
|
|
66
71
|
}>>;
|
|
72
|
+
export type SearchViewVariants = VariantProps<typeof searchView>;
|
|
73
|
+
/**
|
|
74
|
+
* M3 Expressive search view, contained style (https://m3.material.io/components/search/specs).
|
|
75
|
+
* In both layouts the bar is a 56dp circular surface-container-high field with a back button,
|
|
76
|
+
* 12dp from the view's edges (`search-view.contained.leading/trailing-margin`). The layout table
|
|
77
|
+
* below places the view and colours its container.
|
|
78
|
+
*/
|
|
79
|
+
export declare const searchView: import("tailwind-variants").TVReturnType<{
|
|
80
|
+
hasTrailing: {
|
|
81
|
+
true: "";
|
|
82
|
+
false: {
|
|
83
|
+
input: string;
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
}, {
|
|
87
|
+
base: string;
|
|
88
|
+
bar: string;
|
|
89
|
+
leading: string;
|
|
90
|
+
input: string;
|
|
91
|
+
trailing: string;
|
|
92
|
+
results: string;
|
|
93
|
+
}, undefined, {
|
|
94
|
+
hasTrailing: {
|
|
95
|
+
true: "";
|
|
96
|
+
false: {
|
|
97
|
+
input: string;
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
}, {
|
|
101
|
+
base: string;
|
|
102
|
+
bar: string;
|
|
103
|
+
leading: string;
|
|
104
|
+
input: string;
|
|
105
|
+
trailing: string;
|
|
106
|
+
results: string;
|
|
107
|
+
}, import("tailwind-variants").TVReturnTypeLike<{
|
|
108
|
+
hasTrailing: {
|
|
109
|
+
true: "";
|
|
110
|
+
false: {
|
|
111
|
+
input: string;
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
}, {
|
|
115
|
+
base: string;
|
|
116
|
+
bar: string;
|
|
117
|
+
leading: string;
|
|
118
|
+
input: string;
|
|
119
|
+
trailing: string;
|
|
120
|
+
results: string;
|
|
121
|
+
}>>;
|
|
122
|
+
type ViewSlot = 'base' | 'results';
|
|
123
|
+
/** M3's layout guidance: full-screen on compact windows, docked from medium up. */
|
|
124
|
+
export declare const defaultSearchLayout: {
|
|
125
|
+
readonly small: "fullScreen";
|
|
126
|
+
readonly medium: "docked";
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* Resolves a responsive search view layout into classes for the view's slots. Tiers left out
|
|
130
|
+
* inherit from the one below; below the first given tier the view is full-screen.
|
|
131
|
+
*/
|
|
132
|
+
export declare function searchViewLayout(layout: Responsive<SearchLayout>): Record<ViewSlot, string>;
|
|
133
|
+
export {};
|
|
@@ -3,11 +3,14 @@ import { tv } from '../../../utils/tv.js';
|
|
|
3
3
|
* M3 Expressive contained search bar (https://m3.material.io/components/search/specs):
|
|
4
4
|
* 56dp, circular, surface-container-high, flat. 48dp leading/trailing targets at a 4dp inset,
|
|
5
5
|
* 4dp between the leading element and the label, 0 between trailing actions; 16dp at an edge
|
|
6
|
-
* with no action.
|
|
6
|
+
* with no action. Circular as `rounded-xl` (28dp, half the height), not `rounded-full`: the
|
|
7
|
+
* search view's container transform interpolates the corner radius, and an infinite one balloons
|
|
8
|
+
* the bar into a circle mid-morph. The focus ring is dropped while a view transition runs: it's
|
|
9
|
+
* outside the bar's box, and a snapshot of it would trail the morph as a stray arc. Hover 8% / pressed 10% on-surface; focus 3dp secondary outline, 2dp offset.
|
|
7
10
|
*/
|
|
8
11
|
export const search = tv({
|
|
9
12
|
slots: {
|
|
10
|
-
base: 'state-layer relative flex h-spacing-700 w-full max-w-[720px]
|
|
13
|
+
base: 'state-layer relative flex h-spacing-700 w-full max-w-[720px] cursor-text items-center rounded-xl bg-md-sys-color-surface-container-high ps-spacing-50 pe-spacing-50 before:rounded-xl hover:before:bg-md-sys-color-on-surface/8 active:before:bg-md-sys-color-on-surface/10 has-[input:focus-visible]:outline-3 has-[input:focus-visible]:outline-offset-2 has-[input:focus-visible]:outline-md-sys-color-secondary [:root:active-view-transition_&]:outline-0',
|
|
11
14
|
leading: 'text-md-sys-color-on-surface relative flex shrink-0 items-center',
|
|
12
15
|
leadingIcon: 'text-md-sys-color-on-surface grid size-spacing-600 shrink-0 place-items-center',
|
|
13
16
|
input: 'md-sys-typescale-body-large text-md-sys-color-on-surface placeholder:text-md-sys-color-on-surface-variant relative ms-spacing-50 me-spacing-50 w-full min-w-spacing-0 bg-transparent outline-none [&::-webkit-search-cancel-button]:hidden',
|
|
@@ -18,3 +21,89 @@ export const search = tv({
|
|
|
18
21
|
hasTrailing: { true: '', false: { input: 'me-spacing-150' } }
|
|
19
22
|
}
|
|
20
23
|
});
|
|
24
|
+
/**
|
|
25
|
+
* M3 Expressive search view, contained style (https://m3.material.io/components/search/specs).
|
|
26
|
+
* In both layouts the bar is a 56dp circular surface-container-high field with a back button,
|
|
27
|
+
* 12dp from the view's edges (`search-view.contained.leading/trailing-margin`). The layout table
|
|
28
|
+
* below places the view and colours its container.
|
|
29
|
+
*/
|
|
30
|
+
export const searchView = tv({
|
|
31
|
+
slots: {
|
|
32
|
+
base: 'fixed z-layer-modal flex flex-col outline-none',
|
|
33
|
+
bar: 'flex h-spacing-700 w-full shrink-0 items-center rounded-xl bg-md-sys-color-surface-container-high ps-spacing-50 pe-spacing-50',
|
|
34
|
+
leading: 'text-md-sys-color-on-surface flex shrink-0 items-center',
|
|
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
|
+
trailing: 'text-md-sys-color-on-surface-variant flex shrink-0 items-center',
|
|
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'
|
|
40
|
+
},
|
|
41
|
+
variants: {
|
|
42
|
+
hasTrailing: { true: '', false: { input: 'me-spacing-150' } }
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
/*
|
|
46
|
+
Every class is a literal so the Tailwind scanner sees it; `searchViewLayout` picks one row per
|
|
47
|
+
tier. Each row restates every property a tier changes, so a smaller tier never leaks upward.
|
|
48
|
+
|
|
49
|
+
Full-screen: the whole window, surface-container-low, 0 radius; the bar sits 12dp in from the
|
|
50
|
+
sides and 8dp from the top, so it lines up with a 64dp app bar. The results run edge to edge.
|
|
51
|
+
Docked: the bar widened over the search bar (JS measures it into --sv-*), results 2dp below in
|
|
52
|
+
a 12dp-radius surface-container-high container. The transparent wrapper takes their corners
|
|
53
|
+
(28dp top, 12dp bottom) only so the container transform morphs into the right shape. 360–720dp wide, 240dp to ⅔ of the window tall.
|
|
54
|
+
*/
|
|
55
|
+
const layoutClasses = {
|
|
56
|
+
small: {
|
|
57
|
+
fullScreen: {
|
|
58
|
+
base: 'top-spacing-0 left-spacing-0 w-full min-w-spacing-0 max-w-none h-dvh min-h-spacing-0 max-h-none gap-spacing-100 px-spacing-150 pt-spacing-100 rounded-none bg-md-sys-color-surface-container-low',
|
|
59
|
+
results: 'flex-1 -mx-spacing-150 rounded-none bg-transparent'
|
|
60
|
+
},
|
|
61
|
+
docked: {
|
|
62
|
+
base: 'top-(--sv-top) left-(--sv-left) w-(--sv-width) min-w-(--md-comp-search-view-contained-docked-container-width-minimum) max-w-(--md-comp-search-view-contained-docked-container-width-maximum) h-auto min-h-(--md-comp-search-view-contained-docked-container-height-minimum) max-h-(--sv-max-height) gap-spacing-25 px-spacing-0 pt-spacing-0 rounded-t-xl rounded-b-md bg-transparent',
|
|
63
|
+
results: 'flex-1 mx-spacing-0 rounded-md bg-md-sys-color-surface-container-high'
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
medium: {
|
|
67
|
+
fullScreen: {
|
|
68
|
+
base: 'md:top-spacing-0 md:left-spacing-0 md:w-full md:min-w-spacing-0 md:max-w-none md:h-dvh md:min-h-spacing-0 md:max-h-none md:gap-spacing-100 md:px-spacing-150 md:pt-spacing-100 md:rounded-none md:bg-md-sys-color-surface-container-low',
|
|
69
|
+
results: 'md:flex-1 md:-mx-spacing-150 md:rounded-none md:bg-transparent'
|
|
70
|
+
},
|
|
71
|
+
docked: {
|
|
72
|
+
base: 'md:top-(--sv-top) md:left-(--sv-left) md:w-(--sv-width) md:min-w-(--md-comp-search-view-contained-docked-container-width-minimum) md:max-w-(--md-comp-search-view-contained-docked-container-width-maximum) md:h-auto md:min-h-(--md-comp-search-view-contained-docked-container-height-minimum) md:max-h-(--sv-max-height) md:gap-spacing-25 md:px-spacing-0 md:pt-spacing-0 md:rounded-t-xl md:rounded-b-md md:bg-transparent',
|
|
73
|
+
results: 'md:flex-1 md:mx-spacing-0 md:rounded-md md:bg-md-sys-color-surface-container-high'
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
large: {
|
|
77
|
+
fullScreen: {
|
|
78
|
+
base: 'lg:top-spacing-0 lg:left-spacing-0 lg:w-full lg:min-w-spacing-0 lg:max-w-none lg:h-dvh lg:min-h-spacing-0 lg:max-h-none lg:gap-spacing-100 lg:px-spacing-150 lg:pt-spacing-100 lg:rounded-none lg:bg-md-sys-color-surface-container-low',
|
|
79
|
+
results: 'lg:flex-1 lg:-mx-spacing-150 lg:rounded-none lg:bg-transparent'
|
|
80
|
+
},
|
|
81
|
+
docked: {
|
|
82
|
+
base: 'lg:top-(--sv-top) lg:left-(--sv-left) lg:w-(--sv-width) lg:min-w-(--md-comp-search-view-contained-docked-container-width-minimum) lg:max-w-(--md-comp-search-view-contained-docked-container-width-maximum) lg:h-auto lg:min-h-(--md-comp-search-view-contained-docked-container-height-minimum) lg:max-h-(--sv-max-height) lg:gap-spacing-25 lg:px-spacing-0 lg:pt-spacing-0 lg:rounded-t-xl lg:rounded-b-md lg:bg-transparent',
|
|
83
|
+
results: 'lg:flex-1 lg:mx-spacing-0 lg:rounded-md lg:bg-md-sys-color-surface-container-high'
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
extraLarge: {
|
|
87
|
+
fullScreen: {
|
|
88
|
+
base: 'xl:top-spacing-0 xl:left-spacing-0 xl:w-full xl:min-w-spacing-0 xl:max-w-none xl:h-dvh xl:min-h-spacing-0 xl:max-h-none xl:gap-spacing-100 xl:px-spacing-150 xl:pt-spacing-100 xl:rounded-none xl:bg-md-sys-color-surface-container-low',
|
|
89
|
+
results: 'xl:flex-1 xl:-mx-spacing-150 xl:rounded-none xl:bg-transparent'
|
|
90
|
+
},
|
|
91
|
+
docked: {
|
|
92
|
+
base: 'xl:top-(--sv-top) xl:left-(--sv-left) xl:w-(--sv-width) xl:min-w-(--md-comp-search-view-contained-docked-container-width-minimum) xl:max-w-(--md-comp-search-view-contained-docked-container-width-maximum) xl:h-auto xl:min-h-(--md-comp-search-view-contained-docked-container-height-minimum) xl:max-h-(--sv-max-height) xl:gap-spacing-25 xl:px-spacing-0 xl:pt-spacing-0 xl:rounded-t-xl xl:rounded-b-md xl:bg-transparent',
|
|
93
|
+
results: 'xl:flex-1 xl:mx-spacing-0 xl:rounded-md xl:bg-md-sys-color-surface-container-high'
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
/** M3's layout guidance: full-screen on compact windows, docked from medium up. */
|
|
98
|
+
export const defaultSearchLayout = { small: 'fullScreen', medium: 'docked' };
|
|
99
|
+
/**
|
|
100
|
+
* Resolves a responsive search view layout into classes for the view's slots. Tiers left out
|
|
101
|
+
* inherit from the one below; below the first given tier the view is full-screen.
|
|
102
|
+
*/
|
|
103
|
+
export function searchViewLayout(layout) {
|
|
104
|
+
const tiers = typeof layout === 'string' ? { small: layout } : { small: 'fullScreen', ...layout };
|
|
105
|
+
const pick = (slot) => Object.entries(tiers)
|
|
106
|
+
.map(([bp, value]) => layoutClasses[bp][value][slot])
|
|
107
|
+
.join(' ');
|
|
108
|
+
return { base: pick('base'), results: pick('results') };
|
|
109
|
+
}
|
|
@@ -1,10 +1,78 @@
|
|
|
1
1
|
import type { IconProps } from '../../../utils/index.js';
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
4
|
+
import type { Responsive } from '../../containers/pane/theme.js';
|
|
5
|
+
/**
|
|
6
|
+
* Where the search view puts its suggestions and results
|
|
7
|
+
* (https://m3.material.io/components/search/guidelines).
|
|
8
|
+
* - `fullScreen`: the view covers the window; for compact windows.
|
|
9
|
+
* - `docked`: the bar widens in place and results open in a container below it.
|
|
10
|
+
*/
|
|
11
|
+
export type SearchLayout = 'fullScreen' | 'docked';
|
|
12
|
+
/**
|
|
13
|
+
* Handed to the `results` snippet. Spread it onto the `List` that holds the suggestions, and give
|
|
14
|
+
* each item `role="option"`, so the field can drive it as a combobox.
|
|
15
|
+
*
|
|
16
|
+
* ```svelte
|
|
17
|
+
* {#snippet results(listbox)}
|
|
18
|
+
* <List {...listbox}>
|
|
19
|
+
* <ListItem role="option" asChild headline="Material" onclick={pick} />
|
|
20
|
+
* </List>
|
|
21
|
+
* {/snippet}
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export interface SearchResultsProps {
|
|
25
|
+
id: string;
|
|
26
|
+
role: 'listbox';
|
|
27
|
+
'aria-label': string;
|
|
28
|
+
}
|
|
29
|
+
/** Props shared by the search view and the components that open it. */
|
|
30
|
+
export interface SearchViewOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Suggestions and results, shown in the search view. Setting it gives the bar a search view,
|
|
33
|
+
* opened by clicking or typing in the bar.
|
|
34
|
+
*/
|
|
35
|
+
results?: Snippet<[SearchResultsProps]>;
|
|
36
|
+
/**
|
|
37
|
+
* The search view's layout, one value or one per window tier.
|
|
38
|
+
* @default { small: 'fullScreen', medium: 'docked' }
|
|
39
|
+
*/
|
|
40
|
+
layout?: Responsive<SearchLayout>;
|
|
41
|
+
/** Accessible label for the back button that closes the search view. */
|
|
42
|
+
backLabel?: string;
|
|
43
|
+
/** Accessible name of the search view and its suggestion list. Defaults to the placeholder. */
|
|
44
|
+
resultsLabel?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Props for the SearchView component.
|
|
48
|
+
*/
|
|
49
|
+
export interface SearchViewProps extends Omit<SearchViewOptions, 'results'> {
|
|
50
|
+
/** Whether the view is open. Opening and closing morph between `anchor` and the view. */
|
|
51
|
+
open?: boolean;
|
|
52
|
+
/** The search query, shared with the bar that opened the view. */
|
|
53
|
+
value?: string | null;
|
|
54
|
+
/**
|
|
55
|
+
* The search bar the view opens from: the container transform starts there, the docked layout
|
|
56
|
+
* is placed over it, and focus returns to its input on close.
|
|
57
|
+
*/
|
|
58
|
+
anchor: HTMLElement | undefined;
|
|
59
|
+
/** Suggestions and results. */
|
|
60
|
+
results: Snippet<[SearchResultsProps]>;
|
|
61
|
+
/** Hint text in the view's field. */
|
|
62
|
+
placeholder?: string;
|
|
63
|
+
/** Actions after the clear button in the view's bar, e.g. a mic icon button. */
|
|
64
|
+
trailing?: Snippet;
|
|
65
|
+
/** Accessible label for the clear button. */
|
|
66
|
+
clearLabel?: string;
|
|
67
|
+
/** Extra props for the view's `<input>`. */
|
|
68
|
+
inputProps?: HTMLInputAttributes;
|
|
69
|
+
/** Reference to the view's `<input>` while it is open. */
|
|
70
|
+
inputRef?: HTMLInputElement;
|
|
71
|
+
}
|
|
4
72
|
/**
|
|
5
73
|
* Props for the Search component.
|
|
6
74
|
*/
|
|
7
|
-
export interface SearchProps extends Omit<HTMLInputAttributes, 'size'
|
|
75
|
+
export interface SearchProps extends Omit<HTMLInputAttributes, 'size' | 'results'>, SearchViewOptions {
|
|
8
76
|
/**
|
|
9
77
|
* The current value of the search input.
|
|
10
78
|
*/
|
|
@@ -13,12 +81,17 @@ export interface SearchProps extends Omit<HTMLInputAttributes, 'size'> {
|
|
|
13
81
|
* Reference to the HTML input element.
|
|
14
82
|
*/
|
|
15
83
|
elementRef?: HTMLInputElement;
|
|
84
|
+
/**
|
|
85
|
+
* Whether the search view is open. Only has an effect with `results`.
|
|
86
|
+
*/
|
|
87
|
+
open?: boolean;
|
|
16
88
|
/**
|
|
17
89
|
* A leading action, e.g. a menu or back icon button. Replaces the leading icon.
|
|
18
90
|
*/
|
|
19
91
|
leading?: Snippet;
|
|
20
92
|
/**
|
|
21
|
-
* Trailing actions and/or an avatar, e.g. a mic icon button. Rendered after the clear button
|
|
93
|
+
* Trailing actions and/or an avatar, e.g. a mic icon button. Rendered after the clear button,
|
|
94
|
+
* in the bar and in the search view.
|
|
22
95
|
*/
|
|
23
96
|
trailing?: Snippet;
|
|
24
97
|
/**
|
|
@@ -24,12 +24,16 @@ The props form a union, so the bar is one of two kinds:
|
|
|
24
24
|
`{ small: 'small', large: 'large' }` to grow the bar on wider windows. The switch happens in CSS,
|
|
25
25
|
so server rendering needs no JS.
|
|
26
26
|
- **Search bar.** Pass `search` (the hint text) instead. The bar is always 64dp. `title` then
|
|
27
|
-
becomes a screen-reader-only heading.
|
|
27
|
+
becomes a screen-reader-only heading. Per M3, selecting the field should open the search view:
|
|
28
|
+
pass `searchResults` (and optionally `bind:searchOpen`, `searchLayout`) and it opens the same
|
|
29
|
+
view as `Search`, with a container transform from the field. See Forms/Search for the view.
|
|
28
30
|
|
|
29
31
|
<Canvas of={AppBarStories.MediumFlexible} />
|
|
30
32
|
|
|
31
33
|
<Canvas of={AppBarStories.Search} />
|
|
32
34
|
|
|
35
|
+
<Canvas of={AppBarStories.SearchView} />
|
|
36
|
+
|
|
33
37
|
## Placement
|
|
34
38
|
|
|
35
39
|
The bar is fixed at the top at every window size. Pass `ghost` to render a same-height spacer
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
|
3
3
|
import AppBar from './AppBar.svelte';
|
|
4
4
|
import ButtonIcon from '../../buttons/ButtonIcon.svelte';
|
|
5
|
+
import List from '../../containers/list/List.svelte';
|
|
6
|
+
import ListItem from '../../containers/list/ListItem.svelte';
|
|
5
7
|
|
|
6
8
|
const { Story } = defineMeta({
|
|
7
9
|
title: 'Navigation/App Bar',
|
|
@@ -30,6 +32,11 @@
|
|
|
30
32
|
|
|
31
33
|
<script lang="ts">
|
|
32
34
|
let scroller = $state<HTMLElement | null>(null);
|
|
35
|
+
|
|
36
|
+
const products = ['Headphones', 'Keyboard', 'Laptop stand', 'Monitor', 'Mouse', 'Webcam'];
|
|
37
|
+
let query = $state('');
|
|
38
|
+
let searchOpen = $state(false);
|
|
39
|
+
const found = $derived(products.filter((p) => p.toLowerCase().includes(query.toLowerCase())));
|
|
33
40
|
</script>
|
|
34
41
|
|
|
35
42
|
<Story name="Playground">
|
|
@@ -125,6 +132,37 @@
|
|
|
125
132
|
</AppBar>
|
|
126
133
|
</Story>
|
|
127
134
|
|
|
135
|
+
<!-- Selecting the search field opens the search view: full-screen here, docked from medium up. -->
|
|
136
|
+
<Story
|
|
137
|
+
name="Search view"
|
|
138
|
+
asChild
|
|
139
|
+
parameters={{ docs: { story: { inline: false, height: '560px' } } }}
|
|
140
|
+
>
|
|
141
|
+
<AppBar search="Search products" title="Products" bind:query bind:searchOpen ghost>
|
|
142
|
+
{#snippet leading()}
|
|
143
|
+
<ButtonIcon variant="standard" iconProps={{ name: 'menu' }} aria-label="Menu" />
|
|
144
|
+
{/snippet}
|
|
145
|
+
{#snippet searchTrailing()}
|
|
146
|
+
<ButtonIcon variant="standard" iconProps={{ name: 'mic' }} aria-label="Voice search" />
|
|
147
|
+
{/snippet}
|
|
148
|
+
{#snippet searchResults(listbox)}
|
|
149
|
+
<List {...listbox}>
|
|
150
|
+
{#each found as product (product)}
|
|
151
|
+
<ListItem
|
|
152
|
+
role="option"
|
|
153
|
+
asChild
|
|
154
|
+
headline={product}
|
|
155
|
+
onclick={() => {
|
|
156
|
+
query = product;
|
|
157
|
+
searchOpen = false;
|
|
158
|
+
}}
|
|
159
|
+
/>
|
|
160
|
+
{/each}
|
|
161
|
+
</List>
|
|
162
|
+
{/snippet}
|
|
163
|
+
</AppBar>
|
|
164
|
+
</Story>
|
|
165
|
+
|
|
128
166
|
<Story name="Scroll container" asChild>
|
|
129
167
|
<div bind:this={scroller} class="h-[480px] overflow-y-auto">
|
|
130
168
|
<AppBar title="Scroll this box" scrollContainer={scroller} ghost />
|