@threadlabs/looma 0.37.5 → 0.38.0

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 (56) hide show
  1. package/components/ui-button/ui-button.html +20 -5
  2. package/components/ui-combobox/ui-combobox.html +24 -3
  3. package/components/ui-combobox/ui-combobox.js +18 -3
  4. package/components/ui-icon-button/ui-icon-button.html +17 -8
  5. package/components/ui-menu-item/ui-menu-item.html +6 -1
  6. package/components/ui-select/ui-select.html +12 -0
  7. package/components/ui-skeleton/ui-skeleton.html +91 -0
  8. package/dist/index.js +1 -1
  9. package/docs/README.md +23 -10
  10. package/docs/catalog.json +246 -31
  11. package/docs/customizer-render.mjs +36 -20
  12. package/docs/customizer.js +76 -25
  13. package/docs/documentation.css +2 -0
  14. package/docs/palette.mjs +3 -3
  15. package/docs/site.mjs +1 -1
  16. package/docs/theme-session.mjs +1 -0
  17. package/docs/theme.mjs +11 -10
  18. package/package.json +2 -2
  19. package/styles/ui-button.css +19 -9
  20. package/styles/ui-combobox.css +40 -2
  21. package/styles/ui-icon-button.css +12 -2
  22. package/styles/ui-menu-item.css +6 -1
  23. package/styles/ui-select.css +9 -0
  24. package/styles/ui-skeleton.css +91 -0
  25. package/theme-system.css +7 -2
  26. package/vanilla/UiButton.js +1 -1
  27. package/vanilla/UiCombobox.d.ts +1 -0
  28. package/vanilla/UiCombobox.js +38 -37
  29. package/vanilla/UiIconButton.d.ts +1 -1
  30. package/vanilla/UiIconButton.js +1 -1
  31. package/vanilla/UiSelect.d.ts +1 -0
  32. package/vanilla/UiSelect.js +7 -7
  33. package/vanilla/UiSkeleton.d.ts +26 -0
  34. package/vanilla/UiSkeleton.js +42 -0
  35. package/vanilla/index.js +1 -0
  36. package/vue/UiButton.vue +30 -12
  37. package/vue/UiCombobox.d.ts +6 -0
  38. package/vue/UiCombobox.vue +70 -6
  39. package/vue/UiIconButton.d.ts +2 -2
  40. package/vue/UiIconButton.vue +23 -18
  41. package/vue/UiMenuItem.vue +6 -1
  42. package/vue/UiSelect.d.ts +6 -0
  43. package/vue/UiSelect.vue +30 -1
  44. package/vue/UiSkeleton.d.ts +37 -0
  45. package/vue/UiSkeleton.js +2 -0
  46. package/vue/UiSkeleton.vue +235 -0
  47. package/vue/chunks/UiButton.js +4 -4
  48. package/vue/chunks/UiCombobox.js +72 -46
  49. package/vue/chunks/UiIconButton.js +6 -7
  50. package/vue/chunks/UiMenuItem.js +1 -1
  51. package/vue/chunks/UiSelect.js +27 -2
  52. package/vue/chunks/UiSkeleton.js +151 -0
  53. package/vue/components.css +197 -25
  54. package/vue/editor/index.js +2 -3
  55. package/vue/index.d.ts +1 -0
  56. package/vue/index.js +2 -1
@@ -24,7 +24,7 @@
24
24
  keep their normal layout.</prop>
25
25
  <prop name="shape" type="keyword" values="rounded, pill" default="rounded"
26
26
  >rounded uses the theme's action radius; pill rounds the ends fully, for a compact choice such as a status or a person.</prop>
27
- <prop name="tone" type="keyword" values="accent, neutral, danger, success, warning, info" default="accent"
27
+ <prop name="tone" type="keyword" values="accent, neutral, danger, success, warning, info" default="neutral"
28
28
  >Which colour the button speaks in. It applies to every variant, so an outline, a ghost, and a solid in the same tone are the same action at three volumes:
29
29
  tone="danger" variant="outline" is a destructive secondary action. neutral is the quiet one, for a button that should not claim a colour. The field
30
30
  variant always uses neutral field colors.</prop>
@@ -375,7 +375,6 @@
375
375
  background-color: var(--ui-button-hover-surface, oklch(from var(--_tone) l c h / 12%));
