@zvk/ui 0.1.9 → 0.1.12
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/CHANGELOG.md +23 -0
- package/README.md +4 -0
- package/dist/components/grid-list/grid-list.d.ts +29 -0
- package/dist/components/grid-list/grid-list.js +125 -0
- package/dist/components/grid-list/index.d.ts +2 -0
- package/dist/components/grid-list/index.js +2 -0
- package/dist/components/index.d.ts +16 -0
- package/dist/components/index.js +8 -0
- package/dist/components/list-row/index.d.ts +2 -0
- package/dist/components/list-row/index.js +2 -0
- package/dist/components/list-row/list-row.d.ts +54 -0
- package/dist/components/list-row/list-row.js +60 -0
- package/dist/components/multi-select/index.d.ts +2 -0
- package/dist/components/multi-select/index.js +2 -0
- package/dist/components/multi-select/multi-select.d.ts +32 -0
- package/dist/components/multi-select/multi-select.js +212 -0
- package/dist/components/splitter/index.d.ts +2 -0
- package/dist/components/splitter/index.js +2 -0
- package/dist/components/splitter/splitter.d.ts +63 -0
- package/dist/components/splitter/splitter.js +163 -0
- package/dist/components/stepper/index.d.ts +2 -0
- package/dist/components/stepper/index.js +2 -0
- package/dist/components/stepper/stepper.d.ts +30 -0
- package/dist/components/stepper/stepper.js +61 -0
- package/dist/components/tags-input/index.d.ts +2 -0
- package/dist/components/tags-input/index.js +2 -0
- package/dist/components/tags-input/tags-input.d.ts +21 -0
- package/dist/components/tags-input/tags-input.js +132 -0
- package/dist/components/toolbar/index.d.ts +2 -0
- package/dist/components/toolbar/index.js +2 -0
- package/dist/components/toolbar/toolbar.d.ts +33 -0
- package/dist/components/toolbar/toolbar.js +106 -0
- package/dist/components/tree-view/index.d.ts +2 -0
- package/dist/components/tree-view/index.js +2 -0
- package/dist/components/tree-view/tree-view.d.ts +33 -0
- package/dist/components/tree-view/tree-view.js +190 -0
- package/dist/internal/collection/index.d.ts +2 -0
- package/dist/internal/collection/index.js +1 -0
- package/dist/internal/collection/selection.d.ts +11 -0
- package/dist/internal/collection/selection.js +60 -0
- package/dist/styles.css +1006 -161
- package/package.json +43 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Field } from "../field/field.js";
|
|
5
|
+
import { useControllableState } from "../../hooks/use-controllable-state.js";
|
|
6
|
+
import { composeEventHandlers } from "../../utils/compose-event-handlers.js";
|
|
7
|
+
import { cn } from "../../utils/cn.js";
|
|
8
|
+
const defaultDelimiterKeys = [",", "\n", "Enter"];
|
|
9
|
+
function hasRenderableNode(value) {
|
|
10
|
+
return value !== undefined && value !== null && value !== false;
|
|
11
|
+
}
|
|
12
|
+
function joinIds(...ids) {
|
|
13
|
+
const value = ids.filter(Boolean).join(" ");
|
|
14
|
+
return value.length > 0 ? value : undefined;
|
|
15
|
+
}
|
|
16
|
+
function defaultNormalizeTag(value) {
|
|
17
|
+
return value.trim();
|
|
18
|
+
}
|
|
19
|
+
function splitByTextDelimiters(value, delimiterKeys) {
|
|
20
|
+
const delimiters = new Set(delimiterKeys.filter((delimiter) => delimiter.length === 1));
|
|
21
|
+
const parts = [];
|
|
22
|
+
let current = "";
|
|
23
|
+
for (const character of value) {
|
|
24
|
+
if (delimiters.has(character)) {
|
|
25
|
+
parts.push(current);
|
|
26
|
+
current = "";
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
current += character;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
parts.push(current);
|
|
33
|
+
return parts;
|
|
34
|
+
}
|
|
35
|
+
function tagKey(value) {
|
|
36
|
+
return value.toLocaleLowerCase();
|
|
37
|
+
}
|
|
38
|
+
export function TagsInput({ "aria-describedby": ariaDescribedBy, allowDuplicates = false, className, defaultValue = [], delimiterKeys = defaultDelimiterKeys, description, disabled, error, id, invalid, label, maxItems, name, normalizeTag = defaultNormalizeTag, onBlur, onFocus, onKeyDown, onValueChange, placeholder, ref, required, size = "md", validateTag, value, ...props }) {
|
|
39
|
+
const generatedId = React.useId();
|
|
40
|
+
const inputId = id ?? generatedId;
|
|
41
|
+
const validationId = `${inputId}-validation`;
|
|
42
|
+
const hasLabel = hasRenderableNode(label);
|
|
43
|
+
const hasDescription = hasRenderableNode(description);
|
|
44
|
+
const hasError = hasRenderableNode(error);
|
|
45
|
+
const [validationMessage, setValidationMessage] = React.useState();
|
|
46
|
+
const invalidState = invalid || hasError || validationMessage !== undefined;
|
|
47
|
+
const descriptionId = hasDescription ? `${inputId}-description` : undefined;
|
|
48
|
+
const errorId = hasError ? `${inputId}-error` : undefined;
|
|
49
|
+
const describedBy = joinIds(ariaDescribedBy, descriptionId, errorId, validationMessage ? validationId : undefined);
|
|
50
|
+
const inputRef = React.useRef(null);
|
|
51
|
+
const [values, setValues] = useControllableState({
|
|
52
|
+
...(value !== undefined ? { value: [...value] } : {}),
|
|
53
|
+
defaultValue: [...defaultValue],
|
|
54
|
+
...(onValueChange ? { onChange: onValueChange } : {})
|
|
55
|
+
});
|
|
56
|
+
const [inputValue, setInputValue] = React.useState("");
|
|
57
|
+
React.useEffect(() => {
|
|
58
|
+
inputRef.current?.setCustomValidity(required && values.length === 0 ? "Add at least one tag" : "");
|
|
59
|
+
}, [required, values.length]);
|
|
60
|
+
function removeValue(index) {
|
|
61
|
+
setValues((currentValues) => currentValues.filter((_, valueIndex) => valueIndex !== index));
|
|
62
|
+
setValidationMessage(undefined);
|
|
63
|
+
inputRef.current?.focus();
|
|
64
|
+
}
|
|
65
|
+
function addCandidate(rawValue, currentValues) {
|
|
66
|
+
const nextValue = normalizeTag(rawValue);
|
|
67
|
+
if (nextValue.length === 0) {
|
|
68
|
+
return { nextValues: [...currentValues], added: false };
|
|
69
|
+
}
|
|
70
|
+
if (!allowDuplicates && currentValues.some((value) => tagKey(value) === tagKey(nextValue))) {
|
|
71
|
+
return { nextValues: [...currentValues], added: false, message: "Tag already exists" };
|
|
72
|
+
}
|
|
73
|
+
if (maxItems !== undefined && currentValues.length >= maxItems) {
|
|
74
|
+
return { nextValues: [...currentValues], added: false, message: `Maximum ${maxItems} tags` };
|
|
75
|
+
}
|
|
76
|
+
const customMessage = validateTag?.(nextValue, currentValues);
|
|
77
|
+
if (typeof customMessage === "string" && customMessage.length > 0) {
|
|
78
|
+
return { nextValues: [...currentValues], added: false, message: customMessage };
|
|
79
|
+
}
|
|
80
|
+
return { nextValues: [...currentValues, nextValue], added: true };
|
|
81
|
+
}
|
|
82
|
+
function commitRawValue(rawValue) {
|
|
83
|
+
const candidates = splitByTextDelimiters(rawValue, delimiterKeys);
|
|
84
|
+
let added = false;
|
|
85
|
+
let message;
|
|
86
|
+
setValues((currentValues) => {
|
|
87
|
+
let nextValues = [...currentValues];
|
|
88
|
+
for (const candidate of candidates) {
|
|
89
|
+
const result = addCandidate(candidate, nextValues);
|
|
90
|
+
nextValues = result.nextValues;
|
|
91
|
+
added = result.added || added;
|
|
92
|
+
message = result.message ?? message;
|
|
93
|
+
}
|
|
94
|
+
return nextValues;
|
|
95
|
+
});
|
|
96
|
+
setValidationMessage(message);
|
|
97
|
+
if (added) {
|
|
98
|
+
setInputValue("");
|
|
99
|
+
}
|
|
100
|
+
return added;
|
|
101
|
+
}
|
|
102
|
+
const input = (_jsxs("div", { className: "zvk-ui-tags-input__control", "data-disabled": disabled ? "true" : undefined, "data-invalid": invalidState ? "true" : undefined, "data-size": size, onClick: () => inputRef.current?.focus(), children: [values.map((tag, index) => (_jsxs("span", { className: "zvk-ui-tags-input__tag", children: [_jsx("span", { className: "zvk-ui-tags-input__tag-label", children: tag }), _jsx("button", { "aria-label": `Remove ${tag}`, className: "zvk-ui-tags-input__remove", disabled: disabled, onClick: () => removeValue(index), onMouseDown: (event) => event.preventDefault(), type: "button", children: "x" })] }, `${tag}-${index}`))), _jsx("input", { ...props, ref: (node) => {
|
|
103
|
+
inputRef.current = node;
|
|
104
|
+
if (typeof ref === "function") {
|
|
105
|
+
ref(node);
|
|
106
|
+
}
|
|
107
|
+
else if (ref && "current" in ref) {
|
|
108
|
+
ref.current = node;
|
|
109
|
+
}
|
|
110
|
+
}, "aria-describedby": describedBy, "aria-invalid": invalidState ? "true" : undefined, className: cn("zvk-ui-tags-input__input", className), disabled: disabled, id: inputId, onBlur: onBlur, onChange: (event) => {
|
|
111
|
+
const nextValue = event.currentTarget.value;
|
|
112
|
+
setInputValue(nextValue);
|
|
113
|
+
setValidationMessage(undefined);
|
|
114
|
+
}, onFocus: onFocus, onKeyDown: composeEventHandlers(onKeyDown, (event) => {
|
|
115
|
+
if (delimiterKeys.includes(event.key)) {
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
commitRawValue(inputValue);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (event.key === "Backspace" && inputValue.length === 0 && values.length > 0) {
|
|
121
|
+
event.preventDefault();
|
|
122
|
+
removeValue(values.length - 1);
|
|
123
|
+
}
|
|
124
|
+
}), onPaste: (event) => {
|
|
125
|
+
const pastedValue = event.clipboardData.getData("text");
|
|
126
|
+
if (splitByTextDelimiters(pastedValue, delimiterKeys).length > 1) {
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
commitRawValue(pastedValue);
|
|
129
|
+
}
|
|
130
|
+
}, placeholder: values.length === 0 ? placeholder : undefined, required: required && values.length === 0, value: inputValue }), name ? values.map((tag, index) => (_jsx("input", { name: name, type: "hidden", value: tag }, `${tag}-${index}`))) : null] }));
|
|
131
|
+
return (_jsxs(Field, { disabled: Boolean(disabled), invalid: invalidState, required: Boolean(required), children: [hasLabel ? _jsx(Field.Label, { htmlFor: inputId, children: label }) : null, input, hasDescription ? _jsx(Field.Description, { id: descriptionId, children: description }) : null, hasError ? _jsx(Field.Error, { id: errorId, children: error }) : null, validationMessage ? (_jsx(Field.Error, { id: validationId, children: validationMessage })) : null] }));
|
|
132
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export type ToolbarOrientation = "horizontal" | "vertical";
|
|
3
|
+
export interface ToolbarProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
4
|
+
orientation?: ToolbarOrientation;
|
|
5
|
+
ref?: React.Ref<HTMLDivElement>;
|
|
6
|
+
}
|
|
7
|
+
export interface ToolbarGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
8
|
+
ref?: React.Ref<HTMLDivElement>;
|
|
9
|
+
}
|
|
10
|
+
export interface ToolbarButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
11
|
+
ref?: React.Ref<HTMLButtonElement>;
|
|
12
|
+
}
|
|
13
|
+
export interface ToolbarToggleProps extends Omit<ToolbarButtonProps, "aria-pressed"> {
|
|
14
|
+
defaultPressed?: boolean;
|
|
15
|
+
onPressedChange?: (pressed: boolean) => void;
|
|
16
|
+
pressed?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface ToolbarSeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
19
|
+
orientation?: ToolbarOrientation;
|
|
20
|
+
ref?: React.Ref<HTMLDivElement>;
|
|
21
|
+
}
|
|
22
|
+
declare function ToolbarRoot({ className, onKeyDown, orientation, ref, role, ...props }: ToolbarProps): React.JSX.Element;
|
|
23
|
+
declare function ToolbarGroup({ className, ref, role, ...props }: ToolbarGroupProps): React.JSX.Element;
|
|
24
|
+
declare function ToolbarButton({ className, disabled, ref, tabIndex, type, ...props }: ToolbarButtonProps): React.JSX.Element;
|
|
25
|
+
declare function ToolbarToggle({ className, defaultPressed, disabled, onClick, onPressedChange, pressed, ref, tabIndex, type, ...props }: ToolbarToggleProps): React.JSX.Element;
|
|
26
|
+
declare function ToolbarSeparator({ className, orientation, ref, role, ...props }: ToolbarSeparatorProps): React.JSX.Element;
|
|
27
|
+
export declare const Toolbar: typeof ToolbarRoot & {
|
|
28
|
+
Button: typeof ToolbarButton;
|
|
29
|
+
Group: typeof ToolbarGroup;
|
|
30
|
+
Separator: typeof ToolbarSeparator;
|
|
31
|
+
Toggle: typeof ToolbarToggle;
|
|
32
|
+
};
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useControllableState } from "../../hooks/use-controllable-state.js";
|
|
5
|
+
import { cn } from "../../utils/cn.js";
|
|
6
|
+
import { composeEventHandlers } from "../../utils/compose-event-handlers.js";
|
|
7
|
+
const toolbarControlSelector = '[data-zvk-ui-toolbar-control="true"]';
|
|
8
|
+
function assignRef(ref, value) {
|
|
9
|
+
if (typeof ref === "function") {
|
|
10
|
+
ref(value);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (ref) {
|
|
14
|
+
ref.current = value;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function isEnabledToolbarControl(element) {
|
|
18
|
+
return element instanceof HTMLElement && !element.hasAttribute("disabled") && element.getAttribute("aria-disabled") !== "true";
|
|
19
|
+
}
|
|
20
|
+
function getToolbarControls(root) {
|
|
21
|
+
return Array.from(root.querySelectorAll(toolbarControlSelector)).filter(isEnabledToolbarControl);
|
|
22
|
+
}
|
|
23
|
+
function updateToolbarTabStops(root, activeControl) {
|
|
24
|
+
const allControls = Array.from(root.querySelectorAll(toolbarControlSelector)).filter((element) => element instanceof HTMLElement);
|
|
25
|
+
const enabledControls = allControls.filter(isEnabledToolbarControl);
|
|
26
|
+
const fallback = enabledControls[0] ?? null;
|
|
27
|
+
const active = activeControl && enabledControls.includes(activeControl) ? activeControl : fallback;
|
|
28
|
+
for (const control of allControls) {
|
|
29
|
+
control.tabIndex = control === active ? 0 : -1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function moveToolbarFocus(root, target) {
|
|
33
|
+
updateToolbarTabStops(root, target);
|
|
34
|
+
target.focus();
|
|
35
|
+
}
|
|
36
|
+
function ToolbarRoot({ className, onKeyDown, orientation = "horizontal", ref, role = "toolbar", ...props }) {
|
|
37
|
+
const rootRef = React.useRef(null);
|
|
38
|
+
const handleRef = React.useCallback((node) => {
|
|
39
|
+
rootRef.current = node;
|
|
40
|
+
assignRef(ref, node);
|
|
41
|
+
}, [ref]);
|
|
42
|
+
React.useLayoutEffect(() => {
|
|
43
|
+
if (rootRef.current) {
|
|
44
|
+
updateToolbarTabStops(rootRef.current, document.activeElement instanceof HTMLElement ? document.activeElement : null);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
return (_jsx("div", { ...props, ref: handleRef, "aria-orientation": orientation, className: cn("zvk-ui-toolbar", className), "data-orientation": orientation, onKeyDown: composeEventHandlers(onKeyDown, (event) => {
|
|
48
|
+
const root = rootRef.current;
|
|
49
|
+
const currentControl = event.target instanceof HTMLElement ? event.target.closest(toolbarControlSelector) : null;
|
|
50
|
+
if (!root || !currentControl) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const controls = getToolbarControls(root);
|
|
54
|
+
const currentIndex = controls.indexOf(currentControl);
|
|
55
|
+
if (currentIndex === -1) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let nextIndex = currentIndex;
|
|
59
|
+
if (event.key === "Home") {
|
|
60
|
+
nextIndex = 0;
|
|
61
|
+
}
|
|
62
|
+
else if (event.key === "End") {
|
|
63
|
+
nextIndex = controls.length - 1;
|
|
64
|
+
}
|
|
65
|
+
else if (event.key === "ArrowRight" ||
|
|
66
|
+
event.key === "ArrowDown") {
|
|
67
|
+
nextIndex = Math.min(currentIndex + 1, controls.length - 1);
|
|
68
|
+
}
|
|
69
|
+
else if (event.key === "ArrowLeft" ||
|
|
70
|
+
event.key === "ArrowUp") {
|
|
71
|
+
nextIndex = Math.max(currentIndex - 1, 0);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
moveToolbarFocus(root, controls[nextIndex] ?? currentControl);
|
|
78
|
+
}), role: role }));
|
|
79
|
+
}
|
|
80
|
+
function ToolbarGroup({ className, ref, role = "group", ...props }) {
|
|
81
|
+
return _jsx("div", { ...props, ref: ref, className: cn("zvk-ui-toolbar__group", className), role: role });
|
|
82
|
+
}
|
|
83
|
+
function ToolbarButton({ className, disabled, ref, tabIndex, type = "button", ...props }) {
|
|
84
|
+
return (_jsx("button", { ...props, ref: ref, className: cn("zvk-ui-toolbar__button", className), "data-disabled": disabled ? "true" : undefined, "data-zvk-ui-toolbar-control": "true", disabled: disabled, tabIndex: disabled ? -1 : tabIndex ?? -1, type: type }));
|
|
85
|
+
}
|
|
86
|
+
function ToolbarToggle({ className, defaultPressed = false, disabled, onClick, onPressedChange, pressed, ref, tabIndex, type = "button", ...props }) {
|
|
87
|
+
const [currentPressed, setPressed] = useControllableState({
|
|
88
|
+
...(pressed !== undefined ? { value: pressed } : {}),
|
|
89
|
+
defaultValue: defaultPressed,
|
|
90
|
+
...(onPressedChange ? { onChange: onPressedChange } : {})
|
|
91
|
+
});
|
|
92
|
+
return (_jsx("button", { ...props, ref: ref, "aria-pressed": currentPressed, className: cn("zvk-ui-toolbar__button", "zvk-ui-toolbar__toggle", className), "data-disabled": disabled ? "true" : undefined, "data-state": currentPressed ? "on" : "off", "data-zvk-ui-toolbar-control": "true", disabled: disabled, onClick: composeEventHandlers(onClick, () => {
|
|
93
|
+
if (!disabled) {
|
|
94
|
+
setPressed((value) => !value);
|
|
95
|
+
}
|
|
96
|
+
}), tabIndex: disabled ? -1 : tabIndex ?? -1, type: type }));
|
|
97
|
+
}
|
|
98
|
+
function ToolbarSeparator({ className, orientation = "vertical", ref, role = "separator", ...props }) {
|
|
99
|
+
return (_jsx("div", { ...props, ref: ref, "aria-orientation": orientation, className: cn("zvk-ui-toolbar__separator", className), "data-orientation": orientation, role: role }));
|
|
100
|
+
}
|
|
101
|
+
export const Toolbar = Object.assign(ToolbarRoot, {
|
|
102
|
+
Button: ToolbarButton,
|
|
103
|
+
Group: ToolbarGroup,
|
|
104
|
+
Separator: ToolbarSeparator,
|
|
105
|
+
Toggle: ToolbarToggle
|
|
106
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { type CollectionSelectionMode } from "../../internal/collection/index.js";
|
|
3
|
+
export type TreeViewSelectionMode = CollectionSelectionMode;
|
|
4
|
+
export interface TreeViewItemState {
|
|
5
|
+
active: boolean;
|
|
6
|
+
branch: boolean;
|
|
7
|
+
disabled: boolean;
|
|
8
|
+
expanded: boolean;
|
|
9
|
+
id: string;
|
|
10
|
+
level: number;
|
|
11
|
+
selected: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface TreeViewProps<Item> extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "onSelect"> {
|
|
14
|
+
activeKey?: string;
|
|
15
|
+
defaultActiveKey?: string;
|
|
16
|
+
defaultExpandedKeys?: readonly string[];
|
|
17
|
+
defaultSelectedKeys?: readonly string[];
|
|
18
|
+
expandedKeys?: readonly string[];
|
|
19
|
+
getItemChildren?: (item: Item) => readonly Item[] | undefined;
|
|
20
|
+
getItemId: (item: Item) => string;
|
|
21
|
+
getItemLabel: (item: Item) => string;
|
|
22
|
+
isItemDisabled?: (item: Item) => boolean;
|
|
23
|
+
items: readonly Item[];
|
|
24
|
+
onActiveKeyChange?: (key: string | undefined) => void;
|
|
25
|
+
onExpandedKeysChange?: (keys: string[]) => void;
|
|
26
|
+
onItemAction?: (key: string, item: Item) => void;
|
|
27
|
+
onSelectedKeysChange?: (keys: string[]) => void;
|
|
28
|
+
ref?: React.Ref<HTMLDivElement>;
|
|
29
|
+
renderItem?: (item: Item, state: TreeViewItemState) => React.ReactNode;
|
|
30
|
+
selectedKeys?: readonly string[];
|
|
31
|
+
selectionMode?: TreeViewSelectionMode;
|
|
32
|
+
}
|
|
33
|
+
export declare function TreeView<Item>({ activeKey, className, defaultActiveKey, defaultExpandedKeys, defaultSelectedKeys, expandedKeys, getItemChildren, getItemId, getItemLabel, isItemDisabled, items, onActiveKeyChange, onExpandedKeysChange, onItemAction, onKeyDown, onSelectedKeysChange, ref, renderItem, role, selectedKeys, selectionMode, ...props }: TreeViewProps<Item>): React.JSX.Element;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useControllableState } from "../../hooks/use-controllable-state.js";
|
|
5
|
+
import { findTypeaheadKey, moveCollectionKey, normalizeSelectionKeys, toggleSelectionKey } from "../../internal/collection/index.js";
|
|
6
|
+
import { cn } from "../../utils/cn.js";
|
|
7
|
+
import { composeEventHandlers } from "../../utils/compose-event-handlers.js";
|
|
8
|
+
function flattenTreeItems({ expandedKeys, getItemChildren, getItemId, getItemLabel, isItemDisabled, items }) {
|
|
9
|
+
const expandedSet = new Set(expandedKeys);
|
|
10
|
+
function flattenSiblings(siblings, level, parentId) {
|
|
11
|
+
return siblings.flatMap((item, index) => {
|
|
12
|
+
const id = getItemId(item);
|
|
13
|
+
const children = getItemChildren?.(item) ?? [];
|
|
14
|
+
const branch = children.length > 0;
|
|
15
|
+
const expanded = branch && expandedSet.has(id);
|
|
16
|
+
const record = {
|
|
17
|
+
branch,
|
|
18
|
+
disabled: isItemDisabled?.(item) ?? false,
|
|
19
|
+
expanded,
|
|
20
|
+
id,
|
|
21
|
+
item,
|
|
22
|
+
label: getItemLabel(item),
|
|
23
|
+
level,
|
|
24
|
+
parentId,
|
|
25
|
+
posInSet: index + 1,
|
|
26
|
+
setSize: siblings.length
|
|
27
|
+
};
|
|
28
|
+
return expanded ? [record, ...flattenSiblings(children, level + 1, id)] : [record];
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return flattenSiblings(items, 1, undefined);
|
|
32
|
+
}
|
|
33
|
+
export function TreeView({ activeKey, className, defaultActiveKey, defaultExpandedKeys = [], defaultSelectedKeys = [], expandedKeys, getItemChildren, getItemId, getItemLabel, isItemDisabled, items, onActiveKeyChange, onExpandedKeysChange, onItemAction, onKeyDown, onSelectedKeysChange, ref, renderItem, role = "tree", selectedKeys, selectionMode = "none", ...props }) {
|
|
34
|
+
const [currentExpandedKeys, setCurrentExpandedKeys] = useControllableState({
|
|
35
|
+
...(expandedKeys !== undefined ? { value: normalizeSelectionKeys(expandedKeys) } : {}),
|
|
36
|
+
defaultValue: normalizeSelectionKeys(defaultExpandedKeys),
|
|
37
|
+
...(onExpandedKeysChange ? { onChange: onExpandedKeysChange } : {})
|
|
38
|
+
});
|
|
39
|
+
const records = React.useMemo(() => flattenTreeItems({ expandedKeys: currentExpandedKeys, getItemChildren, getItemId, getItemLabel, isItemDisabled, items }), [currentExpandedKeys, getItemChildren, getItemId, getItemLabel, isItemDisabled, items]);
|
|
40
|
+
const navigationItems = records.map((record) => ({
|
|
41
|
+
disabled: record.disabled,
|
|
42
|
+
id: record.id,
|
|
43
|
+
textValue: record.label
|
|
44
|
+
}));
|
|
45
|
+
const firstEnabledKey = moveCollectionKey(navigationItems, undefined, "first");
|
|
46
|
+
const rowRefs = React.useRef(new Map());
|
|
47
|
+
const [currentActiveKey, setCurrentActiveKey] = useControllableState({
|
|
48
|
+
...(activeKey !== undefined ? { value: activeKey } : {}),
|
|
49
|
+
defaultValue: defaultActiveKey ?? firstEnabledKey,
|
|
50
|
+
...(onActiveKeyChange ? { onChange: onActiveKeyChange } : {})
|
|
51
|
+
});
|
|
52
|
+
const [currentSelectedKeys, setCurrentSelectedKeys] = useControllableState({
|
|
53
|
+
...(selectedKeys !== undefined ? { value: normalizeSelectionKeys(selectedKeys) } : {}),
|
|
54
|
+
defaultValue: normalizeSelectionKeys(defaultSelectedKeys),
|
|
55
|
+
...(onSelectedKeysChange ? { onChange: onSelectedKeysChange } : {})
|
|
56
|
+
});
|
|
57
|
+
const rovingActiveKey = currentActiveKey ?? firstEnabledKey;
|
|
58
|
+
React.useLayoutEffect(() => {
|
|
59
|
+
if (rovingActiveKey === undefined) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const activeRecord = records.find((record) => record.id === rovingActiveKey);
|
|
63
|
+
if (!activeRecord || activeRecord.disabled) {
|
|
64
|
+
setCurrentActiveKey(firstEnabledKey);
|
|
65
|
+
}
|
|
66
|
+
}, [firstEnabledKey, records, rovingActiveKey, setCurrentActiveKey]);
|
|
67
|
+
function focusItem(key) {
|
|
68
|
+
if (key === undefined) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
rowRefs.current.get(key)?.focus();
|
|
72
|
+
}
|
|
73
|
+
function setExpanded(record, expanded) {
|
|
74
|
+
if (!record.branch) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
setCurrentExpandedKeys((keys) => {
|
|
78
|
+
const normalizedKeys = normalizeSelectionKeys(keys);
|
|
79
|
+
if (expanded) {
|
|
80
|
+
return normalizedKeys.includes(record.id) ? normalizedKeys : [...normalizedKeys, record.id];
|
|
81
|
+
}
|
|
82
|
+
return normalizedKeys.filter((key) => key !== record.id);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function selectRecord(record) {
|
|
86
|
+
if (record.disabled) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
setCurrentSelectedKeys((keys) => toggleSelectionKey(keys, record.id, selectionMode));
|
|
90
|
+
}
|
|
91
|
+
function moveActiveItem(movement) {
|
|
92
|
+
const nextKey = moveCollectionKey(navigationItems, rovingActiveKey, movement);
|
|
93
|
+
setCurrentActiveKey(nextKey);
|
|
94
|
+
focusItem(nextKey);
|
|
95
|
+
}
|
|
96
|
+
return (_jsx("div", { ...props, ref: ref, "aria-multiselectable": selectionMode === "multiple" ? true : undefined, className: cn("zvk-ui-tree-view", className), "data-selection-mode": selectionMode, onKeyDown: onKeyDown, role: role, children: records.map((record) => {
|
|
97
|
+
const selected = currentSelectedKeys.includes(record.id);
|
|
98
|
+
const active = rovingActiveKey === record.id;
|
|
99
|
+
const state = {
|
|
100
|
+
active,
|
|
101
|
+
branch: record.branch,
|
|
102
|
+
disabled: record.disabled,
|
|
103
|
+
expanded: record.expanded,
|
|
104
|
+
id: record.id,
|
|
105
|
+
level: record.level,
|
|
106
|
+
selected
|
|
107
|
+
};
|
|
108
|
+
const style = { "--zvk-ui-tree-view-indent": `${Math.max(record.level - 1, 0)}rem` };
|
|
109
|
+
return (_jsx("div", { ref: (node) => {
|
|
110
|
+
if (node) {
|
|
111
|
+
rowRefs.current.set(record.id, node);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
rowRefs.current.delete(record.id);
|
|
115
|
+
}
|
|
116
|
+
}, "aria-disabled": record.disabled ? "true" : undefined, "aria-expanded": record.branch ? (record.expanded ? "true" : "false") : undefined, "aria-level": record.level, "aria-posinset": record.posInSet, "aria-selected": selectionMode === "none" ? undefined : selected ? "true" : "false", "aria-setsize": record.setSize, className: "zvk-ui-tree-view__item", "data-active": active ? "true" : undefined, "data-disabled": record.disabled ? "true" : undefined, "data-expanded": record.branch ? (record.expanded ? "true" : "false") : undefined, "data-selected": selected ? "true" : undefined, onClick: () => {
|
|
117
|
+
if (record.disabled) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
setCurrentActiveKey(record.id);
|
|
121
|
+
onItemAction?.(record.id, record.item);
|
|
122
|
+
}, onFocus: () => {
|
|
123
|
+
if (!record.disabled) {
|
|
124
|
+
setCurrentActiveKey(record.id);
|
|
125
|
+
}
|
|
126
|
+
}, onKeyDown: composeEventHandlers(undefined, (event) => {
|
|
127
|
+
if (event.key === "ArrowDown") {
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
moveActiveItem("next");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (event.key === "ArrowUp") {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
moveActiveItem("previous");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (event.key === "Home") {
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
moveActiveItem("first");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (event.key === "End") {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
moveActiveItem("last");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (event.key === "ArrowRight") {
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
if (record.branch && !record.expanded) {
|
|
150
|
+
setExpanded(record, true);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const child = records.find((candidate) => candidate.parentId === record.id);
|
|
154
|
+
setCurrentActiveKey(child?.id);
|
|
155
|
+
focusItem(child?.id);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (event.key === "ArrowLeft") {
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
if (record.branch && record.expanded) {
|
|
161
|
+
setExpanded(record, false);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
setCurrentActiveKey(record.parentId);
|
|
165
|
+
focusItem(record.parentId);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (event.key === " ") {
|
|
169
|
+
event.preventDefault();
|
|
170
|
+
selectRecord(record);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (event.key === "Enter") {
|
|
174
|
+
event.preventDefault();
|
|
175
|
+
if (!record.disabled) {
|
|
176
|
+
onItemAction?.(record.id, record.item);
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
|
181
|
+
const nextKey = findTypeaheadKey(navigationItems, record.id, event.key);
|
|
182
|
+
if (nextKey !== undefined) {
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
setCurrentActiveKey(nextKey);
|
|
185
|
+
focusItem(nextKey);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}), role: "treeitem", style: style, tabIndex: record.disabled ? -1 : active ? 0 : -1, children: renderItem ? renderItem(record.item, state) : record.label }, record.id));
|
|
189
|
+
}) }));
|
|
190
|
+
}
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export { createCollection } from "./collection.js";
|
|
2
2
|
export type { Collection, CollectionItem } from "./collection.js";
|
|
3
|
+
export { findTypeaheadKey, moveCollectionKey, normalizeSelectionKeys, toggleSelectionKey } from "./selection.js";
|
|
4
|
+
export type { CollectionMovement, CollectionNavigationItem, CollectionSelectionMode } from "./selection.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type CollectionSelectionMode = "none" | "single" | "multiple";
|
|
2
|
+
export type CollectionMovement = "next" | "previous" | "first" | "last";
|
|
3
|
+
export interface CollectionNavigationItem {
|
|
4
|
+
disabled?: boolean | undefined;
|
|
5
|
+
id: string;
|
|
6
|
+
textValue?: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
export declare function normalizeSelectionKeys(keys: Iterable<string> | null | undefined): string[];
|
|
9
|
+
export declare function toggleSelectionKey(keys: Iterable<string> | null | undefined, key: string, mode: CollectionSelectionMode): string[];
|
|
10
|
+
export declare function moveCollectionKey(items: readonly CollectionNavigationItem[], currentKey: string | undefined, movement: CollectionMovement): string | undefined;
|
|
11
|
+
export declare function findTypeaheadKey(items: readonly CollectionNavigationItem[], currentKey: string | undefined, query: string): string | undefined;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export function normalizeSelectionKeys(keys) {
|
|
2
|
+
return Array.from(new Set(keys ?? []));
|
|
3
|
+
}
|
|
4
|
+
export function toggleSelectionKey(keys, key, mode) {
|
|
5
|
+
if (mode === "none") {
|
|
6
|
+
return [];
|
|
7
|
+
}
|
|
8
|
+
const normalizedKeys = normalizeSelectionKeys(keys);
|
|
9
|
+
const selected = normalizedKeys.includes(key);
|
|
10
|
+
if (mode === "single") {
|
|
11
|
+
return selected ? [] : [key];
|
|
12
|
+
}
|
|
13
|
+
if (selected) {
|
|
14
|
+
return normalizedKeys.filter((selectedKey) => selectedKey !== key);
|
|
15
|
+
}
|
|
16
|
+
return [...normalizedKeys, key];
|
|
17
|
+
}
|
|
18
|
+
function enabledItems(items) {
|
|
19
|
+
return items.filter((item) => item.disabled !== true);
|
|
20
|
+
}
|
|
21
|
+
function wrapIndex(index, length) {
|
|
22
|
+
return ((index % length) + length) % length;
|
|
23
|
+
}
|
|
24
|
+
export function moveCollectionKey(items, currentKey, movement) {
|
|
25
|
+
const enabled = enabledItems(items);
|
|
26
|
+
if (enabled.length === 0) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
if (movement === "first") {
|
|
30
|
+
return enabled[0]?.id;
|
|
31
|
+
}
|
|
32
|
+
if (movement === "last") {
|
|
33
|
+
return enabled.at(-1)?.id;
|
|
34
|
+
}
|
|
35
|
+
const currentIndex = currentKey === undefined ? -1 : enabled.findIndex((item) => item.id === currentKey);
|
|
36
|
+
if (currentIndex === -1) {
|
|
37
|
+
return movement === "next" ? enabled[0]?.id : enabled.at(-1)?.id;
|
|
38
|
+
}
|
|
39
|
+
const nextIndex = movement === "next" ? currentIndex + 1 : currentIndex - 1;
|
|
40
|
+
return enabled[wrapIndex(nextIndex, enabled.length)]?.id;
|
|
41
|
+
}
|
|
42
|
+
export function findTypeaheadKey(items, currentKey, query) {
|
|
43
|
+
const normalizedQuery = query.trim().toLocaleLowerCase();
|
|
44
|
+
if (normalizedQuery.length === 0) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const enabled = enabledItems(items).filter((item) => item.textValue?.trim());
|
|
48
|
+
if (enabled.length === 0) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const currentIndex = currentKey === undefined ? -1 : enabled.findIndex((item) => item.id === currentKey);
|
|
52
|
+
const startIndex = currentIndex === -1 ? 0 : currentIndex + 1;
|
|
53
|
+
for (let offset = 0; offset < enabled.length; offset += 1) {
|
|
54
|
+
const item = enabled[wrapIndex(startIndex + offset, enabled.length)];
|
|
55
|
+
if (item?.textValue?.toLocaleLowerCase().startsWith(normalizedQuery)) {
|
|
56
|
+
return item.id;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|