@threadlabs/looma 0.9.2 → 0.10.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/shared/form-reset.js +21 -0
- package/components/ui-checkbox/ui-checkbox.html +17 -4
- package/components/ui-checkbox/ui-checkbox.js +9 -0
- package/components/ui-combobox/ui-combobox.html +23 -11
- package/components/ui-combobox/ui-combobox.js +31 -12
- package/components/ui-editable/ui-editable.html +3 -1
- package/components/ui-editable/ui-editable.js +7 -6
- package/components/ui-input/ui-input.html +2 -2
- package/components/ui-input/ui-input.js +5 -2
- package/components/ui-radio/ui-radio.html +3 -1
- package/components/ui-radio/ui-radio.js +11 -0
- package/components/ui-radio-group/ui-radio-group.html +7 -5
- package/components/ui-radio-group/ui-radio-group.js +16 -3
- package/components/ui-search-result-row/ui-search-result-row.html +3 -3
- package/components/ui-select/ui-select.html +3 -1
- package/components/ui-select/ui-select.js +5 -2
- package/components/ui-switch/ui-switch.html +7 -4
- package/components/ui-switch/ui-switch.js +9 -0
- package/components/ui-textarea/ui-textarea.html +2 -2
- package/components/ui-textarea/ui-textarea.js +5 -2
- package/components/ui-tree-item/ui-tree-item.html +2 -2
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/vanilla/UiCheckbox.d.ts +1 -0
- package/vanilla/UiCheckbox.js +7 -4
- package/vanilla/UiCombobox.js +113 -99
- package/vanilla/UiRadioGroup.js +7 -1
- package/vanilla/UiSearchResultRow.js +4 -1
- package/vanilla/UiSwitch.d.ts +1 -0
- package/vanilla/UiSwitch.js +7 -4
- package/vue/UiCheckbox.d.ts +2 -0
- package/vue/UiCheckbox.vue +3 -0
- package/vue/UiCombobox.vue +27 -4
- package/vue/UiRadioGroup.vue +2 -0
- package/vue/UiSearchResultRow.vue +1 -0
- package/vue/UiSwitch.d.ts +2 -0
- package/vue/UiSwitch.vue +3 -0
- package/vue/chunks/UiCheckbox.js +4 -1
- package/vue/chunks/UiCombobox.js +67 -39
- package/vue/chunks/UiRadioGroup.js +8 -2
- package/vue/chunks/UiSearchResultRow.js +7 -2
- package/vue/chunks/UiSwitch.js +4 -1
- package/vue/components.css +89 -89
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calls back once a form reset has restored `control`'s form to its defaults. The browser restores
|
|
3
|
+
* each native control after the reset event has been dispatched, and a reset button's event runs
|
|
4
|
+
* its listeners before that, so the callback waits a task.
|
|
5
|
+
*
|
|
6
|
+
* @ownership A controller that keeps its own copy of a control's state reads it back here; the
|
|
7
|
+
* native defaults (defaultValue, defaultChecked, defaultSelected) are what the reset restores.
|
|
8
|
+
* @lifecycle Returns the function that stops listening.
|
|
9
|
+
*/
|
|
10
|
+
export function afterFormReset(control, callback) {
|
|
11
|
+
const document = control.ownerDocument;
|
|
12
|
+
let timer;
|
|
13
|
+
const onReset = (event) => {
|
|
14
|
+
if (event.target === control.form) timer = setTimeout(callback);
|
|
15
|
+
};
|
|
16
|
+
document.addEventListener("reset", onReset);
|
|
17
|
+
return () => {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
document.removeEventListener("reset", onReset);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
<template component="ui-checkbox" controller="./ui-checkbox.js">
|
|
2
2
|
<defs>
|
|
3
|
-
<prop name="checked" type="boolean" default="false"
|
|
3
|
+
<prop name="checked" type="boolean" default="false"
|
|
4
|
+
>Whether the checkbox is checked initially or when set externally. A form reset returns it to this.</prop>
|
|
4
5
|
<prop name="disabled" type="boolean" default="false">Disables the native checkbox, so it cannot be toggled or focused.</prop>
|
|
5
6
|
<prop name="indeterminate" type="boolean" default="false"
|
|
6
7
|
>Shows the mixed state, for a checkbox that summarises a group whose items are partly checked. It sets the native indeterminate property, which is
|
|
7
8
|
independent of checked, so update it as the group changes.</prop>
|
|
8
9
|
<prop name="required" type="boolean" default="false"
|
|
9
10
|
>Marks the native checkbox required, so native form validation treats it as invalid until it is checked.</prop>
|
|
11
|
+
<prop name="name" type="string" default=""
|
|
12
|
+
>The native name the checkbox submits under: a form that contains it sends name=value while it is checked, and nothing while it is unchecked, as a native
|
|
13
|
+
checkbox does. Left empty, it takes no part in form submission.</prop>
|
|
10
14
|
<prop name="value" type="string" default="on"
|
|
11
|
-
>The value
|
|
12
|
-
checkbox.</prop>
|
|
15
|
+
>The value a form submits under name while the checkbox is checked, also reported in the change event's detail so one handler can tell checkboxes apart. on
|
|
16
|
+
by default, as on a native checkbox.</prop>
|
|
13
17
|
<state name="internalChecked" :value="false"></state>
|
|
14
18
|
<event name="change" type="object({ checked: boolean, value: string, trigger: keyboard | pointer | programmatic })"></event>
|
|
15
19
|
</defs>
|
|
16
20
|
<label>
|
|
17
|
-
<input
|
|
21
|
+
<input
|
|
22
|
+
type="checkbox"
|
|
23
|
+
$ref="input"
|
|
24
|
+
.checked="internalChecked"
|
|
25
|
+
.indeterminate="indeterminate"
|
|
26
|
+
:disabled="disabled"
|
|
27
|
+
:required="required"
|
|
28
|
+
:name="name"
|
|
29
|
+
:value="value"
|
|
30
|
+
>
|
|
18
31
|
<span class="label"><slot></slot></span>
|
|
19
32
|
</label>
|
|
20
33
|
<style>
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
+
import { afterFormReset } from "../shared/form-reset.js";
|
|
1
2
|
import { trackTrigger } from "../shared/trigger.js";
|
|
2
3
|
|
|
3
4
|
// `checked` sets the control initially and whenever it changes; the user's changes update the state.
|
|
5
|
+
// A form reset returns the control to `checked`. Vue writes the checked attribute on every render, so
|
|
6
|
+
// the native default alone cannot hold it there.
|
|
4
7
|
export default function controller(host) {
|
|
5
8
|
const input = host.refs.input;
|
|
6
9
|
const [trigger, stopTracking] = trackTrigger(host);
|
|
7
10
|
let external = host.state.checked;
|
|
8
11
|
host.state.internalChecked = Boolean(external);
|
|
9
12
|
const stop = host.effect(() => {
|
|
13
|
+
input.defaultChecked = Boolean(host.state.checked);
|
|
10
14
|
if (host.state.checked === external) return;
|
|
11
15
|
external = host.state.checked;
|
|
12
16
|
host.state.internalChecked = Boolean(external);
|
|
@@ -16,8 +20,13 @@ export default function controller(host) {
|
|
|
16
20
|
host.dispatch("change", { checked: input.checked, value: String(host.state.value ?? "on"), trigger: trigger() });
|
|
17
21
|
};
|
|
18
22
|
input.addEventListener("change", onChange);
|
|
23
|
+
const stopReset = afterFormReset(input, () => {
|
|
24
|
+
host.state.internalChecked = Boolean(external);
|
|
25
|
+
input.checked = Boolean(external);
|
|
26
|
+
});
|
|
19
27
|
return () => {
|
|
20
28
|
stop();
|
|
29
|
+
stopReset();
|
|
21
30
|
stopTracking();
|
|
22
31
|
input.removeEventListener("change", onChange);
|
|
23
32
|
};
|
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
<prop name="placeholder" type="string" default=""
|
|
9
9
|
>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
10
|
<prop name="name" type="string" default=""
|
|
11
|
-
>The form field name
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
>The form field name the combobox submits its value under, never the label shown in the field. Single mode sends one entry: the selected option's value, the
|
|
12
|
+
typed text when allowFreeText is set and no option is selected, or an empty string. Multiple mode sends one entry per chosen item's value, and nothing
|
|
13
|
+
when none is chosen. A disabled combobox sends nothing.</prop>
|
|
14
|
+
<prop name="value" type="string | null"
|
|
15
|
+
>The selected value in single mode, which a named combobox submits with its form. The field shows the matching option's label, and a form reset returns to
|
|
16
|
+
this value.</prop>
|
|
14
17
|
<prop name="items" type="list(object({ id: string, value: string, label: string, group?: string, disabled?: boolean }))"
|
|
15
|
-
>The selected items in multiple mode, as JSON.</prop>
|
|
18
|
+
>The selected items in multiple mode, as JSON; a named combobox submits each one's value. A form reset returns to these items, or to none when unset.</prop>
|
|
16
19
|
<prop name="multiple" type="boolean" default="false"
|
|
17
20
|
>Lets the user choose several options. Each choice becomes a removable badge before the input, the list stays open between picks, and choosing a checked
|
|
18
21
|
option removes it. value-change then reports the whole list of items; use items, not value, in this mode.</prop>
|
|
@@ -25,10 +28,14 @@
|
|
|
25
28
|
<prop name="allowFreeText" type="boolean" default="false">Allows values that do not match an authored option.</prop>
|
|
26
29
|
<prop name="allowCreate" type="boolean" default="false">Offers the current query as a new option.</prop>
|
|
27
30
|
<prop name="disabled" type="boolean" default="false"
|
|
28
|
-
>Disables the input
|
|
29
|
-
|
|
31
|
+
>Disables the input and every button in the field: the chosen-item badges, the clear and disclosure buttons, and the help button. The list closes and cannot
|
|
32
|
+
be opened, the value cannot be cleared, and a named combobox submits nothing with its form.</prop>
|
|
33
|
+
<prop name="readonly" type="boolean" default="false"
|
|
34
|
+
>Shows the current value without letting it change: the text can be selected but not edited, the list does not open, and the clear, disclosure, and badge
|
|
35
|
+
remove buttons are disabled. A named combobox still submits its value.</prop>
|
|
30
36
|
<prop name="required" type="boolean" default="false"
|
|
31
|
-
>
|
|
37
|
+
>Requires a value: adds an asterisk to the label, marks the input required for native form validation, and makes validation report "A value is required."
|
|
38
|
+
when the field is left empty. In multiple mode a chosen item satisfies it, whatever is left in the input.</prop>
|
|
32
39
|
<prop name="size" type="sm | md" default="md"
|
|
33
40
|
>Field height. md is the standard control height; sm is the small control height with tighter padding, for dense forms.</prop>
|
|
34
41
|
<prop name="labelVisibility" type="visible | sr-only" default="visible"
|
|
@@ -63,6 +70,7 @@
|
|
|
63
70
|
:value="{ status: 'pristine', touched: false, dirty: false, issues: [] }"
|
|
64
71
|
></state>
|
|
65
72
|
<state name="uid" :value="''"></state>
|
|
73
|
+
<state name="submitted" :value="''"></state>
|
|
66
74
|
<state
|
|
67
75
|
name="groups"
|
|
68
76
|
type="list(object({ name: string, role: group | presentation, label: string | null, rows: list(object({ id: string, value: string, label: string, group?: string, disabled: boolean, selected: boolean, index: integer })) }))"
|
|
@@ -135,12 +143,15 @@
|
|
|
135
143
|
:aria-busy="validationStatus = 'pending'"
|
|
136
144
|
.value="display"
|
|
137
145
|
:placeholder="placeholder"
|
|
138
|
-
:name="name"
|
|
139
146
|
:disabled="disabled"
|
|
140
147
|
:readonly="readonly"
|
|
141
|
-
:required="required"
|
|
148
|
+
:required="required and not (multiple and internalItems[0])"
|
|
142
149
|
>
|
|
143
|
-
<
|
|
150
|
+
<input $if="name and not multiple" type="hidden" :name="name" :value="submitted" :disabled="disabled">
|
|
151
|
+
<template $if="name and multiple">
|
|
152
|
+
<input $each="item of internalItems" $key="item.id" type="hidden" :name="name" :value="item.value" :disabled="disabled">
|
|
153
|
+
</template>
|
|
154
|
+
<button $if="clearable" type="button" data-combobox-action="clear" aria-label="Clear" :disabled="disabled or readonly">
|
|
144
155
|
<svg class="affordance-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
145
156
|
<path d="M7 7l10 10M17 7 7 17"></path>
|
|
146
157
|
</svg>
|
|
@@ -152,6 +163,7 @@
|
|
|
152
163
|
tabindex="-1"
|
|
153
164
|
aria-label="Show suggestions"
|
|
154
165
|
:aria-controls="format('%s-listbox', uid)"
|
|
166
|
+
:disabled="disabled or readonly"
|
|
155
167
|
>
|
|
156
168
|
<svg
|
|
157
169
|
class="affordance-icon"
|
|
@@ -218,7 +230,7 @@
|
|
|
218
230
|
</div>
|
|
219
231
|
<slot name="footer"></slot>
|
|
220
232
|
</div>
|
|
221
|
-
<div class="message" :id="format('%s-validation', uid)" :hidden="not validation.issues
|
|
233
|
+
<div class="message" :id="format('%s-validation', uid)" :hidden="not validation.issues[0]">
|
|
222
234
|
<div $each="issue of validation.issues" $value="issue.message"></div>
|
|
223
235
|
</div>
|
|
224
236
|
<div class="sr-only" role="status" aria-live="polite" aria-atomic="true" $value="statusText"></div>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { afterFormReset } from "../shared/form-reset.js";
|
|
1
2
|
import { closeOverlay, createAnchoredSurface, openOverlay } from "../shared/overlay.js";
|
|
2
3
|
|
|
3
4
|
const instances = new WeakMap();
|
|
@@ -288,7 +289,8 @@ export default function controller(host) {
|
|
|
288
289
|
// Native constraints and the free-text policy; application validation stays in the form.
|
|
289
290
|
const current = config();
|
|
290
291
|
const result = { output: host.state.raw, issues: [] };
|
|
291
|
-
|
|
292
|
+
const empty = host.state.multiple ? !items().length : !String(host.state.raw).trim();
|
|
293
|
+
if (host.state.required && empty) result.issues = [...result.issues, { message: "A value is required." }];
|
|
292
294
|
else if (host.state.raw && host.state.selected === null && !current.allowFreeText && !current.allowCreate) result.issues = [...result.issues, { message: "Choose a suggestion." }];
|
|
293
295
|
if (result.issues.some((issue) => issue.severity !== "warning")) result.output = undefined;
|
|
294
296
|
if (!run.signal.aborted && alive) setValidation(result);
|
|
@@ -335,6 +337,8 @@ export default function controller(host) {
|
|
|
335
337
|
host.state.groups = groups;
|
|
336
338
|
host.state.creatable = canCreate();
|
|
337
339
|
host.state.createIndex = rows.length;
|
|
340
|
+
// What a named combobox submits in single mode: the value, never the label the field shows.
|
|
341
|
+
host.state.submitted = String(host.state.selected ?? (host.state.allowFreeText ? host.state.raw ?? "" : ""));
|
|
338
342
|
host.state.message = host.state.loading ? "Loading suggestions…"
|
|
339
343
|
: host.state.lookupError || (!rows.length && !host.state.creatable ? "No suggestions." : "");
|
|
340
344
|
const validation = host.state.validation ?? { status: "pristine", issues: [] };
|
|
@@ -388,6 +392,7 @@ export default function controller(host) {
|
|
|
388
392
|
const option = event.target.closest?.('[role="option"][data-index]');
|
|
389
393
|
if (option) { choose(Number(option.dataset.index), "pointer"); return; }
|
|
390
394
|
const action = event.target.closest?.("[data-combobox-action]")?.dataset.comboboxAction;
|
|
395
|
+
if (action && (host.state.disabled || host.state.readonly)) return;
|
|
391
396
|
if (action === "clear") { lastSelection = null; commit(null, "", null, "clear", "pointer"); close(); input.focus(); }
|
|
392
397
|
else if (action === "disclosure") { host.state.expanded ? close() : open("disclosure"); input.focus(); }
|
|
393
398
|
else if (field.contains(event.target) && !event.target.closest?.("button")) input.focus();
|
|
@@ -406,18 +411,22 @@ export default function controller(host) {
|
|
|
406
411
|
const onTooltipOpen = (event) => { if (event.target.closest?.('[data-component~="ui-tooltip"]')) host.state.helpOpen = true; };
|
|
407
412
|
const onTooltipClose = (event) => { if (event.target.closest?.('[data-component~="ui-tooltip"]')) host.state.helpOpen = false; };
|
|
408
413
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
host.state.raw =
|
|
414
|
+
// The selection the props describe: where the combobox starts, and where a form reset returns it.
|
|
415
|
+
const applyDefaults = () => {
|
|
416
|
+
if (host.state.multiple) {
|
|
417
|
+
host.state.selected = null;
|
|
418
|
+
host.state.raw = host.state.query ?? "";
|
|
419
|
+
} else {
|
|
420
|
+
host.state.selected = host.state.value ?? null;
|
|
421
|
+
host.state.raw = host.state.query ?? "";
|
|
422
|
+
if (host.state.query === undefined && !host.state.raw && host.state.selected !== null) {
|
|
423
|
+
host.state.raw = config().options?.find((row) => row.value === host.state.selected)?.label ?? host.state.selected;
|
|
424
|
+
}
|
|
425
|
+
if (host.state.query === undefined && host.state.selected !== null && host.state.raw === host.state.selected) awaitingLabel = host.state.selected;
|
|
417
426
|
}
|
|
418
|
-
|
|
419
|
-
}
|
|
420
|
-
|
|
427
|
+
host.state.display = host.state.raw;
|
|
428
|
+
};
|
|
429
|
+
applyDefaults();
|
|
421
430
|
initialRaw = host.state.raw;
|
|
422
431
|
host.state.validation = { status: "pristine", touched: false, dirty: false, issues: [] };
|
|
423
432
|
surface = createAnchoredSurface(popup, { anchor: field, placement: "bottom-start" });
|
|
@@ -437,6 +446,15 @@ export default function controller(host) {
|
|
|
437
446
|
host.state.display = label;
|
|
438
447
|
awaitingLabel = null;
|
|
439
448
|
};
|
|
449
|
+
// Like a native control, a reset reports no change; the browser has already emptied the input.
|
|
450
|
+
const stopReset = afterFormReset(input, () => {
|
|
451
|
+
close();
|
|
452
|
+
lastSelection = null;
|
|
453
|
+
host.state.internalItems = Array.isArray(host.state.items) ? host.state.items : [];
|
|
454
|
+
applyDefaults();
|
|
455
|
+
input.value = host.state.display;
|
|
456
|
+
resetValidation();
|
|
457
|
+
});
|
|
440
458
|
const observer = new MutationObserver(relabel);
|
|
441
459
|
observer.observe(authored, { childList: true, subtree: true, characterData: true, attributes: true });
|
|
442
460
|
let lastItems = host.state.items;
|
|
@@ -456,6 +474,7 @@ export default function controller(host) {
|
|
|
456
474
|
return () => {
|
|
457
475
|
alive = false;
|
|
458
476
|
stop();
|
|
477
|
+
stopReset();
|
|
459
478
|
observer.disconnect();
|
|
460
479
|
for (const [name, listener] of Object.entries(listeners)) element.removeEventListener(name, listener);
|
|
461
480
|
close();
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<template component="ui-editable" controller="./ui-editable.js">
|
|
2
2
|
<defs>
|
|
3
|
-
<prop name="value" type="string" default=""
|
|
3
|
+
<prop name="value" type="string" default=""
|
|
4
|
+
>The displayed value, and the text the editor starts from. It is not a form field: nothing is submitted with a form, so a form that needs the value copies
|
|
5
|
+
it from the change event into a field of its own.</prop>
|
|
4
6
|
<prop name="label" type="string" default="Edit value">Accessible name for the editor.</prop>
|
|
5
7
|
<prop name="edit" type="boolean" default="false">Whether edit mode is active initially or when set externally.</prop>
|
|
6
8
|
<prop name="disabled" type="boolean" default="false">Prevents entering or changing edit mode.</prop>
|
|
@@ -31,10 +31,11 @@ export default function controller(host) {
|
|
|
31
31
|
input.tabIndex = host.state.internalEdit ? 0 : -1;
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
const setEditing = (next, reason, trigger) => {
|
|
34
|
+
const setEditing = (next, reason, trigger, returnFocus = true) => {
|
|
35
35
|
if (host.state.disabled || Boolean(host.state.internalEdit) === next) return;
|
|
36
|
-
// Measured before the input is disabled, which drops focus to the body.
|
|
37
|
-
|
|
36
|
+
// Measured before the input is disabled, which drops focus to the body. A press elsewhere has
|
|
37
|
+
// not moved focus yet, so it says not to return it.
|
|
38
|
+
const focusWasInside = returnFocus && element.contains(document.activeElement);
|
|
38
39
|
if (next) host.state.draft = host.state.internalValue;
|
|
39
40
|
host.state.internalEdit = next;
|
|
40
41
|
apply();
|
|
@@ -51,11 +52,11 @@ export default function controller(host) {
|
|
|
51
52
|
input.select();
|
|
52
53
|
});
|
|
53
54
|
};
|
|
54
|
-
const commit = (trigger) => {
|
|
55
|
+
const commit = (trigger, returnFocus) => {
|
|
55
56
|
const previousValue = String(host.state.internalValue ?? "");
|
|
56
57
|
const value = String(host.state.draft ?? "");
|
|
57
58
|
host.state.internalValue = value;
|
|
58
|
-
setEditing(false, "commit", trigger);
|
|
59
|
+
setEditing(false, "commit", trigger, returnFocus);
|
|
59
60
|
if (value !== previousValue) host.dispatch("change", { value, previousValue, trigger });
|
|
60
61
|
};
|
|
61
62
|
const cancel = (reason, trigger) => {
|
|
@@ -87,7 +88,7 @@ export default function controller(host) {
|
|
|
87
88
|
};
|
|
88
89
|
// Leaving the field saves, as with other in-place editors; Escape is the way to discard.
|
|
89
90
|
const onDocumentPointerdown = (event) => {
|
|
90
|
-
if (host.state.internalEdit && !event.composedPath().includes(element)) commit("pointer");
|
|
91
|
+
if (host.state.internalEdit && !event.composedPath().includes(element)) commit("pointer", false);
|
|
91
92
|
};
|
|
92
93
|
// Only the input's own blur counts: hiding the display button during the swap also moves focus,
|
|
93
94
|
// and must not end the edit. Tabbing to another control saves; clicks away are handled above.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<template component="ui-input" controller="./ui-input.js">
|
|
2
2
|
<defs>
|
|
3
3
|
<prop name="value" type="string"
|
|
4
|
-
>The input's text. Setting or changing it replaces the live text; between changes the user's typing stands, so follow input events to keep it in
|
|
5
|
-
|
|
4
|
+
>The input's text. Setting or changing it replaces the live text; between changes the user's typing stands, so follow input events to keep it in step. It is
|
|
5
|
+
also the input's default, so a form reset returns to it.</prop>
|
|
6
6
|
<prop name="disabled" type="boolean" default="false">Prevents editing 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="readonly" type="boolean" default="false">Allows selection without allowing edits.</prop>
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
// The value attribute only sets the default; a controlled value is applied to the live input.
|
|
1
|
+
// The value attribute only sets the default; a controlled value is applied to the live input. The
|
|
2
|
+
// value is also made the native default, so a form reset returns to it.
|
|
2
3
|
export default function controller(host) {
|
|
3
4
|
return host.effect(() => {
|
|
4
5
|
const value = host.state.value;
|
|
5
|
-
if (value
|
|
6
|
+
if (value === undefined || value === null) return;
|
|
7
|
+
host.element.defaultValue = String(value);
|
|
8
|
+
if (host.element.value !== String(value)) host.element.value = String(value);
|
|
6
9
|
});
|
|
7
10
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<template component="ui-radio" controller="./ui-radio.js">
|
|
2
2
|
<defs>
|
|
3
|
-
<prop name="checked" type="boolean" default="false"
|
|
3
|
+
<prop name="checked" type="boolean" default="false"
|
|
4
|
+
>Whether the radio is checked initially or when set externally. A form reset returns it to this. Inside a ui-radio-group the group's value decides
|
|
5
|
+
instead.</prop>
|
|
4
6
|
<prop name="disabled" type="boolean" default="false">Disables the native radio, so it cannot be chosen, focused, or reached with the arrow keys.</prop>
|
|
5
7
|
<prop name="name" type="string" default=""
|
|
6
8
|
>The native name that groups radios: of the radios that share a name, only one can be checked, and the arrow keys move between them. Inside a ui-radio-group
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
+
import { afterFormReset } from "../shared/form-reset.js";
|
|
1
2
|
import { trackTrigger } from "../shared/trigger.js";
|
|
2
3
|
|
|
3
4
|
// `checked` sets the control initially and whenever it changes; the user's changes update the state.
|
|
5
|
+
// A form reset returns the control to `checked`. Vue writes the checked attribute on every render, so
|
|
6
|
+
// the native default alone cannot hold it there.
|
|
4
7
|
export default function controller(host) {
|
|
5
8
|
const input = host.refs.input;
|
|
6
9
|
const [trigger, stopTracking] = trackTrigger(host);
|
|
7
10
|
let external = host.state.checked;
|
|
8
11
|
host.state.internalChecked = Boolean(external);
|
|
9
12
|
const stop = host.effect(() => {
|
|
13
|
+
input.defaultChecked = Boolean(host.state.checked);
|
|
10
14
|
if (host.state.checked === external) return;
|
|
11
15
|
external = host.state.checked;
|
|
12
16
|
host.state.internalChecked = Boolean(external);
|
|
@@ -17,8 +21,15 @@ export default function controller(host) {
|
|
|
17
21
|
host.dispatch("change", { checked: input.checked, value: String(host.state.value ?? "on"), trigger: trigger() });
|
|
18
22
|
};
|
|
19
23
|
input.addEventListener("change", onChange);
|
|
24
|
+
const stopReset = afterFormReset(input, () => {
|
|
25
|
+
// Inside a ui-radio-group the group's value decides, whichever reset runs first.
|
|
26
|
+
if (input.closest('[data-component~="ui-radio-group"]')) return;
|
|
27
|
+
host.state.internalChecked = Boolean(external);
|
|
28
|
+
input.checked = Boolean(external);
|
|
29
|
+
});
|
|
20
30
|
return () => {
|
|
21
31
|
stop();
|
|
32
|
+
stopReset();
|
|
22
33
|
stopTracking();
|
|
23
34
|
input.removeEventListener("change", onChange);
|
|
24
35
|
};
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<defs>
|
|
3
3
|
<prop name="value" type="string" default=""
|
|
4
4
|
>The value of the checked radio, initially or when set externally; empty checks none. The user's choice updates it from there and is reported as select and
|
|
5
|
-
change events.</prop>
|
|
5
|
+
change events, and a form reset returns the group to it.</prop>
|
|
6
6
|
<prop name="name" type="string" default=""
|
|
7
7
|
>The native name given to every radio in the group, under which a form submits the chosen value. Left empty, the group generates a unique name that keeps
|
|
8
8
|
the radios together, so set it when the choice submits with a form.</prop>
|
|
@@ -10,15 +10,17 @@
|
|
|
10
10
|
>The group's legend, shown above the radios and announced as the group's name. Phrase it as the question the radios answer.</prop>
|
|
11
11
|
<prop name="orientation" type="horizontal | vertical" default="horizontal"
|
|
12
12
|
>horizontal lays the radios out in a row; vertical stacks them in a column. The arrow keys move between radios either way.</prop>
|
|
13
|
-
<prop name="disabled" type="boolean" default="false"
|
|
13
|
+
<prop name="disabled" type="boolean" default="false"
|
|
14
|
+
>Disables every radio in the group through the native fieldset, so no choice can be made and the group submits nothing with its form. A radio disabled on
|
|
15
|
+
its own stays disabled when the group is enabled.</prop>
|
|
14
16
|
<prop name="required" type="boolean" default="false"
|
|
15
|
-
>
|
|
16
|
-
form validation
|
|
17
|
+
>Makes a choice required, as a required native radio does for its group: the group states aria-required, every radio in it is marked required, and native
|
|
18
|
+
form validation fails until one is checked.</prop>
|
|
17
19
|
<state name="internalValue" :value="''"></state>
|
|
18
20
|
<event name="select" type="object({ value: string, previousValue: string, trigger: keyboard | pointer | programmatic })"></event>
|
|
19
21
|
<event name="change" type="object({ checked: boolean, value: string, trigger: keyboard | pointer | programmatic })"></event>
|
|
20
22
|
</defs>
|
|
21
|
-
<fieldset role="radiogroup">
|
|
23
|
+
<fieldset role="radiogroup" :aria-required="required" :disabled="disabled">
|
|
22
24
|
<legend><template $value="label"></template></legend>
|
|
23
25
|
<slot></slot>
|
|
24
26
|
</fieldset>
|
|
@@ -1,15 +1,20 @@
|
|
|
1
|
+
import { afterFormReset } from "../shared/form-reset.js";
|
|
1
2
|
import { trackTrigger } from "../shared/trigger.js";
|
|
2
3
|
|
|
3
4
|
let groups = 0;
|
|
4
5
|
|
|
5
6
|
// Native radios that share a name already move and check with the arrow keys. The group gives its
|
|
6
|
-
// radios that name, applies a controlled value, and reports the user's choice.
|
|
7
|
+
// radios that name, applies a controlled value, and reports the user's choice. Its fieldset disables
|
|
8
|
+
// them, so a radio's own disabled is never overwritten.
|
|
7
9
|
export default function controller(host) {
|
|
8
10
|
const element = host.element;
|
|
9
11
|
const name = String(host.state.name || `ui-radio-group-${++groups}`);
|
|
10
12
|
const [trigger, stopTracking] = trackTrigger(host);
|
|
11
13
|
const inputs = () => Array.from(element.querySelectorAll('[data-component~="ui-radio"] input[type="radio"]'));
|
|
12
14
|
let external = host.state.value;
|
|
15
|
+
// A radio's own required survives until the group's required changes, since either one makes the
|
|
16
|
+
// whole name group required.
|
|
17
|
+
let required = false;
|
|
13
18
|
host.state.internalValue = String(external ?? "");
|
|
14
19
|
const stop = host.effect(() => {
|
|
15
20
|
if (host.state.value !== external) {
|
|
@@ -17,11 +22,14 @@ export default function controller(host) {
|
|
|
17
22
|
host.state.internalValue = String(external ?? "");
|
|
18
23
|
}
|
|
19
24
|
const value = host.state.internalValue;
|
|
20
|
-
const
|
|
25
|
+
const applyRequired = Boolean(host.state.required) !== required;
|
|
26
|
+
required = Boolean(host.state.required);
|
|
21
27
|
for (const input of inputs()) {
|
|
28
|
+
if (applyRequired) input.required = required;
|
|
22
29
|
input.name = name;
|
|
23
30
|
input.checked = input.value === value;
|
|
24
|
-
|
|
31
|
+
// The value prop is the choice a form reset returns to.
|
|
32
|
+
input.defaultChecked = input.value === String(external ?? "");
|
|
25
33
|
}
|
|
26
34
|
});
|
|
27
35
|
const onChange = (event) => {
|
|
@@ -34,8 +42,13 @@ export default function controller(host) {
|
|
|
34
42
|
host.dispatch("change", { checked: true, value: input.value, trigger: how });
|
|
35
43
|
};
|
|
36
44
|
element.addEventListener("change", onChange);
|
|
45
|
+
const stopReset = afterFormReset(element, () => {
|
|
46
|
+
host.state.internalValue = String(external ?? "");
|
|
47
|
+
for (const input of inputs()) input.checked = input.value === host.state.internalValue;
|
|
48
|
+
});
|
|
37
49
|
return () => {
|
|
38
50
|
stop();
|
|
51
|
+
stopReset();
|
|
39
52
|
stopTracking();
|
|
40
53
|
element.removeEventListener("change", onChange);
|
|
41
54
|
};
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
<defs>
|
|
3
3
|
<prop name="disabled" type="boolean" default="false">Disables the row's button, so it cannot be chosen or focused, and fades it to 60% opacity.</prop>
|
|
4
4
|
<prop name="selected" type="boolean" default="false"
|
|
5
|
-
>
|
|
6
|
-
|
|
5
|
+
>Marks the row as the current result: it gets the hover surface and aria-current="true", which assistive technology announces as current. Set it on one row
|
|
6
|
+
at a time, such as the result the arrow keys have moved to.</prop>
|
|
7
7
|
</defs>
|
|
8
|
-
<button type="button" :disabled="disabled">
|
|
8
|
+
<button type="button" :disabled="disabled" :aria-current="selected">
|
|
9
9
|
<span class="leading"><slot name="leading"></slot></span>
|
|
10
10
|
<span class="content">
|
|
11
11
|
<span class="title"><slot name="title"></slot></span>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<template component="ui-select" controller="./ui-select.js">
|
|
2
2
|
<defs>
|
|
3
|
-
<prop name="value" type="string"
|
|
3
|
+
<prop name="value" type="string"
|
|
4
|
+
>The value of the selected option, initially or when set externally. It is also the default, so a form reset returns to it rather than to the first
|
|
5
|
+
option.</prop>
|
|
4
6
|
<prop name="disabled" type="boolean" default="false">Prevents selection and form submission.</prop>
|
|
5
7
|
<prop name="invalid" type="boolean" default="false">Exposes invalid state to assistive technology and styling.</prop>
|
|
6
8
|
<prop name="required" type="boolean" default="false">Requires a non-empty option for form validation.</prop>
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// A controlled value can arrive before its projected <option> elements, so it is applied again
|
|
2
|
-
// whenever the options change.
|
|
2
|
+
// whenever the options change. The value's option is also marked the native default, so a form
|
|
3
|
+
// reset returns to it rather than to the first option.
|
|
3
4
|
export default function controller(host) {
|
|
4
5
|
const select = host.element;
|
|
5
6
|
const apply = () => {
|
|
6
7
|
const value = host.state.value;
|
|
7
|
-
if (value
|
|
8
|
+
if (value === undefined || value === null) return;
|
|
9
|
+
for (const option of select.options) option.defaultSelected = option.value === String(value);
|
|
10
|
+
if (select.value !== String(value)) select.value = String(value);
|
|
8
11
|
};
|
|
9
12
|
const observer = new MutationObserver(apply);
|
|
10
13
|
observer.observe(select, { childList: true, subtree: true });
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
<template component="ui-switch" controller="./ui-switch.js">
|
|
2
2
|
<defs>
|
|
3
|
-
<prop name="checked" type="boolean" default="false">Whether the switch is checked initially or when set externally.</prop>
|
|
3
|
+
<prop name="checked" type="boolean" default="false">Whether the switch is checked initially or when set externally. A form reset returns it to this.</prop>
|
|
4
4
|
<prop name="disabled" type="boolean" default="false">Disables the native switch, so it cannot be toggled or focused.</prop>
|
|
5
5
|
<prop name="required" type="boolean" default="false">Marks the native switch required, so native form validation treats it as invalid until it is on.</prop>
|
|
6
|
+
<prop name="name" type="string" default=""
|
|
7
|
+
>The native name the switch submits under: a form that contains it sends name=value while it is on, and nothing while it is off, as a native checkbox does.
|
|
8
|
+
Left empty, it takes no part in form submission.</prop>
|
|
6
9
|
<prop name="value" type="string" default="on"
|
|
7
|
-
>The value
|
|
8
|
-
checkbox.</prop>
|
|
10
|
+
>The value a form submits under name while the switch is on, also reported in the change event's detail so one handler can tell switches apart. on by
|
|
11
|
+
default, as on a native checkbox.</prop>
|
|
9
12
|
<state name="internalChecked" :value="false"></state>
|
|
10
13
|
<event name="change" type="object({ checked: boolean, value: string, trigger: keyboard | pointer | programmatic })"></event>
|
|
11
14
|
</defs>
|
|
12
15
|
<label>
|
|
13
|
-
<input type="checkbox" role="switch" $ref="input" .checked="internalChecked" :disabled="disabled" :required="required" :value="value">
|
|
16
|
+
<input type="checkbox" role="switch" $ref="input" .checked="internalChecked" :disabled="disabled" :required="required" :name="name" :value="value">
|
|
14
17
|
<span class="label"><slot></slot></span>
|
|
15
18
|
</label>
|
|
16
19
|
<style>
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
+
import { afterFormReset } from "../shared/form-reset.js";
|
|
1
2
|
import { trackTrigger } from "../shared/trigger.js";
|
|
2
3
|
|
|
3
4
|
// `checked` sets the control initially and whenever it changes; the user's changes update the state.
|
|
5
|
+
// A form reset returns the control to `checked`. Vue writes the checked attribute on every render, so
|
|
6
|
+
// the native default alone cannot hold it there.
|
|
4
7
|
export default function controller(host) {
|
|
5
8
|
const input = host.refs.input;
|
|
6
9
|
const [trigger, stopTracking] = trackTrigger(host);
|
|
7
10
|
let external = host.state.checked;
|
|
8
11
|
host.state.internalChecked = Boolean(external);
|
|
9
12
|
const stop = host.effect(() => {
|
|
13
|
+
input.defaultChecked = Boolean(host.state.checked);
|
|
10
14
|
if (host.state.checked === external) return;
|
|
11
15
|
external = host.state.checked;
|
|
12
16
|
host.state.internalChecked = Boolean(external);
|
|
@@ -16,8 +20,13 @@ export default function controller(host) {
|
|
|
16
20
|
host.dispatch("change", { checked: input.checked, value: String(host.state.value ?? "on"), trigger: trigger() });
|
|
17
21
|
};
|
|
18
22
|
input.addEventListener("change", onChange);
|
|
23
|
+
const stopReset = afterFormReset(input, () => {
|
|
24
|
+
host.state.internalChecked = Boolean(external);
|
|
25
|
+
input.checked = Boolean(external);
|
|
26
|
+
});
|
|
19
27
|
return () => {
|
|
20
28
|
stop();
|
|
29
|
+
stopReset();
|
|
21
30
|
stopTracking();
|
|
22
31
|
input.removeEventListener("change", onChange);
|
|
23
32
|
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<template component="ui-textarea" controller="./ui-textarea.js">
|
|
2
2
|
<defs>
|
|
3
3
|
<prop name="value" type="string"
|
|
4
|
-
>The textarea's text. Setting or changing it replaces the live text; between changes the user's typing stands, so follow input events to keep it in
|
|
5
|
-
|
|
4
|
+
>The textarea's text. Setting or changing it replaces the live text; between changes the user's typing stands, so follow input events to keep it in step. It
|
|
5
|
+
is also the textarea's default, so a form reset returns to it.</prop>
|
|
6
6
|
<prop name="disabled" type="boolean" default="false">Prevents editing 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="readonly" type="boolean" default="false">Allows selection without allowing edits.</prop>
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
// The value attribute only sets the default; a controlled value is applied to the live textarea.
|
|
1
|
+
// The value attribute only sets the default; a controlled value is applied to the live textarea. The
|
|
2
|
+
// value is also made the native default, so a form reset returns to it.
|
|
2
3
|
export default function controller(host) {
|
|
3
4
|
return host.effect(() => {
|
|
4
5
|
const value = host.state.value;
|
|
5
|
-
if (value
|
|
6
|
+
if (value === undefined || value === null) return;
|
|
7
|
+
host.element.defaultValue = String(value);
|
|
8
|
+
if (host.element.value !== String(value)) host.element.value = String(value);
|
|
6
9
|
});
|
|
7
10
|
}
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
>The item's kind, for drag rules: it can be dropped before or after only items of the same type, and inside a branch only if that branch's accepts allows
|
|
21
21
|
it. Reported as sourceType and targetType.</prop>
|
|
22
22
|
<prop name="dropScope" type="string" default=""
|
|
23
|
-
>
|
|
24
|
-
|
|
23
|
+
>Names the list the item sits in, such as its parent's id, and is reported as sourceScope and targetScope in the tree's reorder events, so a handler can
|
|
24
|
+
tell a reorder within one list from a move between lists. It never restricts where an item can be dropped; dragType and accepts do that.</prop>
|
|
25
25
|
<prop name="accepts" type="string" default=""
|
|
26
26
|
>A comma-separated list of the dragType values this branch accepts dropped inside it. Empty accepts any type. It does not affect drops before or after the
|
|
27
27
|
item.</prop>
|