@alacris/ui 0.0.0 → 0.1.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.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -1
  3. package/package.json +75 -3
  4. package/src/components/base.js +46 -0
  5. package/src/components/ui-accordion-item.js +157 -0
  6. package/src/components/ui-accordion.js +58 -0
  7. package/src/components/ui-alert.js +168 -0
  8. package/src/components/ui-app-bar.js +119 -0
  9. package/src/components/ui-autocomplete.js +371 -0
  10. package/src/components/ui-avatar.js +104 -0
  11. package/src/components/ui-backdrop.js +66 -0
  12. package/src/components/ui-badge.js +95 -0
  13. package/src/components/ui-bottom-app-bar.js +78 -0
  14. package/src/components/ui-bottom-nav.js +83 -0
  15. package/src/components/ui-breadcrumbs.js +100 -0
  16. package/src/components/ui-button-group.js +49 -0
  17. package/src/components/ui-button.js +143 -0
  18. package/src/components/ui-card.js +86 -0
  19. package/src/components/ui-carousel-item.js +53 -0
  20. package/src/components/ui-carousel.js +301 -0
  21. package/src/components/ui-checkbox.js +165 -0
  22. package/src/components/ui-chip-set.js +90 -0
  23. package/src/components/ui-chip.js +226 -0
  24. package/src/components/ui-container.js +59 -0
  25. package/src/components/ui-date-picker.js +774 -0
  26. package/src/components/ui-dialog.js +179 -0
  27. package/src/components/ui-divider.js +58 -0
  28. package/src/components/ui-drawer.js +169 -0
  29. package/src/components/ui-fab-menu.js +122 -0
  30. package/src/components/ui-fab.js +117 -0
  31. package/src/components/ui-icon-button.js +112 -0
  32. package/src/components/ui-icon.js +64 -0
  33. package/src/components/ui-list-item.js +166 -0
  34. package/src/components/ui-list.js +38 -0
  35. package/src/components/ui-loading-indicator.js +74 -0
  36. package/src/components/ui-menu-item.js +117 -0
  37. package/src/components/ui-menu.js +192 -0
  38. package/src/components/ui-nav-item.js +138 -0
  39. package/src/components/ui-nav-rail.js +111 -0
  40. package/src/components/ui-option.js +85 -0
  41. package/src/components/ui-pagination.js +173 -0
  42. package/src/components/ui-progress.js +100 -0
  43. package/src/components/ui-radio-group.js +104 -0
  44. package/src/components/ui-radio.js +143 -0
  45. package/src/components/ui-rating.js +126 -0
  46. package/src/components/ui-search.js +318 -0
  47. package/src/components/ui-select.js +418 -0
  48. package/src/components/ui-sheet.js +205 -0
  49. package/src/components/ui-side-sheet.js +221 -0
  50. package/src/components/ui-skeleton.js +89 -0
  51. package/src/components/ui-slider.js +266 -0
  52. package/src/components/ui-snackbar.js +233 -0
  53. package/src/components/ui-spinner.js +101 -0
  54. package/src/components/ui-split-button.js +158 -0
  55. package/src/components/ui-stack.js +37 -0
  56. package/src/components/ui-step.js +91 -0
  57. package/src/components/ui-stepper.js +86 -0
  58. package/src/components/ui-surface.js +51 -0
  59. package/src/components/ui-switch.js +153 -0
  60. package/src/components/ui-tab-panel.js +54 -0
  61. package/src/components/ui-tab.js +116 -0
  62. package/src/components/ui-table-footer.js +141 -0
  63. package/src/components/ui-table-toolbar.js +219 -0
  64. package/src/components/ui-table.js +735 -0
  65. package/src/components/ui-tabs.js +178 -0
  66. package/src/components/ui-text-field.js +319 -0
  67. package/src/components/ui-text.js +36 -0
  68. package/src/components/ui-time-picker.js +753 -0
  69. package/src/components/ui-toggle-button.js +170 -0
  70. package/src/components/ui-toggle-group.js +111 -0
  71. package/src/components/ui-toolbar.js +64 -0
  72. package/src/components/ui-tooltip.js +148 -0
  73. package/src/index.js +121 -0
  74. package/src/motion/animate.js +145 -0
  75. package/src/motion/flip.js +46 -0
  76. package/src/motion/index.js +4 -0
  77. package/src/motion/presence.js +104 -0
  78. package/src/motion/ripple.js +108 -0
  79. package/src/theme/apply-theme.js +142 -0
  80. package/src/theme/create-theme.js +70 -0
  81. package/src/theme/index.js +5 -0
  82. package/src/tokens/color.js +237 -0
  83. package/src/tokens/index.js +14 -0
  84. package/src/tokens/sys.js +76 -0
  85. package/src/tokens/system.js +114 -0
  86. package/src/tokens/typography.js +189 -0
  87. package/src/util/focus.js +89 -0
  88. package/src/util/form.js +69 -0
  89. package/src/util/icons.js +161 -0
  90. package/src/util/keys.js +110 -0
  91. package/src/util/position.js +102 -0
  92. package/src/util/table.js +230 -0
  93. package/types/elements.d.ts +78 -0
  94. package/types/index.d.ts +168 -0
