@threadlabs/looma 0.17.3 → 0.18.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 (71) hide show
  1. package/components/ui-avatar/ui-avatar.html +8 -4
  2. package/components/ui-badge/ui-badge.html +28 -34
  3. package/components/ui-button/ui-button.html +5 -0
  4. package/components/ui-checkbox/ui-checkbox.html +5 -5
  5. package/components/ui-combobox/ui-combobox.html +4 -48
  6. package/components/ui-icon-button/ui-icon-button.html +7 -7
  7. package/components/ui-input/ui-input.html +1 -1
  8. package/components/ui-input-group/ui-input-group.html +2 -2
  9. package/components/ui-listbox/ui-listbox.html +88 -12
  10. package/components/ui-listbox/ui-listbox.js +162 -18
  11. package/components/ui-search-result-row/ui-search-result-row.html +2 -6
  12. package/components/ui-search-shell/ui-search-shell.html +2 -11
  13. package/components/ui-select/ui-select.html +1 -1
  14. package/components/ui-spinner/ui-spinner.html +45 -18
  15. package/components/ui-textarea/ui-textarea.html +1 -1
  16. package/components/ui-tooltip/ui-tooltip.html +2 -2
  17. package/dist/index.js +1 -1
  18. package/editor/chunks/extensions.js +5 -0
  19. package/package.json +1 -1
  20. package/styles/ui-avatar.css +6 -2
  21. package/styles/ui-badge.css +30 -40
  22. package/styles/ui-button.css +7 -0
  23. package/styles/ui-checkbox.css +6 -6
  24. package/styles/ui-combobox.css +0 -52
  25. package/styles/ui-icon-button.css +7 -7
  26. package/styles/ui-input-group.css +2 -2
  27. package/styles/ui-input.css +1 -1
  28. package/styles/ui-listbox.css +33 -8
  29. package/styles/ui-search-result-row.css +2 -6
  30. package/styles/ui-search-shell.css +2 -13
  31. package/styles/ui-select.css +1 -1
  32. package/styles/ui-spinner.css +38 -19
  33. package/styles/ui-textarea.css +1 -1
  34. package/styles/ui-tooltip.css +2 -2
  35. package/tokens.css +67 -2
  36. package/vanilla/UiCombobox.js +9 -1
  37. package/vanilla/UiListbox.d.ts +3 -1
  38. package/vanilla/UiListbox.js +65 -18
  39. package/vanilla/UiSpinner.js +19 -1
  40. package/vue/UiAvatar.vue +11 -2
  41. package/vue/UiBadge.vue +30 -36
  42. package/vue/UiButton.vue +5 -0
  43. package/vue/UiCheckbox.vue +5 -5
  44. package/vue/UiCombobox.vue +4 -49
  45. package/vue/UiIconButton.vue +10 -7
  46. package/vue/UiInput.vue +1 -1
  47. package/vue/UiInputGroup.vue +2 -2
  48. package/vue/UiListbox.d.ts +18 -5
  49. package/vue/UiListbox.vue +142 -25
  50. package/vue/UiSearchResultRow.vue +2 -6
  51. package/vue/UiSearchShell.vue +2 -13
  52. package/vue/UiSelect.vue +1 -1
  53. package/vue/UiSpinner.vue +45 -18
  54. package/vue/UiTextarea.vue +1 -1
  55. package/vue/UiTooltip.vue +2 -2
  56. package/vue/chunks/UiAvatar.js +2 -1
  57. package/vue/chunks/UiBadge.js +1 -1
  58. package/vue/chunks/UiButton.js +1 -1
  59. package/vue/chunks/UiCheckbox.js +1 -1
  60. package/vue/chunks/UiCombobox.js +11 -2
  61. package/vue/chunks/UiIconButton.js +1 -1
  62. package/vue/chunks/UiInput.js +1 -1
  63. package/vue/chunks/UiInputGroup.js +1 -1
  64. package/vue/chunks/UiListbox.js +134 -20
  65. package/vue/chunks/UiSearchResultRow.js +1 -1
  66. package/vue/chunks/UiSearchShell.js +1 -1
  67. package/vue/chunks/UiSelect.js +1 -1
  68. package/vue/chunks/UiSpinner.js +29 -3
  69. package/vue/chunks/UiTextarea.js +1 -1
  70. package/vue/chunks/UiTooltip.js +1 -1
  71. package/vue/components.css +426 -440
