@flood-md/react 0.2.3 → 0.2.5
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 +398 -3
- package/dist/alert-dialog.d.ts +2 -0
- package/dist/alert-dialog.js +23 -0
- 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/combobox.d.ts +11 -0
- package/dist/combobox.js +301 -0
- package/dist/content-elements.d.ts +8 -0
- package/dist/content-elements.js +47 -0
- package/dist/data-display.d.ts +3 -0
- package/dist/data-display.js +11 -0
- package/dist/disclosure.js +2 -1
- package/dist/feedback-actions.d.ts +3 -0
- package/dist/feedback-actions.js +106 -0
- package/dist/feedback.d.ts +8 -0
- package/dist/feedback.js +29 -0
- package/dist/file-input.d.ts +8 -0
- package/dist/file-input.js +86 -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/form-controls.d.ts +6 -0
- package/dist/form-controls.js +250 -0
- package/dist/foundations.css +543 -12
- package/dist/index.d.ts +513 -2
- package/dist/index.js +223 -36
- package/dist/menu.d.ts +2 -0
- package/dist/menu.js +130 -0
- package/dist/navigation-layout.d.ts +7 -0
- package/dist/navigation-layout.js +77 -0
- package/dist/navigation.js +8 -8
- package/dist/numeric-input.d.ts +8 -0
- package/dist/numeric-input.js +68 -0
- package/dist/overlay-runtime.js +43 -16
- package/dist/overlays.d.ts +7 -1
- package/dist/overlays.js +17 -6
- package/dist/scroll-area.js +17 -2
- package/dist/select.js +96 -47
- package/dist/slider.d.ts +7 -0
- package/dist/slider.js +89 -0
- package/dist/styles.css +9 -0
- package/dist/text-inputs.d.ts +9 -0
- package/dist/text-inputs.js +45 -0
- package/dist/toolbar.d.ts +2 -0
- package/dist/toolbar.js +80 -0
- package/package.json +2 -2
package/dist/select.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { resolveFieldStatus } from "./field-state.js";
|
|
3
3
|
import { Children, Fragment, forwardRef, isValidElement, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState, } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { ScrollArea } from "./index.js";
|
|
5
|
+
import { positionLayer, registerLayer, returnFocus } from "./overlay-runtime.js";
|
|
5
6
|
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
6
7
|
const text = (children) => Children.toArray(children).map((child) => typeof child === "string" || typeof child === "number" ? String(child) : isValidElement(child) ? text(child.props.children) : "").join("");
|
|
7
8
|
function initialChoices(children, group, groupDisabled = false, groupHidden = false) {
|
|
@@ -28,8 +29,9 @@ export const SingleSelect = forwardRef(function SingleSelect({ children, value,
|
|
|
28
29
|
const [choices, setChoices] = useState(() => initialChoices(children));
|
|
29
30
|
const [selected, setSelected] = useState(() => String(defaultValue ?? initialChoices(children).find((choice) => !choice.disabled && !choice.hidden)?.value ?? ""));
|
|
30
31
|
const [open, setOpen] = useState(false), [active, setActive] = useState(-1);
|
|
32
|
+
const activeIndex = useRef(active);
|
|
33
|
+
activeIndex.current = active;
|
|
31
34
|
const [validation, setValidation] = useState("");
|
|
32
|
-
const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 0, viewportHeight: 0 });
|
|
33
35
|
const controlled = value !== undefined;
|
|
34
36
|
const currentValue = controlled ? String(value) : selected;
|
|
35
37
|
const current = useRef({ controlled, value: currentValue });
|
|
@@ -152,54 +154,101 @@ export const SingleSelect = forwardRef(function SingleSelect({ children, value,
|
|
|
152
154
|
}
|
|
153
155
|
}
|
|
154
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
|
+
}
|
|
155
167
|
useBrowserLayoutEffect(() => {
|
|
156
168
|
if (!open || !menu.current || !trigger.current)
|
|
157
169
|
return;
|
|
158
|
-
const popup = menu.current, button = trigger.current;
|
|
159
|
-
|
|
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";
|
|
160
218
|
popup.showPopover();
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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]);
|
|
179
244
|
useEffect(() => {
|
|
180
245
|
if (!open || (choices[active] && enabled(choices[active])))
|
|
181
246
|
return;
|
|
182
247
|
const selectedIndex = choices.findIndex((choice) => choice.value === currentValue && enabled(choice));
|
|
183
248
|
setActive(selectedIndex >= 0 ? selectedIndex : choices.findIndex(enabled));
|
|
184
249
|
}, [choices, open, active, currentValue]);
|
|
185
|
-
useEffect(() => {
|
|
186
|
-
|
|
187
|
-
return;
|
|
188
|
-
menu.current?.querySelector(`[data-option-index="${active}"]`)?.scrollIntoView({ block: "nearest" });
|
|
189
|
-
}, [open, active]);
|
|
190
|
-
useEffect(() => {
|
|
191
|
-
if (!open)
|
|
192
|
-
return;
|
|
193
|
-
const outside = (event) => { if (event.target instanceof Node && !trigger.current?.contains(event.target) && !menu.current?.contains(event.target))
|
|
194
|
-
setOpen(false); };
|
|
195
|
-
const scroll = (event) => { if (event.target instanceof Node && menu.current?.contains(event.target))
|
|
196
|
-
return; setOpen(false); };
|
|
197
|
-
const resize = () => setOpen(false);
|
|
198
|
-
document.addEventListener("pointerdown", outside, true);
|
|
199
|
-
document.addEventListener("scroll", scroll, true);
|
|
200
|
-
window.addEventListener("resize", resize);
|
|
201
|
-
return () => { document.removeEventListener("pointerdown", outside, true); document.removeEventListener("scroll", scroll, true); window.removeEventListener("resize", resize); };
|
|
202
|
-
}, [open]);
|
|
250
|
+
useEffect(() => { if (open)
|
|
251
|
+
revealOption(active); }, [open, active]);
|
|
203
252
|
const displayed = choices.find((choice) => choice.value === currentValue)?.label ?? "Select an option";
|
|
204
253
|
const descriptions = [describedBy, validation && validationId].filter(Boolean).join(" ") || undefined;
|
|
205
254
|
const visibleAttributes = Object.fromEntries(Object.entries(nativeProps).filter(([key]) => key.startsWith("data-") || key.startsWith("aria-")));
|
|
@@ -214,14 +263,13 @@ export const SingleSelect = forwardRef(function SingleSelect({ children, value,
|
|
|
214
263
|
else
|
|
215
264
|
groups.push({ label: choice.group, items: [{ choice, index }] });
|
|
216
265
|
});
|
|
217
|
-
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) =>
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
} }, children: [_jsx("span", { children: choice.label }), _jsx("span", { className: "flood-select__selected", "aria-hidden": "true", children: choice.value === currentValue ? "✓" : "" })] }, `${index}-${choice.value}`);
|
|
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}`);
|
|
222
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)
|
|
223
271
|
mirror(new FocusEvent("focusin", { bubbles: true, relatedTarget: event.relatedTarget })); }, onBlur: (event) => { if (onBlur)
|
|
224
|
-
mirror(new FocusEvent("focusout", { bubbles: true, relatedTarget: event.relatedTarget }));
|
|
272
|
+
mirror(new FocusEvent("focusout", { bubbles: true, relatedTarget: event.relatedTarget })); }, onClick: () => { if (onClick && !mirror(new MouseEvent("click", { bubbles: true, cancelable: true })))
|
|
225
273
|
return; if (open)
|
|
226
274
|
setOpen(false);
|
|
227
275
|
else
|
|
@@ -280,5 +328,6 @@ export const SingleSelect = forwardRef(function SingleSelect({ children, value,
|
|
|
280
328
|
onKeyDown: (event) => { onKeyDown?.(event); event.stopPropagation(); }, onKeyUp: onKeyUp, onClick: onClick, onInvalid: (event) => { onInvalid?.(event); const prevented = event.defaultPrevented; event.preventDefault(); if (!prevented) {
|
|
281
329
|
setValidation(event.currentTarget.validationMessage);
|
|
282
330
|
trigger.current?.focus();
|
|
283
|
-
} }, children: children }), open && _jsx("div", { ref: menu,
|
|
331
|
+
} }, children: children }), open && _jsx("div", { ref: menu, popover: "manual", className: "flood-select__menu", onPointerDown: (event) => { if (event.pointerType === "mouse")
|
|
332
|
+
event.preventDefault(); }, onClick: (event) => event.preventDefault(), children: _jsxs(ScrollArea, { ref: scrollViewport, id: menuId, role: "listbox", "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : labelledBy ?? triggerId, 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 })] });
|
|
284
333
|
});
|
package/dist/slider.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SliderProps, Fieldset } from "./index.js";
|
|
2
|
+
interface Props extends SliderProps {
|
|
3
|
+
fieldsetComponent: typeof Fieldset;
|
|
4
|
+
}
|
|
5
|
+
/** Native range inputs keep keyboard/AT interaction; the shared rail handles pointer selection. */
|
|
6
|
+
export declare const RangeControl: import("react").ForwardRefExoticComponent<Props & import("react").RefAttributes<HTMLFieldSetElement>>;
|
|
7
|
+
export {};
|
package/dist/slider.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useId, useRef, useState } from "react";
|
|
3
|
+
import { hasFieldContent, mergeDescriptions, resolveFieldStatus } from "./field-state.js";
|
|
4
|
+
/** Native range inputs keep keyboard/AT interaction; the shared rail handles pointer selection. */
|
|
5
|
+
export const RangeControl = forwardRef(function RangeControl({ fieldsetComponent: Group, label, value, defaultValue = [50], onValueChange, min = 0, max = 100, step = 1, thumbLabels, showValue = true, readOnly = false, controlSize = "md", disabled, className, name, onKeyDown, ...props }, ref) {
|
|
6
|
+
if (![min, max, step, max - min, (max - min) / step].every(Number.isFinite) || min >= max || step <= 0)
|
|
7
|
+
throw new Error("Flood Slider requires finite increasing bounds and a positive representable step");
|
|
8
|
+
if (!label.trim())
|
|
9
|
+
throw new Error("Flood Slider requires an accessible label");
|
|
10
|
+
const generatedId = useId();
|
|
11
|
+
const id = props.id ?? `flood-slider-${generatedId}`;
|
|
12
|
+
const hasError = hasFieldContent(props.error);
|
|
13
|
+
const status = resolveFieldStatus(props.status, hasError, props["aria-invalid"]);
|
|
14
|
+
const describedBy = mergeDescriptions(props["aria-describedby"], hasFieldContent(props.hint) && `${id}-hint`, hasError && `${id}-error`, !hasError && hasFieldContent(props.message) && `${id}-message`);
|
|
15
|
+
const snap = (number) => Number(Math.max(min, Math.min(min + Math.floor((max - min) / step + 1e-9) * step, min + Math.round((number - min) / step) * step)).toPrecision(14));
|
|
16
|
+
const normalize = (numbers) => {
|
|
17
|
+
if ((numbers.length !== 1 && numbers.length !== 2) || numbers.some(number => !Number.isFinite(number)) || (numbers.length === 2 && numbers[0] > numbers[1]))
|
|
18
|
+
throw new Error("Flood Slider accepts one value or an ordered pair of finite values");
|
|
19
|
+
return numbers.map(snap);
|
|
20
|
+
};
|
|
21
|
+
const initial = useRef(normalize(defaultValue));
|
|
22
|
+
const [local, setLocal] = useState(initial.current);
|
|
23
|
+
const values = normalize(value ?? local);
|
|
24
|
+
if (thumbLabels && (thumbLabels.length !== values.length || thumbLabels.some(text => !text.trim())))
|
|
25
|
+
throw new Error("Flood Slider needs one nonempty label per thumb");
|
|
26
|
+
const inputs = useRef([]);
|
|
27
|
+
const drag = useRef(null);
|
|
28
|
+
const valuesRef = useRef(values);
|
|
29
|
+
valuesRef.current = values;
|
|
30
|
+
const update = (index, next) => {
|
|
31
|
+
const before = valuesRef.current;
|
|
32
|
+
const number = Math.max(index === 1 ? before[0] : min, Math.min(index === 0 && before.length === 2 ? before[1] : max, snap(next)));
|
|
33
|
+
const result = before.map((old, position) => position === index ? number : old);
|
|
34
|
+
if (result.every((number, position) => number === before[position]))
|
|
35
|
+
return;
|
|
36
|
+
if (value === undefined) {
|
|
37
|
+
valuesRef.current = result;
|
|
38
|
+
setLocal(result);
|
|
39
|
+
}
|
|
40
|
+
onValueChange?.([...result]);
|
|
41
|
+
};
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const form = inputs.current[0]?.form;
|
|
44
|
+
if (!form || value !== undefined)
|
|
45
|
+
return;
|
|
46
|
+
const reset = (event) => queueMicrotask(() => { if (!event.defaultPrevented)
|
|
47
|
+
setLocal(initial.current); });
|
|
48
|
+
form.addEventListener("reset", reset);
|
|
49
|
+
return () => form.removeEventListener("reset", reset);
|
|
50
|
+
}, [value, props.form]);
|
|
51
|
+
const percent = (number) => 100 * (number - min) / (max - min);
|
|
52
|
+
const pointValue = (node, clientX) => {
|
|
53
|
+
const box = node.getBoundingClientRect();
|
|
54
|
+
const ratio = Math.max(0, Math.min(1, (clientX - box.left) / box.width));
|
|
55
|
+
return min + (node.ownerDocument.defaultView?.getComputedStyle(node).direction === "rtl" ? 1 - ratio : ratio) * (max - min);
|
|
56
|
+
};
|
|
57
|
+
const inert = (node) => disabled || readOnly || Boolean(node.closest("fieldset:disabled"));
|
|
58
|
+
const start = values.length === 1 ? 0 : percent(values[0]);
|
|
59
|
+
const end = percent(values[values.length - 1]);
|
|
60
|
+
return _jsxs(Group, { ...props, id: id, ref: ref, legend: label, name: name, disabled: disabled, className: ["flood-slider", `flood-control--${controlSize}`, className].filter(Boolean).join(" "), "data-readonly": readOnly || undefined, onKeyDown: event => {
|
|
61
|
+
onKeyDown?.(event);
|
|
62
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing)
|
|
63
|
+
return;
|
|
64
|
+
if (readOnly && ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"].includes(event.key))
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
const index = inputs.current.indexOf(event.target);
|
|
67
|
+
if (index < 0 || inert(event.currentTarget) || (event.key !== "ArrowLeft" && event.key !== "ArrowRight"))
|
|
68
|
+
return;
|
|
69
|
+
// Native WebKit does not reverse horizontal range keys in RTL.
|
|
70
|
+
const rtl = event.currentTarget.ownerDocument.defaultView?.getComputedStyle(event.currentTarget).direction === "rtl";
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
update(index, values[index] + step * (event.key === (rtl ? "ArrowLeft" : "ArrowRight") ? 1 : -1));
|
|
73
|
+
}, children: [showValue && _jsx("div", { className: "flood-slider__value", "aria-hidden": "true", children: _jsx("bdi", { dir: "ltr", children: values.join(" – ") }) }), _jsxs("div", { className: "flood-slider__rail", style: { "--flood-slider-start": `${start}%`, "--flood-slider-end": `${100 - end}%` }, onPointerDown: event => {
|
|
74
|
+
if (inputs.current.includes(event.target) || inert(event.currentTarget) || event.button !== 0)
|
|
75
|
+
return;
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
const next = pointValue(event.currentTarget, event.clientX);
|
|
78
|
+
const lowerDistance = Math.abs(next - values[0]);
|
|
79
|
+
const upperDistance = values.length === 2 ? Math.abs(next - values[1]) : Infinity;
|
|
80
|
+
const index = upperDistance < lowerDistance || (values.length === 2 && upperDistance === lowerDistance && next >= values[0]) ? 1 : 0;
|
|
81
|
+
drag.current = index;
|
|
82
|
+
inputs.current[index]?.focus();
|
|
83
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
84
|
+
update(index, next);
|
|
85
|
+
}, onPointerMove: event => { if (drag.current !== null && !inert(event.currentTarget))
|
|
86
|
+
update(drag.current, pointValue(event.currentTarget, event.clientX)); }, onPointerUp: () => { drag.current = null; }, onLostPointerCapture: () => { drag.current = null; }, onPointerCancel: () => { drag.current = null; }, children: [_jsx("span", { className: "flood-slider__track", "aria-hidden": "true", children: _jsx("span", { className: "flood-slider__fill" }) }), values.map((number, index) => _jsx("input", { ref: node => { inputs.current[index] = node; }, type: "range", className: "flood-slider__input", name: name, form: props.form, value: number, min: min, max: max, step: step, disabled: disabled, "aria-label": thumbLabels?.[index] ?? (values.length === 1 ? label : `${label}: ${index === 0 ? "minimum" : "maximum"}`), "aria-valuemin": index === 1 ? values[0] : min, "aria-valuemax": index === 0 && values.length === 2 ? values[1] : max, "aria-describedby": describedBy, "aria-readonly": readOnly || undefined, "aria-invalid": status === "error" || undefined, onPointerDown: event => { if (readOnly)
|
|
87
|
+
event.preventDefault(); }, onChange: event => { if (!inert(event.currentTarget))
|
|
88
|
+
update(index, event.currentTarget.valueAsNumber); } }, index))] })] });
|
|
89
|
+
});
|
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);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SearchInputProps, PasswordInputProps, InputGroup, IconButton, IconSlot } from "./index.js";
|
|
2
|
+
type Parts = {
|
|
3
|
+
group: typeof InputGroup;
|
|
4
|
+
action: typeof IconButton;
|
|
5
|
+
icon: typeof IconSlot;
|
|
6
|
+
};
|
|
7
|
+
export declare const SearchField: import("react").ForwardRefExoticComponent<SearchInputProps & Parts & import("react").RefAttributes<HTMLInputElement>>;
|
|
8
|
+
export declare const PasswordField: import("react").ForwardRefExoticComponent<PasswordInputProps & Parts & import("react").RefAttributes<HTMLInputElement>>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
3
|
+
const glyph = (kind, visible = false) => _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: kind === "search" ? _jsxs(_Fragment, { children: [_jsx("circle", { cx: "10.5", cy: "10.5", r: "6.5" }), _jsx("path", { d: "m16 16 4 4" })] }) : kind === "clear" ? _jsx("path", { d: "m6 6 12 12M18 6 6 18" }) : _jsxs(_Fragment, { children: [_jsx("path", { d: "M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" }), _jsx("circle", { cx: "12", cy: "12", r: "3" }), visible && _jsx("path", { d: "m3 3 18 18" })] }) });
|
|
4
|
+
export const SearchField = forwardRef(function SearchField({ group: Group, action: Action, icon: Icon, clearable = true, clearLabel = "Clear search", controlSize = "md", className, value, defaultValue, onChange, disabled, readOnly, ...props }, ref) {
|
|
5
|
+
const input = useRef(null);
|
|
6
|
+
useImperativeHandle(ref, () => input.current, []);
|
|
7
|
+
const [text, setText] = useState(String(defaultValue ?? ""));
|
|
8
|
+
const current = value === undefined ? text : String(value);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const form = input.current?.form;
|
|
11
|
+
if (!form)
|
|
12
|
+
return;
|
|
13
|
+
const reset = (event) => setTimeout(() => { if (!event.defaultPrevented && input.current)
|
|
14
|
+
setText(input.current.value); }, 0);
|
|
15
|
+
form.addEventListener("reset", reset);
|
|
16
|
+
return () => form.removeEventListener("reset", reset);
|
|
17
|
+
}, [props.form]);
|
|
18
|
+
const clear = () => {
|
|
19
|
+
const node = input.current;
|
|
20
|
+
if (!node || readOnly || node.matches(":disabled"))
|
|
21
|
+
return;
|
|
22
|
+
const view = node.ownerDocument.defaultView;
|
|
23
|
+
Object.getOwnPropertyDescriptor(view.HTMLInputElement.prototype, "value").set.call(node, "");
|
|
24
|
+
node.dispatchEvent(new view.Event("input", { bubbles: true }));
|
|
25
|
+
node.focus();
|
|
26
|
+
};
|
|
27
|
+
return _jsx(Group, { ...props, ref: input, type: "search", controlSize: controlSize, className: ["flood-search-input", className].filter(Boolean).join(" "), value: value, defaultValue: defaultValue, disabled: disabled, readOnly: readOnly, onChange: event => { setText(event.currentTarget.value); onChange?.(event); }, start: _jsx(Icon, { size: controlSize, children: glyph("search") }), end: clearable && current.length > 0 && !readOnly ? _jsx(Action, { label: clearLabel, variant: "ghost", size: controlSize, disabled: disabled, onPointerDown: event => event.preventDefault(), onClick: clear, children: glyph("clear") }) : undefined });
|
|
28
|
+
});
|
|
29
|
+
export const PasswordField = forwardRef(function PasswordField({ group: Group, action: Action, icon: _Icon, visible, defaultVisible = false, onVisibleChange, showPasswordLabel = "Show password", hidePasswordLabel = "Hide password", controlSize = "md", className, disabled, ...props }, ref) {
|
|
30
|
+
const input = useRef(null);
|
|
31
|
+
useImperativeHandle(ref, () => input.current, []);
|
|
32
|
+
const [local, setLocal] = useState(defaultVisible);
|
|
33
|
+
const shown = visible ?? local;
|
|
34
|
+
return _jsx(Group, { ...props, ref: input, type: shown ? "text" : "password", disabled: disabled, controlSize: controlSize, className: ["flood-password-input", className].filter(Boolean).join(" "), end: _jsx(Action, { label: shown ? hidePasswordLabel : showPasswordLabel, variant: "ghost", size: controlSize, disabled: disabled, onPointerDown: event => event.preventDefault(), onClick: () => {
|
|
35
|
+
const node = input.current;
|
|
36
|
+
if (!node || node.matches(":disabled"))
|
|
37
|
+
return;
|
|
38
|
+
const start = node.selectionStart, end = node.selectionEnd, direction = node.selectionDirection;
|
|
39
|
+
if (visible === undefined)
|
|
40
|
+
setLocal(!shown);
|
|
41
|
+
onVisibleChange?.(!shown);
|
|
42
|
+
requestAnimationFrame(() => { if (start !== null && end !== null && node.isConnected)
|
|
43
|
+
node.setSelectionRange(start, end, direction ?? undefined); });
|
|
44
|
+
}, children: glyph("eye", shown) }) });
|
|
45
|
+
});
|
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.5",
|
|
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.5"
|
|
34
34
|
},
|
|
35
35
|
"publishConfig": {
|
|
36
36
|
"access": "public"
|