@alacris/ui 0.4.0 → 0.4.2

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/README.md CHANGED
@@ -46,10 +46,10 @@ The published package is plain ESM. Point an import map at a pinned CDN build of
46
46
  <script type="importmap">
47
47
  {
48
48
  "imports": {
49
- "@alacris/core": "https://cdn.jsdelivr.net/npm/@alacris/core@0.11.3/dist/alacris.js",
50
- "@alacris/ui": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.4.0/src/index.js",
51
- "@alacris/ui/theme": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.4.0/src/theme/index.js",
52
- "@alacris/ui/components/": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.4.0/src/components/"
49
+ "@alacris/core": "https://cdn.jsdelivr.net/npm/@alacris/core@0.11.4/dist/alacris.js",
50
+ "@alacris/ui": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.4.2/src/index.js",
51
+ "@alacris/ui/theme": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.4.2/src/theme/index.js",
52
+ "@alacris/ui/components/": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.4.2/src/components/"
53
53
  }
54
54
  }
55
55
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alacris/ui",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Themeable design system for Alacris — Material defaults, sixty-eight custom elements, ESM-only, no build step.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,7 +13,7 @@ export const base = css`
13
13
  font-synthesis: none;
14
14
  -webkit-tap-highlight-color: transparent;
15
15
  }