@@ -1,27 +1,171 @@
1
- // Keep externally supplied selections in sync when options arrive after the listbox upgrades.
2
- // Without value or values, the native select owns selection, keyboard behavior, and form reset.
1
+ let nextId = 0;
2
+
3
+ const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
4
+
5
+ // Authored options remain the source of labels, order, disabled state, and default selections.
6
+ function readOptions(container) {
7
+ return Array.from(container.querySelectorAll("option"), (option, index) => ({
8
+ value: option.value,
9
+ label: option.label || option.textContent?.trim() || option.value,
10
+ disabled: option.disabled || Boolean(option.closest("optgroup[disabled]")),
11
+ selected: option.hasAttribute("selected"),
12
+ index,
13
+ }));
14
+ }
15
+
3
16
  export default function controller(host) {
4
- const select = host.element;
5
- const apply = () => {
17
+ const element = host.element;
18
+ const { options: authored, validity } = host.refs;
19
+ const form = element.closest("form");
20
+ host.state.uid = `ui-listbox-${++nextId}`;
21
+ if (element.id && !element.hasAttribute("aria-label") && !element.hasAttribute("aria-labelledby")) {
22
+ const label = Array.from(element.ownerDocument.querySelectorAll("label[for]")).find((candidate) => candidate.htmlFor === element.id);
23
+ if (label) {
24
+ label.id ||= `${host.state.uid}-label`;
25
+ element.setAttribute("aria-labelledby", label.id);
26
+ }
27
+ }
28
+ let options = [];
29
+ let initial = [];
30
+ let initialized = false;
31
+ let external = "";
32
+ let typeahead = "";
33
+ let typeaheadTimer;
34
+
35
+ const selected = () => Array.isArray(host.state.selection) ? host.state.selection : [];
36
+ const externallySelected = () => host.state.multiple
37
+ ? (Array.isArray(host.state.values) ? Array.from(new Set(host.state.values.map(String))) : null)
38
+ : (host.state.value === undefined || host.state.value === null ? null : [String(host.state.value)]);
39
+ const defaultSelection = () => externallySelected()
40
+ ?? options.filter((option) => option.selected).map((option) => option.value).slice(0, host.state.multiple ? undefined : 1);
41
+ const enabledIndices = () => options.filter((option) => !option.disabled).map((option) => option.index);
42
+ const render = () => {
43
+ const set = new Set(selected());
44
+ const rows = options.map((option) => ({
45
+ value: option.value,
46
+ label: option.label,
47
+ disabled: option.disabled,
48
+ selected: set.has(option.value),
49
+ index: option.index,
50
+ }));
51
+ if (!same(host.state.choices, rows)) host.state.choices = rows;
52
+ const active = host.state.active;
53
+ if (active < 0 || !options[active] || options[active].disabled) element.removeAttribute("aria-activedescendant");
54
+ else element.setAttribute("aria-activedescendant", `${host.state.uid}-option-${active}`);
55
+ };
56
+ const setSelection = (values, trigger) => {
57
+ const next = Array.from(new Set(values.map(String)));
58
+ if (same(selected(), next)) return;
59
+ host.state.selection = next;
60
+ host.state.hasSelection = next.some((value) => value !== "");
61
+ host.state.validationError = false;
62
+ render();
63
+ if (trigger) host.dispatch("change", { value: next[0] ?? null, values: next, trigger });
64
+ };
65
+ const syncOptions = () => {
66
+ const next = readOptions(authored);
67
+ if (same(options, next) && initialized) return;
68
+ const hadOptions = options.length > 0;
69
+ options = next;
70
+ if (!initialized || (!hadOptions && options.length && !selected().length)) {
71
+ initial = defaultSelection();
72
+ host.state.selection = initial;
73
+ host.state.hasSelection = initial.some((value) => value !== "");
74
+ initialized = true;
75
+ }
76
+ render();
77
+ };
78
+ const activate = (index) => {
79
+ if (index < 0 || options[index]?.disabled || !options[index]) return;
80
+ host.state.active = index;
81
+ render();
82
+ element.querySelector(`[data-index="${index}"]`)?.scrollIntoView({ block: "nearest" });
83
+ };
84
+ const choose = (index, trigger) => {
85
+ const option = options[index];
86
+ if (!option || option.disabled || host.state.disabled) return;
87
+ activate(index);
6
88
  if (host.state.multiple) {
7
- if (host.state.values === undefined || host.state.values === null) return;
8
- const values = new Set(host.state.values.map(String));
9
- for (const option of select.options) {
10
- option.defaultSelected = values.has(option.value);
11
- option.selected = values.has(option.value);
89
+ const current = selected();
90
+ setSelection(current.includes(option.value)
91
+ ? current.filter((value) => value !== option.value)
92
+ : options.filter((candidate) => current.includes(candidate.value) || candidate.value === option.value).map((candidate) => candidate.value), trigger);
93
+ } else setSelection([option.value], trigger);
94
+ };
95
+ const move = (key) => {
96
+ const indices = enabledIndices();
97
+ if (!indices.length) return;
98
+ const current = indices.indexOf(host.state.active);
99
+ const next = key === "Home" ? indices[0]
100
+ : key === "End" ? indices[indices.length - 1]
101
+ : key === "ArrowDown" ? indices[(current + 1) % indices.length]
102
+ : indices[(current <= 0 ? indices.length : current) - 1];
103
+ activate(next);
104
+ if (!host.state.multiple) choose(next, "keyboard");
105
+ };
106
+ const onClick = (event) => {
107
+ if (host.state.disabled) return;
108
+ const row = event.target.closest("[data-index]");
109
+ if (!row || !element.contains(row)) return;
110
+ element.focus();
111
+ choose(Number(row.dataset.index), "pointer");
112
+ };
113
+ const onKeydown = (event) => {
114
+ if (host.state.disabled || event.altKey || event.metaKey || event.ctrlKey) return;
115
+ if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
116
+ event.preventDefault();
117
+ move(event.key);
118
+ } else if (event.key === " " || event.key === "Enter") {
119
+ event.preventDefault();
120
+ choose(host.state.active >= 0 ? host.state.active : enabledIndices()[0], "keyboard");
121
+ } else if (event.key.length === 1 && !event.shiftKey) {
122
+ typeahead += event.key.toLocaleLowerCase();
123
+ clearTimeout(typeaheadTimer);
124
+ typeaheadTimer = setTimeout(() => { typeahead = ""; }, 700);
125
+ const match = options.find((option) => !option.disabled && option.label.toLocaleLowerCase().startsWith(typeahead));
126
+ if (match) {
127
+ activate(match.index);
128
+ if (!host.state.multiple) choose(match.index, "keyboard");
12
129
  }
13
- return;
14
130
  }
15
- if (host.state.value === undefined || host.state.value === null) return;
16
- const value = String(host.state.value);
17
- for (const option of select.options) option.defaultSelected = option.value === value;
18
- if (select.value !== value) select.value = value;
19
- };
20
- const observer = new MutationObserver(apply);
21
- observer.observe(select, { childList: true, subtree: true });
22
- const stop = host.effect(apply);
131
+ };
132
+ const onReset = () => queueMicrotask(() => {
133
+ setSelection(initial);
134
+ host.state.validationError = false;
135
+ });
136
+ const onInvalid = (event) => {
137
+ event.preventDefault();
138
+ host.state.validationError = true;
139
+ requestAnimationFrame(() => element.focus());
140
+ };
141
+
142
+ const observer = new MutationObserver(syncOptions);
143
+ observer.observe(authored, { childList: true, subtree: true, attributes: true, attributeFilter: ["selected", "disabled", "value", "label"] });
144
+ syncOptions();
145
+ authored.disabled = true;
146
+ element.setAttribute("data-enhanced", "");
147
+ const stop = host.effect(() => {
148
+ const signature = JSON.stringify([host.state.multiple, host.state.value, host.state.values]);
149
+ if (signature !== external) {
150
+ external = signature;
151
+ initial = defaultSelection();
152
+ setSelection(initial);
153
+ }
154
+ render();
155
+ });
156
+ element.addEventListener("click", onClick);
157
+ element.addEventListener("keydown", onKeydown);
158
+ form?.addEventListener("reset", onReset);
159
+ validity.addEventListener("invalid", onInvalid);
23
160
  return () => {
24
161
  stop();
25
162
  observer.disconnect();
163
+ authored.disabled = host.state.disabled;
164
+ element.removeAttribute("data-enhanced");
165
+ clearTimeout(typeaheadTimer);
166
+ element.removeEventListener("click", onClick);
167
+ element.removeEventListener("keydown", onKeydown);
168
+ form?.removeEventListener("reset", onReset);
169
+ validity.removeEventListener("invalid", onInvalid);
26
170
  };
27
171
  }