376
376
  }
377
377
 
378
- /* A neutral card encloses content quietly; ordinary outlines keep their action-tone edge. */
379
378
  :host[data-appearance="card"][data-tone="neutral"] {
380
379
  border-color: var(--ui-button-border, var(--ui-border));
381
380
  }
@@ -384,6 +383,12 @@
384
383
  border-color: var(--ui-button-hover-border, var(--ui-border-strong));
385
384
  }
386
385
 
386
+ :host[data-appearance="card"][aria-pressed="true"] {
387
+ background-color: var(--ui-selection-surface);
388
+ border-color: var(--ui-selection-border);
389
+ color: var(--ui-selection-text);
390
+ }
391
+
387
392
  /* Pressed reads as pressed in: the highlight goes, and the shadow moves inside. */
388
393
  :host:active:not(:disabled, [aria-disabled="true"]) {
389
394
  scale: var(--ui-press-scale, 1);
@@ -431,6 +436,18 @@
431
436
  background-color: var(--ui-button-surface, var(--ui-neutral-surface));
432
437
  color: var(--ui-button-text, var(--ui-text));
433
438
  }
439
+ :host[data-tone="neutral"][data-appearance="outline"] {
440
+ border-color: var(--ui-button-border, var(--ui-control-border));
441
+ background-color: var(--ui-button-surface, transparent);
442
+ }
443
+ :host[data-tone="neutral"][data-appearance="outline"]:hover:not(:disabled, [aria-disabled="true"]) {
444
+ border-color: var(--ui-button-hover-border, var(--ui-control-border-hover, var(--ui-control-border)));
445
+ background-color: var(--ui-button-hover-surface, var(--ui-neutral-hover));
446
+ }
447
+ :host[data-tone="neutral"][data-appearance="outline"]:active:not(:disabled, [aria-disabled="true"]) {
448
+ border-color: var(--ui-button-hover-border, var(--ui-control-border-hover, var(--ui-control-border)));
449
+ background-color: var(--ui-button-active-surface, var(--ui-neutral-active));
450
+ }
434
451
  :host[data-tone="neutral"][data-appearance="solid"]:hover:not(:disabled, [aria-disabled="true"]) {
435
452
  background-color: var(--ui-button-hover-surface, var(--ui-neutral-hover));
436
453
  }
@@ -518,10 +535,8 @@
518
535
  border-color: transparent;
519
536
  }
520
537
 
521
- /* Pressed, or holding its menu open, a ghost button sinks slightly: a light surface with a faint
522
- edge and a soft inner shadow. Lighter than hover, so an open menu's trigger never reads as heavy. */
523
538
  :host[data-appearance="ghost"]:is(:active, [aria-expanded="true"]):not(:disabled, [aria-disabled="true"]) {
524
- background-color: var(--ui-button-ghost-active-surface, color-mix(in oklch, var(--ui-surface), var(--_tone) 3%));
539
+ background-color: var(--ui-button-ghost-active-surface, oklch(from var(--_tone) l c h / 18%));
525
540
  border-color: transparent;
526
541
  box-shadow: inset 0 0 0 1px oklch(from var(--_tone) l c h / 10%), inset 0 1px 2px oklch(from var(--_tone) l c h / 8%);
527
542
  }
@@ -5,6 +5,8 @@
5
5
  <prop name="label" type="string" default=""
6
6
  >The field's label, tied to its input and shown above it; required adds an asterisk. It also names the suggestion list. Pair it with
7
7
  labelVisibility="sr-only" when the context already labels the field visibly.</prop>
8
+ <prop name="muted" type="boolean" default="false"
9
+ >Shows the selected field value with supporting-text emphasis, for an inherited or default choice. Suggestions keep normal emphasis and the field stays interactive.</prop>
8
10
  <prop name="placeholder" type="string" default=""
9
11
  >Hint text shown in the empty input, as on a native input. It disappears as the user types, so it never replaces label.</prop>
10
12
  <prop name="name" type="string" default=""
@@ -79,7 +81,7 @@
79
81
  when the field is left empty. In multiple mode a chosen item satisfies it, whatever is left in the input.</prop>
80
82
  <prop name="invalid" type="boolean" default="false"