16
- :host([hidden]) { display: none; }
16
+ :host([hidden]) { display: none !important; }
17
17
  @media (prefers-reduced-motion: reduce) {
18
18
  *, *::before, *::after {
19
19
  transition-duration: 0.01ms;
@@ -185,7 +185,7 @@ const styles = css`
185
185
 
186
186
  .panel {
187
187
  position: fixed;
188
- z-index: ${sys.z.modal};
188
+ z-index: ${sys.z.popup};
189
189
  min-inline-size: 112px;
190
190
  max-block-size: 40vh;
191
191
  overflow: auto;
@@ -276,8 +276,16 @@ define('ui-autocomplete', {
276
276
  open.set(false);
277
277
  };
278
278
 
279
+ let isComposing = false;
280
+ const onCompositionStart = () => { isComposing = true; };
281
+ const onCompositionEnd = (e) => {
282
+ isComposing = false;
283
+ onInput(e);
284
+ };
285
+
279
286
  const onInput = (e) => {
280
287
  e.stopPropagation();
288
+ if (isComposing) return;
281
289
  query.set(e.target.value);
282
290
  open.set(true);
283
291
  host.emit('input', { value: e.target.value });
@@ -358,6 +366,8 @@ define('ui-autocomplete', {
358
366
  .value=${query}
359
367
  placeholder=${() => placeholder() || null}
360
368
  ?disabled=${disabled} ?required=${required}
369
+ @compositionstart=${onCompositionStart}
370
+ @compositionend=${onCompositionEnd}
361
371
  @input=${onInput} @change=${(e) => e.stopPropagation()} @keydown=${onKeydown}
362
372
  @focus=${onFocus} @blur=${onBlur}>
363
373
  <ui-icon class="arrow" name="arrow-drop-down"></ui-icon>
@@ -26,8 +26,8 @@
26
26
  // @prop {string} placeholder=''
27
27
  // @event change — committed; detail: { value } or { start, end, value } when range
28
28
  // @event input — field keystroke; detail: { value } (the raw text)
29
- // @event open — calendar visible (after the enter animation)
30
- // @event close — calendar removed (after the exit animation)
29
+ // @event open — calendar visible (after the enter animation); does not bubble
30
+ // @event close — calendar removed (after the exit animation); does not bubble
31
31
  // @part field, input, label, panel, day
32
32
  // @vars see `t` below (`themeVars.names`)
33
33
 
@@ -39,6 +39,7 @@ import { presence } from '../motion/presence.js';
39
39
  import { animate, fx } from '../motion/animate.js';
40
40
  import { autoUpdate } from '../util/position.js';
41
41
  import { escapeLayer } from '../util/keys.js';
42
+ import { popupEvent } from '../util/popup.js';
42
43
  import { focusTrap, scrollLock } from '../util/focus.js';
43
44
  import './ui-icon-button.js';
44
45
  import './ui-button.js';
@@ -262,7 +263,7 @@ const styles = css`
262
263
 
263
264
  .panel {
264
265
  position: fixed;
265
- z-index: ${sys.z.modal};
266
+ z-index: ${sys.z.popup};
266
267
  padding: ${sys.space(3)} ${sys.space(3)} ${sys.space(4)};
267
268
  background: ${t.panelBg};
268
269
  border-radius: ${t.panelRadius};
@@ -273,7 +274,7 @@ const styles = css`
273
274
  .overlay {
274
275
  position: fixed;
275
276
  inset: 0;
276
- z-index: ${sys.z.modal};
277
+ z-index: ${sys.z.popup};
277
278
  display: grid;
278
279
  place-items: center;
279
280
  }
@@ -758,16 +759,16 @@ define('ui-date-picker', {
758
759
  exit: fx.scaleOut,
759
760
  enterDuration: 'short4',
760
761
  exitDuration: 'short4',
761
- onEntered: () => host.emit('open'),
762
- onExited: () => host.emit('close'),
762
+ onEntered: () => host.emit('open', null, popupEvent),
763
+ onExited: () => host.emit('close', null, popupEvent),
763
764
  })}
764
765
  ${presence(() => open() && presentation() === 'modal', modalView, {
765
766
  enter: fx.fadeIn,
766
767
  exit: fx.fadeOut,
767
768
  enterDuration: 'medium2',
768
769
  exitDuration: 'short4',
769
- onEntered: () => host.emit('open'),
770
- onExited: () => host.emit('close'),
770
+ onEntered: () => host.emit('open', null, popupEvent),
771
+ onExited: () => host.emit('close', null, popupEvent),
771
772
  })}
772
773
  </div>`;
773
774
  },
@@ -111,19 +111,22 @@ define('ui-dialog', {
111
111
  };
112
112
 
113
113
  // Trap focus + lock scroll exactly while open; presence handles the DOM.
114
+ let prevActive = null;
114
115
  effect(() => {
115
116
  if (open()) {
117
+ prevActive = document.activeElement;
116
118
  document.addEventListener('keydown', onDocKeydown, true);
117
119
  unlock = scrollLock();
118
120
  // The surface renders synchronously with the signal write, but wait a
119
121
  // microtask so slotted content is distributed before we look for it.
120
122
  queueMicrotask(() => {
121
- if (open() && !releaseTrap) releaseTrap = focusTrap(host);
123
+ if (open() && !releaseTrap) releaseTrap = focusTrap(host, { restore: prevActive });
122
124
  });
123
125
  } else {
124
126
  document.removeEventListener('keydown', onDocKeydown, true);
125
127
  releaseTrap?.();
126
128
  releaseTrap = null;
129
+ prevActive = null;
127
130
  unlock?.();
128
131
  unlock = null;
129
132
  if (surfaceEl?.isConnected) {
@@ -26,7 +26,7 @@ import { define, html, css, vars, effect, onCleanup } from '@alacris/core';
26
26
  import { sys } from '../tokens/sys.js';
27
27
  import { base } from './base.js';
28
28
  import { presence } from '../motion/presence.js';
29
- import { animate, fx } from '../motion/animate.js';
29
+ import { animate, fx, releaseFill } from '../motion/animate.js';
30
30
  import { focusTrap, scrollLock } from '../util/focus.js';
31
31
 
32
32
  const t = vars('ui-drawer', {
@@ -105,17 +105,20 @@ define('ui-drawer', {
105
105
  };
106
106
 
107
107
  // Trap focus + lock scroll exactly while the modal drawer is open.
108
+ let prevActive = null;
108
109
  effect(() => {
109
110
  if (open() && variant() === 'modal') {
111
+ prevActive = document.activeElement;
110
112
  document.addEventListener('keydown', onDocKeydown, true);
111
113
  if (!unlock) unlock = scrollLock();
112
114
  queueMicrotask(() => {
113
- if (open.peek() && !releaseTrap) releaseTrap = focusTrap(host);
115
+ if (open.peek() && !releaseTrap) releaseTrap = focusTrap(host, { restore: prevActive });
114
116
  });
115
117
  } else {
116
118
  document.removeEventListener('keydown', onDocKeydown, true);
117
119
  releaseTrap?.();
118
120
  releaseTrap = null;
121
+ prevActive = null;
119
122
  unlock?.();
120
123
  unlock = null;
121
124
  }
@@ -135,7 +138,7 @@ define('ui-drawer', {
135
138
 
136
139
  const surfaceRef = (el) => {
137
140
  surfaceEl = el;
138
- animate(el, slideIn(), { duration: 'medium2', easing: 'emphasizedDecelerate' });
141
+ releaseFill(animate(el, slideIn(), { duration: 'medium2', easing: 'emphasizedDecelerate' }));
139
142
  };
140
143
 
141
144
  const overlay = () => html`
@@ -14,8 +14,8 @@
14
14
  //
15
15
  // @prop {boolean} open=false
16
16
  // @prop {string} label='' — accessible name for the action list
17
- // @event open — menu visible (after the enter animation)
18
- // @event close — menu removed (after the exit animation)
17
+ // @event open — menu visible (after the enter animation); does not bubble
18
+ // @event close — menu removed (after the exit animation); does not bubble
19
19
  // @slot trigger — the <ui-fab> that toggles the menu
20
20
  // @slot (default) — related <ui-fab> actions
21
21
  // @part actions, trigger
@@ -26,6 +26,7 @@ import { sys } from '../tokens/sys.js';
26
26
  import { base } from './base.js';
27
27
  import { presence } from '../motion/presence.js';
28
28
  import { fx } from '../motion/animate.js';
29
+ import { popupEvent } from '../util/popup.js';
29
30
  import './ui-fab.js';
30
31
 
31
32
  const t = vars('ui-fab-menu', {
@@ -112,8 +113,8 @@ define('ui-fab-menu', {
112
113
  exit: fx.scaleOut,
113
114
  enterDuration: 'short4',
114
115
  exitDuration: 'short2',
115
- onEntered: () => host.emit('open'),
116
- onExited: () => host.emit('close'),
116
+ onEntered: () => host.emit('open', null, popupEvent),
117
+ onExited: () => host.emit('close', null, popupEvent),
117
118
  })}`;
118
119
  },
119
120
  });
@@ -16,8 +16,8 @@
16
16
  // @prop {boolean} open=false
17
17
  // @prop {string} placement='bottom-start' — side[-alignment] (see util/position.js)
18
18
  // @event select — an item was chosen; detail: { value }
19
- // @event open — panel visible (after the enter animation)
20
- // @event close — panel removed (after the exit animation)
19
+ // @event open — panel visible (after the enter animation); does not bubble
20
+ // @event close — panel removed (after the exit animation); does not bubble
21
21
  // @slot anchor — the trigger element
22
22
  // @slot (default) — <ui-menu-item> children
23
23
  // @part panel — the floating menu surface
@@ -30,6 +30,7 @@ import { presence } from '../motion/presence.js';
30
30
  import { fx } from '../motion/animate.js';
31
31
  import { autoUpdate } from '../util/position.js';
32
32
  import { rovingTabindex } from '../util/keys.js';
33
+ import { popupEvent } from '../util/popup.js';
33
34
  import './ui-menu-item.js';
34
35
 
35
36
  const t = vars('ui-menu', {
@@ -47,7 +48,7 @@ const styles = css`
47
48
  position: fixed;
48
49
  inset-inline-start: 0;
49
50
  inset-block-start: 0;
50
- z-index: ${sys.z.modal};
51
+ z-index: ${sys.z.popup};
51
52
  min-inline-size: ${t.minWidth};
52
53
  max-inline-size: ${t.maxWidth};
53
54
  padding-block: ${sys.space(2)};
@@ -182,8 +183,8 @@ define('ui-menu', {
182
183
  exitDuration: 'short2',
183
184
  enterEasing: 'emphasizedDecelerate',
184
185
  exitEasing: 'emphasizedAccelerate',
185
- onEntered: () => host.emit('open'),
186
- onExited: () => host.emit('close'),
186
+ onEntered: () => host.emit('open', null, popupEvent),
187
+ onExited: () => host.emit('close', null, popupEvent),
187
188
  })}`;
188
189
  },
189
190
  });
@@ -28,8 +28,8 @@
28
28
  // @event change — committed (blur/Enter); detail: { value }
29
29
  // @event submit — Enter pressed; detail: { value }
30
30
  // @event clear — the clear affordance was used
31
- // @event open — suggestions visible (after the enter animation)
32
- // @event close — suggestions removed (after the exit animation)
31
+ // @event open — suggestions visible (after the enter animation); does not bubble
32
+ // @event close — suggestions removed (after the exit animation); does not bubble
33
33
  // @slot leading — replaces the search icon
34
34
  // @slot trailing — after the clear button (avatar, extra actions)
35
35
  // @slot (default) — suggestion rows (ui-list-item, …). The panel is a list
@@ -43,6 +43,7 @@ import { base } from './base.js';
43
43
  import { formBind } from '../util/form.js';
44
44
  import { presence } from '../motion/presence.js';
45
45
  import { fx } from '../motion/animate.js';
46
+ import { popupEvent } from '../util/popup.js';
46
47
  import './ui-icon.js';
47
48
  import './ui-icon-button.js';
48
49
 
@@ -75,7 +76,7 @@ const styles = css`
75
76
  }
76
77
  .shell.open {
77
78
  overflow: visible;
78
- z-index: ${sys.z.modal};
79
+ z-index: ${sys.z.popup};
79
80
  background: ${t.panelBg};
80
81
  border-start-start-radius: ${t.panelRadius};
81
82
  border-start-end-radius: ${t.panelRadius};
@@ -307,8 +308,8 @@ define('ui-search', {
307
308
  enterEasing: 'emphasizedDecelerate',
308
309
  exitDuration: 'short4',
309
310
  exitEasing: 'emphasizedAccelerate',
310
- onEntered: () => host.emit('open'),
311
- onExited: () => host.emit('close'),
311
+ onEntered: () => host.emit('open', null, popupEvent),
312
+ onExited: () => host.emit('close', null, popupEvent),
312
313
  })}
313
314
  </div>`;
314
315
  },
@@ -10,7 +10,9 @@
10
10
  // arrows move the active option, Enter/Space selects it, Escape closes the
11
11
  // panel only — an enclosing dialog keeps its own Escape for a second press,
12
12
  // typing jumps to the next option starting with that letter. The panel closes
13
- // on outside pointerdown and returns focus to the field.
13
+ // on outside pointerdown and returns focus to the field. `open`/`close` do
14
+ // not bubble: they share those names with dialogs and sheets, and a bubbling
15
+ // select-close looks like the sheet dismissed itself.
14
16
  //
15
17
  // Past a handful of options the panel gets a filter field, because scrolling
16
18
  // is not a way to find one entry among several hundred. It takes focus when
@@ -31,8 +33,8 @@
31
33
  // @prop {number} searchThreshold=8
32
34
  // @prop {string} searchPlaceholder='Search'
33
35
  // @event change — an option was chosen; detail: { value }
34
- // @event open — panel enter animation finished
35
- // @event close — panel exit animation finished
36
+ // @event open — panel enter animation finished; does not bubble
37
+ // @event close — panel exit animation finished; does not bubble
36
38
  // @slot (default) — <ui-option> children (projected into the panel)
37
39
  // @part control — the field button (role="combobox")
38
40
  // @part label, panel
@@ -50,6 +52,7 @@ import { presence } from '../motion/presence.js';
50
52
  import { fx } from '../motion/animate.js';
51
53
  import { autoUpdate } from '../util/position.js';
52
54
  import { escapeLayer } from '../util/keys.js';
55
+ import { popupEvent } from '../util/popup.js';
53
56
  import './ui-icon.js';
54
57
  import './ui-option.js';
55
58
 
@@ -210,7 +213,7 @@ const styles = css`
210
213
 
211
214
  .panel {
212
215
  position: fixed;
213
- z-index: ${sys.z.modal};
216
+ z-index: ${sys.z.popup};
214
217
  min-inline-size: 112px;
215
218
  max-block-size: 40vh;
216
219
  /* The filter field stays put while the options scroll under it, so the
@@ -553,8 +556,8 @@ define('ui-select', {
553
556
  exitDuration: 'short2',
554
557
  enterEasing: 'emphasizedDecelerate',
555
558
  exitEasing: 'emphasizedAccelerate',
556
- onEntered: () => host.emit('open'),
557
- onExited: () => host.emit('close'),
559
+ onEntered: () => host.emit('open', null, popupEvent),
560
+ onExited: () => host.emit('close', null, popupEvent),
558
561
  })}
559
562
  </div>`;
560
563
  },
@@ -29,7 +29,7 @@ import { define, html, css, vars, effect, onCleanup, signal } from '@alacris/cor
29
29
  import { sys } from '../tokens/sys.js';
30
30
  import { base } from './base.js';
31
31
  import { presence } from '../motion/presence.js';
32
- import { animate, fx } from '../motion/animate.js';
32
+ import { animate, fx, releaseFill } from '../motion/animate.js';
33
33
  import { focusTrap, scrollLock } from '../util/focus.js';
34
34
 
35
35
  const t = vars('ui-sheet', {
@@ -126,17 +126,20 @@ define('ui-sheet', {
126
126
  if (e.key === 'Escape') requestClose('esc');
127
127
  };
128
128
 
129
+ let prevActive = null;
129
130
  effect(() => {
130
131
  if (open() && variant() !== 'standard') {
132
+ prevActive = document.activeElement;
131
133
  document.addEventListener('keydown', onDocKeydown, true);
132
134
  unlock = scrollLock();
133
135
  queueMicrotask(() => {
134
- if (open() && !releaseTrap) releaseTrap = focusTrap(host);
136
+ if (open() && !releaseTrap) releaseTrap = focusTrap(host, { restore: prevActive });
135
137
  });
136
138
  } else {
137
139
  document.removeEventListener('keydown', onDocKeydown, true);
138
140
  releaseTrap?.();
139
141
  releaseTrap = null;
142
+ prevActive = null;
140
143
  unlock?.();
141
144
  unlock = null;
142
145
  if (surfaceEl?.isConnected) {
@@ -163,7 +166,7 @@ define('ui-sheet', {
163
166
  aria-labelledby=${() => (hasHeadline() ? 'headline' : null)}
164
167
  aria-label=${() => (hasHeadline() ? null : (label() || 'Sheet'))}
165
168
  tabindex="-1"
166
- ref=${(el) => { surfaceEl = el; animate(el, fx.sheetIn, { duration: 'medium2', easing: 'emphasizedDecelerate' }); }}>
169
+ ref=${(el) => { surfaceEl = el; releaseFill(animate(el, fx.sheetIn, { duration: 'medium2', easing: 'emphasizedDecelerate' })); }}>
167
170
  <div class="handle" part="handle" aria-hidden="true"></div>
168
171
  <div class="headline" part="headline" id="headline"
169
172
  ref=${(el) => hasSlot(el.querySelector('slot'), (has) => {
@@ -29,7 +29,7 @@ import { define, html, css, vars, effect, onCleanup, signal } from '@alacris/cor
29
29
  import { sys } from '../tokens/sys.js';
30
30
  import { base } from './base.js';
31
31
  import { presence } from '../motion/presence.js';
32
- import { animate, fx } from '../motion/animate.js';
32
+ import { animate, fx, releaseFill } from '../motion/animate.js';
33
33
  import { focusTrap, scrollLock } from '../util/focus.js';
34
34
  import './ui-icon-button.js';
35
35
 
@@ -130,17 +130,20 @@ define('ui-side-sheet', {
130
130
  if (e.key === 'Escape') requestClose('esc');
131
131
  };
132
132
 
133
+ let prevActive = null;
133
134
  effect(() => {
134
135
  if (open() && variant() !== 'standard') {
136
+ prevActive = document.activeElement;
135
137
  document.addEventListener('keydown', onDocKeydown, true);
136
138
  unlock = scrollLock();
137
139
  queueMicrotask(() => {
138
- if (open() && !releaseTrap) releaseTrap = focusTrap(host);
140
+ if (open() && !releaseTrap) releaseTrap = focusTrap(host, { restore: prevActive });
139
141
  });
140
142
  } else {
141
143
  document.removeEventListener('keydown', onDocKeydown, true);
142
144
  releaseTrap?.();
143
145
  releaseTrap = null;
146
+ prevActive = null;
144
147
  unlock?.();
145
148
  unlock = null;
146
149
  }
@@ -158,7 +161,7 @@ define('ui-side-sheet', {
158
161
 
159
162
  const surfaceRef = (el) => {
160
163
  surfaceEl = el;
161
- animate(el, slideIn(), { duration: 'medium2', easing: 'emphasizedDecelerate' });
164
+ releaseFill(animate(el, slideIn(), { duration: 'medium2', easing: 'emphasizedDecelerate' }));
162
165
  };
163
166
 
164
167
  const hasSlot = (el, set) => {
@@ -144,7 +144,7 @@ define('ui-switch', {
144
144
  ${() => (icons() ? html`<ui-icon name=${() => (checked() ? 'check' : 'close')}></ui-icon>` : null)}
145
145
  </span>
146
146
  </button>
147
- ${() => (label() ? html`<span id="label">${label}</span>` : null)}
147
+ ${() => (label() ? html`<span id="label" part="label">${label}</span>` : html`<slot></slot>`)}
148
148
  </label>`;
149
149
  },
150
150
  });
@@ -320,7 +320,15 @@ define('ui-text-field', {
320
320
  hasLeading() && 'with-leading', type() === 'textarea' && 'multiline',
321
321
  type() === 'number' && 'numeric'].filter(Boolean).join(' '));
322
322
 
323
+ let isComposing = false;
324
+ const onCompositionStart = () => { isComposing = true; };
325
+ const onCompositionEnd = (e) => {
326
+ isComposing = false;
327
+ onInput(e);
328
+ };
329
+
323
330
  const onInput = (e) => {
331
+ if (isComposing) return;
324
332
  value.set(e.target.value);
325
333
  host.emit('input', { value: value() });
326
334
  };
@@ -350,6 +358,8 @@ define('ui-text-field', {
350
358
  spellcheck=${attr(spellcheck)}
351
359
  inputmode=${attr(inputmode)}
352
360
  enterkeyhint=${attr(enterkeyhint)}
361
+ @compositionstart=${onCompositionStart}
362
+ @compositionend=${onCompositionEnd}
353
363
  @input=${onInput} @change=${commit}
354
364
  @focus=${() => focused.set(true)} @blur=${() => focused.set(false)}></textarea></span>`
355
365
  : html`<input part="input" ref=${(el) => (input = el)}
@@ -364,6 +374,8 @@ define('ui-text-field', {
364
374
  spellcheck=${attr(spellcheck)}
365
375
  inputmode=${attr(inputmode)}
366
376
  enterkeyhint=${attr(enterkeyhint)}
377
+ @compositionstart=${onCompositionStart}
378
+ @compositionend=${onCompositionEnd}
367
379
  @input=${onInput} @change=${commit}
368
380
  @focus=${() => focused.set(true)} @blur=${() => focused.set(false)}>`)}
369
381
  ${() => (clearable() && value() !== '' && !disabled()
@@ -21,8 +21,8 @@
21
21
  // @prop {string} placeholder=''
22
22
  // @event change — committed; detail: { value }
23
23
  // @event input — field keystroke; detail: { value } (the raw text)
24
- // @event open — panel visible (after the enter animation)
25
- // @event close — panel removed (after the exit animation)
24
+ // @event open — panel visible (after the enter animation); does not bubble
25
+ // @event close — panel removed (after the exit animation); does not bubble
26
26
  // @part field, input, label, panel, dial
27
27
  // @vars see `t` below (`themeVars.names`)
28
28
 
@@ -34,6 +34,7 @@ import { presence } from '../motion/presence.js';
34
34
  import { fx } from '../motion/animate.js';
35
35
  import { autoUpdate } from '../util/position.js';
36
36
  import { rovingTabindex } from '../util/keys.js';
37
+ import { popupEvent } from '../util/popup.js';
37
38
  import './ui-icon-button.js';
38
39
 
39
40
  const TIME = /^(\d{1,2}):(\d{2})$/;
@@ -215,7 +216,7 @@ const styles = css`
215
216
 
216
217
  .panel {
217
218
  position: fixed;
218
- z-index: ${sys.z.modal};
219
+ z-index: ${sys.z.popup};
219
220
  display: flex;
220
221
  flex-direction: column;
221
222
  gap: ${sys.space(3)};
@@ -742,8 +743,8 @@ define('ui-time-picker', {
742
743
  exit: fx.scaleOut,
743
744
  enterDuration: 'short4',
744
745
  exitDuration: 'short4',
745
- onEntered: () => host.emit('open'),
746
- onExited: () => host.emit('close'),
746
+ onEntered: () => host.emit('open', null, popupEvent),
747
+ onExited: () => host.emit('close', null, popupEvent),
747
748
  })}
748
749
  </div>`;
749
750
  },
package/src/index.js CHANGED
@@ -20,7 +20,7 @@ export {
20
20
  } from './tokens/typography.js';
21
21
 
22
22
  // Motion
23
- export { animate, settled, duration, easing, fx, prefersReducedMotion } from './motion/animate.js';
23
+ export { animate, releaseFill, settled, duration, easing, fx, prefersReducedMotion } from './motion/animate.js';
24
24
  export { presence } from './motion/presence.js';
25
25
  export { withFlip } from './motion/flip.js';
26
26
  export { ripple } from './motion/ripple.js';
@@ -104,6 +104,20 @@ export function animate(el, keyframes, opts = {}) {
104
104
  /** Await an animation without throwing when it is cancelled mid-flight. */
105
105
  export const settled = (anim) => anim.finished.catch(() => {});
106
106
 
107
+ /**
108
+ * Drop fill:both once an enter animation has settled.
109
+ *
110
+ * A leftover transform is a containing block for `position:fixed`
111
+ * descendants — a select inside a side sheet is then placed in viewport
112
+ * pixels against the surface, so the list sits beside the sheet, under
113
+ * it, or clipped by the body's overflow. presence() already releases
114
+ * the overlay fade; surfaces animated directly need the same.
115
+ */
116
+ export function releaseFill(anim) {
117
+ anim.finished.then(() => { try { anim.cancel(); } catch { /* already cancelled */ } });
118
+ return anim;
119
+ }
120
+
107
121
  // ------------------------------------------------------------------ presets
108
122
  //
109
123
  // Keyframe presets tuned to the Material motion spec. Enter presets pair with
@@ -1,4 +1,4 @@
1
- export { animate, settled, duration, easing, fx, prefersReducedMotion } from './animate.js';
1
+ export { animate, releaseFill, settled, duration, easing, fx, prefersReducedMotion } from './animate.js';
2
2
  export { presence } from './presence.js';
3
3
  export { withFlip } from './flip.js';
4
4
  export { ripple } from './ripple.js';
package/src/tokens/sys.js CHANGED
@@ -65,6 +65,7 @@ export const sys = {
65
65
  appBar: v('z-app-bar'),
66
66
  drawer: v('z-drawer'),
67
67
  modal: v('z-modal'),
68
+ popup: v('z-popup'),
68
69
  snackbar: v('z-snackbar'),
69
70
  tooltip: v('z-tooltip'),
70
71
  },
@@ -103,6 +103,7 @@ export function zTokens() {
103
103
  'z-app-bar': '1100',
104
104
  'z-drawer': '1200',
105
105
  'z-modal': '1300',
106
+ 'z-popup': '1350',
106
107
  'z-snackbar': '1400',
107
108
  'z-tooltip': '1500',
108
109
  };
package/src/util/focus.js CHANGED
@@ -32,8 +32,8 @@ export function focusables(host) {
32
32
  *
33
33
  * const release = focusTrap(host, { initial: shadowCloseButton });
34
34
  */
35
- export function focusTrap(host, { initial } = {}) {
36
- const previous = document.activeElement;
35
+ export function focusTrap(host, { initial, restore } = {}) {
36
+ const previous = restore || document.activeElement;
37
37
 
38
38
  const first = initial || focusables(host)[0] || host;
39
39
  first.focus?.();
@@ -0,0 +1,5 @@
1
+ // Popup lifecycle events share names with dialogs and sheets (`open` /
2
+ // `close`). They must not bubble: choosing a select option would otherwise
3
+ // look like the sheet dismissed itself. Listeners on the popup host still
4
+ // fire — emit dispatches on the host regardless of bubbles.
5
+ export const popupEvent = { bubbles: false };
package/types/index.d.ts CHANGED
@@ -84,6 +84,7 @@ export interface AnimateOptions extends KeyframeAnimationOptions {
84
84
  }
85
85
 
86
86
  export function animate(el: Element, keyframes: Keyframes, opts?: AnimateOptions): Pick<Animation, 'finished' | 'cancel' | 'finish' | 'play' | 'pause'>;
87
+ export function releaseFill<T extends { finished: Promise<unknown>; cancel(): void }>(anim: T): T;
87
88
  export function settled(anim: { finished: Promise<unknown> }): Promise<void>;
88
89
  export function duration(key: number | string): number;
89
90
  export function easing(key: string): string;