@@ -61,18 +61,14 @@
61
61
  align-items: flex-start;
62
62
  gap: var(--ui-search-result-row-gap, var(--ui-space-3));
63
63
  background: var(--ui-search-result-row-surface, transparent);
64
- border: none;
65
- border-block-end: 1px solid var(--ui-search-result-row-border, var(--ui-border-divider));
64
+ border: 1px solid var(--ui-search-result-row-border, transparent);
65
+ border-radius: var(--ui-radius-md);
66
66
  color: inherit;
67
67
  cursor: pointer;
68
68
  text-align: left;
69
69
  transition: background-color var(--ui-motion-fast) var(--ui-motion-ease);
70
70
  }
71
71
 
72
- :host:last-child {
73
- border-block-end: none;
74
- }
75
-
76
72
  :host:hover:enabled {
77
73
  background: var(--ui-search-result-row-hover-surface, color-mix(in srgb, var(--ui-surface) 85%, black 3%));
78
74
  }
@@ -117,14 +117,6 @@
117
117
 
118
118
  .search {
119
119
  padding: var(--ui-space-3);
120
- border-block-end: 1px solid var(--ui-border-divider);
121
- transition: border-color var(--ui-motion-fast) var(--ui-motion-ease), box-shadow var(--ui-motion-fast) var(--ui-motion-ease);
122
- }
123
-
124
- /* The search region shows where typing goes, whatever the author put in it. */
125
- .search:focus-within {
126
- border-block-end-color: var(--_ui-search-shell-focus-color, var(--ui-control-focus));
127
- box-shadow: inset 0 -1px 0 var(--_ui-search-shell-focus-color, var(--ui-control-focus));
128
120
  }
