@alacris/ui 0.2.1 → 0.2.3

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/dist/alacris.js",
50
- "@alacris/ui": "https://cdn.jsdelivr.net/npm/@alacris/ui/src/index.js",
51
- "@alacris/ui/theme": "https://cdn.jsdelivr.net/npm/@alacris/ui/src/theme/index.js",
52
- "@alacris/ui/components/": "https://cdn.jsdelivr.net/npm/@alacris/ui/src/components/"
49
+ "@alacris/core": "https://cdn.jsdelivr.net/npm/@alacris/core@0.11.1/dist/alacris.js",
50
+ "@alacris/ui": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.2.3/src/index.js",
51
+ "@alacris/ui/theme": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.2.3/src/theme/index.js",
52
+ "@alacris/ui/components/": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.2.3/src/components/"
53
53
  }
54
54
  }
55
55
  </script>
@@ -61,7 +61,7 @@ The published package is plain ESM. Point an import map at a pinned CDN build of
61
61
  <ui-button>Hello</ui-button>
62
62
  ```
63
63
 
64
- Pin both packages in production.
64
+ Never mix two versions of `@alacris/core` on one page — two copies means two reactive graphs.
65
65
 
66
66
  ## Theming
67
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alacris/ui",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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": {
@@ -28,6 +28,7 @@ import { define, html, css, vars, computed, signal, effect, onCleanup, each } fr
28
28
  import { sys } from '../tokens/sys.js';
29
29
  import { base } from './base.js';
30
30
  import { formBind } from '../util/form.js';
31
+ import { escapeLayer } from '../util/keys.js';
31
32
  import { presence } from '../motion/presence.js';
32
33
  import { fx } from '../motion/animate.js';
33
34
  import { autoUpdate } from '../util/position.js';
@@ -295,9 +296,15 @@ define('ui-autocomplete', {
295
296
  if (showPanel() && activeOpt()) { e.preventDefault(); commit(activeOpt().value); }
296
297
  else if (freeSolo()) commit(query().trim());
297
298
  break;
298
- case 'Escape': open.set(false); break;
299
299
  }
300
300
  };
301
+ // The panel owns Escape while it is up, so that closing it inside a
302
+ // dialog does not close the dialog as well.
303
+ effect(() => {
304
+ if (!showPanel()) return;
305
+ return escapeLayer(() => open.set(false));
306
+ });
307
+
301
308
  const onFocus = () => { focused.set(true); open.set(true); };
302
309
  const onBlur = () => {
303
310
  focused.set(false);
@@ -38,6 +38,7 @@ import { formBind } from '../util/form.js';
38
38
  import { presence } from '../motion/presence.js';
39
39
  import { animate, fx } from '../motion/animate.js';
40
40
  import { autoUpdate } from '../util/position.js';
41
+ import { escapeLayer } from '../util/keys.js';
41
42
  import { focusTrap, scrollLock } from '../util/focus.js';
42
43
  import './ui-icon-button.js';
43
44
  import './ui-button.js';
@@ -607,12 +608,14 @@ define('ui-date-picker', {
607
608
  if (e.composedPath().includes(host)) return;
608
609
  closePanel();
609
610
  };
610
- const onEsc = (e) => { if (e.key === 'Escape') closePanel(); };
611
+ // Capture at the document is not early enough: a dialog registers the
612
+ // same way when it opens, so it is already listening by the time this
613
+ // panel does and one Escape closes both.
614
+ const releaseEsc = escapeLayer(closePanel);
611
615
  document.addEventListener('pointerdown', onDoc);
612
- document.addEventListener('keydown', onEsc, true);
613
616
  return () => {
614
617
  document.removeEventListener('pointerdown', onDoc);
615
- document.removeEventListener('keydown', onEsc, true);
618
+ releaseEsc();
616
619
  };
617
620
  });
618
621
  effect(() => {
@@ -7,7 +7,8 @@
7
7
  // </ui-select>
8
8
  //
9
9
  // Keyboard (APG select-only combobox): Enter/Space/ArrowDown/ArrowUp open;
10
- // arrows move the active option, Enter/Space selects it, Escape closes,
10
+ // arrows move the active option, Enter/Space selects it, Escape closes the
11
+ // panel only — an enclosing dialog keeps its own Escape for a second press,
11
12
  // typing jumps to the next option starting with that letter. The panel closes
12
13
  // on outside pointerdown and returns focus to the field.
13
14
  //
@@ -37,6 +38,7 @@ import { formBind } from '../util/form.js';
37
38
  import { presence } from '../motion/presence.js';
38
39
  import { fx } from '../motion/animate.js';
39
40
  import { autoUpdate } from '../util/position.js';
41
+ import { escapeLayer } from '../util/keys.js';
40
42
  import './ui-icon.js';
41
43
  import './ui-option.js';
42
44
 
@@ -294,6 +296,18 @@ define('ui-select', {
294
296
  fieldEl?.focus();
295
297
  };
296
298
 
299
+ // Escape belongs to the panel while it is open, not to whatever encloses
300
+ // it. A dialog listens for the key in the capture phase at the document,
301
+ // so without claiming it a step earlier one press closes the panel and the
302
+ // dialog together.
303
+ effect(() => {
304
+ if (!open()) return;
305
+ return escapeLayer(() => {
306
+ closePanel();
307
+ fieldEl?.focus();
308
+ });
309
+ });
310
+
297
311
  // Outside pointerdown closes (scrim-less popup).
298
312
  effect(() => {
299
313
  if (!open()) return;
@@ -362,7 +376,6 @@ define('ui-select', {
362
376
  }
363
377
  case 'Enter':
364
378
  case ' ': e.preventDefault(); commit(opts[activeIndex()]); break;
365
- case 'Escape': e.preventDefault(); closePanel(); break;
366
379
  case 'Tab': closePanel(); break;
367
380
  default:
368
381
  if (e.key.length === 1 && e.key !== ' ') typeahead(e.key);
@@ -37,6 +37,11 @@ const t = vars('ui-text-field', {
37
37
  radius: sys.radius.xs,
38
38
  font: sys.type.bodyLg,
39
39
  height: '56px',
40
+
41
+ // The lane reserved at the end of a number field for its stepper. It is a
42
+ // variable because the buttons are the browser's and their width is not the
43
+ // same on every engine — a consumer with a denser field can shorten it.
44
+ stepperWidth: '18px',
40
45
  });
41
46
 
42
47
  const styles = css`