81
83
  >Exposes an externally validated error on the input and field. It remains until the consumer clears it, independently of native validation.</prop>
82
- <prop name="openOnFocus" type="boolean" default="false">Opens suggestions when the input receives focus, before typing.</prop>
84
+ <prop name="openOnFocus" type="boolean" default="false">Opens suggestions when the input receives focus or is clicked. A committed selection shows all choices until typing filters them; an explicitly supplied query keeps its filtering.</prop>
83
85
  <prop name="loading" type="boolean" default="false"
84
86
  >Marks the suggestion list busy while the consumer loads options. It announces loading and suppresses the empty state and create offer until cleared.</prop>
85
87
  <prop name="size" type="keyword" values="sm, md, lg" default="md"
@@ -423,7 +425,7 @@
423
425
  <div class="control">
424
426
  <div class="field" $ref="field">
425
427
  <div class="entry">
426
- <slot name="start"></slot>
428
+ <span class="start"><slot name="start"></slot></span>
427
429
  <template $if="$multiple">
428
430
  <button
429
431
  $each="item, index of $internalItems"
@@ -698,6 +700,21 @@
698
700
  flex: 1 1 auto;
699
701
  min-inline-size: 0;
700
702
  }
703
+ .start {
704
+ display: flex !important;
705
+ align-items: center !important;
706
+ flex: none;
707
+ padding-inline-start: var(--ui-space-3) !important;
708
+ }
709
+ .start:empty {
710
+ display: none !important;
711
+ }
712
+ :host([size="sm"]) .start {
713
+ padding-inline-start: var(--ui-space-2) !important;
714
+ }
715
+ :host([size="lg"]) .start {
716
+ padding-inline-start: var(--ui-space-4) !important;
717
+ }
701
718
  /* A ghost icon button: the affordance is the mark, not a box around it. */
702
719
  .help {
703
720
  flex: none;
@@ -736,6 +753,10 @@
736
753
  block-size: var(--ui-icon-size-sm);
737
754
  }
738
755
 
756
+ :host([muted]) input:not(:disabled) {
757
+ color: var(--ui-text-secondary);
758
+ }
759
+
739
760
  input::selection {
740
761
  background: color-mix(in srgb, var(--ui-accent-solid) 20%, transparent);
741
762
  color: inherit;
@@ -820,7 +841,7 @@
820
841
  padding: var(--ui-space-1) !important;
821
842
  border: var(--ui-border-width) solid var(--_ui-combobox-popup-border, var(--ui-overlay-border, var(--ui-border-strong)));
822
843
  border-radius: var(--_ui-combobox-popup-radius, var(--ui-overlay-radius, var(--ui-radius-md)));
823
- background: var(--_ui-combobox-popup-surface, var(--ui-overlay-surface, var(--ui-surface-elevated)));
844
+ background: var(--_ui-combobox-popup-surface, var(--ui-material-content, var(--ui-surface-elevated)));
824
845
  color: var(--ui-text-primary);
825
846
  box-shadow: var(--_ui-combobox-popup-shadow, var(--ui-overlay-shadow, var(--ui-elevation-overlay, var(--ui-shadow-lg))));
826
847
  }
@@ -35,6 +35,7 @@ export function connect(host) {
35
35
  let composing = false;
36
36
  let alive = true;
37
37
  let fullSet = false;
38
+ let suppressFocusOpening = false;
38
39
  let initialRaw = "";
39
40
  let awaitingLabel = null;
40
41
  let proposedChange;
@@ -461,14 +462,28 @@ export function connect(host) {
461
462
  else if (config().allowFreeText) { event.preventDefault(); commit(null, host.state.raw, null, "free-entry", "keyboard"); close(); }
462
463
  }
463
464
  };
