@joewinke/jatui 0.40.0 → 0.41.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/CLAUDE.md +29 -0
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/dist/components/AnimatedNumber.svelte +42 -1
- package/dist/components/AnimatedNumber.svelte.d.ts +7 -0
- package/dist/components/SegmentedControl.svelte +263 -0
- package/dist/components/SegmentedControl.svelte.d.ts +48 -0
- package/dist/components/settings/SettingsRow.svelte +177 -0
- package/dist/components/settings/SettingsRow.svelte.d.ts +32 -0
- package/dist/components/settings/SettingsSection.svelte +139 -0
- package/dist/components/settings/SettingsSection.svelte.d.ts +21 -0
- package/dist/components/stats/ActivityHeatmap.svelte +412 -0
- package/dist/components/stats/ActivityHeatmap.svelte.d.ts +72 -0
- package/dist/components/stats/BarList.svelte +262 -0
- package/dist/components/stats/BarList.svelte.d.ts +57 -0
- package/dist/components/stats/ChartCard.svelte +230 -0
- package/dist/components/stats/ChartCard.svelte.d.ts +63 -0
- package/dist/components/stats/StatCard.svelte +387 -0
- package/dist/components/stats/StatCard.svelte.d.ts +37 -0
- package/dist/components/stats/StatGroup.svelte +67 -0
- package/dist/components/stats/StatGroup.svelte.d.ts +14 -0
- package/dist/components/stats/heatmap.d.ts +76 -0
- package/dist/components/stats/heatmap.js +151 -0
- package/dist/components/tree/TreeView.svelte +680 -0
- package/dist/components/tree/TreeView.svelte.d.ts +123 -0
- package/dist/components/tree/flattenTree.d.ts +57 -0
- package/dist/components/tree/flattenTree.js +91 -0
- package/dist/components/tree/treeKeys.d.ts +46 -0
- package/dist/components/tree/treeKeys.js +80 -0
- package/dist/data/grid/cells.d.ts +40 -0
- package/dist/data/grid/cells.js +89 -0
- package/dist/data/grid/clipboard.d.ts +46 -0
- package/dist/data/grid/clipboard.js +131 -0
- package/dist/data/grid/edits.d.ts +148 -0
- package/dist/data/grid/edits.js +222 -0
- package/dist/data/grid/index.d.ts +12 -0
- package/dist/data/grid/index.js +12 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +21 -0
- package/package.json +5 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export interface TreeRowContext {
|
|
2
|
+
id: string;
|
|
3
|
+
level: number;
|
|
4
|
+
expanded: boolean;
|
|
5
|
+
hasChildren: boolean;
|
|
6
|
+
selected: boolean;
|
|
7
|
+
focused: boolean;
|
|
8
|
+
disabled: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** Pass `createRowWindow` from `jatui/virtual`; TreeView never imports it itself. */
|
|
11
|
+
export interface TreeViewVirtualOptions {
|
|
12
|
+
createRowWindow: (opts: {
|
|
13
|
+
count: () => number;
|
|
14
|
+
getKey: (index: number) => string;
|
|
15
|
+
estimateSize: (index: number) => number;
|
|
16
|
+
overscan?: number;
|
|
17
|
+
threshold?: number;
|
|
18
|
+
scrollRoot?: HTMLElement | Window | 'auto';
|
|
19
|
+
}) => {
|
|
20
|
+
readonly items: ReadonlyArray<{
|
|
21
|
+
index: number;
|
|
22
|
+
key: string;
|
|
23
|
+
gapBefore: number;
|
|
24
|
+
}>;
|
|
25
|
+
readonly paddingTop: number;
|
|
26
|
+
readonly paddingBottom: number;
|
|
27
|
+
readonly windowed: boolean;
|
|
28
|
+
measure: (node: HTMLElement, key: string) => {
|
|
29
|
+
update?(key: string): void;
|
|
30
|
+
destroy(): void;
|
|
31
|
+
};
|
|
32
|
+
anchor: (node: HTMLElement) => {
|
|
33
|
+
destroy(): void;
|
|
34
|
+
};
|
|
35
|
+
scrollToKey(key: string, opts?: {
|
|
36
|
+
align?: 'auto' | 'start' | 'center' | 'end';
|
|
37
|
+
}): void;
|
|
38
|
+
};
|
|
39
|
+
/** Row height estimate in px (default: the size's row height). */
|
|
40
|
+
estimateRowSize?: number;
|
|
41
|
+
overscan?: number;
|
|
42
|
+
/** Render everything below this many visible rows (default 0 = always window). */
|
|
43
|
+
threshold?: number;
|
|
44
|
+
scrollRoot?: HTMLElement | Window | 'auto';
|
|
45
|
+
}
|
|
46
|
+
import { type Snippet } from 'svelte';
|
|
47
|
+
declare function $$render<T>(): {
|
|
48
|
+
props: {
|
|
49
|
+
nodes: readonly T[];
|
|
50
|
+
/** Default: `node.id`. */
|
|
51
|
+
getId?: (node: T) => string;
|
|
52
|
+
/** Default: `node.children`. */
|
|
53
|
+
getChildren?: (node: T) => readonly T[] | null | undefined;
|
|
54
|
+
/** Text for the row and typeahead. Default: `node.label ?? node.name ?? id`. */
|
|
55
|
+
getLabel?: (node: T) => string;
|
|
56
|
+
/** Branch override for lazily-loaded folders (show a chevron before children arrive). */
|
|
57
|
+
hasChildren?: (node: T) => boolean;
|
|
58
|
+
isDisabled?: (node: T) => boolean;
|
|
59
|
+
/** Open branch ids. Replaced (never mutated) on change. */
|
|
60
|
+
expanded?: Set<string>;
|
|
61
|
+
/** Selected ids. Replaced (never mutated) on change. */
|
|
62
|
+
selected?: Set<string>;
|
|
63
|
+
selectionMode?: "none" | "single" | "multiple";
|
|
64
|
+
size?: "sm" | "md";
|
|
65
|
+
showGuides?: boolean;
|
|
66
|
+
/** Default folder/file icons. False hides them (an `icon` snippet still wins). */
|
|
67
|
+
icons?: boolean;
|
|
68
|
+
/** Clicking a branch row (not just its chevron) toggles it. */
|
|
69
|
+
expandOnClick?: boolean;
|
|
70
|
+
/** Expand/collapse animation. Always off under reduced motion and while windowed. */
|
|
71
|
+
animate?: boolean;
|
|
72
|
+
/** CSS max-height; makes the tree its own scroller. */
|
|
73
|
+
maxHeight?: string;
|
|
74
|
+
ariaLabel?: string;
|
|
75
|
+
emptyText?: string;
|
|
76
|
+
virtual?: TreeViewVirtualOptions;
|
|
77
|
+
label?: Snippet<[T, TreeRowContext]>;
|
|
78
|
+
icon?: Snippet<[T, TreeRowContext]>;
|
|
79
|
+
trailing?: Snippet<[T, TreeRowContext]>;
|
|
80
|
+
empty?: Snippet;
|
|
81
|
+
onExpandedChange?: (expanded: Set<string>) => void;
|
|
82
|
+
onSelectionChange?: (selected: Set<string>) => void;
|
|
83
|
+
/** A branch just opened: load its children here if they are lazy. */
|
|
84
|
+
onExpand?: (node: T) => void;
|
|
85
|
+
/** Enter or double-click. */
|
|
86
|
+
onAction?: (node: T) => void;
|
|
87
|
+
onRowContextMenu?: (node: T, event: MouseEvent) => void;
|
|
88
|
+
class?: string;
|
|
89
|
+
};
|
|
90
|
+
exports: {
|
|
91
|
+
expandAll: () => void;
|
|
92
|
+
collapseAll: () => void;
|
|
93
|
+
reveal: (id: string) => Promise<void>;
|
|
94
|
+
focus: (id?: string) => void;
|
|
95
|
+
visibleIds: () => string[];
|
|
96
|
+
};
|
|
97
|
+
bindings: "expanded" | "selected";
|
|
98
|
+
slots: {};
|
|
99
|
+
events: {};
|
|
100
|
+
};
|
|
101
|
+
declare class __sveltets_Render<T> {
|
|
102
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
103
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
104
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
105
|
+
bindings(): "expanded" | "selected";
|
|
106
|
+
exports(): {
|
|
107
|
+
expandAll: () => void;
|
|
108
|
+
collapseAll: () => void;
|
|
109
|
+
reveal: (id: string) => Promise<void>;
|
|
110
|
+
focus: (id?: string) => void;
|
|
111
|
+
visibleIds: () => string[];
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
interface $$IsomorphicComponent {
|
|
115
|
+
new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
116
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
117
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
118
|
+
<T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
119
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
120
|
+
}
|
|
121
|
+
declare const TreeView: $$IsomorphicComponent;
|
|
122
|
+
type TreeView<T> = InstanceType<typeof TreeView<T>>;
|
|
123
|
+
export default TreeView;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* flattenTree: nested data → the flat, visible row list a TreeView renders
|
|
3
|
+
* (BoardUI wave 3, jatui-6kitr).
|
|
4
|
+
*
|
|
5
|
+
* A tree is rendered as ONE flat list of `role="treeitem"` rows carrying
|
|
6
|
+
* aria-level / aria-posinset / aria-setsize, not nested `<ul>`s. That keeps the
|
|
7
|
+
* row list windowable (`jatui/virtual`) and makes the keyboard model a walk
|
|
8
|
+
* over an array. Collapsed branches contribute their own row only.
|
|
9
|
+
*
|
|
10
|
+
* Connector guides are computed here in the same O(n) pass. Column c (0-based)
|
|
11
|
+
* is the trunk of the ancestor at level c+1, centred under its chevron. A row
|
|
12
|
+
* at level L may draw:
|
|
13
|
+
* - `guides[c]` for c = 0..L-3: a full-height trunk, drawn when the row's
|
|
14
|
+
* ancestor at level c+2 still has a LATER sibling (that trunk keeps running
|
|
15
|
+
* past this row);
|
|
16
|
+
* - an elbow in column L-2 (its parent's trunk turning into the row), with a
|
|
17
|
+
* tail to the bottom unless `isLastSibling`. Roots have no elbow;
|
|
18
|
+
* - a bridge in column L-1, below its own chevron, when it is an expanded
|
|
19
|
+
* branch with children showing.
|
|
20
|
+
*
|
|
21
|
+
* Pure, DOM-free and unit-tested (tests/unit/flattenTree.test.ts).
|
|
22
|
+
*/
|
|
23
|
+
export interface FlattenTreeOptions<T> {
|
|
24
|
+
/** Stable id per node. */
|
|
25
|
+
getId: (node: T) => string;
|
|
26
|
+
/** Child list, or undefined/empty for a leaf. */
|
|
27
|
+
getChildren: (node: T) => readonly T[] | null | undefined;
|
|
28
|
+
/** Which branches are open: a Set of ids or a predicate. */
|
|
29
|
+
expanded: ReadonlySet<string> | ((id: string, node: T) => boolean);
|
|
30
|
+
/**
|
|
31
|
+
* Override "is this a branch". Lets a lazily-loaded folder whose children
|
|
32
|
+
* have not arrived yet still show a chevron. Default: has ≥1 child.
|
|
33
|
+
*/
|
|
34
|
+
hasChildren?: (node: T) => boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface FlatTreeNode<T> {
|
|
37
|
+
id: string;
|
|
38
|
+
node: T;
|
|
39
|
+
/** 1-based depth (aria-level). */
|
|
40
|
+
level: number;
|
|
41
|
+
/** 1-based position among its siblings (aria-posinset). */
|
|
42
|
+
posinset: number;
|
|
43
|
+
/** Sibling count (aria-setsize). */
|
|
44
|
+
setsize: number;
|
|
45
|
+
parentId: string | null;
|
|
46
|
+
hasChildren: boolean;
|
|
47
|
+
/** Open AND a branch. */
|
|
48
|
+
expanded: boolean;
|
|
49
|
+
isLastSibling: boolean;
|
|
50
|
+
/** Length max(0, level-2): ancestor trunk columns that continue through this row. */
|
|
51
|
+
guides: boolean[];
|
|
52
|
+
}
|
|
53
|
+
export declare function flattenTree<T>(nodes: readonly T[], opts: FlattenTreeOptions<T>): FlatTreeNode<T>[];
|
|
54
|
+
/** Every branch id in the tree (expand-all). */
|
|
55
|
+
export declare function collectBranchIds<T>(nodes: readonly T[], getId: (node: T) => string, getChildren: (node: T) => readonly T[] | null | undefined): string[];
|
|
56
|
+
/** Ids from the root down to `id` (inclusive), or null when absent. Open these to reveal a node. */
|
|
57
|
+
export declare function pathToNode<T>(nodes: readonly T[], id: string, getId: (node: T) => string, getChildren: (node: T) => readonly T[] | null | undefined): string[] | null;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* flattenTree: nested data → the flat, visible row list a TreeView renders
|
|
3
|
+
* (BoardUI wave 3, jatui-6kitr).
|
|
4
|
+
*
|
|
5
|
+
* A tree is rendered as ONE flat list of `role="treeitem"` rows carrying
|
|
6
|
+
* aria-level / aria-posinset / aria-setsize, not nested `<ul>`s. That keeps the
|
|
7
|
+
* row list windowable (`jatui/virtual`) and makes the keyboard model a walk
|
|
8
|
+
* over an array. Collapsed branches contribute their own row only.
|
|
9
|
+
*
|
|
10
|
+
* Connector guides are computed here in the same O(n) pass. Column c (0-based)
|
|
11
|
+
* is the trunk of the ancestor at level c+1, centred under its chevron. A row
|
|
12
|
+
* at level L may draw:
|
|
13
|
+
* - `guides[c]` for c = 0..L-3: a full-height trunk, drawn when the row's
|
|
14
|
+
* ancestor at level c+2 still has a LATER sibling (that trunk keeps running
|
|
15
|
+
* past this row);
|
|
16
|
+
* - an elbow in column L-2 (its parent's trunk turning into the row), with a
|
|
17
|
+
* tail to the bottom unless `isLastSibling`. Roots have no elbow;
|
|
18
|
+
* - a bridge in column L-1, below its own chevron, when it is an expanded
|
|
19
|
+
* branch with children showing.
|
|
20
|
+
*
|
|
21
|
+
* Pure, DOM-free and unit-tested (tests/unit/flattenTree.test.ts).
|
|
22
|
+
*/
|
|
23
|
+
function isOpen(expanded, id, node) {
|
|
24
|
+
return typeof expanded === 'function' ? expanded(id, node) : expanded.has(id);
|
|
25
|
+
}
|
|
26
|
+
export function flattenTree(nodes, opts) {
|
|
27
|
+
const out = [];
|
|
28
|
+
const { getId, getChildren } = opts;
|
|
29
|
+
const branch = opts.hasChildren ?? ((n) => (getChildren(n)?.length ?? 0) > 0);
|
|
30
|
+
// `trail[i]` = "the ancestor at level i+2 has a later sibling".
|
|
31
|
+
const walk = (list, level, parentId, trail) => {
|
|
32
|
+
const setsize = list.length;
|
|
33
|
+
for (let i = 0; i < setsize; i++) {
|
|
34
|
+
const node = list[i];
|
|
35
|
+
const id = getId(node);
|
|
36
|
+
const kids = getChildren(node) ?? [];
|
|
37
|
+
const hasChildren = branch(node);
|
|
38
|
+
const open = hasChildren && isOpen(opts.expanded, id, node);
|
|
39
|
+
const isLastSibling = i === setsize - 1;
|
|
40
|
+
out.push({
|
|
41
|
+
id,
|
|
42
|
+
node,
|
|
43
|
+
level,
|
|
44
|
+
posinset: i + 1,
|
|
45
|
+
setsize,
|
|
46
|
+
parentId,
|
|
47
|
+
hasChildren,
|
|
48
|
+
expanded: open,
|
|
49
|
+
isLastSibling,
|
|
50
|
+
guides: trail.slice()
|
|
51
|
+
});
|
|
52
|
+
if (open && kids.length) {
|
|
53
|
+
// Children of this row: their column level-1 trunk is this row's
|
|
54
|
+
// parent trunk, alive while this row has a later sibling.
|
|
55
|
+
walk(kids, level + 1, id, level === 1 ? trail : [...trail, !isLastSibling]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
walk(nodes, 1, null, []);
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** Every branch id in the tree (expand-all). */
|
|
63
|
+
export function collectBranchIds(nodes, getId, getChildren) {
|
|
64
|
+
const ids = [];
|
|
65
|
+
const walk = (list) => {
|
|
66
|
+
for (const n of list) {
|
|
67
|
+
const kids = getChildren(n);
|
|
68
|
+
if (kids && kids.length) {
|
|
69
|
+
ids.push(getId(n));
|
|
70
|
+
walk(kids);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
walk(nodes);
|
|
75
|
+
return ids;
|
|
76
|
+
}
|
|
77
|
+
/** Ids from the root down to `id` (inclusive), or null when absent. Open these to reveal a node. */
|
|
78
|
+
export function pathToNode(nodes, id, getId, getChildren) {
|
|
79
|
+
for (const n of nodes) {
|
|
80
|
+
const nid = getId(n);
|
|
81
|
+
if (nid === id)
|
|
82
|
+
return [nid];
|
|
83
|
+
const kids = getChildren(n);
|
|
84
|
+
if (kids && kids.length) {
|
|
85
|
+
const sub = pathToNode(kids, id, getId, getChildren);
|
|
86
|
+
if (sub)
|
|
87
|
+
return [nid, ...sub];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAI-ARIA tree keyboard model over a flattened row list (jatui-6kitr).
|
|
3
|
+
*
|
|
4
|
+
* Pure: takes the visible rows + the focused id + a key and returns what
|
|
5
|
+
* should happen. TreeView applies the result. Covers the APG tree pattern:
|
|
6
|
+
*
|
|
7
|
+
* ArrowDown / ArrowUp next / previous enabled row
|
|
8
|
+
* ArrowRight closed branch → expand; open branch → first enabled
|
|
9
|
+
* child (the step react-aria skips); leaf → nothing
|
|
10
|
+
* ArrowLeft open branch → collapse; otherwise → parent
|
|
11
|
+
* Home / End first / last enabled row
|
|
12
|
+
* * expand every sibling branch of the focused row
|
|
13
|
+
* Enter action (open / activate)
|
|
14
|
+
* Space toggle selection
|
|
15
|
+
*
|
|
16
|
+
* Typeahead lives in `treeTypeahead`.
|
|
17
|
+
*/
|
|
18
|
+
import type { FlatTreeNode } from './flattenTree';
|
|
19
|
+
export type TreeKeyResult = {
|
|
20
|
+
type: 'focus';
|
|
21
|
+
id: string;
|
|
22
|
+
} | {
|
|
23
|
+
type: 'expand';
|
|
24
|
+
id: string;
|
|
25
|
+
} | {
|
|
26
|
+
type: 'collapse';
|
|
27
|
+
id: string;
|
|
28
|
+
} | {
|
|
29
|
+
type: 'expandMany';
|
|
30
|
+
ids: string[];
|
|
31
|
+
} | {
|
|
32
|
+
type: 'action';
|
|
33
|
+
id: string;
|
|
34
|
+
} | {
|
|
35
|
+
type: 'select';
|
|
36
|
+
id: string;
|
|
37
|
+
};
|
|
38
|
+
type Row = Pick<FlatTreeNode<unknown>, 'id' | 'level' | 'parentId' | 'hasChildren' | 'expanded'>;
|
|
39
|
+
export declare function resolveTreeKey(rows: readonly Row[], focusedId: string | null, key: string, isDisabled?: (id: string) => boolean): TreeKeyResult | null;
|
|
40
|
+
/**
|
|
41
|
+
* Typeahead: the next enabled row (after `fromIndex`, wrapping) whose label
|
|
42
|
+
* starts with `query`, case-insensitive. A query of one repeated character
|
|
43
|
+
* ("aaa") cycles through rows starting with that character, as in the APG.
|
|
44
|
+
*/
|
|
45
|
+
export declare function treeTypeahead(labels: readonly string[], fromIndex: number, query: string, isDisabled?: (index: number) => boolean): number;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export function resolveTreeKey(rows, focusedId, key, isDisabled = () => false) {
|
|
2
|
+
if (!rows.length)
|
|
3
|
+
return null;
|
|
4
|
+
let i = focusedId == null ? -1 : rows.findIndex((r) => r.id === focusedId);
|
|
5
|
+
const enabled = (r) => !!r && !isDisabled(r.id);
|
|
6
|
+
const scan = (from, step) => {
|
|
7
|
+
for (let j = from; j >= 0 && j < rows.length; j += step)
|
|
8
|
+
if (enabled(rows[j]))
|
|
9
|
+
return rows[j];
|
|
10
|
+
return null;
|
|
11
|
+
};
|
|
12
|
+
const focus = (r) => (r ? { type: 'focus', id: r.id } : null);
|
|
13
|
+
if (i < 0) {
|
|
14
|
+
// Nothing focused yet: any navigation key lands on the first row.
|
|
15
|
+
return ['ArrowDown', 'ArrowUp', 'Home', 'End', 'ArrowRight', 'ArrowLeft'].includes(key)
|
|
16
|
+
? focus(key === 'End' || key === 'ArrowUp' ? scan(rows.length - 1, -1) : scan(0, 1))
|
|
17
|
+
: null;
|
|
18
|
+
}
|
|
19
|
+
const row = rows[i];
|
|
20
|
+
switch (key) {
|
|
21
|
+
case 'ArrowDown':
|
|
22
|
+
return focus(scan(i + 1, 1));
|
|
23
|
+
case 'ArrowUp':
|
|
24
|
+
return focus(scan(i - 1, -1));
|
|
25
|
+
case 'Home':
|
|
26
|
+
return focus(scan(0, 1));
|
|
27
|
+
case 'End':
|
|
28
|
+
return focus(scan(rows.length - 1, -1));
|
|
29
|
+
case 'ArrowRight': {
|
|
30
|
+
if (!row.hasChildren)
|
|
31
|
+
return null;
|
|
32
|
+
if (!row.expanded)
|
|
33
|
+
return isDisabled(row.id) ? null : { type: 'expand', id: row.id };
|
|
34
|
+
// Open branch: step into the first enabled child (rows directly below at level+1).
|
|
35
|
+
for (let j = i + 1; j < rows.length && rows[j].level > row.level; j++) {
|
|
36
|
+
if (rows[j].level === row.level + 1 && enabled(rows[j]))
|
|
37
|
+
return { type: 'focus', id: rows[j].id };
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
case 'ArrowLeft': {
|
|
42
|
+
if (row.hasChildren && row.expanded && !isDisabled(row.id))
|
|
43
|
+
return { type: 'collapse', id: row.id };
|
|
44
|
+
return row.parentId != null ? { type: 'focus', id: row.parentId } : null;
|
|
45
|
+
}
|
|
46
|
+
case '*': {
|
|
47
|
+
const ids = rows
|
|
48
|
+
.filter((r) => r.parentId === row.parentId && r.hasChildren && !r.expanded && enabled(r))
|
|
49
|
+
.map((r) => r.id);
|
|
50
|
+
return ids.length ? { type: 'expandMany', ids } : null;
|
|
51
|
+
}
|
|
52
|
+
case 'Enter':
|
|
53
|
+
return enabled(row) ? { type: 'action', id: row.id } : null;
|
|
54
|
+
case ' ':
|
|
55
|
+
return enabled(row) ? { type: 'select', id: row.id } : null;
|
|
56
|
+
default:
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Typeahead: the next enabled row (after `fromIndex`, wrapping) whose label
|
|
62
|
+
* starts with `query`, case-insensitive. A query of one repeated character
|
|
63
|
+
* ("aaa") cycles through rows starting with that character, as in the APG.
|
|
64
|
+
*/
|
|
65
|
+
export function treeTypeahead(labels, fromIndex, query, isDisabled = () => false) {
|
|
66
|
+
if (!query || !labels.length)
|
|
67
|
+
return -1;
|
|
68
|
+
const q = query.toLocaleLowerCase();
|
|
69
|
+
const repeated = q.length > 1 && [...q].every((c) => c === q[0]);
|
|
70
|
+
const needle = repeated ? q[0] : q;
|
|
71
|
+
// A multi-char query keeps matching the current row; a fresh/repeated one moves on.
|
|
72
|
+
const start = needle.length > 1 ? fromIndex : fromIndex + 1;
|
|
73
|
+
const n = labels.length;
|
|
74
|
+
for (let k = 0; k < n; k++) {
|
|
75
|
+
const j = (((start + k) % n) + n) % n;
|
|
76
|
+
if (!isDisabled(j) && labels[j].toLocaleLowerCase().startsWith(needle))
|
|
77
|
+
return j;
|
|
78
|
+
}
|
|
79
|
+
return -1;
|
|
80
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cell addressing + typed value parsing (jatui/grid, jatui-6kitr).
|
|
3
|
+
*/
|
|
4
|
+
/** 0 → "A", 25 → "Z", 26 → "AA", 701 → "ZZ", 702 → "AAA". */
|
|
5
|
+
export declare function columnLetter(index: number): string;
|
|
6
|
+
/** "A" → 0, "AA" → 26 (case-insensitive). -1 for anything that is not letters. */
|
|
7
|
+
export declare function columnIndex(letters: string): number;
|
|
8
|
+
/** "B3" → { col: 1, row: 2 } (0-based). Null when it is not an A1 reference. */
|
|
9
|
+
export declare function parseCellRef(ref: string): {
|
|
10
|
+
col: number;
|
|
11
|
+
row: number;
|
|
12
|
+
} | null;
|
|
13
|
+
export type GridCellType = 'text' | 'number' | 'select' | 'boolean';
|
|
14
|
+
export interface GridCellSpec {
|
|
15
|
+
type?: GridCellType;
|
|
16
|
+
required?: boolean;
|
|
17
|
+
/** number: inclusive bounds. */
|
|
18
|
+
min?: number;
|
|
19
|
+
max?: number;
|
|
20
|
+
/** number: round to this many decimals. */
|
|
21
|
+
decimals?: number;
|
|
22
|
+
/** select: allowed values (matched case-insensitively, returned in their canonical case). */
|
|
23
|
+
options?: readonly string[];
|
|
24
|
+
/** text: max length. */
|
|
25
|
+
maxLength?: number;
|
|
26
|
+
}
|
|
27
|
+
export type ParseCellResult<V = unknown> = {
|
|
28
|
+
ok: true;
|
|
29
|
+
value: V;
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
error: string;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Parse what a person typed or pasted into a typed cell value, with a
|
|
36
|
+
* human-readable error. Numbers accept thousands separators, a leading
|
|
37
|
+
* currency sign and a trailing %, e.g. "$1,234.50" → 1234.5, "12%" → 0.12.
|
|
38
|
+
* Empty input is `null` unless `required`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function parseCellValue(raw: string, spec?: GridCellSpec): ParseCellResult;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cell addressing + typed value parsing (jatui/grid, jatui-6kitr).
|
|
3
|
+
*/
|
|
4
|
+
/** 0 → "A", 25 → "Z", 26 → "AA", 701 → "ZZ", 702 → "AAA". */
|
|
5
|
+
export function columnLetter(index) {
|
|
6
|
+
if (!Number.isInteger(index) || index < 0)
|
|
7
|
+
throw new RangeError(`columnLetter: bad index ${index}`);
|
|
8
|
+
let s = '';
|
|
9
|
+
let n = index + 1;
|
|
10
|
+
while (n > 0) {
|
|
11
|
+
const r = (n - 1) % 26;
|
|
12
|
+
s = String.fromCharCode(65 + r) + s;
|
|
13
|
+
n = Math.floor((n - 1) / 26);
|
|
14
|
+
}
|
|
15
|
+
return s;
|
|
16
|
+
}
|
|
17
|
+
/** "A" → 0, "AA" → 26 (case-insensitive). -1 for anything that is not letters. */
|
|
18
|
+
export function columnIndex(letters) {
|
|
19
|
+
if (!/^[A-Za-z]+$/.test(letters))
|
|
20
|
+
return -1;
|
|
21
|
+
let n = 0;
|
|
22
|
+
for (const ch of letters.toUpperCase())
|
|
23
|
+
n = n * 26 + (ch.charCodeAt(0) - 64);
|
|
24
|
+
return n - 1;
|
|
25
|
+
}
|
|
26
|
+
/** "B3" → { col: 1, row: 2 } (0-based). Null when it is not an A1 reference. */
|
|
27
|
+
export function parseCellRef(ref) {
|
|
28
|
+
const m = /^\s*([A-Za-z]+)(\d+)\s*$/.exec(ref);
|
|
29
|
+
if (!m || Number(m[2]) < 1)
|
|
30
|
+
return null;
|
|
31
|
+
return { col: columnIndex(m[1]), row: Number(m[2]) - 1 };
|
|
32
|
+
}
|
|
33
|
+
const TRUE = new Set(['true', 'yes', 'y', '1', 'on', '✓', 'x']);
|
|
34
|
+
const FALSE = new Set(['false', 'no', 'n', '0', 'off', '']);
|
|
35
|
+
/**
|
|
36
|
+
* Parse what a person typed or pasted into a typed cell value, with a
|
|
37
|
+
* human-readable error. Numbers accept thousands separators, a leading
|
|
38
|
+
* currency sign and a trailing %, e.g. "$1,234.50" → 1234.5, "12%" → 0.12.
|
|
39
|
+
* Empty input is `null` unless `required`.
|
|
40
|
+
*/
|
|
41
|
+
export function parseCellValue(raw, spec = {}) {
|
|
42
|
+
const type = spec.type ?? 'text';
|
|
43
|
+
const s = raw.trim();
|
|
44
|
+
if (s === '' && type !== 'boolean') {
|
|
45
|
+
return spec.required ? { ok: false, error: 'Required' } : { ok: true, value: null };
|
|
46
|
+
}
|
|
47
|
+
switch (type) {
|
|
48
|
+
case 'number': {
|
|
49
|
+
const pct = s.endsWith('%');
|
|
50
|
+
const neg = /^\(.*\)$/.test(s); // accounting negatives: (12.50)
|
|
51
|
+
const cleaned = s.replace(/^\(|\)$/g, '').replace(/%$/, '').replace(/[\p{Sc},\s_]/gu, '');
|
|
52
|
+
if (!/^[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i.test(cleaned))
|
|
53
|
+
return { ok: false, error: 'Not a number' };
|
|
54
|
+
let v = Number(cleaned);
|
|
55
|
+
if (neg)
|
|
56
|
+
v = -v;
|
|
57
|
+
if (pct)
|
|
58
|
+
v = v / 100;
|
|
59
|
+
if (spec.decimals != null)
|
|
60
|
+
v = Number(v.toFixed(spec.decimals));
|
|
61
|
+
if (spec.min != null && v < spec.min)
|
|
62
|
+
return { ok: false, error: `Must be at least ${spec.min}` };
|
|
63
|
+
if (spec.max != null && v > spec.max)
|
|
64
|
+
return { ok: false, error: `Must be at most ${spec.max}` };
|
|
65
|
+
return { ok: true, value: v };
|
|
66
|
+
}
|
|
67
|
+
case 'select': {
|
|
68
|
+
const opts = spec.options ?? [];
|
|
69
|
+
const hit = opts.find((o) => o.toLocaleLowerCase() === s.toLocaleLowerCase());
|
|
70
|
+
return hit != null ? { ok: true, value: hit } : { ok: false, error: `Choose one of: ${opts.join(', ')}` };
|
|
71
|
+
}
|
|
72
|
+
case 'boolean': {
|
|
73
|
+
const k = s.toLocaleLowerCase();
|
|
74
|
+
if (TRUE.has(k))
|
|
75
|
+
return { ok: true, value: true };
|
|
76
|
+
if (FALSE.has(k)) {
|
|
77
|
+
if (k === '' && spec.required)
|
|
78
|
+
return { ok: false, error: 'Required' };
|
|
79
|
+
return { ok: true, value: false };
|
|
80
|
+
}
|
|
81
|
+
return { ok: false, error: 'Not yes or no' };
|
|
82
|
+
}
|
|
83
|
+
default: {
|
|
84
|
+
if (spec.maxLength != null && raw.length > spec.maxLength)
|
|
85
|
+
return { ok: false, error: `At most ${spec.maxLength} characters` };
|
|
86
|
+
return { ok: true, value: raw };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spreadsheet clipboard + CSV I/O (jatui/grid, jatui-6kitr).
|
|
3
|
+
*
|
|
4
|
+
* Behaviour follows BoardUI's data-grid utils (MIT, © 2026 Mertcan Dundar
|
|
5
|
+
* Esmergul): TSV that round-trips quoted tabs/newlines from Excel and Google
|
|
6
|
+
* Sheets, and CSV export with a BOM and formula-injection escaping. Clean
|
|
7
|
+
* re-implementation.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Parse clipboard text from Excel / Sheets / Numbers into a cell matrix.
|
|
11
|
+
*
|
|
12
|
+
* - Tab separates cells, newline (LF or CRLF) separates rows.
|
|
13
|
+
* - A cell that STARTS with `"` is quoted: tabs and newlines inside it are
|
|
14
|
+
* literal and `""` is an escaped quote. (Sheets quotes any cell holding a
|
|
15
|
+
* newline or a quote.) A `"` mid-cell is literal.
|
|
16
|
+
* - One trailing newline (every spreadsheet adds it) does not make an empty row.
|
|
17
|
+
* - Ragged rows are padded with '' to the widest row.
|
|
18
|
+
*/
|
|
19
|
+
export declare function parseClipboardTsv(text: string): string[][];
|
|
20
|
+
/** Serialize a matrix as clipboard TSV, quoting cells that hold a tab, newline or quote. */
|
|
21
|
+
export declare function serializeTsv(rows: readonly (readonly unknown[])[]): string;
|
|
22
|
+
/**
|
|
23
|
+
* Neutralise a cell a spreadsheet would execute (`=HYPERLINK(...)`,
|
|
24
|
+
* `+cmd|...`, `@SUM(...)`) by prefixing a single quote. Only STRINGS are
|
|
25
|
+
* escaped: a real number such as -5 is data, not a formula.
|
|
26
|
+
*/
|
|
27
|
+
export declare function escapeCsvFormula(value: unknown): unknown;
|
|
28
|
+
export interface SerializeCsvOptions {
|
|
29
|
+
/** Prefix a UTF-8 BOM so Excel opens non-ASCII text correctly. Default true. */
|
|
30
|
+
bom?: boolean;
|
|
31
|
+
/** Escape formula-looking string cells. Default true — turn off only for trusted data. */
|
|
32
|
+
escapeFormulas?: boolean;
|
|
33
|
+
/** Default ','. */
|
|
34
|
+
delimiter?: string;
|
|
35
|
+
/** Default '\r\n' (RFC 4180). */
|
|
36
|
+
newline?: string;
|
|
37
|
+
}
|
|
38
|
+
/** RFC 4180 CSV from a matrix (first row = header if you include one). */
|
|
39
|
+
export declare function serializeCsv(rows: readonly (readonly unknown[])[], opts?: SerializeCsvOptions): string;
|
|
40
|
+
export interface CsvColumn<TRow> {
|
|
41
|
+
header: string;
|
|
42
|
+
/** Field name or accessor. */
|
|
43
|
+
value: keyof TRow | ((row: TRow) => unknown);
|
|
44
|
+
}
|
|
45
|
+
/** CSV from objects: one header row, then one row per object. */
|
|
46
|
+
export declare function serializeRowsToCsv<TRow>(data: readonly TRow[], columns: readonly CsvColumn<TRow>[], opts?: SerializeCsvOptions): string;
|