@lumiose/ui 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +53 -2
- package/dist/accordion/accordion.css +20 -0
- package/dist/accordion/accordion.script.ts +66 -0
- package/dist/accordion/accordion.tsx +288 -0
- package/dist/animation/animation.ts +34 -0
- package/dist/autocomplete/autocomplete.tsx +239 -0
- package/dist/avatar/avatar.css +54 -0
- package/dist/avatar/avatar.script.ts +96 -0
- package/dist/avatar/avatar.tsx +110 -0
- package/dist/button/button.script.ts +35 -0
- package/dist/button/button.tsx +40 -0
- package/dist/checkbox/checkbox.script.ts +225 -0
- package/dist/checkbox/checkbox.tsx +156 -0
- package/dist/checkbox/context.ts +17 -0
- package/dist/checkbox-group/checkbox-group.tsx +111 -0
- package/dist/collapsible/collapsible.css +12 -0
- package/dist/collapsible/collapsible.script.ts +7 -0
- package/dist/collapsible/collapsible.tsx +153 -0
- package/dist/collapsible/panel.ts +115 -0
- package/dist/combobox/combobox.css +37 -0
- package/dist/combobox/combobox.script.ts +974 -0
- package/dist/combobox/combobox.tsx +556 -0
- package/dist/combobox/context.ts +46 -0
- package/dist/context-menu/context-menu.css +16 -0
- package/dist/context-menu/context-menu.script.ts +172 -0
- package/dist/context-menu/context-menu.tsx +178 -0
- package/dist/dialog/dialog.css +35 -0
- package/dist/dialog/dialog.script.ts +269 -0
- package/dist/dialog/dialog.tsx +214 -0
- package/dist/drawer/drawer.css +38 -0
- package/dist/drawer/drawer.script.ts +207 -0
- package/dist/drawer/drawer.tsx +113 -0
- package/dist/field/context.ts +156 -0
- package/dist/field/field.css +16 -0
- package/dist/field/field.script.ts +422 -0
- package/dist/field/field.tsx +465 -0
- package/dist/fieldset/fieldset.tsx +41 -0
- package/dist/form/form.tsx +50 -0
- package/dist/form/native.ts +21 -0
- package/dist/form/reset.ts +53 -0
- package/dist/index.ts +261 -0
- package/dist/input/input.tsx +67 -0
- package/dist/layer/layer.ts +191 -0
- package/dist/locale/digits.ts +21 -0
- package/dist/menu/menu.css +22 -0
- package/dist/menu/menu.script.ts +591 -0
- package/dist/menu/menu.tsx +520 -0
- package/dist/menubar/menubar.css +16 -0
- package/dist/menubar/menubar.script.ts +195 -0
- package/dist/menubar/menubar.tsx +67 -0
- package/dist/meter/meter.tsx +134 -0
- package/dist/navigation-menu/navigation-menu.css +14 -0
- package/dist/navigation-menu/navigation-menu.script.ts +466 -0
- package/dist/navigation-menu/navigation-menu.tsx +266 -0
- package/dist/number-field/format.ts +70 -0
- package/dist/number-field/number-field.css +38 -0
- package/dist/number-field/number-field.script.ts +490 -0
- package/dist/number-field/number-field.tsx +403 -0
- package/dist/otp-field/otp-field.css +45 -0
- package/dist/otp-field/otp-field.script.ts +330 -0
- package/dist/otp-field/otp-field.tsx +320 -0
- package/dist/placement/place.ts +148 -0
- package/dist/placement/placement.css +155 -0
- package/dist/placement/placement.ts +60 -0
- package/dist/popover/hover.ts +178 -0
- package/dist/popover/popover.css +20 -0
- package/dist/popover/popover.script.ts +256 -0
- package/dist/popover/popover.tsx +317 -0
- package/dist/preview-card/preview-card.css +7 -0
- package/dist/preview-card/preview-card.script.ts +136 -0
- package/dist/preview-card/preview-card.tsx +128 -0
- package/dist/progress/progress.tsx +155 -0
- package/dist/progress/value.ts +23 -0
- package/dist/radio-group/radio-group.tsx +156 -0
- package/dist/scroll-area/scroll-area.css +72 -0
- package/dist/scroll-area/scroll-area.script.ts +281 -0
- package/dist/scroll-area/scroll-area.tsx +118 -0
- package/dist/select/select.css +25 -0
- package/dist/select/select.tsx +171 -0
- package/dist/separator/separator.css +12 -0
- package/dist/separator/separator.tsx +38 -0
- package/dist/slider/format.ts +33 -0
- package/dist/slider/slider.css +93 -0
- package/dist/slider/slider.script.ts +355 -0
- package/dist/slider/slider.tsx +374 -0
- package/dist/styles.css +30 -0
- package/dist/switch/switch.tsx +92 -0
- package/dist/tabs/tabs.css +54 -0
- package/dist/tabs/tabs.script.ts +225 -0
- package/dist/tabs/tabs.tsx +272 -0
- package/dist/themes/default.css +2111 -0
- package/dist/toast/command.ts +3 -0
- package/dist/toast/toast.css +68 -0
- package/dist/toast/toast.script.ts +516 -0
- package/dist/toast/toast.tsx +432 -0
- package/dist/toggle/context.ts +16 -0
- package/dist/toggle/toggle.tsx +99 -0
- package/dist/toggle-group/toggle-group.script.ts +166 -0
- package/dist/toggle-group/toggle-group.tsx +117 -0
- package/dist/toolbar/context.ts +18 -0
- package/dist/toolbar/toolbar.css +12 -0
- package/dist/toolbar/toolbar.script.ts +123 -0
- package/dist/toolbar/toolbar.tsx +180 -0
- package/dist/tooltip/tooltip.css +8 -0
- package/dist/tooltip/tooltip.script.ts +222 -0
- package/dist/tooltip/tooltip.tsx +149 -0
- package/docs/README.md +174 -0
- package/docs/accordion.md +208 -0
- package/docs/autocomplete.md +161 -0
- package/docs/avatar.md +198 -0
- package/docs/button.md +98 -0
- package/docs/checkbox-group.md +112 -0
- package/docs/checkbox.md +133 -0
- package/docs/collapsible.md +165 -0
- package/docs/combobox.md +369 -0
- package/docs/context-menu.md +197 -0
- package/docs/dialog.md +256 -0
- package/docs/drawer.md +204 -0
- package/docs/field.md +295 -0
- package/docs/fieldset.md +92 -0
- package/docs/form.md +131 -0
- package/docs/input.md +87 -0
- package/docs/installation.md +65 -0
- package/docs/llms-full.txt +6923 -0
- package/docs/llms.txt +53 -0
- package/docs/menu.md +365 -0
- package/docs/menubar.md +186 -0
- package/docs/meter.md +103 -0
- package/docs/navigation-menu.md +257 -0
- package/docs/number-field.md +181 -0
- package/docs/otp-field.md +160 -0
- package/docs/popover.md +271 -0
- package/docs/preview-card.md +130 -0
- package/docs/progress.md +115 -0
- package/docs/radio-group.md +127 -0
- package/docs/scroll-area.md +147 -0
- package/docs/select.md +236 -0
- package/docs/separator.md +116 -0
- package/docs/slider.md +184 -0
- package/docs/switch.md +98 -0
- package/docs/tabs.md +254 -0
- package/docs/theming.md +132 -0
- package/docs/toast.md +311 -0
- package/docs/toggle-group.md +119 -0
- package/docs/toggle.md +94 -0
- package/docs/toolbar.md +138 -0
- package/docs/tooltip.md +128 -0
- package/package.json +102 -3
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// The client script of Checkbox, Checkbox Group, Radio Group and Switch (decision-62, decision-63).
|
|
2
|
+
// They render native inputs, which toggle, submit, reset and validate on their own, in server HTML
|
|
3
|
+
// and without JavaScript. This script closes the gaps next to Base UI (doc-9 sections 10 to 12):
|
|
4
|
+
//
|
|
5
|
+
// - Indeterminate: a checkbox's `indeterminate` is a property only, so the server renders
|
|
6
|
+
// `data-indeterminate` and the script turns it into the property (mixed for assistive
|
|
7
|
+
// technology, `:indeterminate` for CSS) and drops the attribute. Islands set the property.
|
|
8
|
+
// - A parent checkbox (`data-parent`) in a Checkbox Group, after Base UI's: it is checked,
|
|
9
|
+
// unchecked or indeterminate as its group's checkboxes are, disabled ones counted (as the server
|
|
10
|
+
// counts them). Toggling it goes from a partial selection to all, then none, then back to the
|
|
11
|
+
// selection the user last made with the checkboxes; disabled checkboxes keep their state
|
|
12
|
+
// (TASK-458). Its `aria-controls` names the checkboxes. It has no `name`, so it is never
|
|
13
|
+
// submitted.
|
|
14
|
+
// - Read-only (`aria-readonly="true"` on the input, or on a radio group): a click, Space or an arrow
|
|
15
|
+
// key does not change it. Native `readonly` does not apply to checkboxes and radios.
|
|
16
|
+
// - A Radio Group's arrow keys, after the APG: Down and Right (Left right to left) check the next
|
|
17
|
+
// enabled radio, Up and Left the previous one, around the ends and past disabled radios. Native
|
|
18
|
+
// radios do it too, but not the same everywhere: WebKit stops at the ends and does not mirror
|
|
19
|
+
// Left and Right right to left (TASK-424).
|
|
20
|
+
// - `data-unchecked-value` (a Checkbox's or Switch's `uncheckedValue`): an unchecked, enabled box
|
|
21
|
+
// with a name submits that value, appended to the form's data (the `formdata` event), so it
|
|
22
|
+
// comes after the form's other values. Without the script it submits nothing, as natively.
|
|
23
|
+
//
|
|
24
|
+
// It must not import `lumiose`: server-rendered pages load it without the runtime.
|
|
25
|
+
|
|
26
|
+
// A form's controls and descendants, read through the prototypes: a control named `elements` or
|
|
27
|
+
// `querySelectorAll` shadows the form's own member (DOM clobbering, TASK-473 L9).
|
|
28
|
+
const formElements = (form: HTMLFormElement): HTMLFormControlsCollection =>
|
|
29
|
+
Object.getOwnPropertyDescriptor(HTMLFormElement.prototype, "elements")!.get!.call(form);
|
|
30
|
+
const formQuery = <E extends Element>(form: HTMLFormElement, selectors: string) =>
|
|
31
|
+
Element.prototype.querySelectorAll.call(form, selectors) as NodeListOf<E>;
|
|
32
|
+
|
|
33
|
+
const BOX = "input[type=checkbox]";
|
|
34
|
+
|
|
35
|
+
/** Checkboxes the server rendered indeterminate: a form reset puts them back (TASK-446, L11). */
|
|
36
|
+
const mixed = new WeakSet<HTMLInputElement>();
|
|
37
|
+
|
|
38
|
+
/** The fieldset a checkbox belongs to, for a group. */
|
|
39
|
+
const groupOf = (input: Element) => input.closest("fieldset");
|
|
40
|
+
|
|
41
|
+
/** The group's checkboxes a parent follows: not parents, not nested; disabled ones included. */
|
|
42
|
+
function children(group: HTMLFieldSetElement): HTMLInputElement[] {
|
|
43
|
+
return [...group.querySelectorAll<HTMLInputElement>(`${BOX}:not([data-parent])`)].filter(
|
|
44
|
+
(input) => groupOf(input) === group,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Each group's checked boxes as the user last chose them, one at a time: a parent restores it. */
|
|
49
|
+
const chosen = new WeakMap<HTMLFieldSetElement, HTMLInputElement[]>();
|
|
50
|
+
|
|
51
|
+
const same = (a: HTMLInputElement[], b: HTMLInputElement[]) =>
|
|
52
|
+
a.length === b.length && a.every((box) => b.includes(box));
|
|
53
|
+
|
|
54
|
+
/** A parent's toggle: a partial choice → all → none → the choice again (Base UI's cycle). */
|
|
55
|
+
function cycle(group: HTMLFieldSetElement): void {
|
|
56
|
+
const boxes = children(group);
|
|
57
|
+
const now = boxes.filter((box) => box.checked);
|
|
58
|
+
// Disabled boxes keep their state: "none" keeps the checked ones, "all" leaves out the others.
|
|
59
|
+
const none = boxes.filter((box) => box.disabled && box.checked);
|
|
60
|
+
const all = boxes.filter((box) => !box.disabled || box.checked);
|
|
61
|
+
const choice = chosen.get(group) ?? now;
|
|
62
|
+
const partial = choice.length > 0 && choice.length < all.length;
|
|
63
|
+
let next: HTMLInputElement[];
|
|
64
|
+
if (!partial) next = same(now, all) ? none : all;
|
|
65
|
+
else if (same(now, all)) next = none;
|
|
66
|
+
else if (same(now, none)) next = choice;
|
|
67
|
+
else next = all;
|
|
68
|
+
for (const box of boxes) if (!box.disabled) box.checked = next.includes(box);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Sets the group's parent checkboxes from their children. */
|
|
72
|
+
function follow(group: HTMLFieldSetElement): void {
|
|
73
|
+
const boxes = children(group);
|
|
74
|
+
const checked = boxes.filter((input) => input.checked).length;
|
|
75
|
+
for (const parent of group.querySelectorAll<HTMLInputElement>(`${BOX}[data-parent]`)) {
|
|
76
|
+
if (groupOf(parent) !== group) continue;
|
|
77
|
+
parent.checked = boxes.length > 0 && checked === boxes.length;
|
|
78
|
+
parent.indeterminate = checked > 0 && checked < boxes.length;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const readOnly = (input: HTMLInputElement) =>
|
|
83
|
+
input.getAttribute("aria-readonly") === "true" ||
|
|
84
|
+
input.closest("[role=radiogroup]")?.getAttribute("aria-readonly") === "true";
|
|
85
|
+
|
|
86
|
+
document.addEventListener(
|
|
87
|
+
"click",
|
|
88
|
+
(event) => {
|
|
89
|
+
const input = event.target;
|
|
90
|
+
if (!(input instanceof HTMLInputElement) || !/^(checkbox|radio)$/.test(input.type)) return;
|
|
91
|
+
// Cancelling the click undoes the toggle, for the mouse, a label, Space and the arrow keys.
|
|
92
|
+
if (readOnly(input)) event.preventDefault();
|
|
93
|
+
},
|
|
94
|
+
true,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
document.addEventListener(
|
|
98
|
+
"change",
|
|
99
|
+
(event) => {
|
|
100
|
+
const input = event.target;
|
|
101
|
+
if (!(input instanceof HTMLInputElement) || input.type !== "checkbox") return;
|
|
102
|
+
// A toggle ends the server's indeterminate state.
|
|
103
|
+
input.removeAttribute("data-indeterminate");
|
|
104
|
+
const group = groupOf(input);
|
|
105
|
+
if (!group) return;
|
|
106
|
+
if (input.hasAttribute("data-parent")) cycle(group);
|
|
107
|
+
else
|
|
108
|
+
chosen.set(
|
|
109
|
+
group,
|
|
110
|
+
children(group).filter((box) => box.checked),
|
|
111
|
+
);
|
|
112
|
+
// Before the group's own `change` listener, which reads the checkboxes.
|
|
113
|
+
follow(group);
|
|
114
|
+
},
|
|
115
|
+
true,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// `formdata` does not bubble: a capturing listener sees it.
|
|
119
|
+
document.addEventListener(
|
|
120
|
+
"formdata",
|
|
121
|
+
(event) => {
|
|
122
|
+
const form = event.target as HTMLFormElement;
|
|
123
|
+
for (const input of formElements(form)) {
|
|
124
|
+
if (
|
|
125
|
+
input instanceof HTMLInputElement &&
|
|
126
|
+
input.type === "checkbox" &&
|
|
127
|
+
input.hasAttribute("data-unchecked-value") &&
|
|
128
|
+
input.name &&
|
|
129
|
+
!input.checked &&
|
|
130
|
+
!input.matches(":disabled")
|
|
131
|
+
) {
|
|
132
|
+
event.formData.append(input.name, input.getAttribute("data-unchecked-value")!);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
true,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
document.addEventListener("keydown", (event) => {
|
|
140
|
+
const radio = event.target;
|
|
141
|
+
if (!(radio instanceof HTMLInputElement) || radio.type !== "radio" || event.defaultPrevented) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (event.altKey || event.ctrlKey || event.metaKey || event.isComposing) return;
|
|
145
|
+
const group = radio.closest<HTMLElement>("[role=radiogroup]");
|
|
146
|
+
if (!group) return;
|
|
147
|
+
const rtl = getComputedStyle(radio).direction === "rtl";
|
|
148
|
+
const steps: Record<string, number> = {
|
|
149
|
+
ArrowDown: 1,
|
|
150
|
+
ArrowUp: -1,
|
|
151
|
+
ArrowRight: rtl ? -1 : 1,
|
|
152
|
+
ArrowLeft: rtl ? 1 : -1,
|
|
153
|
+
};
|
|
154
|
+
const step = steps[event.key];
|
|
155
|
+
if (!step) return;
|
|
156
|
+
const radios = [...group.querySelectorAll<HTMLInputElement>("input[type=radio]")].filter(
|
|
157
|
+
(each) =>
|
|
158
|
+
each.name === radio.name &&
|
|
159
|
+
each.closest("[role=radiogroup]") === group &&
|
|
160
|
+
!each.matches(":disabled"),
|
|
161
|
+
);
|
|
162
|
+
const at = radios.indexOf(radio);
|
|
163
|
+
if (at === -1) return;
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
const next = radios[(at + step + radios.length) % radios.length]!;
|
|
166
|
+
next.focus();
|
|
167
|
+
// Checks it, with `input` and `change`; a read-only group cancels the click (above).
|
|
168
|
+
if (next !== radio) next.click();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
document.addEventListener("reset", (event) => {
|
|
172
|
+
const form = event.target as HTMLFormElement;
|
|
173
|
+
// The checkboxes take their default state after the event.
|
|
174
|
+
setTimeout(() => {
|
|
175
|
+
for (const input of formQuery<HTMLInputElement>(form, BOX)) {
|
|
176
|
+
if (mixed.has(input)) input.indeterminate = true;
|
|
177
|
+
}
|
|
178
|
+
for (const parent of formQuery<HTMLInputElement>(form, `${BOX}[data-parent]`)) {
|
|
179
|
+
const group = groupOf(parent);
|
|
180
|
+
if (!group) continue;
|
|
181
|
+
chosen.delete(group);
|
|
182
|
+
follow(group);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Shift+Tab into a radio group with nothing checked lands on its last radio in Chromium and
|
|
188
|
+
// WebKit: the first enabled one takes focus instead, as Tab gives it (TASK-446, L13).
|
|
189
|
+
document.addEventListener("focusin", (event) => {
|
|
190
|
+
const radio = event.target;
|
|
191
|
+
if (!(radio instanceof HTMLInputElement) || radio.type !== "radio") return;
|
|
192
|
+
const group = radio.closest<HTMLElement>("[role=radiogroup]");
|
|
193
|
+
if (!group || group.contains(event.relatedTarget as Node | null)) return;
|
|
194
|
+
const radios = [...group.querySelectorAll<HTMLInputElement>("input[type=radio]")].filter(
|
|
195
|
+
(each) => each.name === radio.name && each.closest("[role=radiogroup]") === group,
|
|
196
|
+
);
|
|
197
|
+
if (radios.some((each) => each.checked)) return;
|
|
198
|
+
const first = radios.find((each) => !each.matches(":disabled"));
|
|
199
|
+
if (first && first !== radio) first.focus();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
function start() {
|
|
203
|
+
for (const input of document.querySelectorAll<HTMLInputElement>(`${BOX}[data-indeterminate]`)) {
|
|
204
|
+
// From here on the property, and `:indeterminate`, hold the state.
|
|
205
|
+
input.indeterminate = true;
|
|
206
|
+
if (!input.hasAttribute("data-parent")) mixed.add(input);
|
|
207
|
+
input.removeAttribute("data-indeterminate");
|
|
208
|
+
}
|
|
209
|
+
// A parent names its checkboxes as they are, ids of their own included.
|
|
210
|
+
for (const parent of document.querySelectorAll<HTMLInputElement>(`${BOX}[data-parent]`)) {
|
|
211
|
+
const group = groupOf(parent);
|
|
212
|
+
if (!group) continue;
|
|
213
|
+
const ids = children(group)
|
|
214
|
+
.map((box) => box.id)
|
|
215
|
+
.filter(Boolean);
|
|
216
|
+
if (ids.length > 0) parent.setAttribute("aria-controls", ids.join(" "));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (document.readyState === "loading") {
|
|
220
|
+
document.addEventListener("DOMContentLoaded", start, { once: true });
|
|
221
|
+
} else queueMicrotask(start);
|
|
222
|
+
|
|
223
|
+
// A module, so it runs once per page whoever imports it.
|
|
224
|
+
// oxlint-disable-next-line unicorn/require-module-specifiers
|
|
225
|
+
export {};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Checkbox (decision-62, TASK-366): a native `<input type="checkbox">`, after Base UI's Checkbox.
|
|
2
|
+
// The platform toggles it with Space and a label click, submits its value when checked, resets it
|
|
3
|
+
// with its form and validates `required`, in server HTML and without JavaScript. Inside a Field it
|
|
4
|
+
// takes the field's id, name and ARIA references; inside a CheckboxGroup, its checked state and
|
|
5
|
+
// name. checkbox.script.ts (decision-63) adds the indeterminate state of server HTML, the parent
|
|
6
|
+
// checkbox of a group and read-only.
|
|
7
|
+
import { effect, isServer, ref, untrack, useContext } from "lumiose";
|
|
8
|
+
import { fieldProps, useField, useFormValues } from "../field/context.ts";
|
|
9
|
+
import checkboxScript from "./checkbox.script.ts?client";
|
|
10
|
+
import { CheckboxGroupContext, optionId, type CheckboxGroupContextValue } from "./context.ts";
|
|
11
|
+
import { afterFormReset, onFormReset } from "../form/reset.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The native checkbox props Root passes through (`name`, `required`, `disabled`, `form`, `id`,
|
|
15
|
+
* `class`, `aria-*`, events), without the brand of intrinsic elements' props, so that
|
|
16
|
+
* `bind:checked` applies to Root.
|
|
17
|
+
*/
|
|
18
|
+
type InputProps = Omit<
|
|
19
|
+
JSX.IntrinsicElements["input"],
|
|
20
|
+
symbol | "type" | "checked" | "value" | "bind:checked" | "bind:value" | "readonly"
|
|
21
|
+
>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `Checkbox.Root` renders an `<input type="checkbox">`. `bind:checked={signal}` stands in for
|
|
25
|
+
* `checked` and `onCheckedChange`.
|
|
26
|
+
*/
|
|
27
|
+
export type CheckboxRootProps = InputProps & {
|
|
28
|
+
/** Checks (`true`) or unchecks it when it changes. Pair it with `onCheckedChange`. */
|
|
29
|
+
checked?: boolean;
|
|
30
|
+
/** Whether it starts checked when `checked` is left out; a form reset goes back to it. */
|
|
31
|
+
defaultChecked?: boolean;
|
|
32
|
+
/** Called after the user checks or unchecks it. */
|
|
33
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
34
|
+
/**
|
|
35
|
+
* Shows it as neither checked nor unchecked (`:indeterminate`, mixed for assistive technology)
|
|
36
|
+
* until the user toggles it.
|
|
37
|
+
*/
|
|
38
|
+
indeterminate?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The value it submits when checked (default `"on"`), and its identity in a CheckboxGroup's
|
|
41
|
+
* `value`.
|
|
42
|
+
*/
|
|
43
|
+
value?: string;
|
|
44
|
+
/**
|
|
45
|
+
* The "select all" checkbox of a CheckboxGroup: it checks every checkbox of the group, and is
|
|
46
|
+
* checked, unchecked or indeterminate as they are. It is not submitted.
|
|
47
|
+
*/
|
|
48
|
+
parent?: boolean;
|
|
49
|
+
/** It cannot be toggled, but stays focusable and is submitted (`aria-readonly`). */
|
|
50
|
+
readOnly?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* The value it submits when unchecked; by default an unchecked checkbox submits nothing. Needs
|
|
53
|
+
* the client script (`data-unchecked-value`).
|
|
54
|
+
*/
|
|
55
|
+
uncheckedValue?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** A parent's `aria-controls`: its group's checkboxes, from `allValues` (TASK-458). */
|
|
59
|
+
const controls = (group: CheckboxGroupContextValue) =>
|
|
60
|
+
(group.allValues() ?? []).map((value) => optionId(group, value)).join(" ") || undefined;
|
|
61
|
+
|
|
62
|
+
function Root({
|
|
63
|
+
checked,
|
|
64
|
+
defaultChecked,
|
|
65
|
+
onCheckedChange,
|
|
66
|
+
indeterminate,
|
|
67
|
+
value,
|
|
68
|
+
parent,
|
|
69
|
+
readOnly,
|
|
70
|
+
uncheckedValue,
|
|
71
|
+
...rest
|
|
72
|
+
}: CheckboxRootProps) {
|
|
73
|
+
// Indeterminate in server HTML, parent checkboxes and read-only; the page loads it once.
|
|
74
|
+
checkboxScript();
|
|
75
|
+
const field = useField();
|
|
76
|
+
const group = useContext(CheckboxGroupContext);
|
|
77
|
+
// A failed submission's values (a Form's `values`): checked when they hold its value (TASK-430).
|
|
78
|
+
const sent =
|
|
79
|
+
group || parent
|
|
80
|
+
? undefined
|
|
81
|
+
: useFormValues(
|
|
82
|
+
untrack(() => rest.name ?? field?.name()),
|
|
83
|
+
untrack(() => rest.disabled),
|
|
84
|
+
);
|
|
85
|
+
const initial = untrack(() => (sent ? sent.includes(value ?? "on") : defaultChecked));
|
|
86
|
+
/** A parent's state from the group: checked when every value is, mixed when some are. */
|
|
87
|
+
const parentState = () => {
|
|
88
|
+
const all = group?.allValues() ?? [];
|
|
89
|
+
const on = all.filter((each) => group!.values().includes(each)).length;
|
|
90
|
+
return { checked: all.length > 0 && on === all.length, mixed: on > 0 && on < all.length };
|
|
91
|
+
};
|
|
92
|
+
const isChecked = () => {
|
|
93
|
+
if (checked !== undefined) return checked;
|
|
94
|
+
if (group && parent) return parentState().checked;
|
|
95
|
+
if (group && value !== undefined) return group.values().includes(value);
|
|
96
|
+
return initial;
|
|
97
|
+
};
|
|
98
|
+
const isMixed = () =>
|
|
99
|
+
indeterminate === true || (group !== undefined && parent === true && parentState().mixed);
|
|
100
|
+
// `indeterminate` is a property only: the server writes `data-indeterminate`, which the script
|
|
101
|
+
// turns into the property; in the browser the component sets it.
|
|
102
|
+
const input = ref<HTMLInputElement>();
|
|
103
|
+
// A reset makes a checkbox the user toggled indeterminate again, as it started (TASK-446, L11).
|
|
104
|
+
afterFormReset(input, () => {
|
|
105
|
+
const element = input();
|
|
106
|
+
if (element) element.indeterminate = untrack(isMixed);
|
|
107
|
+
});
|
|
108
|
+
// A form reset fires no `change`: the reset state goes to `onCheckedChange` (TASK-435). In a
|
|
109
|
+
// group, the group reports it.
|
|
110
|
+
if (onCheckedChange && !group && !parent) {
|
|
111
|
+
onFormReset(input, () => input()!.checked, onCheckedChange);
|
|
112
|
+
}
|
|
113
|
+
effect(() => {
|
|
114
|
+
const element = input();
|
|
115
|
+
if (element) element.indeterminate = isMixed();
|
|
116
|
+
});
|
|
117
|
+
return (
|
|
118
|
+
<input
|
|
119
|
+
{...rest}
|
|
120
|
+
// In a group the group takes the field's id and ARIA references (TASK-439, M25).
|
|
121
|
+
{...(group ? {} : fieldProps(field, rest))}
|
|
122
|
+
ref={(element: HTMLInputElement) => {
|
|
123
|
+
input(element);
|
|
124
|
+
rest.ref?.(element);
|
|
125
|
+
}}
|
|
126
|
+
type="checkbox"
|
|
127
|
+
// In a group, `<group>-<value>`, which a parent's `aria-controls` names (TASK-458).
|
|
128
|
+
{...(group && !parent && value !== undefined && rest.id === undefined
|
|
129
|
+
? { id: optionId(group, value) }
|
|
130
|
+
: {})}
|
|
131
|
+
{...(group && parent ? { "aria-controls": controls(group) } : {})}
|
|
132
|
+
// A parent is never submitted.
|
|
133
|
+
name={parent ? undefined : (rest.name ?? group?.name() ?? field?.name())}
|
|
134
|
+
value={value}
|
|
135
|
+
checked={isChecked()}
|
|
136
|
+
data-indeterminate={isServer && isMixed() ? "" : undefined}
|
|
137
|
+
data-parent={parent ? "" : undefined}
|
|
138
|
+
aria-readonly={readOnly ? "true" : undefined}
|
|
139
|
+
data-unchecked-value={uncheckedValue}
|
|
140
|
+
on:change={(event: Event) =>
|
|
141
|
+
onCheckedChange?.((event.currentTarget as HTMLInputElement).checked)
|
|
142
|
+
}
|
|
143
|
+
/>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A native checkbox. Unstyled: style it through `class` and `:checked`, `:indeterminate`,
|
|
149
|
+
* `:disabled` and `:focus-visible` (with `appearance: none` to draw your own box).
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* <label>
|
|
153
|
+
* <Checkbox.Root name="terms" required /> I accept the terms
|
|
154
|
+
* </label>
|
|
155
|
+
*/
|
|
156
|
+
export const Checkbox = { Root };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The context a CheckboxGroup gives its checkboxes: the selected values, every value (for a parent
|
|
2
|
+
// checkbox) and the name they submit under.
|
|
3
|
+
import { createContext } from "lumiose";
|
|
4
|
+
|
|
5
|
+
export interface CheckboxGroupContextValue {
|
|
6
|
+
/** The group's id: its checkboxes' ids are `<id>-<value>`, which a parent's `aria-controls` names. */
|
|
7
|
+
id: string;
|
|
8
|
+
values: () => readonly string[];
|
|
9
|
+
allValues: () => readonly string[] | undefined;
|
|
10
|
+
name: () => string | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const CheckboxGroupContext = createContext<CheckboxGroupContextValue>();
|
|
14
|
+
|
|
15
|
+
/** The id of a group's checkbox of `value`: ids may not hold spaces. */
|
|
16
|
+
export const optionId = (group: CheckboxGroupContextValue, value: string) =>
|
|
17
|
+
`${group.id}-${encodeURIComponent(value).replaceAll("%", "_")}`;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// CheckboxGroup (decision-62, TASK-366): a native `<fieldset>` of checkboxes that share a name and
|
|
2
|
+
// a value (the checked ones' values), after Base UI's CheckboxGroup. The form submits each checked
|
|
3
|
+
// box under the name, and a disabled group disables them all, without JavaScript. A parent
|
|
4
|
+
// checkbox (`Checkbox.Root parent`) checks them all; checkbox.script.ts keeps it in step.
|
|
5
|
+
import { effect, ref, signal, untrack, useId } from "lumiose";
|
|
6
|
+
import checkboxScript from "../checkbox/checkbox.script.ts?client";
|
|
7
|
+
import { CheckboxGroupContext } from "../checkbox/context.ts";
|
|
8
|
+
import { describedBy, labelledBy, useField, useFormValues } from "../field/context.ts";
|
|
9
|
+
import { onFormReset } from "../form/reset.ts";
|
|
10
|
+
|
|
11
|
+
/** The fieldset props CheckboxGroup passes through, without the brand of intrinsic elements' props. */
|
|
12
|
+
type FieldsetProps = Omit<JSX.IntrinsicElements["fieldset"], symbol>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `CheckboxGroup` renders a `<fieldset>`; name it with a `Fieldset.Legend` inside.
|
|
16
|
+
* `bind:value={signal}` stands in for `value` and `onValueChange`.
|
|
17
|
+
*/
|
|
18
|
+
export type CheckboxGroupProps = FieldsetProps & {
|
|
19
|
+
/** Checks the checkboxes with these values when it changes. Pair it with `onValueChange`. */
|
|
20
|
+
value?: string[];
|
|
21
|
+
/** The values checked at first, when `value` is left out. */
|
|
22
|
+
defaultValue?: string[];
|
|
23
|
+
/** Called with the checked values after the user checks or unchecks one. */
|
|
24
|
+
onValueChange?: (value: string[]) => void;
|
|
25
|
+
/** Every checkbox's value: a parent checkbox is checked when all of them are. */
|
|
26
|
+
allValues?: string[];
|
|
27
|
+
/** The name the checkboxes submit their values under, unless they have their own. */
|
|
28
|
+
name?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A group of checkboxes with one value, the list of the checked ones, and an optional parent
|
|
33
|
+
* checkbox that checks them all.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* <CheckboxGroup name="toppings" defaultValue={["cheese"]}>
|
|
37
|
+
* <Fieldset.Legend>Toppings</Fieldset.Legend>
|
|
38
|
+
* <label><Checkbox.Root value="cheese" /> Cheese</label>
|
|
39
|
+
* <label><Checkbox.Root value="olives" /> Olives</label>
|
|
40
|
+
* </CheckboxGroup>
|
|
41
|
+
*/
|
|
42
|
+
/** The values of the checked boxes: the DOM holds the state, a parent's toggle included. */
|
|
43
|
+
const checked = (group: HTMLFieldSetElement) =>
|
|
44
|
+
[...group.querySelectorAll<HTMLInputElement>("input[type=checkbox]:not([data-parent])")]
|
|
45
|
+
.filter((input) => input.closest("fieldset") === group && input.checked)
|
|
46
|
+
.map((input) => input.value);
|
|
47
|
+
|
|
48
|
+
export function CheckboxGroup({
|
|
49
|
+
value,
|
|
50
|
+
defaultValue,
|
|
51
|
+
onValueChange,
|
|
52
|
+
allValues,
|
|
53
|
+
name,
|
|
54
|
+
children,
|
|
55
|
+
...rest
|
|
56
|
+
}: CheckboxGroupProps) {
|
|
57
|
+
checkboxScript();
|
|
58
|
+
// In a field, the group takes the field's wiring, not each checkbox (TASK-439, M25).
|
|
59
|
+
const field = useField();
|
|
60
|
+
// A failed submission's values (a Form's `values`) replace the default (TASK-430).
|
|
61
|
+
const sent = useFormValues(
|
|
62
|
+
untrack(() => name ?? field?.name()),
|
|
63
|
+
untrack(() => rest.disabled),
|
|
64
|
+
);
|
|
65
|
+
const values = signal<readonly string[]>(untrack(() => value ?? sent ?? defaultValue ?? []));
|
|
66
|
+
// The `value` prop drives the checkboxes when it changes.
|
|
67
|
+
effect(() => {
|
|
68
|
+
const next = value;
|
|
69
|
+
if (next !== undefined) values.set(next);
|
|
70
|
+
});
|
|
71
|
+
const id = useId();
|
|
72
|
+
const context = {
|
|
73
|
+
id,
|
|
74
|
+
values,
|
|
75
|
+
allValues: () => allValues,
|
|
76
|
+
name: () => name ?? field?.name(),
|
|
77
|
+
};
|
|
78
|
+
const report = (next: string[]) => {
|
|
79
|
+
values.set(next);
|
|
80
|
+
onValueChange?.(next);
|
|
81
|
+
};
|
|
82
|
+
// A form reset puts the boxes back without a `change`: the group follows it (TASK-435).
|
|
83
|
+
const group = ref<HTMLFieldSetElement>();
|
|
84
|
+
onFormReset(group, () => checked(group()!), report);
|
|
85
|
+
return (
|
|
86
|
+
<CheckboxGroupContext value={context}>
|
|
87
|
+
<fieldset
|
|
88
|
+
{...rest}
|
|
89
|
+
ref={(element: HTMLFieldSetElement) => {
|
|
90
|
+
group(element);
|
|
91
|
+
rest.ref?.(element);
|
|
92
|
+
}}
|
|
93
|
+
// Explicit, so Field's script describes and marks the group as one, as a radiogroup.
|
|
94
|
+
role="group"
|
|
95
|
+
disabled={rest.disabled || field?.disabled() || undefined}
|
|
96
|
+
aria-labelledby={
|
|
97
|
+
rest["aria-labelledby"] ?? (rest["aria-label"] ? undefined : labelledBy(field))
|
|
98
|
+
}
|
|
99
|
+
aria-describedby={rest["aria-describedby"] ?? (field ? describedBy(field) : undefined)}
|
|
100
|
+
aria-invalid={rest["aria-invalid"] ?? (field?.invalid() ? "true" : undefined)}
|
|
101
|
+
on:change={(event: Event) => {
|
|
102
|
+
if (!(event.target as Element).matches("input[type=checkbox]")) return;
|
|
103
|
+
// checkbox.script.ts ran first, for a parent's toggle.
|
|
104
|
+
report(checked(event.currentTarget as HTMLFieldSetElement));
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{children}
|
|
108
|
+
</fieldset>
|
|
109
|
+
</CheckboxGroupContext>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Collapsible is a native <details>: the browser shows and hides its panel. What it needs on top:
|
|
3
|
+
* a disabled trigger (aria-disabled, which a <summary> has instead of `disabled`) ignores the
|
|
4
|
+
* pointer, also before the client script loads.
|
|
5
|
+
*/
|
|
6
|
+
@layer lu.core, lu.theme;
|
|
7
|
+
|
|
8
|
+
@layer lu.core {
|
|
9
|
+
summary[aria-disabled="true"] {
|
|
10
|
+
pointer-events: none;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Collapsible's client script (decision-62, decision-63): the component works without it, as a
|
|
2
|
+
// native `<details>`; on a page that renders one, it runs once and adds the panel's open and close
|
|
3
|
+
// measurements and the disabled trigger (./panel.ts), for every Collapsible and Accordion panel.
|
|
4
|
+
// It must not import `lumiose`: server-rendered pages load it without the runtime.
|
|
5
|
+
import { watchPanels } from "./panel.ts";
|
|
6
|
+
|
|
7
|
+
watchPanels();
|