465
+ const openFromField = () => {
466
+ const selected = config().options?.find(option => option.value === host.state.selected) ?? knownOptions.get(host.state.selected);
467
+ // A committed label is a selection, not a search draft. Typing still filters.
468
+ open(host.props.query.value == null && selected?.label === host.state.raw ? "disclosure" : "input");
469
+ };
464
470
  const onClick = (event) => {
465
471
  const option = event.target.closest?.('[role="option"][data-index]');
466
472
  if (option) { choose(Number(option.dataset.index), "pointer"); return; }
467
473
  const action = event.target.closest?.("[data-combobox-action]")?.dataset.comboboxAction;
468
474
  if (action && (host.props.disabled.value || host.props.readonly.value)) return;
469
475
  if (action === "clear") { lastSelection = null; commit(null, "", null, "clear", "pointer"); close(); input.focus(); }
470
- else if (action === "disclosure") { host.state.expanded ? close() : open("disclosure"); input.focus(); }
471
- else if (field.contains(event.target) && !event.target.closest?.("button")) input.focus();
476
+ else if (action === "disclosure") {
477
+ const expanded = host.state.expanded;
478
+ // Returning focus must not undo an explicit request to close the list.
479
+ suppressFocusOpening = true;
480
+ try { input.focus(); } finally { suppressFocusOpening = false; }
481
+ expanded ? close() : open("disclosure");
482
+ }
483
+ else if (field.contains(event.target) && !event.target.closest?.("button")) {
484
+ if (host.props.openOnFocus.value && !host.state.expanded) openFromField();
485
+ input.focus();
486
+ }
472
487
  };
473
488
  const onPointerdown = (event) => { if (event.target.closest?.('[role="option"]')) event.preventDefault(); };
474
489
  const onFocusout = (event) => {
@@ -481,7 +496,7 @@ export function connect(host) {
481
496
  void validateCurrent();
482
497
  };
483
498
  const onFocusin = (event) => {
484
- if (event.target === input && host.props.openOnFocus.value && !host.state.expanded) open();
499
+ if (event.target === input && host.props.openOnFocus.value && !host.state.expanded && !suppressFocusOpening) openFromField();
485
500
  };
486
501
  const onCompositionstart = (event) => { if (event.target === input) composing = true; };
487
502
  const onCompositionend = (event) => { if (event.target === input) { composing = false; onInput(new InputEvent("input")); } };
@@ -4,7 +4,6 @@
4
4
  <prop name="preset" type="keyword" values="none, primary, standard, quiet, destructive" default="none"
5
5
  >The same shortcuts as Button: primary is solid accent, standard is solid neutral, quiet is ghost neutral, and destructive is solid danger. A named preset
6
6
  wins over variant and tone; none preserves independent choices.</prop>
7
- <!-- Computed maps read every member in HTML Next. auto keeps an omitted tone concrete so the preset map can resolve, while retaining the existing variant-dependent appearance. -->
8
7
  <computed name="appearance" from="{ none: $variant, primary: 'solid', standard: 'solid', quiet: 'ghost', destructive: 'solid' }[$preset]"></computed>
9
8
  <computed name="colorTone" from="{ none: $tone, primary: 'accent', standard: 'neutral', quiet: 'neutral', destructive: 'danger' }[$preset]"></computed>
10
9
  <prop name="disabled" type="boolean" default="false"
@@ -21,12 +20,12 @@
21
20
  >Matches a boxed Button of the same size when the two share a flex row. Uses the control-size theme tokens and stretches to the row's height, on mouse and
22
21
  touch alike. Leave it off for compact toolbar and inline icon buttons.</prop>
23
22
  <prop name="variant" type="keyword" values="ghost, outline, solid" default="ghost"
24
- >Emphasis. ghost shows only the icon until it is hovered, for toolbars and dense rows; outline adds a border on the default surface; solid fills with the
25
- accent colour, for the one primary action in a group.</prop>
26
- <prop name="tone" type="keyword" values="auto, accent, neutral, danger, success, warning, info" default="auto"
27
- >Colour for every variant. auto preserves the existing variant-specific default. When omitted, ghost and outline keep their neutral appearance and solid
28
- keeps its accent fill. Set accent for a coloured action or neutral for a quiet one. danger, success, warning and info share Button's intent colors. Native
29
- aria-pressed="true" keeps an outline toggle visibly pressed; the consumer owns that state.</prop>
23
+ >Emphasis. ghost shows only the icon until it is hovered, for toolbars and dense rows; outline adds a border on the default surface; solid fills with its
24
+ chosen tone.</prop>
25
+ <prop name="tone" type="keyword" values="accent, neutral, danger, success, warning, info" default="neutral"
26
+ >Colour for every variant. The default neutral shares the quiet control family with fields and text buttons. Choose accent explicitly for a coloured action,
27
+ or primary for solid accent. danger, success, warning and info share Button's intent colors. Native aria-pressed="true" keeps an outline toggle visibly
28
+ pressed; the consumer owns that state.</prop>
30
29
  <prop name="anticipatory" type="boolean" default="false"
31
30
  >Keeps the button out of sight until it is wanted: at rest a fine pointer sees only a small guide dot, and the button appears on hover, on focus, or when
32
31
  the pointer comes near inside a ui-affordance-scope. Touch devices and the solid variant always show it.</prop>
@@ -217,10 +216,14 @@
217
216
  color: var(--_icon-button-text);
218
217
  }