@@ -217,6 +222,43 @@ const styles = css`
217
222
  .filled.has-label textarea { padding-top: ${sys.space(7)}; }
218
223
  .with-leading.multiline .field { padding-inline-start: ${sys.space(4)}; }
219
224
  .with-leading.multiline textarea { padding-inline-start: 0; }
225
+ /* A number field's stepper gets its own lane.
226
+
227
+ The browser draws the spin buttons inside the input's content box, at the
228
+ inline end, and they are painted over whatever is already there: the
229
+ floating label, the placeholder, and the value itself once it is long
230
+ enough. On a narrow field it lands squarely on the label — a chevron
231
+ sitting on the word it is meant to sit beside.
232
+
233
+ So the end of the field is reserved for it. The input is padded by the
234
+ stepper's width, and the label and legend are shortened by the same
235
+ amount so a long label ellipsises before it reaches the buttons rather
236
+ than sliding underneath them. appearance:none does not help here: it
237
+ removes the field's own chrome and leaves the ::-webkit-*-spin-button
238
+ alone, which is why this needs saying explicitly. */
239
+ .numeric input { padding-inline-end: ${t.stepperWidth}; }
240
+ .numeric .label {
241
+ max-inline-size: calc(100% - ${sys.space(4)} - ${t.stepperWidth});
242
+ overflow: hidden;
243
+ text-overflow: ellipsis;
244
+ white-space: nowrap;
245
+ }
246
+ .numeric legend { max-inline-size: calc(100% - ${t.stepperWidth}); }
247
+ .numeric input::-webkit-outer-spin-button,
248
+ .numeric input::-webkit-inner-spin-button {
249
+ /* Held at the end of the reserved lane rather than tight against the
250
+ text, and always visible: a stepper that appears on hover is a control
251
+ nobody knows is there. */
252
+ margin: 0;
253
+ margin-inline-start: ${sys.space(2)};
254
+ opacity: 1;
255
+ }
256
+ /* Firefox draws no buttons at all unless asked, so the reserved lane would
257
+ be an empty gap. Asking for them makes the two engines agree. */
258
+ @supports (-moz-appearance: number-input) {
259
+ .numeric input { -moz-appearance: number-input; }
260
+ }
261
+
220
262
  input::placeholder, textarea::placeholder { color: ${t.labelFg}; opacity: 0; transition: opacity ${sys.duration.short2} linear; }
221
263
  .floating input::placeholder, .floating textarea::placeholder { opacity: 1; }
222
264
 
@@ -255,7 +297,8 @@ define('ui-text-field', {
255
297
  const cls = computed(() =>
256
298
  ['root', variant(), floating() && 'floating', focused() && 'focused',
257
299
  error() && 'error', disabled() && 'disabled', label() && 'has-label',
258
- hasLeading() && 'with-leading', type() === 'textarea' && 'multiline'].filter(Boolean).join(' '));
300
+ hasLeading() && 'with-leading', type() === 'textarea' && 'multiline',
301
+ type() === 'number' && 'numeric'].filter(Boolean).join(' '));
259
302
 
260
303
  const onInput = (e) => {
261
304
  value.set(e.target.value);
package/src/util/keys.js CHANGED
@@ -108,3 +108,34 @@ export function rovingTabindex(container, opts = {}) {
108
108
  },
109
109
  };
110
110
  }
111
+
112
+ // escapeLayer — claim Escape for the innermost open layer.
113
+ //
114
+ // `ui-dialog` listens for Escape in the capture phase at the document, so that
115
+ // the key works wherever focus happens to be. That is right for a dialog and
116
+ // wrong for anything transient opened inside one: a select's panel, a menu, a
117
+ // date picker. Those handle Escape too, but the dialog has already seen it by
118
+ // then, so one press closes both — and choosing a format in a dialog looks
119
+ // like the dialog is broken rather than like an ordering problem nobody can
120
+ // see.
121
+ //
122
+ // Capture descends window → document → …, so a layer claims the key one step
123
+ // earlier than the dialog and stops it there. Nothing below ever runs.
124
+
125
+ /**
126
+ * escapeLayer(onEscape)
127
+ *
128
+ * Call while a transient layer is open; call the returned function when it
129
+ * closes. Only registers a listener while it is held, so a page with nothing
130
+ * open behaves exactly as before.
131
+ */
132
+ export function escapeLayer(onEscape) {
133
+ const onKeydown = (e) => {
134
+ if (e.key !== 'Escape') return;
135
+ e.preventDefault();
136
+ e.stopPropagation();
137
+ onEscape(e);
138
+ };
139
+ window.addEventListener('keydown', onKeydown, true);
140
+ return () => window.removeEventListener('keydown', onKeydown, true);
141
+ }