@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.
- package/components/ui-avatar/ui-avatar.html +8 -4
- package/components/ui-badge/ui-badge.html +28 -34
- package/components/ui-button/ui-button.html +5 -0
- package/components/ui-checkbox/ui-checkbox.html +5 -5
- package/components/ui-combobox/ui-combobox.html +4 -48
- package/components/ui-icon-button/ui-icon-button.html +7 -7
- package/components/ui-input/ui-input.html +1 -1
- package/components/ui-input-group/ui-input-group.html +2 -2
- package/components/ui-listbox/ui-listbox.html +88 -12
- package/components/ui-listbox/ui-listbox.js +162 -18
- package/components/ui-search-result-row/ui-search-result-row.html +2 -6
- package/components/ui-search-shell/ui-search-shell.html +2 -11
- package/components/ui-select/ui-select.html +1 -1
- package/components/ui-spinner/ui-spinner.html +45 -18
- package/components/ui-textarea/ui-textarea.html +1 -1
- package/components/ui-tooltip/ui-tooltip.html +2 -2
- package/dist/index.js +1 -1
- package/editor/chunks/extensions.js +5 -0
- package/package.json +1 -1
- package/styles/ui-avatar.css +6 -2
- package/styles/ui-badge.css +30 -40
- package/styles/ui-button.css +7 -0
- package/styles/ui-checkbox.css +6 -6
- package/styles/ui-combobox.css +0 -52
- package/styles/ui-icon-button.css +7 -7
- package/styles/ui-input-group.css +2 -2
- package/styles/ui-input.css +1 -1
- package/styles/ui-listbox.css +33 -8
- package/styles/ui-search-result-row.css +2 -6
- package/styles/ui-search-shell.css +2 -13
- package/styles/ui-select.css +1 -1
- package/styles/ui-spinner.css +38 -19
- package/styles/ui-textarea.css +1 -1
- package/styles/ui-tooltip.css +2 -2
- package/tokens.css +67 -2
- package/vanilla/UiCombobox.js +9 -1
- package/vanilla/UiListbox.d.ts +3 -1
- package/vanilla/UiListbox.js +65 -18
- package/vanilla/UiSpinner.js +19 -1
- package/vue/UiAvatar.vue +11 -2
- package/vue/UiBadge.vue +30 -36
- package/vue/UiButton.vue +5 -0
- package/vue/UiCheckbox.vue +5 -5
- package/vue/UiCombobox.vue +4 -49
- package/vue/UiIconButton.vue +10 -7
- package/vue/UiInput.vue +1 -1
- package/vue/UiInputGroup.vue +2 -2
- package/vue/UiListbox.d.ts +18 -5
- package/vue/UiListbox.vue +142 -25
- package/vue/UiSearchResultRow.vue +2 -6
- package/vue/UiSearchShell.vue +2 -13
- package/vue/UiSelect.vue +1 -1
- package/vue/UiSpinner.vue +45 -18
- package/vue/UiTextarea.vue +1 -1
- package/vue/UiTooltip.vue +2 -2
- package/vue/chunks/UiAvatar.js +2 -1
- package/vue/chunks/UiBadge.js +1 -1
- package/vue/chunks/UiButton.js +1 -1
- package/vue/chunks/UiCheckbox.js +1 -1
- package/vue/chunks/UiCombobox.js +11 -2
- package/vue/chunks/UiIconButton.js +1 -1
- package/vue/chunks/UiInput.js +1 -1
- package/vue/chunks/UiInputGroup.js +1 -1
- package/vue/chunks/UiListbox.js +134 -20
- package/vue/chunks/UiSearchResultRow.js +1 -1
- package/vue/chunks/UiSearchShell.js +1 -1
- package/vue/chunks/UiSelect.js +1 -1
- package/vue/chunks/UiSpinner.js +29 -3
- package/vue/chunks/UiTextarea.js +1 -1
- package/vue/chunks/UiTooltip.js +1 -1
- package/vue/components.css +426 -440
|
@@ -1,27 +1,171 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
5
|
-
const
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
};
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
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:
|
|
65
|
-
border-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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-
|
|
62
|
+
@keyframes ui-spinner-rotate {
|
|
63
|
+
100% {
|
|
64
|
+
transform: rotate(1turn);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@keyframes ui-spinner-dash {
|
|
46
69
|
0% {
|
|
47
|
-
|
|
70
|
+
stroke-dasharray: 1, 150;
|
|
71
|
+
stroke-dashoffset: 0;
|
|
48
72
|
}
|
|
49
|
-
|
|
50
|
-
|
|
73
|
+
50% {
|
|
74
|
+
stroke-dasharray: 90, 150;
|
|
75
|
+
stroke-dashoffset: -35;
|
|
51
76
|
}
|
|
52
77
|
100% {
|
|
53
|
-
|
|
78
|
+
stroke-dasharray: 90, 150;
|
|
79
|
+
stroke-dashoffset: -124;
|
|
54
80
|
}
|
|
55
81
|
}
|
|
56
82
|
|
|
57
83
|
@media (prefers-reduced-motion: reduce) {
|
|
58
|
-
|
|
84
|
+
svg,
|
|
85
|
+
.arc {
|
|
59
86
|
animation: none;
|
|
60
87
|
}
|
|
61
88
|
}
|
|
@@ -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-
|
|
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-
|
|
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,
|