219
218
 
220
- /* Omitted tone retains the existing accent solid; explicit neutral is additive. */
219
+ /* Neutral actions share the same quiet surface and border roles as fields. */
221
220
  :host[data-tone="neutral"] {
222
221
  --_tone-highlight: var(--ui-neutral-highlight, none);
223
222
  --_tone-glow: var(--ui-neutral-glow, 0 0 transparent);
223
+ --_icon-button-outline-surface: transparent;
224
+ --_icon-button-border: var(--ui-control-border);
225
+ --_icon-button-hover-border: var(--ui-control-border-hover, var(--ui-control-border));
226
+ --_icon-button-text: var(--ui-text);
224
227
  --_icon-button-solid-surface: var(--ui-neutral-surface);
225
228
  --_icon-button-solid-hover-surface: var(--ui-neutral-hover);
226
229
  --_icon-button-solid-text: var(--ui-text);
@@ -408,6 +411,12 @@
408
411
  :host[data-appearance="outline"]:hover:enabled:not([aria-disabled="true"]) {
409
412
  border-color: var(--ui-icon-button-hover-border, var(--_icon-button-hover-border, var(--ui-control-border-hover, var(--ui-control-border))));
410
413
  }
414
+ :host[data-tone="neutral"][data-appearance="outline"]:hover:enabled:not([aria-disabled="true"]) {
415
+ background-color: var(--ui-icon-button-hover-surface, var(--ui-neutral-hover));
416
+ }
417
+ :host[data-tone="neutral"][data-appearance="outline"]:active:enabled:not([aria-disabled="true"]) {
418
+ background-color: var(--ui-icon-button-active-surface, var(--ui-neutral-active));
419
+ }
411
420
 
412
421
  :host[data-appearance="solid"]:hover:enabled:not([aria-disabled="true"]) {
413
422
  background-color: var(--ui-icon-button-solid-hover-surface, var(--_icon-button-solid-hover-surface, var(--ui-accent-hover)));
@@ -181,7 +181,12 @@
181
181
  }
182
182
 
183
183
  :host:is(:hover, :focus-visible):not([aria-disabled="true"]) {
184
- background: var(--ui-menu-item-hover-surface, var(--ui-surface-subtle));
184
+ background: var(--ui-menu-item-hover-surface, var(--ui-accent-subtle));
185
+ }
186
+
187
+ :host[aria-checked="true"]:not([aria-disabled="true"]) {
188
+ background: var(--ui-selection-surface);
189
+ color: var(--ui-selection-text);
185
190
  }
186
191
 
187
192
  :host:focus-visible {
@@ -6,6 +6,9 @@
6
6
  <prop name="disabled" type="boolean" default="false">Prevents selection and form submission.</prop>
7
7
  <prop name="invalid" type="boolean" default="false">Exposes invalid state to assistive technology and styling.</prop>
8
8
  <prop name="required" type="boolean" default="false">Requires a non-empty option for form validation.</prop>
9
+ <prop name="muted" type="boolean" default="false"
10
+ >Shows the selected value with supporting-text emphasis, for an inherited or default choice. The select remains interactive and its options retain normal
11
+ emphasis.</prop>
9
12
  <prop name="size" type="keyword" values="sm, md, lg" default="md"
10
13
  >Height, padding, chevron, and type size, matching Button's sizes so a field and a button of one size share a row. sm is the small control height (2rem by
11
14
  default) with smaller text and a smaller chevron, for compact rows such as a table row or a filter bar; md is the standard control height; lg is taller
@@ -95,6 +98,15 @@
95
98
  background-size: 1.125rem 1.125rem, 100% 100%;
96
99
  }
97
100
 
