dustyui 1.0.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +56 -1
- package/dist/Avatar/Avatar.d.ts +9 -0
- package/dist/Avatar/Avatar.js +17 -0
- package/dist/Avatar/Avatar.module.css +33 -0
- package/dist/Avatar/getInitials.d.ts +1 -0
- package/dist/Avatar/getInitials.js +13 -0
- package/dist/Badge/Badge.d.ts +3 -13
- package/dist/Badge/Badge.js +5 -31
- package/dist/Badge/Badge.module.css +12 -11
- package/dist/Button/Button.d.ts +6 -11
- package/dist/Button/Button.js +21 -40
- package/dist/Button/Button.module.css +93 -22
- package/dist/IconButton/IconButton.d.ts +7 -0
- package/dist/IconButton/IconButton.js +8 -0
- package/dist/IconButton/IconButton.module.css +17 -0
- package/dist/ObjectInspector/InspectorDetails.d.ts +6 -0
- package/dist/ObjectInspector/InspectorDetails.js +32 -0
- package/dist/ObjectInspector/InspectorDetails.module.css +47 -0
- package/dist/ObjectInspector/InspectorSearch.d.ts +9 -0
- package/dist/ObjectInspector/InspectorSearch.js +12 -0
- package/dist/ObjectInspector/InspectorSearch.module.css +39 -0
- package/dist/ObjectInspector/ObjectInspector.d.ts +8 -0
- package/dist/ObjectInspector/ObjectInspector.js +83 -0
- package/dist/ObjectInspector/ObjectInspector.module.css +46 -0
- package/dist/ObjectInspector/describe/describe.d.ts +31 -0
- package/dist/ObjectInspector/describe/describe.js +123 -0
- package/dist/ObjectInspector/describe/getChildren.d.ts +21 -0
- package/dist/ObjectInspector/describe/getChildren.js +77 -0
- package/dist/ObjectInspector/describe/getNode.d.ts +2 -0
- package/dist/ObjectInspector/describe/getNode.js +9 -0
- package/dist/ObjectInspector/describe/getPath.d.ts +26 -0
- package/dist/ObjectInspector/describe/getPath.js +58 -0
- package/dist/ObjectInspector/describe/getPreview.d.ts +10 -0
- package/dist/ObjectInspector/describe/getPreview.js +189 -0
- package/dist/ObjectInspector/describe/getValueKind.d.ts +2 -0
- package/dist/ObjectInspector/describe/getValueKind.js +64 -0
- package/dist/ObjectInspector/describe/safeRead.d.ts +12 -0
- package/dist/ObjectInspector/describe/safeRead.js +88 -0
- package/dist/ObjectInspector/getCopyText.d.ts +2 -0
- package/dist/ObjectInspector/getCopyText.js +103 -0
- package/dist/ObjectInspector/getLabelText.d.ts +2 -0
- package/dist/ObjectInspector/getLabelText.js +15 -0
- package/dist/ObjectInspector/getValueText.d.ts +2 -0
- package/dist/ObjectInspector/getValueText.js +5 -0
- package/dist/ObjectInspector/search/getHighlightParts.d.ts +5 -0
- package/dist/ObjectInspector/search/getHighlightParts.js +20 -0
- package/dist/ObjectInspector/search/getMatches.d.ts +6 -0
- package/dist/ObjectInspector/search/getMatches.js +46 -0
- package/dist/ObjectInspector/search/getSearchStatus.d.ts +2 -0
- package/dist/ObjectInspector/search/getSearchStatus.js +14 -0
- package/dist/Tree/Tree.d.ts +14 -0
- package/dist/Tree/Tree.js +166 -0
- package/dist/Tree/Tree.module.css +65 -0
- package/dist/Tree/getWindow.d.ts +22 -0
- package/dist/Tree/getWindow.js +43 -0
- package/dist/Tree/treeModel.d.ts +55 -0
- package/dist/Tree/treeModel.js +203 -0
- package/dist/Tree/useRowWindow.d.ts +3 -0
- package/dist/Tree/useRowWindow.js +78 -0
- package/dist/Tree/useTypeahead.d.ts +2 -0
- package/dist/Tree/useTypeahead.js +15 -0
- package/dist/announcer/announcer.d.ts +2 -0
- package/dist/announcer/announcer.js +41 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +9 -6
- package/dist/styles/themes/dusty.css +22 -0
- package/dist/styles/themes/mono.css +21 -0
- package/dist/styles/tokens.css +19 -13
- package/dist/styles/visuallyHidden.module.css +8 -0
- package/package.json +54 -38
- package/dist/Badge/Badge.cjs +0 -67
- package/dist/Button/Button.cjs +0 -74
- package/dist/index.cjs +0 -32
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useEffectEvent, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react";
|
|
4
|
+
import clsx from "clsx";
|
|
5
|
+
import { getWindowItems } from "./getWindow.js";
|
|
6
|
+
import { findVisibleAncestor, keyToAction, reduceTree, visibleRows, } from "./treeModel.js";
|
|
7
|
+
import { useRowWindow } from "./useRowWindow.js";
|
|
8
|
+
import { useTypeahead } from "./useTypeahead.js";
|
|
9
|
+
import styles from "./Tree.module.css";
|
|
10
|
+
const TAB_STOP = ':scope > [tabindex="0"]';
|
|
11
|
+
const INTERACTIVE = [
|
|
12
|
+
"a[href]",
|
|
13
|
+
"button",
|
|
14
|
+
"input",
|
|
15
|
+
"select",
|
|
16
|
+
"textarea",
|
|
17
|
+
"summary",
|
|
18
|
+
"label",
|
|
19
|
+
'[role="button"]',
|
|
20
|
+
'[role="link"]',
|
|
21
|
+
'[role="checkbox"]',
|
|
22
|
+
'[role="switch"]',
|
|
23
|
+
'[contenteditable=""]',
|
|
24
|
+
'[contenteditable="true"]',
|
|
25
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
26
|
+
].join(",");
|
|
27
|
+
function hasRow(rows, id) {
|
|
28
|
+
return id !== undefined && rows.some((row) => row.id === id);
|
|
29
|
+
}
|
|
30
|
+
function getSelected(rows, controlled, own) {
|
|
31
|
+
return hasRow(rows, controlled) ? controlled : own;
|
|
32
|
+
}
|
|
33
|
+
function isInsideControl(target, row) {
|
|
34
|
+
if (!(target instanceof Element)) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
const control = target.closest(INTERACTIVE);
|
|
38
|
+
return control !== null && control !== row && row.contains(control);
|
|
39
|
+
}
|
|
40
|
+
export function Tree({ root, label, renderRow, getRowText, expanded: controlledExpanded, defaultExpanded, onExpandedChange, selected: controlledSelected, onSelectedChange, className, onKeyDown, onFocus, onBlur, ref, ...props }) {
|
|
41
|
+
const [ownExpanded, setOwnExpanded] = useState(() => new Set(defaultExpanded));
|
|
42
|
+
const [ownSelected, setOwnSelected] = useState();
|
|
43
|
+
const element = useRef(null);
|
|
44
|
+
const focusAfterRender = useRef(false);
|
|
45
|
+
const hasFocusWithin = useRef(false);
|
|
46
|
+
const reported = useRef(controlledSelected);
|
|
47
|
+
const expanded = controlledExpanded ?? ownExpanded;
|
|
48
|
+
const rows = useMemo(() => visibleRows(root, expanded), [root, expanded]);
|
|
49
|
+
const [previousRows, setPreviousRows] = useState(rows);
|
|
50
|
+
if (previousRows !== rows) {
|
|
51
|
+
setPreviousRows(rows);
|
|
52
|
+
const before = getSelected(previousRows, controlledSelected, ownSelected);
|
|
53
|
+
if (before !== undefined && !hasRow(rows, before)) {
|
|
54
|
+
setOwnSelected(findVisibleAncestor(previousRows, before, rows));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const focused = getSelected(rows, controlledSelected, ownSelected);
|
|
58
|
+
const tabStop = useMemo(() => Math.max(0, rows.findIndex((row) => row.id === focused)), [rows, focused]);
|
|
59
|
+
const rowWindow = useRowWindow(element, rows.length);
|
|
60
|
+
const withTypedText = useTypeahead();
|
|
61
|
+
const scrolledTo = useRef(focused);
|
|
62
|
+
const notifySelected = useEffectEvent((id) => {
|
|
63
|
+
onSelectedChange?.(id);
|
|
64
|
+
});
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (focused !== undefined &&
|
|
67
|
+
focused !== controlledSelected &&
|
|
68
|
+
focused !== reported.current) {
|
|
69
|
+
reported.current = focused;
|
|
70
|
+
notifySelected(focused);
|
|
71
|
+
}
|
|
72
|
+
}, [focused, controlledSelected]);
|
|
73
|
+
useImperativeHandle(ref, () => element.current);
|
|
74
|
+
useLayoutEffect(() => {
|
|
75
|
+
const container = element.current;
|
|
76
|
+
if (!container) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
for (const child of container.children) {
|
|
80
|
+
if (!(child instanceof HTMLElement)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const gapRows = child.dataset.gapRows;
|
|
84
|
+
if (gapRows === undefined) {
|
|
85
|
+
child.style.setProperty("--tree-level", child.getAttribute("aria-level") ?? "1");
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
child.style.setProperty("--tree-gap-rows", gapRows);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const lostFocus = hasFocusWithin.current && !container.contains(document.activeElement);
|
|
92
|
+
if (focusAfterRender.current || lostFocus) {
|
|
93
|
+
focusAfterRender.current = false;
|
|
94
|
+
const target = container.querySelector(TAB_STOP);
|
|
95
|
+
if (target instanceof HTMLElement) {
|
|
96
|
+
target.focus();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (scrolledTo.current !== focused) {
|
|
100
|
+
scrolledTo.current = focused;
|
|
101
|
+
container
|
|
102
|
+
.querySelector(TAB_STOP)
|
|
103
|
+
?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
function select(id) {
|
|
107
|
+
if (id === focused) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
reported.current = id;
|
|
111
|
+
if (controlledSelected === undefined) {
|
|
112
|
+
setOwnSelected(id);
|
|
113
|
+
}
|
|
114
|
+
onSelectedChange?.(id);
|
|
115
|
+
}
|
|
116
|
+
function dispatch(action) {
|
|
117
|
+
const current = { expanded, focused: rows[tabStop]?.id };
|
|
118
|
+
const next = reduceTree(rows, current, action, getRowText);
|
|
119
|
+
if (next.expanded !== expanded) {
|
|
120
|
+
if (controlledExpanded === undefined) {
|
|
121
|
+
setOwnExpanded(next.expanded);
|
|
122
|
+
}
|
|
123
|
+
onExpandedChange?.(next.expanded);
|
|
124
|
+
}
|
|
125
|
+
if (next.focused !== undefined && next.focused !== focused) {
|
|
126
|
+
focusAfterRender.current = true;
|
|
127
|
+
select(next.focused);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function handleKeyDown(event) {
|
|
131
|
+
onKeyDown?.(event);
|
|
132
|
+
if (event.defaultPrevented ||
|
|
133
|
+
event.ctrlKey ||
|
|
134
|
+
event.metaKey ||
|
|
135
|
+
event.altKey ||
|
|
136
|
+
event.target !== event.currentTarget) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const action = keyToAction(event.key);
|
|
140
|
+
if (action) {
|
|
141
|
+
event.preventDefault();
|
|
142
|
+
dispatch(withTypedText(action, event.timeStamp));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function renderTreeRow(row, index) {
|
|
146
|
+
if (!row) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return (_jsxs("div", { role: "treeitem", "aria-level": row.level, "aria-setsize": row.setSize, "aria-posinset": row.positionInSet, "aria-expanded": row.expandable ? row.expanded : undefined, "aria-selected": row.id === focused, tabIndex: index === tabStop ? 0 : -1, className: styles.row, style: { "--tree-level": row.level }, onClick: (event) => {
|
|
150
|
+
if (!isInsideControl(event.target, event.currentTarget)) {
|
|
151
|
+
dispatch({ type: "toggle", id: row.id });
|
|
152
|
+
}
|
|
153
|
+
}, onFocus: () => select(row.id), onKeyDown: handleKeyDown, children: [_jsx("span", { className: styles.toggle, "aria-hidden": "true" }), _jsx("span", { children: renderRow(row) })] }, row.id));
|
|
154
|
+
}
|
|
155
|
+
return (_jsx("div", { ...props, ref: element, role: "tree", "aria-label": label, tabIndex: -1, className: clsx(styles.tree, className), onFocus: (event) => {
|
|
156
|
+
hasFocusWithin.current = true;
|
|
157
|
+
onFocus?.(event);
|
|
158
|
+
}, onBlur: (event) => {
|
|
159
|
+
if (!event.currentTarget.contains(event.relatedTarget)) {
|
|
160
|
+
hasFocusWithin.current = false;
|
|
161
|
+
}
|
|
162
|
+
onBlur?.(event);
|
|
163
|
+
}, children: rowWindow
|
|
164
|
+
? getWindowItems(rowWindow, rows.length, tabStop).map((item) => item.type === "gap" ? (_jsx("div", { role: "none", className: styles.gap, "data-gap-rows": item.rowCount, style: { "--tree-gap-rows": item.rowCount } }, `gap-${item.id}`)) : (renderTreeRow(rows[item.index], item.index)))
|
|
165
|
+
: rows.map(renderTreeRow) }));
|
|
166
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
.tree {
|
|
2
|
+
overflow-x: auto;
|
|
3
|
+
color: var(--dusty-text);
|
|
4
|
+
font-family: var(--dusty-font-mono);
|
|
5
|
+
font-size: 0.8125rem;
|
|
6
|
+
line-height: 1.6;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.row {
|
|
10
|
+
display: flex;
|
|
11
|
+
align-items: baseline;
|
|
12
|
+
inline-size: max-content;
|
|
13
|
+
min-inline-size: 100%;
|
|
14
|
+
padding-inline: calc((var(--tree-level, 1) - 1) * var(--tree-indent, 1rem))
|
|
15
|
+
0.5rem;
|
|
16
|
+
block-size: var(--tree-row-height, 1.3rem);
|
|
17
|
+
border-radius: var(--dusty-radius-sm);
|
|
18
|
+
white-space: nowrap;
|
|
19
|
+
cursor: default;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.gap {
|
|
23
|
+
block-size: calc(var(--tree-gap-rows, 0) * var(--tree-row-height, 1.3rem));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.row[aria-expanded] {
|
|
27
|
+
cursor: pointer;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.row[aria-selected="true"] {
|
|
31
|
+
background-color: var(--dusty-surface-selected);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.row:focus-visible {
|
|
35
|
+
outline: var(--dusty-focus-ring-width) solid var(--dusty-focus-ring);
|
|
36
|
+
outline-offset: calc(-1 * var(--dusty-focus-ring-width));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.toggle {
|
|
40
|
+
flex: none;
|
|
41
|
+
inline-size: 1rem;
|
|
42
|
+
color: var(--dusty-text-muted);
|
|
43
|
+
text-align: center;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.row[aria-expanded] > .toggle::before {
|
|
47
|
+
display: inline-block;
|
|
48
|
+
content: "▸";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.row[aria-expanded="true"] > .toggle::before {
|
|
52
|
+
rotate: 90deg;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@media (hover: hover) {
|
|
56
|
+
.row:hover:not([aria-selected="true"]) {
|
|
57
|
+
background-color: var(--dusty-surface-hover);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
62
|
+
.row[aria-expanded] > .toggle::before {
|
|
63
|
+
transition: rotate 120ms ease-out;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface RowWindow {
|
|
2
|
+
readonly start: number;
|
|
3
|
+
readonly end: number;
|
|
4
|
+
}
|
|
5
|
+
export interface WindowInput {
|
|
6
|
+
readonly rowCount: number;
|
|
7
|
+
readonly rowHeight: number;
|
|
8
|
+
readonly from: number;
|
|
9
|
+
readonly to: number;
|
|
10
|
+
readonly overscan: number;
|
|
11
|
+
readonly limit: number;
|
|
12
|
+
}
|
|
13
|
+
export type WindowItem = {
|
|
14
|
+
readonly type: "gap";
|
|
15
|
+
readonly id: "start" | "middle" | "end";
|
|
16
|
+
readonly rowCount: number;
|
|
17
|
+
} | {
|
|
18
|
+
readonly type: "row";
|
|
19
|
+
readonly index: number;
|
|
20
|
+
};
|
|
21
|
+
export declare function getWindow({ rowCount, rowHeight, from, to, overscan, limit, }: WindowInput): RowWindow;
|
|
22
|
+
export declare function getWindowItems(rowWindow: RowWindow, rowCount: number, pinned: number): WindowItem[];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
function clamp(value, minimum, maximum) {
|
|
2
|
+
if (Number.isNaN(value)) {
|
|
3
|
+
return minimum;
|
|
4
|
+
}
|
|
5
|
+
return Math.min(Math.max(value, minimum), maximum);
|
|
6
|
+
}
|
|
7
|
+
export function getWindow({ rowCount, rowHeight, from, to, overscan, limit, }) {
|
|
8
|
+
if (!(rowCount > 0) || !(rowHeight > 0)) {
|
|
9
|
+
return { start: 0, end: 0 };
|
|
10
|
+
}
|
|
11
|
+
const start = clamp(Math.floor(from / rowHeight) - overscan, 0, rowCount);
|
|
12
|
+
const end = clamp(Math.ceil(to / rowHeight) + overscan, start, Math.min(rowCount, start + Math.max(limit, 0)));
|
|
13
|
+
return { start, end };
|
|
14
|
+
}
|
|
15
|
+
export function getWindowItems(rowWindow, rowCount, pinned) {
|
|
16
|
+
const start = clamp(rowWindow.start, 0, rowCount);
|
|
17
|
+
const end = clamp(rowWindow.end, start, rowCount);
|
|
18
|
+
const isPinnedBefore = pinned >= 0 && pinned < start;
|
|
19
|
+
const isPinnedAfter = pinned >= end && pinned < rowCount;
|
|
20
|
+
const items = [];
|
|
21
|
+
function addGap(id, gapRowCount) {
|
|
22
|
+
if (gapRowCount > 0 || id === "start") {
|
|
23
|
+
items.push({ type: "gap", id, rowCount: gapRowCount });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
addGap("start", isPinnedBefore ? pinned : start);
|
|
27
|
+
if (isPinnedBefore) {
|
|
28
|
+
items.push({ type: "row", index: pinned });
|
|
29
|
+
addGap("middle", start - pinned - 1);
|
|
30
|
+
}
|
|
31
|
+
for (let index = start; index < end; index++) {
|
|
32
|
+
items.push({ type: "row", index });
|
|
33
|
+
}
|
|
34
|
+
if (isPinnedAfter) {
|
|
35
|
+
addGap("middle", pinned - end);
|
|
36
|
+
items.push({ type: "row", index: pinned });
|
|
37
|
+
addGap("end", rowCount - pinned - 1);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
addGap("end", rowCount - end);
|
|
41
|
+
}
|
|
42
|
+
return items;
|
|
43
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface TreeNode {
|
|
2
|
+
readonly id: string;
|
|
3
|
+
readonly childCount: number;
|
|
4
|
+
children(): readonly this[];
|
|
5
|
+
}
|
|
6
|
+
export interface TreeRow<Node extends TreeNode = TreeNode> {
|
|
7
|
+
readonly node: Node;
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly parentId: string | undefined;
|
|
10
|
+
readonly level: number;
|
|
11
|
+
readonly positionInSet: number;
|
|
12
|
+
readonly setSize: number;
|
|
13
|
+
readonly expandable: boolean;
|
|
14
|
+
readonly expanded: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface TreeState {
|
|
17
|
+
readonly expanded: ReadonlySet<string>;
|
|
18
|
+
readonly focused: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
export type TreeAction = {
|
|
21
|
+
readonly type: "moveNext";
|
|
22
|
+
} | {
|
|
23
|
+
readonly type: "movePrevious";
|
|
24
|
+
} | {
|
|
25
|
+
readonly type: "moveFirst";
|
|
26
|
+
} | {
|
|
27
|
+
readonly type: "moveLast";
|
|
28
|
+
} | {
|
|
29
|
+
readonly type: "moveIn";
|
|
30
|
+
} | {
|
|
31
|
+
readonly type: "moveOut";
|
|
32
|
+
} | {
|
|
33
|
+
readonly type: "toggle";
|
|
34
|
+
readonly id?: string;
|
|
35
|
+
} | {
|
|
36
|
+
readonly type: "expand";
|
|
37
|
+
readonly id: string;
|
|
38
|
+
} | {
|
|
39
|
+
readonly type: "collapse";
|
|
40
|
+
readonly id: string;
|
|
41
|
+
} | {
|
|
42
|
+
readonly type: "expandSiblings";
|
|
43
|
+
} | {
|
|
44
|
+
readonly type: "focus";
|
|
45
|
+
readonly id: string;
|
|
46
|
+
} | {
|
|
47
|
+
readonly type: "typeahead";
|
|
48
|
+
readonly text: string;
|
|
49
|
+
};
|
|
50
|
+
export declare const INITIAL_TREE_STATE: TreeState;
|
|
51
|
+
export declare function visibleRows<Node extends TreeNode>(root: Node, expanded: ReadonlySet<string>): TreeRow<Node>[];
|
|
52
|
+
export declare function reduceTree<Node extends TreeNode>(rows: readonly TreeRow<Node>[], state: TreeState, action: TreeAction, labelOf?: (row: TreeRow<Node>) => string): TreeState;
|
|
53
|
+
export declare function findVisibleAncestor(previousRows: readonly TreeRow[], id: string, rows: readonly TreeRow[]): string | undefined;
|
|
54
|
+
export declare function getTypeaheadText(typed: string, key: string, isContinuing: boolean): string;
|
|
55
|
+
export declare function keyToAction(key: string): TreeAction | undefined;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
export const INITIAL_TREE_STATE = {
|
|
2
|
+
expanded: new Set(),
|
|
3
|
+
focused: undefined,
|
|
4
|
+
};
|
|
5
|
+
export function visibleRows(root, expanded) {
|
|
6
|
+
const rows = [];
|
|
7
|
+
const pending = [toRow(root, undefined, 1, 1, 1, expanded)];
|
|
8
|
+
for (let row = pending.pop(); row; row = pending.pop()) {
|
|
9
|
+
rows.push(row);
|
|
10
|
+
if (!row.expanded) {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const children = row.node.children();
|
|
14
|
+
for (let index = children.length - 1; index >= 0; index--) {
|
|
15
|
+
const child = children[index];
|
|
16
|
+
if (child) {
|
|
17
|
+
pending.push(toRow(child, row.id, row.level + 1, index + 1, children.length, expanded));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return rows;
|
|
22
|
+
}
|
|
23
|
+
function toRow(node, parentId, level, positionInSet, setSize, expanded) {
|
|
24
|
+
return {
|
|
25
|
+
node,
|
|
26
|
+
id: node.id,
|
|
27
|
+
parentId,
|
|
28
|
+
level,
|
|
29
|
+
positionInSet,
|
|
30
|
+
setSize,
|
|
31
|
+
get expandable() {
|
|
32
|
+
return node.childCount > 0;
|
|
33
|
+
},
|
|
34
|
+
expanded: expanded.has(node.id) && node.childCount > 0,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function withExpanded(state, change) {
|
|
38
|
+
const next = new Set(state.expanded);
|
|
39
|
+
change(next);
|
|
40
|
+
return next;
|
|
41
|
+
}
|
|
42
|
+
function isInside(rows, index, ancestorId) {
|
|
43
|
+
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
44
|
+
for (let current = rows[index]; current?.parentId !== undefined; current = byId.get(current.parentId)) {
|
|
45
|
+
if (current.parentId === ancestorId) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
function focusedIndex(rows, state) {
|
|
52
|
+
return state.focused === undefined
|
|
53
|
+
? -1
|
|
54
|
+
: rows.findIndex((row) => row.id === state.focused);
|
|
55
|
+
}
|
|
56
|
+
function focusRow(state, rows, index) {
|
|
57
|
+
const row = rows[Math.min(Math.max(index, 0), rows.length - 1)];
|
|
58
|
+
return row && row.id !== state.focused
|
|
59
|
+
? { ...state, focused: row.id }
|
|
60
|
+
: state;
|
|
61
|
+
}
|
|
62
|
+
function expand(state, row) {
|
|
63
|
+
return row?.expandable && !row.expanded
|
|
64
|
+
? {
|
|
65
|
+
...state,
|
|
66
|
+
expanded: withExpanded(state, (expanded) => expanded.add(row.id)),
|
|
67
|
+
}
|
|
68
|
+
: state;
|
|
69
|
+
}
|
|
70
|
+
function collapse(state, rows, row) {
|
|
71
|
+
if (!row?.expanded) {
|
|
72
|
+
return state;
|
|
73
|
+
}
|
|
74
|
+
const index = focusedIndex(rows, state);
|
|
75
|
+
return {
|
|
76
|
+
expanded: withExpanded(state, (expanded) => expanded.delete(row.id)),
|
|
77
|
+
focused: index >= 0 && isInside(rows, index, row.id) ? row.id : state.focused,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function expandSiblings(state, rows, current) {
|
|
81
|
+
const closed = rows.filter((row) => row.parentId === current.parentId && row.expandable && !row.expanded);
|
|
82
|
+
if (closed.length === 0) {
|
|
83
|
+
return state;
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
...state,
|
|
87
|
+
expanded: withExpanded(state, (expanded) => {
|
|
88
|
+
for (const row of closed) {
|
|
89
|
+
expanded.add(row.id);
|
|
90
|
+
}
|
|
91
|
+
}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function typeahead(state, rows, index, text, labelOf) {
|
|
95
|
+
const query = text.toLocaleLowerCase();
|
|
96
|
+
if (query === "") {
|
|
97
|
+
return state;
|
|
98
|
+
}
|
|
99
|
+
const start = query.length === 1 ? index + 1 : Math.max(index, 0);
|
|
100
|
+
for (let offset = 0; offset < rows.length; offset++) {
|
|
101
|
+
const candidate = (start + offset) % rows.length;
|
|
102
|
+
const row = rows[candidate];
|
|
103
|
+
if (row && labelOf(row).toLocaleLowerCase().startsWith(query)) {
|
|
104
|
+
return focusRow(state, rows, candidate);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return state;
|
|
108
|
+
}
|
|
109
|
+
export function reduceTree(rows, state, action, labelOf = () => "") {
|
|
110
|
+
if (rows.length === 0) {
|
|
111
|
+
return state;
|
|
112
|
+
}
|
|
113
|
+
const index = focusedIndex(rows, state);
|
|
114
|
+
const current = rows[index];
|
|
115
|
+
const byId = (id) => rows.find((row) => row.id === id);
|
|
116
|
+
switch (action.type) {
|
|
117
|
+
case "moveNext":
|
|
118
|
+
return focusRow(state, rows, index + 1);
|
|
119
|
+
case "movePrevious":
|
|
120
|
+
return focusRow(state, rows, index < 0 ? 0 : index - 1);
|
|
121
|
+
case "moveFirst":
|
|
122
|
+
return focusRow(state, rows, 0);
|
|
123
|
+
case "moveLast":
|
|
124
|
+
return focusRow(state, rows, rows.length - 1);
|
|
125
|
+
case "moveIn":
|
|
126
|
+
if (!current) {
|
|
127
|
+
return focusRow(state, rows, 0);
|
|
128
|
+
}
|
|
129
|
+
return current.expanded
|
|
130
|
+
? focusRow(state, rows, index + 1)
|
|
131
|
+
: expand(state, current);
|
|
132
|
+
case "moveOut": {
|
|
133
|
+
if (!current) {
|
|
134
|
+
return focusRow(state, rows, 0);
|
|
135
|
+
}
|
|
136
|
+
if (current.expanded) {
|
|
137
|
+
return collapse(state, rows, current);
|
|
138
|
+
}
|
|
139
|
+
const parent = rows.findIndex((row) => row.id === current.parentId);
|
|
140
|
+
return parent >= 0 ? focusRow(state, rows, parent) : state;
|
|
141
|
+
}
|
|
142
|
+
case "toggle": {
|
|
143
|
+
const row = action.id === undefined ? current : byId(action.id);
|
|
144
|
+
return row?.expanded ? collapse(state, rows, row) : expand(state, row);
|
|
145
|
+
}
|
|
146
|
+
case "expand":
|
|
147
|
+
return expand(state, byId(action.id));
|
|
148
|
+
case "collapse":
|
|
149
|
+
return collapse(state, rows, byId(action.id));
|
|
150
|
+
case "expandSiblings":
|
|
151
|
+
return current ? expandSiblings(state, rows, current) : state;
|
|
152
|
+
case "focus":
|
|
153
|
+
return byId(action.id) ? { ...state, focused: action.id } : state;
|
|
154
|
+
case "typeahead":
|
|
155
|
+
return typeahead(state, rows, index, action.text, labelOf);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export function findVisibleAncestor(previousRows, id, rows) {
|
|
159
|
+
const visible = new Set(rows.map((row) => row.id));
|
|
160
|
+
const previous = new Map(previousRows.map((row) => [row.id, row]));
|
|
161
|
+
for (let current = previous.get(id); current; current =
|
|
162
|
+
current.parentId === undefined
|
|
163
|
+
? undefined
|
|
164
|
+
: previous.get(current.parentId)) {
|
|
165
|
+
if (visible.has(current.id)) {
|
|
166
|
+
return current.id;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
export function getTypeaheadText(typed, key, isContinuing) {
|
|
172
|
+
if (!isContinuing) {
|
|
173
|
+
return key;
|
|
174
|
+
}
|
|
175
|
+
const letter = key.toLocaleLowerCase();
|
|
176
|
+
const isRepeat = Array.from(typed.toLocaleLowerCase()).every((character) => character === letter);
|
|
177
|
+
return isRepeat ? key : typed + key;
|
|
178
|
+
}
|
|
179
|
+
export function keyToAction(key) {
|
|
180
|
+
switch (key) {
|
|
181
|
+
case "ArrowDown":
|
|
182
|
+
return { type: "moveNext" };
|
|
183
|
+
case "ArrowUp":
|
|
184
|
+
return { type: "movePrevious" };
|
|
185
|
+
case "ArrowRight":
|
|
186
|
+
return { type: "moveIn" };
|
|
187
|
+
case "ArrowLeft":
|
|
188
|
+
return { type: "moveOut" };
|
|
189
|
+
case "Home":
|
|
190
|
+
return { type: "moveFirst" };
|
|
191
|
+
case "End":
|
|
192
|
+
return { type: "moveLast" };
|
|
193
|
+
case "Enter":
|
|
194
|
+
case " ":
|
|
195
|
+
return { type: "toggle" };
|
|
196
|
+
case "*":
|
|
197
|
+
return { type: "expandSiblings" };
|
|
198
|
+
default:
|
|
199
|
+
return Array.from(key).length === 1
|
|
200
|
+
? { type: "typeahead", text: key }
|
|
201
|
+
: undefined;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useLayoutEffect, useState } from "react";
|
|
2
|
+
import { getWindow } from "./getWindow.js";
|
|
3
|
+
const WINDOW_THRESHOLD = 200;
|
|
4
|
+
const WINDOW_OVERSCAN = 10;
|
|
5
|
+
const WINDOW_LIMIT = 500;
|
|
6
|
+
const INITIAL_WINDOW = { start: 0, end: 50 };
|
|
7
|
+
const ESTIMATED_ROW_HEIGHT = 21;
|
|
8
|
+
const TREE_ITEM = ':scope > [role="treeitem"]';
|
|
9
|
+
const GAP = ":scope > [data-gap-rows]";
|
|
10
|
+
function getRowHeight(tree) {
|
|
11
|
+
let gapHeight = 0;
|
|
12
|
+
let gapRows = 0;
|
|
13
|
+
for (const gap of tree.querySelectorAll(GAP)) {
|
|
14
|
+
gapHeight += gap.getBoundingClientRect().height;
|
|
15
|
+
gapRows += Number(gap.dataset.gapRows);
|
|
16
|
+
}
|
|
17
|
+
if (gapRows > 0 && gapHeight > 0) {
|
|
18
|
+
return gapHeight / gapRows;
|
|
19
|
+
}
|
|
20
|
+
return (tree.querySelector(TREE_ITEM)?.getBoundingClientRect().height ||
|
|
21
|
+
ESTIMATED_ROW_HEIGHT);
|
|
22
|
+
}
|
|
23
|
+
function getVisibleSpan(tree) {
|
|
24
|
+
const bounds = tree.getBoundingClientRect();
|
|
25
|
+
let top = Math.max(bounds.top, 0);
|
|
26
|
+
let bottom = Math.min(bounds.bottom, window.innerHeight);
|
|
27
|
+
for (let ancestor = tree.parentElement; ancestor; ancestor = ancestor.parentElement) {
|
|
28
|
+
if (getComputedStyle(ancestor).overflowY !== "visible") {
|
|
29
|
+
const clip = ancestor.getBoundingClientRect();
|
|
30
|
+
top = Math.max(top, clip.top);
|
|
31
|
+
bottom = Math.min(bottom, clip.bottom);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { top, bottom };
|
|
35
|
+
}
|
|
36
|
+
export function useRowWindow(element, rowCount) {
|
|
37
|
+
const [rowWindow, setRowWindow] = useState(INITIAL_WINDOW);
|
|
38
|
+
const isWindowed = rowCount > WINDOW_THRESHOLD;
|
|
39
|
+
useLayoutEffect(() => {
|
|
40
|
+
const tree = element.current;
|
|
41
|
+
if (!isWindowed || !tree) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const container = tree;
|
|
45
|
+
function update() {
|
|
46
|
+
const origin = container.firstElementChild;
|
|
47
|
+
if (!origin) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const start = origin.getBoundingClientRect().top;
|
|
51
|
+
const { top, bottom } = getVisibleSpan(container);
|
|
52
|
+
const next = getWindow({
|
|
53
|
+
rowCount,
|
|
54
|
+
rowHeight: getRowHeight(container),
|
|
55
|
+
from: top - start,
|
|
56
|
+
to: bottom - start,
|
|
57
|
+
overscan: WINDOW_OVERSCAN,
|
|
58
|
+
limit: WINDOW_LIMIT,
|
|
59
|
+
});
|
|
60
|
+
setRowWindow((current) => current.start === next.start && current.end === next.end
|
|
61
|
+
? current
|
|
62
|
+
: next);
|
|
63
|
+
}
|
|
64
|
+
const observer = new ResizeObserver(update);
|
|
65
|
+
observer.observe(container);
|
|
66
|
+
document.addEventListener("scroll", update, {
|
|
67
|
+
capture: true,
|
|
68
|
+
passive: true,
|
|
69
|
+
});
|
|
70
|
+
window.addEventListener("resize", update);
|
|
71
|
+
return () => {
|
|
72
|
+
observer.disconnect();
|
|
73
|
+
document.removeEventListener("scroll", update, { capture: true });
|
|
74
|
+
window.removeEventListener("resize", update);
|
|
75
|
+
};
|
|
76
|
+
}, [element, isWindowed, rowCount]);
|
|
77
|
+
return isWindowed ? rowWindow : undefined;
|
|
78
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
import { getTypeaheadText } from "./treeModel.js";
|
|
3
|
+
const TYPEAHEAD_PAUSE = 500;
|
|
4
|
+
export function useTypeahead() {
|
|
5
|
+
const typed = useRef({ text: "", at: 0 });
|
|
6
|
+
return (action, timeStamp) => {
|
|
7
|
+
if (action.type !== "typeahead") {
|
|
8
|
+
return action;
|
|
9
|
+
}
|
|
10
|
+
const isContinuing = timeStamp - typed.current.at < TYPEAHEAD_PAUSE;
|
|
11
|
+
const text = getTypeaheadText(typed.current.text, action.text, isContinuing);
|
|
12
|
+
typed.current = { text, at: timeStamp };
|
|
13
|
+
return { type: "typeahead", text };
|
|
14
|
+
};
|
|
15
|
+
}
|