@@ -0,0 +1,418 @@
1
+ // <ui-select> — Material select: a text-field-style field button that opens a
2
+ // listbox of slotted <ui-option>s.
3
+ //
4
+ // <ui-select label="Flavor" value=${flavor} @change=${(e) => flavor(e.detail.value)}>
5
+ // <ui-option value="vanilla">Vanilla</ui-option>
6
+ // <ui-option value="mint">Mint</ui-option>
7
+ // </ui-select>
8
+ //
9
+ // Keyboard (APG select-only combobox): Enter/Space/ArrowDown/ArrowUp open;
10
+ // arrows move the active option, Enter/Space selects it, Escape closes,
11
+ // typing jumps to the next option starting with that letter. The panel closes
12
+ // on outside pointerdown and returns focus to the field.
13
+ //
14
+ // @prop {string} label=''
15
+ // @prop {string} value='' — the selected option's value
16
+ // @prop {string} variant='filled' — filled | outlined
17
+ // @prop {boolean} disabled=false
18
+ // @prop {boolean} required=false
19
+ // @prop {string} name='' — form participation
20
+ // @prop {string} placeholder='' — shown while nothing is selected
21
+ // @event change — an option was chosen; detail: { value }
22
+ // @event open — panel enter animation finished
23
+ // @event close — panel exit animation finished
24
+ // @slot (default) — <ui-option> children (projected into the panel)
25
+ // @part control — the field button (role="combobox")
26
+ // @part label, panel
27
+ // @vars see `t` below (`themeVars.names`)
28
+ //
29
+ // Note: the combobox's aria-activedescendant references option ids in the
30
+ // host's light DOM; the options also carry aria-selected for AT that walks
31
+ // the composed tree.
32
+
33
+ import { define, html, css, vars, computed, signal, effect, onCleanup } from '@alacris/core';
34
+ import { sys } from '../tokens/sys.js';
35
+ import { base } from './base.js';
36
+ import { formBind } from '../util/form.js';
37
+ import { presence } from '../motion/presence.js';
38
+ import { fx } from '../motion/animate.js';
39
+ import { autoUpdate } from '../util/position.js';
40
+ import './ui-icon.js';
41
+ import './ui-option.js';
42
+
43
+ const t = vars('ui-select', {
44
+ bg: sys.color.surfaceContainerHighest,
45
+ fg: sys.color.onSurface,
46
+ labelFg: sys.color.onSurfaceVariant,
47
+ accent: sys.color.primary,
48
+ outlineColor: sys.color.outline,
49
+ radius: sys.radius.xs,
50
+ font: sys.type.bodyLg,
51
+ height: '56px',
52
+ panelBg: sys.color.surfaceContainer,
53
+ });
54
+
55
+ const styles = css`
56
+ :host { display: block; inline-size: 240px; }
57
+ .root { display: block; position: relative; }
58
+ .field-wrap { position: relative; display: block; }
59
+ .field {
60
+ position: relative;
61
+ display: flex;
62
+ align-items: center;
63
+ gap: ${sys.space(2)};
64
+ inline-size: 100%;
65
+ min-block-size: calc(${t.height} + var(--ui-density, 0) * 4px);
66
+ padding-inline: ${sys.space(4)};
67
+ margin: 0;
68
+ border: none;
69
+ outline: none;
70
+ appearance: none;
71
+ border-radius: ${t.radius};
72
+ background: transparent;
73
+ font: ${t.font};
74
+ letter-spacing: ${sys.tracking.bodyLg};
75
+ text-align: start;
76
+ color: ${t.fg};
77
+ cursor: pointer;
78
+ --ui-icon-size: 1.5rem;
79
+ }
80
+ .filled .field {
81
+ background: ${t.bg};
82
+ border-start-start-radius: ${t.radius};
83
+ border-start-end-radius: ${t.radius};
84
+ border-end-start-radius: 0;
85
+ border-end-end-radius: 0;
86
+ }
87
+ .filled .field::after {
88
+ content: '';
89
+ position: absolute;
90
+ inset-inline: 0;
91
+ inset-block-end: 0;
92
+ block-size: 1px;
93
+ background: ${sys.color.onSurfaceVariant};
94
+ transition: block-size ${sys.duration.short2} ${sys.easing.standard},
95
+ background-color ${sys.duration.short2} ${sys.easing.standard};
96
+ }
97
+ .filled:focus-within .field::after,
98
+ .filled.open .field::after { block-size: 2px; background: ${t.accent}; }
99
+ .filled .field::before {
100
+ content: '';
101
+ position: absolute; inset: 0; pointer-events: none;
102
+ background: ${sys.color.onSurface};
103
+ opacity: 0;
104
+ border-radius: inherit;
105
+ transition: opacity ${sys.duration.short2} ${sys.easing.standard};
106
+ }
107
+ .filled:hover:not(:focus-within):not(.open):not(.disabled) .field::before {
108
+ opacity: ${sys.state.hover};
109
+ }
110
+ .filled:hover:not(:focus-within):not(.open):not(.disabled) .field::after {
111
+ background: ${sys.color.onSurface};
112
+ }
113
+
114
+ /* Outlined: a fieldset (sibling of the button — never inside it, or the
115
+ UA paints a second inner border) draws the notchable outline. */
116
+ fieldset {
117
+ position: absolute;
118
+ inset: -6px 0 0;
119
+ margin: 0;
120
+ padding: 0 calc(${sys.space(3)} - 2px);
121
+ min-inline-size: 0;
122
+ box-sizing: border-box;
123
+ appearance: none;
124
+ border: 1px solid ${t.outlineColor};
125
+ border-radius: ${t.radius};
126
+ pointer-events: none;
127
+ transition: border-color ${sys.duration.short2} ${sys.easing.standard},
128
+ border-width ${sys.duration.short2} ${sys.easing.standard};
129
+ }
130
+ legend {
131
+ float: unset;
132
+ display: block;
133
+ width: max-content;
134
+ padding: 0;
135
+ margin: 0;
136
+ margin-inline-start: 0;
137
+ white-space: nowrap;
138
+ overflow: hidden;
139
+ font: ${t.font};
140
+ font-size: 0.75em;
141
+ letter-spacing: calc(${sys.tracking.bodyLg} * 0.75);
142
+ visibility: hidden;
143
+ max-inline-size: 0.01px;
144
+ height: 12px;
145
+ line-height: 12px;
146
+ transition: max-inline-size ${sys.duration.short2} ${sys.easing.standard};
147
+ }
148
+ legend span {
149
+ padding-inline: ${sys.space(1)} calc(${sys.space(1)} + 3.5px);
150
+ display: inline-block;
151
+ opacity: 0;
152
+ visibility: visible;
153
+ }
154
+ .outlined.floating legend {
155
+ max-inline-size: 100%;
156
+ transition: max-inline-size ${sys.duration.short3} ${sys.easing.standard};
157
+ }
158
+ .outlined:focus-within fieldset,
159
+ .outlined.open fieldset { border-width: 2px; border-color: ${t.accent}; }
160
+ .root:hover:not(:focus-within):not(.open) fieldset { border-color: ${sys.color.onSurface}; }
161
+
162
+ .label {
163
+ position: absolute;
164
+ inset-inline-start: ${sys.space(4)};
165
+ inset-block-start: 50%;
166
+ translate: 0 -50%;
167
+ color: ${t.labelFg};
168
+ pointer-events: none;
169
+ transform-origin: 0 50%;
170
+ transition: translate ${sys.duration.short3} ${sys.easing.standard},
171
+ scale ${sys.duration.short3} ${sys.easing.standard},
172
+ color ${sys.duration.short2} ${sys.easing.standard};
173
+ }
174
+ .filled.floating .label { translate: 0 calc(-50% - 16px); scale: 0.75; }
175
+ .outlined.floating .label {
176
+ translate: 0 calc(-50% - (${t.height} + var(--ui-density, 0) * 4px) / 2);
177
+ scale: 0.75;
178
+ }
179
+ .open .label, :focus-within .label { color: ${t.accent}; }
180
+
181
+ .value {
182
+ flex: 1;
183
+ min-inline-size: 0;
184
+ overflow: hidden;
185
+ text-overflow: ellipsis;
186
+ white-space: nowrap;
187
+ min-block-size: 1lh;
188
+ }
189
+ .filled.has-label .value { padding-block-start: 18px; }
190
+ .value.empty { color: ${t.labelFg}; }
191
+
192
+ .arrow {
193
+ color: ${t.labelFg};
194
+ transition: rotate ${sys.duration.short3} ${sys.easing.standard};
195
+ }
196
+ .open .arrow { rotate: 180deg; }
197
+
198
+ .panel {
199
+ position: fixed;
200
+ z-index: ${sys.z.modal};
201
+ min-inline-size: 112px;
202
+ max-block-size: 40vh;
203
+ overflow: auto;
204
+ padding-block: ${sys.space(2)};
205
+ background: ${t.panelBg};
206
+ border-radius: ${sys.radius.xs};
207
+ box-shadow: ${sys.elevation[2]};
208
+ transform-origin: top center;
209
+ }
210
+
211
+ .disabled { opacity: ${sys.state.disabledContent}; pointer-events: none; }
212
+ `;
213
+
214
+ define('ui-select', {
215
+ formAssociated: true,
216
+ props: {
217
+ label: '', value: '', variant: 'filled', disabled: false, required: false,
218
+ name: '', placeholder: '',
219
+ },
220
+ styles: [base, styles],
221
+ setup(p, host) {
222
+ const { label, value, variant, disabled, required, name, placeholder } = p;
223
+ formBind(host, { name, value, disabled });
224
+
225
+ const open = signal(false);
226
+ const activeIndex = signal(-1);
227
+ let fieldEl = null;
228
+ let stopAuto = null;
229
+
230
+ // ---- option tracking: light-DOM children, live through a version signal
231
+ const optsVersion = signal(0);
232
+ const MO = typeof MutationObserver === 'function'
233
+ ? MutationObserver
234
+ : (typeof window !== 'undefined' ? window.MutationObserver : null);
235
+ if (MO) {
236
+ const mo = new MO(() => optsVersion.update((n) => n + 1));
237
+ mo.observe(host, { childList: true, subtree: true, characterData: true });
238
+ onCleanup(() => mo.disconnect());
239
+ }
240
+ // Children may not be parsed yet when we upgrade mid-stream.
241
+ queueMicrotask(() => optsVersion.update((n) => n + 1));
242
+
243
+ const optionEls = computed(() => {
244
+ optsVersion();
245
+ return [...host.querySelectorAll('ui-option')];
246
+ });
247
+ const optValue = (o) => {
248
+ const v = o.value;
249
+ return (v === undefined || v === null || v === '') ? (o.getAttribute('value') || '') : String(v);
250
+ };
251
+ const isDisabled = (o) => !!o.disabled || o.hasAttribute('disabled');
252
+ const optLabel = (o) => (o.textContent || '').trim();
253
+
254
+ const display = computed(() => {
255
+ const match = optionEls().find((o) => optValue(o) === value());
256
+ return match ? optLabel(match) : value();
257
+ });
258
+ const floating = computed(() => display() !== '' || placeholder() !== '' || open());
259
+ const cls = computed(() =>
260
+ ['root', variant(), floating() && 'floating', open() && 'open',
261
+ disabled() && 'disabled', label() && 'has-label'].filter(Boolean).join(' '));
262
+
263
+ // Mirror selection + keyboard-active state onto the options (attributes,
264
+ // so options that upgrade after us still pick the state up).
265
+ effect(() => {
266
+ const opts = optionEls();
267
+ const v = value();
268
+ const isOpen = open();
269
+ const ai = activeIndex();
270
+ opts.forEach((o, i) => {
271
+ o.toggleAttribute('selected', optValue(o) === v);
272
+ o.toggleAttribute('active', isOpen && i === ai);
273
+ });
274
+ });
275
+ const activeId = computed(() => (open() ? optionEls()[activeIndex()]?.id ?? null : null));
276
+
277
+ // ---- open/close
278
+ const openPanel = () => {
279
+ if (disabled() || open()) return;
280
+ const opts = optionEls();
281
+ let i = opts.findIndex((o) => optValue(o) === value() && !isDisabled(o));
282
+ if (i < 0) i = opts.findIndex((o) => !isDisabled(o));
283
+ activeIndex.set(i);
284
+ open.set(true);
285
+ };
286
+ const closePanel = () => open.set(false);
287
+
288
+ const commit = (o) => {
289
+ if (!o || isDisabled(o)) return;
290
+ const v = optValue(o);
291
+ value.set(v);
292
+ host.emit('change', { value: v });
293
+ closePanel();
294
+ fieldEl?.focus();
295
+ };
296
+
297
+ // Outside pointerdown closes (scrim-less popup).
298
+ effect(() => {
299
+ if (!open()) return;
300
+ const onDoc = (e) => {
301
+ if (e.composedPath().includes(host)) return;
302
+ closePanel();
303
+ };
304
+ document.addEventListener('pointerdown', onDoc);
305
+ return () => document.removeEventListener('pointerdown', onDoc);
306
+ });
307
+ effect(() => {
308
+ if (!open() && stopAuto) { stopAuto(); stopAuto = null; }
309
+ });
310
+ onCleanup(() => stopAuto?.());
311
+
312
+ // Option activation: option clicks bubble up from the light DOM.
313
+ host.addEventListener('click', (e) => {
314
+ const o = e.target instanceof Element && e.target.closest('ui-option');
315
+ if (o && open()) commit(o);
316
+ });
317
+
318
+ // ---- keyboard
319
+ const move = (delta) => {
320
+ const opts = optionEls();
321
+ if (!opts.length) return;
322
+ let i = activeIndex();
323
+ for (let n = 0; n < opts.length; n++) {
324
+ i = i < 0 ? (delta > 0 ? 0 : opts.length - 1) : (i + delta + opts.length) % opts.length;
325
+ if (!isDisabled(opts[i])) { activeIndex.set(i); return; }
326
+ }
327
+ };
328
+ const typeahead = (ch) => {
329
+ const opts = optionEls();
330
+ const lower = ch.toLowerCase();
331
+ const start = activeIndex() + 1;
332
+ for (let n = 0; n < opts.length; n++) {
333
+ const i = (start + n) % opts.length;
334
+ if (!isDisabled(opts[i]) && optLabel(opts[i]).toLowerCase().startsWith(lower)) {
335
+ activeIndex.set(i);
336
+ return;
337
+ }
338
+ }
339
+ };
340
+ const onKeydown = (e) => {
341
+ if (disabled()) return;
342
+ const opts = optionEls();
343
+ if (!open()) {
344
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
345
+ e.preventDefault();
346
+ openPanel();
347
+ } else if (e.key.length === 1 && e.key !== ' ') {
348
+ typeahead(e.key);
349
+ const o = opts[activeIndex()];
350
+ if (o) { value.set(optValue(o)); host.emit('change', { value: value() }); }
351
+ }
352
+ return;
353
+ }
354
+ switch (e.key) {
355
+ case 'ArrowDown': e.preventDefault(); move(1); break;
356
+ case 'ArrowUp': e.preventDefault(); move(-1); break;
357
+ case 'Home': e.preventDefault(); activeIndex.set(opts.findIndex((o) => !isDisabled(o))); break;
358
+ case 'End': {
359
+ e.preventDefault();
360
+ for (let i = opts.length - 1; i >= 0; i--) if (!isDisabled(opts[i])) { activeIndex.set(i); break; }
361
+ break;
362
+ }
363
+ case 'Enter':
364
+ case ' ': e.preventDefault(); commit(opts[activeIndex()]); break;
365
+ case 'Escape': e.preventDefault(); closePanel(); break;
366
+ case 'Tab': closePanel(); break;
367
+ default:
368
+ if (e.key.length === 1 && e.key !== ' ') typeahead(e.key);
369
+ }
370
+ };
371
+
372
+ const panelRef = (el) => {
373
+ stopAuto?.();
374
+ stopAuto = autoUpdate(el, fieldEl, { placement: 'bottom-start', matchWidth: true, offset: 4 });
375
+ };
376
+ const panelView = () => html`
377
+ <div class="panel" part="panel" role="listbox" id="listbox"
378
+ aria-label=${() => label() || null} ref=${panelRef}>
379
+ <slot></slot>
380
+ </div>`;
381
+
382
+ return html`
383
+ <div class=${cls}>
384
+ <div class="field-wrap">
385
+ ${() => (variant() === 'outlined'
386
+ ? html`<fieldset aria-hidden="true"><legend><span>${label}${() => (required() ? ' *' : '')}</span></legend></fieldset>`
387
+ : null)}
388
+ <button type="button" class="field" part="control" role="combobox"
389
+ aria-haspopup="listbox" aria-controls="listbox"
390
+ aria-expanded=${() => String(open())}
391
+ aria-required=${() => (required() ? 'true' : null)}
392
+ aria-activedescendant=${activeId}
393
+ aria-label=${() => label() || placeholder() || null}
394
+ ?disabled=${disabled}
395
+ @click=${() => (open() ? closePanel() : openPanel())}
396
+ @keydown=${onKeydown}
397
+ ref=${(el) => (fieldEl = el)}>
398
+ ${() => (label() ? html`<span class="label" part="label">${label}${() => (required() ? ' *' : '')}</span>` : null)}
399
+ <span class=${() => `value${display() ? '' : ' empty'}`}>${() => display() || placeholder()}</span>
400
+ <ui-icon class="arrow" name="arrow-drop-down"></ui-icon>
401
+ </button>
402
+ </div>
403
+ ${presence(open, panelView, {
404
+ enter: fx.scaleIn,
405
+ exit: fx.scaleOut,
406
+ enterDuration: 'short4',
407
+ exitDuration: 'short2',
408
+ enterEasing: 'emphasizedDecelerate',
409
+ exitEasing: 'emphasizedAccelerate',
410
+ onEntered: () => host.emit('open'),
411
+ onExited: () => host.emit('close'),
412
+ })}
413
+ </div>`;
414
+ },
415
+ });
416
+
417
+ export const tag = 'ui-select';
418
+ export const themeVars = t;
@@ -0,0 +1,205 @@
1
+ // <ui-sheet> — a Material bottom sheet.
2
+ //
3
+ // <ui-sheet open=${open} @close=${() => open(false)}>
4
+ // <span slot="headline">Title</span>
5
+ // Sheet body
6
+ // <ui-button slot="actions" variant="text">Close</ui-button>
7
+ // </ui-sheet>
8
+ //
9
+ // Modal (default): a scrim plus a panel that slides up from the bottom.
10
+ // Focus is trapped and page scroll locked while open. The PARENT owns
11
+ // `open`: Escape and scrim clicks emit `close` with a reason.
12
+ // Standard: an in-flow panel that expands from zero height — no scrim,
13
+ // no trap.
14
+ //
15
+ // @prop {boolean} open=false
16
+ // @prop {string} variant='modal' — modal | standard
17
+ // @prop {boolean} persistent=false — Escape/scrim do not request closing
18
+ // @prop {string} label='' — accessible name if no headline slot
19
+ // @event close — detail: { reason: 'esc' | 'scrim' | 'method' }
20
+ // @event opened — enter animation finished
21
+ // @event closed — exit animation finished, DOM removed
22
+ // @slot (default) — body content
23
+ // @slot headline
24
+ // @slot actions
25
+ // @part scrim, surface, handle, headline, body, actions
26
+ // @vars see `t` below (`themeVars.names`)
27
+
28
+ import { define, html, css, vars, effect, onCleanup, signal } from '@alacris/core';
29
+ import { sys } from '../tokens/sys.js';
30
+ import { base } from './base.js';
31
+ import { presence } from '../motion/presence.js';
32
+ import { animate, fx } from '../motion/animate.js';
33
+ import { focusTrap, scrollLock } from '../util/focus.js';
34
+
35
+ const t = vars('ui-sheet', {
36
+ bg: sys.color.surfaceContainerLow,
37
+ fg: sys.color.onSurface,
38
+ radius: sys.radius.xl,
39
+ width: 'min(640px, 100%)',
40
+ handle: sys.color.outline,
41
+ scrim: `color-mix(in srgb, ${sys.color.scrim} 32%, transparent)`,
42
+ });
43
+
44
+ const styles = css`
45
+ :host { display: contents; }
46
+ .overlay {
47
+ position: fixed;
48
+ inset: 0;
49
+ z-index: ${sys.z.modal};
50
+ display: flex;
51
+ align-items: flex-end;
52
+ justify-content: center;
53
+ }
54
+ .scrim { position: absolute; inset: 0; background: ${t.scrim}; }
55
+ .surface {
56
+ position: relative;
57
+ display: flex;
58
+ flex-direction: column;
59
+ inline-size: ${t.width};
60
+ max-block-size: calc(100vh - 72px);
61
+ background: ${t.bg};
62
+ color: ${t.fg};
63
+ border-start-start-radius: ${t.radius};
64
+ border-start-end-radius: ${t.radius};
65
+ box-shadow: ${sys.elevation[3]};
66
+ }
67
+ .handle {
68
+ flex: none;
69
+ inline-size: 32px;
70
+ block-size: 4px;
71
+ margin: ${sys.space(4)} auto ${sys.space(2)};
72
+ border-radius: ${sys.radius.full};
73
+ background: ${t.handle};
74
+ }
75
+ .headline {
76
+ padding-inline: ${sys.space(6)};
77
+ padding-block-end: ${sys.space(2)};
78
+ font: ${sys.type.titleLg};
79
+ letter-spacing: ${sys.tracking.titleLg};
80
+ }
81
+ .headline:not(.has) { display: none; }
82
+ .body {
83
+ overflow: auto;
84
+ padding: ${sys.space(2)} ${sys.space(6)} ${sys.space(6)};
85
+ font: ${sys.type.bodyMd};
86
+ letter-spacing: ${sys.tracking.bodyMd};
87
+ color: ${sys.color.onSurfaceVariant};
88
+ }
89
+ .actions {
90
+ display: flex;
91
+ justify-content: flex-end;
92
+ gap: ${sys.space(2)};
93
+ padding: ${sys.space(2)} ${sys.space(4)} ${sys.space(4)};
94
+ }
95
+ .actions:not(.has) { display: none; }
96
+
97
+ .std {
98
+ display: grid;
99
+ grid-template-rows: 0fr;
100
+ background: ${t.bg};
101
+ color: ${t.fg};
102
+ border-start-start-radius: ${t.radius};
103
+ border-start-end-radius: ${t.radius};
104
+ overflow: hidden;
105
+ transition: grid-template-rows ${sys.duration.medium2} ${sys.easing.emphasized};
106
+ }
107
+ .std.open { grid-template-rows: 1fr; }
108
+ .std-inner { min-block-size: 0; overflow: hidden; }
109
+ `;
110
+
111
+ define('ui-sheet', {
112
+ props: { open: false, variant: 'modal', persistent: false, label: '' },
113
+ styles: [base, styles],
114
+ setup({ open, variant, persistent, label }, host) {
115
+ let releaseTrap = null;
116
+ let unlock = null;
117
+ let surfaceEl = null;
118
+ const hasHeadline = signal(false);
119
+
120
+ const requestClose = (reason) => {
121
+ if (reason !== 'method' && persistent()) return;
122
+ host.emit('close', { reason });
123
+ };
124
+
125
+ const onDocKeydown = (e) => {
126
+ if (e.key === 'Escape') requestClose('esc');
127
+ };
128
+
129
+ effect(() => {
130
+ if (open() && variant() !== 'standard') {
131
+ document.addEventListener('keydown', onDocKeydown, true);
132
+ unlock = scrollLock();
133
+ queueMicrotask(() => {
134
+ if (open() && !releaseTrap) releaseTrap = focusTrap(host);
135
+ });
136
+ } else {
137
+ document.removeEventListener('keydown', onDocKeydown, true);
138
+ releaseTrap?.();
139
+ releaseTrap = null;
140
+ unlock?.();
141
+ unlock = null;
142
+ if (surfaceEl?.isConnected) {
143
+ animate(surfaceEl, fx.sheetOut, { duration: 'short4', easing: 'emphasizedAccelerate' });
144
+ }
145
+ }
146
+ });
147
+ onCleanup(() => {
148
+ document.removeEventListener('keydown', onDocKeydown, true);
149
+ releaseTrap?.();
150
+ unlock?.();
151
+ });
152
+
153
+ const hasSlot = (el, set) => {
154
+ const sync = () => set(el.assignedElements().length > 0);
155
+ el.addEventListener('slotchange', sync);
156
+ sync();
157
+ };
158
+
159
+ const modalView = () => html`
160
+ <div class="overlay">
161
+ <div class="scrim" part="scrim" aria-hidden="true" @click=${() => requestClose('scrim')}></div>
162
+ <div class="surface" part="surface" role="dialog" aria-modal="true"
163
+ aria-labelledby=${() => (hasHeadline() ? 'headline' : null)}
164
+ aria-label=${() => (hasHeadline() ? null : (label() || 'Sheet'))}
165
+ tabindex="-1"
166
+ ref=${(el) => { surfaceEl = el; animate(el, fx.sheetIn, { duration: 'medium2', easing: 'emphasizedDecelerate' }); }}>
167
+ <div class="handle" part="handle" aria-hidden="true"></div>
168
+ <div class="headline" part="headline" id="headline"
169
+ ref=${(el) => hasSlot(el.querySelector('slot'), (has) => {
170
+ hasHeadline.set(has);
171
+ el.classList.toggle('has', has);
172
+ })}>
173
+ <slot name="headline"></slot>
174
+ </div>
175
+ <div class="body" part="body"><slot></slot></div>
176
+ <div class="actions" part="actions"
177
+ ref=${(el) => hasSlot(el.querySelector('slot'), (has) => el.classList.toggle('has', has))}>
178
+ <slot name="actions"></slot>
179
+ </div>
180
+ </div>
181
+ </div>`;
182
+
183
+ return html`
184
+ ${() =>
185
+ variant() === 'standard'
186
+ ? html`<div class=${() => `std${open() ? ' open' : ''}`} part="surface" role="region"
187
+ aria-label=${() => label() || 'Sheet'}>
188
+ <div class="std-inner">
189
+ <div class="handle" part="handle" aria-hidden="true"></div>
190
+ <div class="body" part="body"><slot></slot></div>
191
+ </div>
192
+ </div>`
193
+ : null}
194
+ ${presence(() => open() && variant() !== 'standard', modalView, {
195
+ enter: fx.fadeIn,
196
+ exit: fx.fadeOut,
197
+ exitDuration: 'short4',
198
+ onEntered: () => host.emit('opened'),
199
+ onExited: () => host.emit('closed'),
200
+ })}`;
201
+ },
202
+ });
203
+
204
+ export const tag = 'ui-sheet';
205
+ export const themeVars = t;