129
121
 
130
122
  :slotted(input[slot="search"]) {
@@ -141,7 +133,7 @@
141
133
  }
142
134
 
143
135
  :slotted(input[slot="search"]:focus-visible) {
144
- border-color: var(--ui-control-focus);
136
+ border-color: var(--_ui-search-shell-focus-color, var(--ui-control-focus));
145
137
  outline: 2px solid var(--ui-focus-ring);
146
138
  outline-offset: 2px;
147
139
  }
@@ -155,7 +147,7 @@
155
147
  }
156
148
 
157
149
  .status {
158
- border-block-end: 1px solid var(--ui-border-divider);
150
+ padding-block-start: 0;
159
151
  }
160
152
 
161
153
  /* Laid out like the dialog's action row: actions right-aligned, spaced as buttons, no fill. */
@@ -166,7 +158,6 @@
166
158
  justify-content: flex-end;
167
159
  gap: var(--ui-space-2);
168
160
  padding: var(--ui-space-3) var(--ui-space-4);
169
- border-block-start: 1px solid var(--ui-border-divider);
170
161
  }
171
162
 
172
163
  .status:empty,
@@ -92,7 +92,7 @@
92
92
  background-size: 1.125rem 1.125rem;
93
93
  }
94
94
 
95
- :host:hover:not(:focus):not(:disabled) {
95
+ :host:hover:not(:focus):not(:disabled):not([aria-invalid="true"]):not(:user-invalid) {
96
96
  border-color: var(--ui-select-border-hover, var(--ui-control-border-hover, var(--ui-text-secondary)));
97
97
  }
98
98
 
@@ -9,16 +9,22 @@
9
9
  class="ring"
10
10
  :role="{ true: 'status', false: null }[format('%s', label != '')]"
11
11
  :aria-label="{ true: label, false: null }[format('%s', label != '')]"
12
+ :aria-live="{ true: 'polite', false: null }[format('%s', label != '')]"
12
13
  :aria-hidden="{ true: null, false: 'true' }[format('%s', label != '')]"
13
- ></span>
14
+ >
15
+ <svg viewBox="0 0 50 50" fill="none" aria-hidden="true" focusable="false">
16
+ <circle class="track" cx="25" cy="25" r="20"></circle>
17
+ <circle class="arc" cx="25" cy="25" r="20"></circle>
18
+ </svg>
19
+ </span>
14
20
  <style>
15
- /* Something is on its way. The ring chases in the current text colour; with reduced motion it holds still,
16
- and the label (or the text beside it) still says what is happening. */
21
+ /* A rounded SVG arc grows and recedes over a quiet full-circle track. */
17
22
  :host {
18
23
  display: inline-flex;
19
24
  flex: none;
20
25
  align-items: center;
21
26
  justify-content: center;
27
+ box-sizing: border-box;
22
28
  inline-size: 1em;
23
29
  block-size: 1em;
24
30
  color: currentColor;
@@ -26,36 +32,57 @@
26
32
  line-height: 1;
27
33
  }
28
34
 
29
- .ring {
35
+ :host-state([size="sm"]) {
36
+ font-size: var(--ui-font-size-md);
37
+ }
38
+
39
+ svg {
30
40
  display: block;
31
- box-sizing: border-box;
32
41
  inline-size: 100%;
33
42
  block-size: 100%;
34
- border: 2px solid transparent;
35
- border-block-start-color: currentColor;
36
- border-inline-end-color: color-mix(in srgb, currentColor 52%, transparent);
37
- border-radius: 50%;
38
- animation: ui-spinner-chase 1s linear infinite;
43
+ animation: ui-spinner-rotate 2s linear infinite;
39
44
  }
40
45
 
41
- :host-state([size="sm"]) {
42
- font-size: var(--ui-font-size-md);
46
+ circle {
47
+ stroke-width: 3;
48
+ stroke-linecap: round;
49
+ }
50
+
51
+ .track {
52
+ stroke: color-mix(in srgb, currentColor 16%, transparent);
53
+ }
54
+
55
+ .arc {
56
+ stroke: currentColor;
57
+ stroke-dasharray: 90, 150;
58
+ stroke-dashoffset: 0;
59
+ animation: ui-spinner-dash 1.5s ease-in-out infinite;
43
60
  }
44
61
 
45
- @keyframes ui-spinner-chase {
62
+ @keyframes ui-spinner-rotate {
63
+ 100% {
64
+ transform: rotate(1turn);
65
+ }
66
+ }
67
+
68
+ @keyframes ui-spinner-dash {
46
69
  0% {
47
- transform: rotate(0);
70
+ stroke-dasharray: 1, 150;
71
+ stroke-dashoffset: 0;
48
72
  }
49
- 70% {
50
- transform: rotate(160deg);
73
+ 50% {
74
+ stroke-dasharray: 90, 150;
75
+ stroke-dashoffset: -35;
51
76
  }
52
77
  100% {
53
- transform: rotate(1turn);
78
+ stroke-dasharray: 90, 150;
79
+ stroke-dashoffset: -124;
54
80
  }
55
81
  }
56
82
 
57
83
  @media (prefers-reduced-motion: reduce) {
58
- .ring {
84
+ svg,
85
+ .arc {
59
86
  animation: none;
60
87
  }
61
88
  }
@@ -74,7 +74,7 @@
74
74
  opacity: 1;
75
75
  }
76
76
 
77
- :host:hover:not(:focus):not(:disabled) {
77
+ :host:hover:not(:focus):not(:disabled):not(:read-only):not([aria-invalid="true"]):not(:user-invalid) {
78
78
  border-color: var(--ui-control-border-hover, var(--ui-text-secondary));
79
79
  }
80
80
 
@@ -172,14 +172,14 @@
172
172
  inset-inline-start: auto;
173
173
  inset-inline-end: calc(var(--_ui-tooltip-arrow-size, 0.625rem) / -2 - 0.5px);
174
174
  border-inline-end-width: 1px;
175
- border-block-end-width: 1px;
175
+ border-block-start-width: 1px;
176
176
  }
177
177
 
178
178
  :host[data-ui-actual-placement="right"] .surface::after {
179
179
  inset-inline-start: calc(var(--_ui-tooltip-arrow-size, 0.625rem) / -2 - 0.5px);
180
180
  inset-inline-end: auto;
181
181
  border-inline-start-width: 1px;
182
- border-block-start-width: 1px;
182
+ border-block-end-width: 1px;
183
183
  }
184
184
 
185
185
  :host[data-ui-actual-placement="left"][data-ui-actual-align="start"] .surface::after,