101
+ :host([muted]) {
102
+ color: var(--ui-text-secondary);
103
+ }
104
+ :host([muted]) :slotted(option),
105
+ :host([muted]) :slotted(optgroup) {
106
+ color: var(--ui-text-primary);
107
+ background-color: var(--ui-control-surface);
108
+ }
109
+
98
110
  :host:hover:not(:focus):not(:disabled):not([aria-invalid="true"]):not(:user-invalid) {
99
111
  border-color: var(--ui-select-border-hover, var(--ui-control-border-hover, var(--ui-text-secondary)));
100
112
  }
@@ -0,0 +1,91 @@
1
+ <template component="ui-skeleton">
2
+ <defs>
3
+ <prop name="shape" type="keyword" values="text, circle, rectangle" default="text"
4
+ >The content's silhouette. text reserves one inherited line box with a shorter bar inside it; circle matches a medium Avatar; rectangle reserves a media
5
+ area.</prop>
6
+ <prop name="width" type="string" default=""
7
+ >Inline size as a CSS length or percentage, constrained to the container. Empty fills the available width for text and rectangles, or matches a medium
8
+ Avatar's outer size for circles.</prop>
9
+ <prop name="height" type="string" default=""
10
+ >Block size as a CSS length. Empty reserves one line for text, a square for circles, or 8rem for rectangles. Set only width to keep a circle square.</prop>
11
+ <prop name="animated" type="boolean" default="false"
12
+ >Gently pulses the placeholder. Off by default, and always still when the reader requests reduced motion.</prop>
13
+ </defs>
14
+ <span aria-hidden="true" style:--ui-skeleton-width="$width" style:--ui-skeleton-height="$height"></span>
15
+ <style>
16
+ /* Dimensions belong to this placeholder, never a nested or neighbouring one. */
17
+ @property --ui-skeleton-width {
18
+ syntax: "*";
19
+ inherits: false;
20
+ }
21
+ @property --ui-skeleton-height {
22
+ syntax: "*";
23
+ inherits: false;
24
+ }
25
+
26
+ :host {
27
+ display: block !important;
28
+ position: relative;
29
+ box-sizing: border-box !important;
30
+ flex: none;
31
+ min-inline-size: 0;
32
+ max-inline-size: 100%;
33
+ inline-size: var(--ui-skeleton-width, 100%);
34
+ block-size: var(--ui-skeleton-height, 1lh);
35
+ margin: 0 !important;
36
+ font: inherit;
37
+ }
38
+
39
+ :host::before {
40
+ content: "";
41
+ position: absolute;
42
+ inset: 0;
43
+ border-radius: var(--ui-radius-sm);
44
+ background: color-mix(in srgb, var(--ui-text-primary) 16%, transparent);
45
+ }
46
+
47
+ :host([shape="text"])::before {
48
+ inset-block: 22%;
49
+ }
50
+
51
+ :host([shape="circle"]) {
52
+ inline-size: var(--ui-skeleton-width, calc(2.5rem + 2 * var(--ui-border-width)));
53
+ block-size: var(--ui-skeleton-height, auto);
54
+ aspect-ratio: 1;
55
+ }
56
+ :host([shape="circle"])::before {
57
+ border-radius: var(--ui-radius-round);
58
+ }
59
+
60
+ :host([shape="rectangle"]) {
61
+ block-size: var(--ui-skeleton-height, 8rem);
62
+ }
63
+ :host([shape="rectangle"])::before {
64
+ border-radius: var(--ui-radius-md);
65
+ }
66
+
67
+ :host([animated])::before {
68
+ animation: ui-skeleton-pulse 1.6s ease-in-out infinite;
69
+ }
70
+ @keyframes ui-skeleton-pulse {
71
+ 50% {
72
+ opacity: 0.45;
73
+ }
74
+ }
75
+ @media (prefers-reduced-motion: reduce) {
76
+ :host([animated])::before {
77
+ animation: none;
78
+ }
79
+ }
80
+ @media (forced-colors: active) {
81
+ :host::before {
82
+ background: CanvasText;
83
+ opacity: 0.2;
84
+ animation: none;
85
+ }
86
+ }
87
+ :host[hidden] {
88
+ display: none !important;
89
+ }
90
+ </style>
91
+ </template>