@flood-md/react 0.2.2 → 0.2.4
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/README.md +758 -3
- package/dist/avatar.d.ts +5 -0
- package/dist/avatar.js +8 -0
- package/dist/checkbox.d.ts +4 -0
- package/dist/checkbox.js +15 -0
- package/dist/choice-group.d.ts +16 -0
- package/dist/choice-group.js +52 -0
- package/dist/data-display.d.ts +3 -0
- package/dist/data-display.js +11 -0
- package/dist/disclosure.d.ts +12 -0
- package/dist/disclosure.js +21 -0
- package/dist/feedback.d.ts +8 -0
- package/dist/feedback.js +29 -0
- package/dist/field-state.d.ts +6 -0
- package/dist/field-state.js +8 -0
- package/dist/focusable-hidden.d.ts +3 -0
- package/dist/focusable-hidden.js +99 -0
- package/dist/fonts/InterVariable-Italic.woff2 +0 -0
- package/dist/fonts/README.md +9 -5
- package/dist/foundations.css +460 -11
- package/dist/index.d.ts +421 -4
- package/dist/index.js +289 -21
- package/dist/menu.d.ts +2 -0
- package/dist/menu.js +130 -0
- package/dist/navigation.d.ts +4 -0
- package/dist/navigation.js +68 -0
- package/dist/overlay-runtime.d.ts +38 -0
- package/dist/overlay-runtime.js +110 -0
- package/dist/overlays.d.ts +7 -0
- package/dist/overlays.js +179 -0
- package/dist/page-window.d.ts +1 -0
- package/dist/page-window.js +15 -0
- package/dist/scroll-area.d.ts +3 -0
- package/dist/scroll-area.js +235 -0
- package/dist/select.d.ts +3 -0
- package/dist/select.js +333 -0
- package/dist/styles.css +11 -0
- package/dist/toolbar.d.ts +2 -0
- package/dist/toolbar.js +80 -0
- package/package.json +2 -2
package/dist/select.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { resolveFieldStatus } from "./field-state.js";
|
|
3
|
+
import { Children, Fragment, forwardRef, isValidElement, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState, } from "react";
|
|
4
|
+
import { ScrollArea } from "./index.js";
|
|
5
|
+
import { positionLayer, registerLayer, returnFocus } from "./overlay-runtime.js";
|
|
6
|
+
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
7
|
+
const text = (children) => Children.toArray(children).map((child) => typeof child === "string" || typeof child === "number" ? String(child) : isValidElement(child) ? text(child.props.children) : "").join("");
|
|
8
|
+
function initialChoices(children, group, groupDisabled = false, groupHidden = false) {
|
|
9
|
+
return Children.toArray(children).flatMap((child) => {
|
|
10
|
+
if (!isValidElement(child))
|
|
11
|
+
return [];
|
|
12
|
+
if (child.type === Fragment)
|
|
13
|
+
return initialChoices(child.props.children, group, groupDisabled, groupHidden);
|
|
14
|
+
if (child.type === "optgroup")
|
|
15
|
+
return initialChoices(child.props.children, child.props.label, groupDisabled || Boolean(child.props.disabled), groupHidden || Boolean(child.props.hidden));
|
|
16
|
+
if (child.type !== "option")
|
|
17
|
+
return [];
|
|
18
|
+
const label = text(child.props.children);
|
|
19
|
+
return [{ value: String(child.props.value ?? label), label: child.props.label ?? label, disabled: groupDisabled || Boolean(child.props.disabled), hidden: groupHidden || Boolean(child.props.hidden), group }];
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** Internal implementation. The public entry retains the native SelectProps/ref contract. */
|
|
23
|
+
export const SingleSelect = forwardRef(function SingleSelect({ children, value, defaultValue, onChange, onInvalid, onFocus, onBlur, onKeyDown, onKeyUp, onClick, disabled, required, id, className, style, title, autoFocus, invalid, status, controlSize = "md", "aria-label": ariaLabel, "aria-labelledby": labelledBy, "aria-describedby": describedBy, "aria-invalid": ariaInvalid, ...nativeProps }, forwardedRef) {
|
|
24
|
+
const generatedId = useId();
|
|
25
|
+
const triggerId = id ?? `flood-select-${generatedId}`;
|
|
26
|
+
const menuId = `${triggerId}-options`, validationId = `${triggerId}-validation`;
|
|
27
|
+
const native = useRef(null), trigger = useRef(null), menu = useRef(null);
|
|
28
|
+
const scrollViewport = useRef(null);
|
|
29
|
+
const [choices, setChoices] = useState(() => initialChoices(children));
|
|
30
|
+
const [selected, setSelected] = useState(() => String(defaultValue ?? initialChoices(children).find((choice) => !choice.disabled && !choice.hidden)?.value ?? ""));
|
|
31
|
+
const [open, setOpen] = useState(false), [active, setActive] = useState(-1);
|
|
32
|
+
const activeIndex = useRef(active);
|
|
33
|
+
activeIndex.current = active;
|
|
34
|
+
const [validation, setValidation] = useState("");
|
|
35
|
+
const controlled = value !== undefined;
|
|
36
|
+
const currentValue = controlled ? String(value) : selected;
|
|
37
|
+
const current = useRef({ controlled, value: currentValue });
|
|
38
|
+
current.current = { controlled, value: currentValue };
|
|
39
|
+
const search = useRef({ value: "", time: 0 });
|
|
40
|
+
useImperativeHandle(forwardedRef, () => native.current, []);
|
|
41
|
+
function readChoices() {
|
|
42
|
+
if (!native.current)
|
|
43
|
+
return choices;
|
|
44
|
+
return Array.from(native.current.options).map((option) => {
|
|
45
|
+
const parent = option.parentElement;
|
|
46
|
+
const group = parent?.tagName === "OPTGROUP" ? parent : undefined;
|
|
47
|
+
return { value: option.value, label: option.label, disabled: option.disabled || Boolean(group?.disabled), hidden: Boolean(option.hidden) || Boolean(group?.hidden), group: group?.label };
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
useBrowserLayoutEffect(() => {
|
|
51
|
+
setChoices(readChoices());
|
|
52
|
+
if (!controlled && native.current)
|
|
53
|
+
setSelected(native.current.value);
|
|
54
|
+
}, [children, value]);
|
|
55
|
+
useBrowserLayoutEffect(() => {
|
|
56
|
+
const control = native.current;
|
|
57
|
+
if (!control)
|
|
58
|
+
return;
|
|
59
|
+
const focus = control.focus;
|
|
60
|
+
control.focus = (options) => trigger.current?.focus(options);
|
|
61
|
+
return () => { control.focus = focus; };
|
|
62
|
+
}, []);
|
|
63
|
+
useEffect(() => { if (autoFocus)
|
|
64
|
+
trigger.current?.focus(); }, [autoFocus]);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const form = native.current?.form;
|
|
67
|
+
if (!form)
|
|
68
|
+
return;
|
|
69
|
+
const reset = (event) => setTimeout(() => {
|
|
70
|
+
if (event.defaultPrevented || !native.current)
|
|
71
|
+
return;
|
|
72
|
+
if (current.current.controlled)
|
|
73
|
+
native.current.value = current.current.value;
|
|
74
|
+
else
|
|
75
|
+
setSelected(native.current.value);
|
|
76
|
+
setChoices(readChoices());
|
|
77
|
+
setValidation("");
|
|
78
|
+
setOpen(false);
|
|
79
|
+
}, 0);
|
|
80
|
+
form.addEventListener("reset", reset);
|
|
81
|
+
return () => form.removeEventListener("reset", reset);
|
|
82
|
+
}, [nativeProps.form]);
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const observer = new MutationObserver(() => { if (trigger.current?.matches(":disabled"))
|
|
85
|
+
setOpen(false); });
|
|
86
|
+
let parent = trigger.current?.parentElement;
|
|
87
|
+
while (parent) {
|
|
88
|
+
if (parent.tagName === "FIELDSET")
|
|
89
|
+
observer.observe(parent, { attributes: true, attributeFilter: ["disabled"] });
|
|
90
|
+
parent = parent.parentElement;
|
|
91
|
+
}
|
|
92
|
+
return () => observer.disconnect();
|
|
93
|
+
}, []);
|
|
94
|
+
useEffect(() => { if (disabled || nativeProps.hidden)
|
|
95
|
+
setOpen(false); }, [disabled, nativeProps.hidden]);
|
|
96
|
+
function enabled(choice) { return !choice.disabled && !choice.hidden; }
|
|
97
|
+
function openMenu(index) {
|
|
98
|
+
if (trigger.current?.matches(":disabled"))
|
|
99
|
+
return;
|
|
100
|
+
const fresh = readChoices();
|
|
101
|
+
setChoices(fresh);
|
|
102
|
+
const nativeValue = !current.current.controlled && native.current ? native.current.value : current.current.value;
|
|
103
|
+
if (!current.current.controlled)
|
|
104
|
+
setSelected(nativeValue);
|
|
105
|
+
const selectedIndex = fresh.findIndex((choice) => choice.value === nativeValue && enabled(choice));
|
|
106
|
+
setActive(index ?? (selectedIndex >= 0 ? selectedIndex : fresh.findIndex(enabled)));
|
|
107
|
+
search.current = { value: "", time: 0 };
|
|
108
|
+
setOpen(true);
|
|
109
|
+
}
|
|
110
|
+
function commit(index) {
|
|
111
|
+
const choice = choices[index], control = native.current;
|
|
112
|
+
if (!choice || !enabled(choice) || !control || trigger.current?.matches(":disabled")) {
|
|
113
|
+
setOpen(false);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (choice.value !== control.value) {
|
|
117
|
+
control.value = choice.value;
|
|
118
|
+
control.dispatchEvent(new Event("input", { bubbles: true }));
|
|
119
|
+
control.dispatchEvent(new Event("change", { bubbles: true }));
|
|
120
|
+
}
|
|
121
|
+
setOpen(false);
|
|
122
|
+
}
|
|
123
|
+
function change(event) {
|
|
124
|
+
const next = event.currentTarget.value;
|
|
125
|
+
onChange?.(event);
|
|
126
|
+
if (!current.current.controlled)
|
|
127
|
+
setSelected(next);
|
|
128
|
+
queueMicrotask(() => {
|
|
129
|
+
if (!native.current)
|
|
130
|
+
return;
|
|
131
|
+
if (current.current.controlled)
|
|
132
|
+
native.current.value = current.current.value;
|
|
133
|
+
if (native.current.validity.valid)
|
|
134
|
+
setValidation("");
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
// Native callbacks receive events dispatched on the actual select, never a cast button event.
|
|
138
|
+
function mirror(event) { return native.current?.dispatchEvent(event) ?? true; }
|
|
139
|
+
function navigate(delta) {
|
|
140
|
+
const fresh = readChoices();
|
|
141
|
+
if (!open) {
|
|
142
|
+
openMenu();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
let index = active;
|
|
146
|
+
for (let step = 0; step < fresh.length; step++) {
|
|
147
|
+
const next = index + delta;
|
|
148
|
+
if (next < 0 || next >= fresh.length)
|
|
149
|
+
break;
|
|
150
|
+
index = next;
|
|
151
|
+
if (enabled(fresh[index])) {
|
|
152
|
+
setActive(index);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function revealOption(index) {
|
|
158
|
+
const option = menu.current?.querySelector(`[data-option-index="${index}"]`), viewport = scrollViewport.current;
|
|
159
|
+
if (!option || !viewport)
|
|
160
|
+
return;
|
|
161
|
+
const item = option.getBoundingClientRect(), bounds = viewport.getBoundingClientRect();
|
|
162
|
+
if (item.top < bounds.top)
|
|
163
|
+
viewport.scrollTop -= bounds.top - item.top;
|
|
164
|
+
else if (item.bottom > bounds.bottom)
|
|
165
|
+
viewport.scrollTop += item.bottom - bounds.bottom;
|
|
166
|
+
}
|
|
167
|
+
useBrowserLayoutEffect(() => {
|
|
168
|
+
if (!open || !menu.current || !trigger.current)
|
|
169
|
+
return;
|
|
170
|
+
const popup = menu.current, button = trigger.current, document = popup.ownerDocument, view = document.defaultView;
|
|
171
|
+
let alive = true, frame = 0, lastViewportHeight = -1, unregister = () => { };
|
|
172
|
+
const assign = (element, property, value) => {
|
|
173
|
+
if (element.style.getPropertyValue(property) !== value)
|
|
174
|
+
element.style.setProperty(property, value);
|
|
175
|
+
};
|
|
176
|
+
const update = () => {
|
|
177
|
+
if (!alive)
|
|
178
|
+
return;
|
|
179
|
+
if (!button.isConnected || !button.getClientRects().length) {
|
|
180
|
+
setOpen(false);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const rect = button.getBoundingClientRect(), visual = view.visualViewport;
|
|
184
|
+
const viewportTop = visual?.offsetTop ?? 0, viewportWidth = visual?.width ?? view.innerWidth, viewportHeight = visual?.height ?? view.innerHeight;
|
|
185
|
+
const rootSize = parseFloat(view.getComputedStyle(document.documentElement).fontSize);
|
|
186
|
+
const length = (name) => { const raw = view.getComputedStyle(button).getPropertyValue(name).trim(); return parseFloat(raw) * (raw.endsWith("rem") ? rootSize : 1); };
|
|
187
|
+
const gap = length("--flood-sys-space-2") || 0;
|
|
188
|
+
const below = Math.max(0, viewportTop + viewportHeight - rect.bottom - gap * 2), above = Math.max(0, rect.top - viewportTop - gap * 2);
|
|
189
|
+
const maxHeight = Math.min(length("--flood-sys-control-md-height") * 8, Math.max(below, above));
|
|
190
|
+
assign(popup, "width", `${Math.min(rect.width, Math.max(0, viewportWidth - gap * 2))}px`);
|
|
191
|
+
assign(popup, "max-height", `${maxHeight}px`);
|
|
192
|
+
// The shared positioner reads column-gap as its placement gap. On this
|
|
193
|
+
// block listbox it does not add layout space between options.
|
|
194
|
+
assign(popup, "column-gap", "var(--flood-sys-space-2)");
|
|
195
|
+
const css = view.getComputedStyle(popup);
|
|
196
|
+
const inset = parseFloat(css.paddingTop) + parseFloat(css.paddingBottom) + parseFloat(css.borderTopWidth) + parseFloat(css.borderBottomWidth);
|
|
197
|
+
const optionHeight = Math.max(0, maxHeight - inset);
|
|
198
|
+
if (scrollViewport.current)
|
|
199
|
+
assign(scrollViewport.current, "max-height", `${optionHeight}px`);
|
|
200
|
+
positionLayer(button, popup, "bottom", "start");
|
|
201
|
+
if (optionHeight !== lastViewportHeight) {
|
|
202
|
+
lastViewportHeight = optionHeight;
|
|
203
|
+
revealOption(activeIndex.current);
|
|
204
|
+
}
|
|
205
|
+
frame = view.requestAnimationFrame(update);
|
|
206
|
+
};
|
|
207
|
+
const start = () => {
|
|
208
|
+
if (!alive || !button.isConnected)
|
|
209
|
+
return;
|
|
210
|
+
const parent = button.closest("dialog");
|
|
211
|
+
if (parent && !parent.open) {
|
|
212
|
+
frame = view.requestAnimationFrame(start);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (typeof popup.showPopover !== "function")
|
|
216
|
+
throw new Error("Flood Select requires the native Popover API");
|
|
217
|
+
popup.style.visibility = "hidden";
|
|
218
|
+
popup.showPopover();
|
|
219
|
+
update();
|
|
220
|
+
unregister = registerLayer(document, {
|
|
221
|
+
panel: popup,
|
|
222
|
+
// Native callbacks are mirrored on the hidden select inside this span.
|
|
223
|
+
// Those synthetic focus events belong to this control, not outside it.
|
|
224
|
+
anchor: () => trigger.current?.parentElement ?? trigger.current,
|
|
225
|
+
close: () => setOpen(false),
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
const toggle = (event) => {
|
|
229
|
+
if (alive && event.newState === "closed" && !popup.matches(":popover-open"))
|
|
230
|
+
setOpen(false);
|
|
231
|
+
};
|
|
232
|
+
popup.addEventListener("toggle", toggle);
|
|
233
|
+
start();
|
|
234
|
+
return () => {
|
|
235
|
+
alive = false;
|
|
236
|
+
view.cancelAnimationFrame(frame);
|
|
237
|
+
unregister();
|
|
238
|
+
popup.removeEventListener("toggle", toggle);
|
|
239
|
+
returnFocus(popup, trigger.current);
|
|
240
|
+
if (popup.matches(":popover-open"))
|
|
241
|
+
popup.hidePopover();
|
|
242
|
+
};
|
|
243
|
+
}, [open]);
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (!open || (choices[active] && enabled(choices[active])))
|
|
246
|
+
return;
|
|
247
|
+
const selectedIndex = choices.findIndex((choice) => choice.value === currentValue && enabled(choice));
|
|
248
|
+
setActive(selectedIndex >= 0 ? selectedIndex : choices.findIndex(enabled));
|
|
249
|
+
}, [choices, open, active, currentValue]);
|
|
250
|
+
useEffect(() => { if (open)
|
|
251
|
+
revealOption(active); }, [open, active]);
|
|
252
|
+
const displayed = choices.find((choice) => choice.value === currentValue)?.label ?? "Select an option";
|
|
253
|
+
const descriptions = [describedBy, validation && validationId].filter(Boolean).join(" ") || undefined;
|
|
254
|
+
const visibleAttributes = Object.fromEntries(Object.entries(nativeProps).filter(([key]) => key.startsWith("data-") || key.startsWith("aria-")));
|
|
255
|
+
const nativeAttributes = Object.fromEntries(Object.entries(nativeProps).filter(([key]) => !key.startsWith("data-") && !key.startsWith("aria-") && !["tabIndex", "accessKey", "dir", "lang", "hidden"].includes(key)));
|
|
256
|
+
const groups = [];
|
|
257
|
+
choices.forEach((choice, index) => {
|
|
258
|
+
if (choice.hidden)
|
|
259
|
+
return;
|
|
260
|
+
const previous = groups[groups.length - 1];
|
|
261
|
+
if (previous && previous.label === choice.group)
|
|
262
|
+
previous.items.push({ choice, index });
|
|
263
|
+
else
|
|
264
|
+
groups.push({ label: choice.group, items: [{ choice, index }] });
|
|
265
|
+
});
|
|
266
|
+
const renderOption = ({ choice, index }) => _jsxs("div", { id: `${menuId}-${index}`, role: "option", "aria-selected": choice.value === currentValue, "aria-disabled": choice.disabled || undefined, className: "flood-select__option", "data-active": index === active || undefined, "data-option-index": index, onPointerDown: (event) => { if (event.pointerType === "mouse")
|
|
267
|
+
event.preventDefault(); }, onPointerMove: () => { if (enabled(choice))
|
|
268
|
+
setActive(index); }, onClick: () => { if (enabled(choice))
|
|
269
|
+
commit(index); }, children: [_jsx("span", { children: choice.label }), _jsx("span", { className: "flood-select__selected", "aria-hidden": "true", children: choice.value === currentValue ? "✓" : "" })] }, `${index}-${choice.value}`);
|
|
270
|
+
return _jsxs("span", { className: `flood-select flood-select--custom flood-control--${controlSize}`, dir: nativeProps.dir, lang: nativeProps.lang, hidden: nativeProps.hidden, children: [_jsx("button", { ...visibleAttributes, ref: trigger, id: triggerId, type: "button", role: "combobox", disabled: disabled, tabIndex: nativeProps.tabIndex, accessKey: nativeProps.accessKey, dir: nativeProps.dir, lang: nativeProps.lang, hidden: nativeProps.hidden, className: ["flood-input flood-select__control", `flood-control--${controlSize}`, className].filter(Boolean).join(" "), style: style, title: title, "aria-label": ariaLabel, "aria-labelledby": labelledBy, "aria-describedby": descriptions, "data-status": resolveFieldStatus(status, invalid || Boolean(validation), ariaInvalid), "aria-invalid": invalid || status === "error" || ariaInvalid || Boolean(validation) || undefined, "aria-required": required || undefined, "aria-expanded": open, "aria-haspopup": "listbox", "aria-controls": menuId, "aria-activedescendant": open && active >= 0 && choices[active] && !choices[active].hidden ? `${menuId}-${active}` : undefined, onFocus: (event) => { if (onFocus)
|
|
271
|
+
mirror(new FocusEvent("focusin", { bubbles: true, relatedTarget: event.relatedTarget })); }, onBlur: (event) => { if (onBlur)
|
|
272
|
+
mirror(new FocusEvent("focusout", { bubbles: true, relatedTarget: event.relatedTarget })); }, onClick: () => { if (onClick && !mirror(new MouseEvent("click", { bubbles: true, cancelable: true })))
|
|
273
|
+
return; if (open)
|
|
274
|
+
setOpen(false);
|
|
275
|
+
else
|
|
276
|
+
openMenu(); }, onKeyUp: (event) => { if (onKeyUp)
|
|
277
|
+
mirror(new KeyboardEvent("keyup", { key: event.key, bubbles: true, cancelable: true })); }, onKeyDown: (event) => {
|
|
278
|
+
if (onKeyDown && !mirror(new KeyboardEvent("keydown", { key: event.key, code: event.code, bubbles: true, cancelable: true, ctrlKey: event.ctrlKey, shiftKey: event.shiftKey, altKey: event.altKey, metaKey: event.metaKey }))) {
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
283
|
+
event.preventDefault();
|
|
284
|
+
navigate(event.key === "ArrowDown" ? 1 : -1);
|
|
285
|
+
}
|
|
286
|
+
else if (event.key === "Home" || event.key === "End") {
|
|
287
|
+
event.preventDefault();
|
|
288
|
+
const fresh = readChoices();
|
|
289
|
+
const index = event.key === "Home" ? fresh.findIndex(enabled) : fresh.reduce((last, choice, index) => enabled(choice) ? index : last, -1);
|
|
290
|
+
if (open)
|
|
291
|
+
setActive(index);
|
|
292
|
+
else
|
|
293
|
+
openMenu(index);
|
|
294
|
+
}
|
|
295
|
+
else if (event.key === "Enter" || event.key === " ") {
|
|
296
|
+
event.preventDefault();
|
|
297
|
+
if (open)
|
|
298
|
+
commit(active);
|
|
299
|
+
else
|
|
300
|
+
openMenu();
|
|
301
|
+
}
|
|
302
|
+
else if (event.key === "Escape" && open) {
|
|
303
|
+
event.preventDefault();
|
|
304
|
+
setOpen(false);
|
|
305
|
+
}
|
|
306
|
+
else if (event.key === "Tab" && open)
|
|
307
|
+
commit(active);
|
|
308
|
+
else if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
|
309
|
+
event.preventDefault();
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
const fresh = readChoices();
|
|
312
|
+
const query = (now - search.current.time > 700 ? "" : search.current.value) + event.key.toLocaleLowerCase();
|
|
313
|
+
search.current = { value: query, time: now };
|
|
314
|
+
const repeated = [...query].every((character) => character === query[0]);
|
|
315
|
+
const needle = repeated ? query[0] : query;
|
|
316
|
+
const from = repeated ? active + 1 : Math.max(0, active);
|
|
317
|
+
const index = fresh.map((_, offset) => (from + offset + fresh.length) % fresh.length).find((candidate) => enabled(fresh[candidate]) && fresh[candidate].label.toLocaleLowerCase().startsWith(needle));
|
|
318
|
+
if (!open) {
|
|
319
|
+
setChoices(fresh);
|
|
320
|
+
setOpen(true);
|
|
321
|
+
}
|
|
322
|
+
if (index !== undefined)
|
|
323
|
+
setActive(index);
|
|
324
|
+
}
|
|
325
|
+
}, children: _jsx("span", { className: "flood-select__value", children: displayed }) }), _jsx("select", { ...nativeAttributes, ref: native, id: `${triggerId}-native`, className: "flood-select__native", hidden: true, "aria-hidden": "true", tabIndex: -1, disabled: disabled, required: required, value: value, defaultValue: defaultValue, onChange: change, onFocus: onFocus, onBlur: onBlur,
|
|
326
|
+
// This bridge exists only to give the callback its native select target.
|
|
327
|
+
// Ancestors must receive the original trigger event after Select handles it.
|
|
328
|
+
onKeyDown: (event) => { onKeyDown?.(event); event.stopPropagation(); }, onKeyUp: onKeyUp, onClick: onClick, onInvalid: (event) => { onInvalid?.(event); const prevented = event.defaultPrevented; event.preventDefault(); if (!prevented) {
|
|
329
|
+
setValidation(event.currentTarget.validationMessage);
|
|
330
|
+
trigger.current?.focus();
|
|
331
|
+
} }, children: children }), open && _jsx("div", { ref: menu, id: menuId, role: "listbox", "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : labelledBy ?? triggerId, popover: "manual", className: "flood-select__menu", onPointerDown: (event) => { if (event.pointerType === "mouse")
|
|
332
|
+
event.preventDefault(); }, onClick: (event) => event.preventDefault(), children: _jsxs(ScrollArea, { ref: scrollViewport, orientation: "vertical", tabIndex: -1, className: "flood-select__viewport", children: [!choices.some((choice) => !choice.hidden) && _jsx("div", { className: "flood-select__empty", children: "No options" }), groups.map((group, index) => group.label ? _jsxs("div", { role: "group", "aria-label": group.label, children: [_jsx("div", { className: "flood-select__group", "aria-hidden": "true", children: group.label }), group.items.map(renderOption)] }, index) : _jsx(Fragment, { children: group.items.map(renderOption) }, index))] }) }), validation && _jsx("span", { className: "flood-select__validation", id: validationId, role: "alert", children: validation })] });
|
|
333
|
+
});
|
package/dist/styles.css
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
@import "@flood-md/tokens/tokens.css";
|
|
2
2
|
@import "./foundations.css";
|
|
3
3
|
|
|
4
|
+
/* A named loading indicator; buttons opt into decorative presentation. */
|
|
5
|
+
.flood-loading { --flood-loading-size: 1.25rem; display: inline-flex; position: relative; flex: none; vertical-align: middle; inline-size: var(--flood-loading-size); block-size: var(--flood-loading-size); }
|
|
6
|
+
.flood-loading[data-size="sm"] { --flood-loading-size: 1rem; }
|
|
7
|
+
.flood-loading[data-size="lg"] { --flood-loading-size: 1.5rem; }
|
|
8
|
+
.flood-loading > .flood-spinner { display: block; box-sizing: border-box; width: 100%; height: 100%; }
|
|
9
|
+
.flood-root .flood-loading .flood-spinner { inline-size: 100%; block-size: 100%; }
|
|
10
|
+
.flood-root .flood-button .flood-loading { --flood-loading-size: var(--flood-control-icon); }
|
|
11
|
+
.flood-loading[hidden] { display: none; }
|
|
12
|
+
|
|
4
13
|
:where(.flood-button, .flood-input, .flood-choice, .flood-field, .flood-link) {
|
|
5
14
|
box-sizing: border-box;
|
|
6
15
|
font-family: var(--flood-font-sans);
|
|
@@ -160,6 +169,8 @@
|
|
|
160
169
|
text-underline-offset: 4px;
|
|
161
170
|
text-decoration-thickness: 1px;
|
|
162
171
|
}
|
|
172
|
+
.flood-link--button { text-decoration: none; }
|
|
173
|
+
.flood-link--button:where(.flood-button--solid) { color: var(--flood-action-text); }
|
|
163
174
|
.flood-spinner {
|
|
164
175
|
width: 12px;
|
|
165
176
|
height: 12px;
|
package/dist/toolbar.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from "react";
|
|
3
|
+
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
4
|
+
const selector = 'button:not([role]),button[role="button"],a[href]:not([role]),a[href][role="link"]';
|
|
5
|
+
const independent = '[role="menu"],[role="tablist"],[role="radiogroup"],[role="listbox"],[role="combobox"],.flood-toggle-group';
|
|
6
|
+
export const RovingToolbar = forwardRef(function RovingToolbar({ label, orientation = "horizontal", loop = true, className, onKeyDown, onKeyDownCapture, onFocus, ...props }, ref) {
|
|
7
|
+
const root = useRef(null), current = useRef(null), within = useRef(false);
|
|
8
|
+
const refresh = useRef(() => []);
|
|
9
|
+
useImperativeHandle(ref, () => root.current, []);
|
|
10
|
+
useBrowserLayoutEffect(() => {
|
|
11
|
+
const element = root.current, document = element.ownerDocument, view = document.defaultView;
|
|
12
|
+
const original = new Map();
|
|
13
|
+
let alive = true;
|
|
14
|
+
const restore = (node) => { const value = original.get(node); if (value == null)
|
|
15
|
+
node.removeAttribute("tabindex");
|
|
16
|
+
else
|
|
17
|
+
node.setAttribute("tabindex", value); };
|
|
18
|
+
const sync = () => {
|
|
19
|
+
if (!alive)
|
|
20
|
+
return [];
|
|
21
|
+
const owned = Array.from(element.querySelectorAll(selector)).filter(node => node.closest('[role="toolbar"]') === element && !node.closest(independent));
|
|
22
|
+
const active = document.activeElement;
|
|
23
|
+
const hadFocus = active === element || original.has(active) || (active === document.body && within.current);
|
|
24
|
+
for (const node of original.keys())
|
|
25
|
+
if (!owned.includes(node)) {
|
|
26
|
+
restore(node);
|
|
27
|
+
original.delete(node);
|
|
28
|
+
}
|
|
29
|
+
const enabled = owned.filter(node => !node.matches(':disabled,[aria-disabled="true"]') && !node.closest('[hidden],[inert]') && node.getClientRects().length > 0 && getComputedStyle(node).visibility !== "hidden");
|
|
30
|
+
const entry = enabled.includes(active) ? active : enabled.includes(current.current) ? current.current : enabled[0] ?? null;
|
|
31
|
+
current.current = entry;
|
|
32
|
+
for (const node of owned) {
|
|
33
|
+
if (!original.has(node))
|
|
34
|
+
original.set(node, node.getAttribute("tabindex"));
|
|
35
|
+
const value = node === entry ? "0" : "-1";
|
|
36
|
+
if (node.getAttribute("tabindex") !== value)
|
|
37
|
+
node.setAttribute("tabindex", value);
|
|
38
|
+
}
|
|
39
|
+
const value = entry ? "-1" : "0";
|
|
40
|
+
if (element.getAttribute("tabindex") !== value)
|
|
41
|
+
element.setAttribute("tabindex", value);
|
|
42
|
+
if (hadFocus && active !== entry && !enabled.includes(active) && element.getClientRects().length)
|
|
43
|
+
(entry ?? element).focus({ preventScroll: true });
|
|
44
|
+
return enabled;
|
|
45
|
+
};
|
|
46
|
+
refresh.current = sync;
|
|
47
|
+
sync();
|
|
48
|
+
const observer = new MutationObserver(records => { if (records.some(record => !(record.target instanceof Element) || !record.target.closest(independent)))
|
|
49
|
+
sync(); });
|
|
50
|
+
observer.observe(element, { subtree: true, childList: true, attributes: true, attributeFilter: ["disabled", "aria-disabled", "hidden", "inert", "style", "class", "tabindex", "href", "role"] });
|
|
51
|
+
const resize = typeof ResizeObserver === "function" ? new ResizeObserver(() => sync()) : undefined;
|
|
52
|
+
resize?.observe(element);
|
|
53
|
+
const focus = (event) => { within.current = element.contains(event.target); };
|
|
54
|
+
document.addEventListener("focusin", focus);
|
|
55
|
+
view.addEventListener("resize", sync);
|
|
56
|
+
return () => { alive = false; observer.disconnect(); resize?.disconnect(); document.removeEventListener("focusin", focus); view.removeEventListener("resize", sync); for (const node of original.keys())
|
|
57
|
+
restore(node); refresh.current = () => []; };
|
|
58
|
+
}, []);
|
|
59
|
+
const move = (event) => {
|
|
60
|
+
const controls = refresh.current(), index = controls.indexOf(event.target);
|
|
61
|
+
if (index < 0 || event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing)
|
|
62
|
+
return;
|
|
63
|
+
const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
|
|
64
|
+
const nextKey = orientation === "vertical" ? "ArrowDown" : rtl ? "ArrowLeft" : "ArrowRight", previousKey = orientation === "vertical" ? "ArrowUp" : rtl ? "ArrowRight" : "ArrowLeft";
|
|
65
|
+
const step = event.key === nextKey ? 1 : event.key === previousKey ? -1 : 0;
|
|
66
|
+
const wanted = event.key === "Home" ? 0 : event.key === "End" ? controls.length - 1 : step ? index + step : -1;
|
|
67
|
+
if (wanted < 0 && !step)
|
|
68
|
+
return;
|
|
69
|
+
const next = loop ? (wanted + controls.length) % controls.length : Math.max(0, Math.min(controls.length - 1, wanted));
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
current.current = controls[next];
|
|
72
|
+
controls[next].focus({ preventScroll: true });
|
|
73
|
+
controls[next].scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
74
|
+
refresh.current();
|
|
75
|
+
};
|
|
76
|
+
return _jsx("div", { ...props, ref: root, role: "toolbar", "aria-label": label, "aria-orientation": orientation, tabIndex: -1, "data-orientation": orientation, className: ["flood-toolbar", className].filter(Boolean).join(" "), onFocus: event => { onFocus?.(event); if (event.defaultPrevented)
|
|
77
|
+
return; within.current = true; refresh.current(); }, onKeyDownCapture: event => { onKeyDownCapture?.(event); if (!event.defaultPrevented && orientation === "vertical" && event.target.getAttribute("aria-haspopup") === "menu" && ["ArrowUp", "ArrowDown"].includes(event.key))
|
|
78
|
+
move(event); }, onKeyDown: event => { onKeyDown?.(event); if (!event.defaultPrevented)
|
|
79
|
+
move(event); } });
|
|
80
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flood-md/react",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Original Flood components for React and Next.js.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"react-dom": "^18.3.0 || ^19.0.0"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@flood-md/tokens": "0.2.
|
|
33
|
+
"@flood-md/tokens": "0.2.4"
|
|
34
34
|
},
|
|
35
35
|
"publishConfig": {
|
|
36
36
|
"access": "public"
|