@alacris/ui 0.2.0 → 0.2.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
@@ -6,7 +6,7 @@ A complete, themeable design system built with **[Alacris](https://github.com/bm
6
6
 
7
7
  The tags are real custom elements, so they work in plain HTML and inside React, Vue, Svelte, Angular, Rails, Django, and [Alacris-Go](https://github.com/bmartel/alacris-go) — anywhere that renders a tag.
8
8
 
9
- **Docs:** [Getting started](https://bmartel.github.io/alacris/ui/getting-started/)
9
+ **Docs:** [Live catalog](https://bmartel.github.io/alacris/ui/) · [Getting started](https://bmartel.github.io/alacris/ui/getting-started/)
10
10
 
11
11
  ## Install
12
12
 
@@ -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.0/dist/alacris.js",
50
+ "@alacris/ui": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.2.2/src/index.js",
51
+ "@alacris/ui/theme": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.2.2/src/theme/index.js",
52
+ "@alacris/ui/components/": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.2.2/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
 
@@ -135,6 +135,8 @@ ui/
135
135
  CONVENTIONS.md rules every component follows
136
136
  ```
137
137
 
138
+ The live catalog is [bmartel.github.io/alacris/ui](https://bmartel.github.io/alacris/ui/) — every component plus a theme playground, no clone required.
139
+
138
140
  From a clone of the repo:
139
141
 
140
142
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alacris/ui",
3
- "version": "0.2.0",
3
+ "version": "0.2.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": {
@@ -66,7 +66,7 @@
66
66
  "url": "git+https://github.com/bmartel/alacris.git",
67
67
  "directory": "ui"
68
68
  },
69
- "homepage": "https://bmartel.github.io/alacris/ui/getting-started/",
69
+ "homepage": "https://bmartel.github.io/alacris/ui/",
70
70
  "bugs": {
71
71
  "url": "https://github.com/bmartel/alacris/issues"
72
72
  },
@@ -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(() => {
@@ -1,6 +1,11 @@
1
1
  // <ui-icon> — an icon from the registry, or any slotted SVG.
2
2
  //
3
- // @prop {string} name='' — registry name (see util/icons.js); empty renders the slot
3
+ // Names are kebab-case (`arrow-forward`). Underscores are accepted
4
+ // (`arrow_forward`). `iconNames()` lists the built-in set; apps add more
5
+ // with `registerIcons({ name: 'M…' })`. An unknown name logs a warning
6
+ // once and renders a placeholder instead of an empty hole.
7
+ //
8
+ // @prop {string} name='' — registry name; empty renders the slot
4
9
  // @prop {string} label='' — accessible name; empty marks the icon decorative
5
10
  // @prop {string} size='' — CSS length; overrides --ui-icon-size for this element
6
11
  // @slot (default) — a custom <svg> when no name is given
@@ -8,7 +13,10 @@
8
13
 
9
14
  import { define, html, svg, css, vars, effect } from '@alacris/core';
10
15
  import { base } from './base.js';
11
- import { iconPath, iconsVersion } from '../util/icons.js';
16
+ import { iconPath, iconsVersion, warnUnknownIcon } from '../util/icons.js';
17
+
18
+ // Hollow square — obviously not a real glyph, so a typo is visible.
19
+ const MISSING = 'M3 3v18h18V3H3zm2 2h14v14H5V5z';
12
20
 
13
21
  const t = vars('ui-icon', {
14
22
  size: '1.5rem',
@@ -52,10 +60,11 @@ define('ui-icon', {
52
60
  });
53
61
  return html`${() => {
54
62
  iconsVersion(); // re-render if icons register late
55
- const d = name() && iconPath(name());
56
- return d
57
- ? svg`<svg viewBox="0 0 24 24" aria-hidden="true"><path d=${d}></path></svg>`
58
- : html`<slot></slot>`;
63
+ const n = name();
64
+ if (!n) return html`<slot></slot>`;
65
+ const d = iconPath(n);
66
+ if (!d) warnUnknownIcon(n);
67
+ return svg`<svg viewBox="0 0 24 24" aria-hidden="true" data-icon=${d ? n : 'unknown'}><path d=${d || MISSING}></path></svg>`;
59
68
  }}`;
60
69
  },
61
70
  });
@@ -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);
@@ -1,6 +1,15 @@
1
1
  // <ui-slider> — a Material slider on native <input type="range">s for
2
2
  // keyboard and screen-reader behavior.
3
3
  //
4
+ // <ui-slider label="Volume" value=${volume}
5
+ // @input=${(e) => volume.set(e.detail.value)}></ui-slider>
6
+ //
7
+ // Bind `value` (or `.value`) to a signal like any other control. `input` /
8
+ // `change` report a number in `detail.value` (or `detail.start` / `detail.end`
9
+ // when `range`). The host `.value` is a string, matching a native range
10
+ // input, so composed-path helpers that look for `typeof node.value ===
11
+ // 'string'` work the same as they do for text fields.
12
+ //
4
13
  // The active track portion is painted with `--ui-slider-fill` (or start/end
5
14
  // when `range`) bound from the template into a gradient; the thumb's
6
15
  // hover/focus halo is a box-shadow state layer.
@@ -157,6 +166,23 @@ define('ui-slider', {
157
166
  });
158
167
  formBind(host, { name, value: submitted, disabled });
159
168
 
169
+ // Native <input type="range">.value is a string. Present the same on the
170
+ // host so composed-path helpers and attribute-based bindings see it, while
171
+ // the prop signal stays numeric for math and `detail.value`.
172
+ const toNum = (v) => {
173
+ const n = typeof v === 'number' ? v : parseFloat(v);
174
+ return Number.isFinite(n) ? n : min();
175
+ };
176
+ Object.defineProperty(host, 'value', {
177
+ configurable: true,
178
+ enumerable: true,
179
+ get() { return String(value()); },
180
+ set(v) { value.set(toNum(v)); },
181
+ });
182
+ effect(() => {
183
+ host.setAttribute('value', String(value()));
184
+ });
185
+
160
186
  const active = signal(false);
161
187
  const activeThumb = signal('end');
162
188
 
@@ -226,7 +252,8 @@ define('ui-slider', {
226
252
  ${() => (range()
227
253
  ? html`<div class="dual">
228
254
  <input part="input" type="range"
229
- min=${min} max=${max} step=${step} .value=${valueStart}
255
+ min=${min} max=${max} step=${step}
256
+ value=${valueStart} .value=${valueStart}
230
257
  ?disabled=${disabled}
231
258
  aria-label=${() => (label() ? label() + ' start' : 'Start')}
232
259
  @input=${onStartInput} @change=${onStartChange}
@@ -234,7 +261,8 @@ define('ui-slider', {
234
261
  @focus=${() => { active.set(true); activeThumb.set('start'); }}
235
262
  @blur=${() => active.set(false)}>
236
263
  <input part="input" type="range"
237
- min=${min} max=${max} step=${step} .value=${valueEnd}
264
+ min=${min} max=${max} step=${step}
265
+ value=${valueEnd} .value=${valueEnd}
238
266
  ?disabled=${disabled}
239
267
  aria-label=${() => (label() ? label() + ' end' : 'End')}
240
268
  @input=${onEndInput} @change=${onEndChange}
@@ -243,7 +271,8 @@ define('ui-slider', {
243
271
  @blur=${() => active.set(false)}>
244
272
  </div>`
245
273
  : html`<input part="input" type="range"
246
- min=${min} max=${max} step=${step} .value=${value}
274
+ min=${min} max=${max} step=${step}
275
+ value=${value} .value=${value}
247
276
  ?disabled=${disabled}
248
277
  aria-label=${() => label() || 'Slider'}
249
278
  @input=${onInput} @change=${onChange}
package/src/util/icons.js CHANGED
@@ -1,29 +1,45 @@
1
1
  // Icon registry.
2
2
  //
3
3
  // A built-in set of Material Design icon paths (24×24 viewBox, from Google's
4
- // material-design-icons, Apache-2.0). Components take what they need; the rest
5
- // is the everyday MD3 filled set so an app can start without a custom registry. Apps add their own with `registerIcons` — any 24×24
6
- // path data works or slot a whole `<svg>` into `<ui-icon>` for one-offs.
4
+ // material-design-icons, Apache-2.0). Names are kebab-case; underscores are
5
+ // treated as hyphens so Material Symbols names (`arrow_forward`) resolve.
6
+ // Components take what they need; the rest is the everyday MD3 filled set so
7
+ // an app can start without a custom registry. Apps add their own with
8
+ // `registerIcons` — any 24×24 path data works — or slot a whole `<svg>` into
9
+ // `<ui-icon>` for one-offs.
7
10
 
8
11
  import { signal } from '@alacris/core';
9
12
 
10
13
  const registry = new Map();
14
+ const warned = new Set();
11
15
 
12
16
  // Bumped on registration so already-rendered <ui-icon>s pick up late icons.
13
17
  export const iconsVersion = signal(0);
14
18
 
15
- /** registerIcons({ name: 'M…' }) later registrations win. */
19
+ // Material Symbols use underscores; the registry is kebab-case. Treat them
20
+ // as the same name so `arrow_forward` resolves to `arrow-forward`.
21
+ const canon = (name) => String(name).replace(/_/g, '-');
22
+
23
+ /** registerIcons({ name: 'M…' }) — later registrations win. Names are stored kebab-case. */
16
24
  export function registerIcons(icons) {
17
- for (const name in icons) registry.set(name, icons[name]);
25
+ for (const name in icons) registry.set(canon(name), icons[name]);
18
26
  iconsVersion.update((n) => n + 1);
19
27
  }
20
28
 
21
- /** Path data for a name, or undefined. */
22
- export const iconPath = (name) => registry.get(name);
29
+ /** Path data for a name, or undefined. Underscores and hyphens are equivalent. */
30
+ export const iconPath = (name) => (name ? registry.get(canon(name)) : undefined);
23
31
 
24
- /** Registered names (built-ins included) — the demo lists these. */
32
+ /** Registered names (built-ins included, kebab-case) — the demo lists these. */
25
33
  export const iconNames = () => [...registry.keys()];
26
34
 
35
+ /** Warn once per unknown name so a blank icon is grep-able, not a silent hole. */
36
+ export function warnUnknownIcon(name) {
37
+ const n = canon(name);
38
+ if (!n || registry.has(n) || warned.has(n)) return;
39
+ warned.add(n);
40
+ console.warn(`ui-icon: "${name}" is not registered. Names are kebab-case (arrow-forward); underscores are accepted. iconNames() lists the set; registerIcons() adds more.`);
41
+ }
42
+
27
43
  registerIcons({
28
44
  'add': 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z',
29
45
  'arrow-back': 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z',
@@ -31,6 +47,7 @@ registerIcons({
31
47
  'arrow-drop-down': 'M7 10l5 5 5-5z',
32
48
  'arrow-drop-up': 'M7 14l5-5 5 5z',
33
49
  'arrow-upward': 'M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z',
50
+ 'arrow-downward': 'M20 12l-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8 8-8z',
34
51
  'calendar': 'M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z',
35
52
  'cancel': 'M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z',
36
53
  'check': 'M8.95 19.1 3.65 13.8 6.45 11 8.95 13.5 17.55 4.9 20.35 7.7z',
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
+ }
package/types/index.d.ts CHANGED
@@ -147,8 +147,11 @@ export function formBind(
147
147
  }
148
148
  ): void;
149
149
 
150
+ /** Register SVG path data. Names are stored kebab-case; underscores are equivalent. */
150
151
  export function registerIcons(icons: Record<string, string>): void;
152
+ /** Path data for a name, or undefined. Underscores and hyphens are equivalent. */
151
153
  export function iconPath(name: string): string | undefined;
154
+ /** Registered names (built-ins included, kebab-case). */
152
155
  export function iconNames(): string[];
153
156
 
154
157
  export function processTable(opts: Record<string, unknown>): Record<string, unknown>;