@adecore/ui 0.35.5 → 0.36.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/dist/Button.d.ts +2 -3
- package/dist/Button.js +21 -46
- package/dist/Checkbox.js +1 -1
- package/dist/ChoiceCards.js +2 -2
- package/dist/IconPicker.d.ts +2 -1
- package/dist/IconPicker.js +504 -217
- package/dist/Input.d.ts +5 -3
- package/dist/Input.js +127 -112
- package/dist/Segmented.js +1 -1
- package/dist/SplitButton.d.ts +17 -0
- package/dist/SplitButton.js +237 -0
- package/dist/Stepper.js +1 -1
- package/dist/Switch.js +1 -1
- package/dist/Tile.js +2 -2
- package/dist/button-class.d.ts +6 -0
- package/dist/button-class.js +92 -0
- package/dist/file-tree/empty-lines.d.ts +11 -0
- package/dist/file-tree/empty-lines.js +89 -0
- package/dist/file-tree/highlight.d.ts +4 -0
- package/dist/file-tree/highlight.js +75 -0
- package/dist/file-tree/parts.d.ts +9 -1
- package/dist/file-tree/parts.js +297 -205
- package/dist/file-tree/style.js +13 -0
- package/dist/file-tree/use-empty-lines.d.ts +3 -0
- package/dist/file-tree/use-empty-lines.js +176 -0
- package/dist/file-tree/use-file-tree.js +5 -1
- package/dist/file-tree/use-slots.d.ts +16 -2
- package/dist/file-tree/use-slots.js +186 -43
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/locales/en.d.ts +1 -0
- package/dist/locales/en.js +1 -0
- package/dist/locales/nl.d.ts +1 -0
- package/dist/locales/nl.js +1 -0
- package/dist/select-shared.d.ts +1 -1
- package/dist/select-shared.js +1 -1
- package/dist/settings/SettingsDialog.js +1 -1
- package/dist/theme.css +33 -3
- package/package.json +1 -1
- package/src/Button.tsx +3 -36
- package/src/Checkbox.tsx +1 -1
- package/src/ChoiceCards.tsx +2 -2
- package/src/IconPicker.tsx +220 -136
- package/src/Input.tsx +19 -10
- package/src/Segmented.tsx +1 -1
- package/src/SplitButton.tsx +72 -0
- package/src/Stepper.tsx +6 -1
- package/src/Switch.tsx +1 -1
- package/src/Tile.tsx +2 -2
- package/src/button-class.ts +111 -0
- package/src/file-tree/empty-lines.ts +105 -0
- package/src/file-tree/highlight.ts +80 -0
- package/src/file-tree/parts.tsx +42 -11
- package/src/file-tree/style.ts +13 -0
- package/src/file-tree/use-empty-lines.ts +115 -0
- package/src/file-tree/use-file-tree.ts +2 -1
- package/src/file-tree/use-slots.ts +176 -17
- package/src/index.ts +1 -0
- package/src/locales/en.ts +1 -0
- package/src/locales/nl.ts +1 -0
- package/src/select-shared.ts +2 -1
- package/src/settings/SettingsDialog.tsx +1 -1
- package/src/theme.css +33 -3
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import clsx from 'clsx';
|
|
2
|
+
const BASE = 'inline-flex shrink-0 items-center justify-center gap-1.5 font-medium disabled:opacity-50';
|
|
3
|
+
const VARIANT = {
|
|
4
|
+
// A filled button darkens under the pointer. Its color is the meaning, so no token of its own for hover.
|
|
5
|
+
primary: 'bg-accent text-accent-text hover:brightness-90 disabled:hover:brightness-100',
|
|
6
|
+
// Drawn like a field, so it stands a step off any ground, a dialog's raised one included.
|
|
7
|
+
secondary: 'border border-field-border bg-field text-text hover:bg-surface-hover disabled:hover:bg-field',
|
|
8
|
+
ghost: 'text-text-muted hover:bg-surface-hover hover:text-text',
|
|
9
|
+
danger: 'bg-status-error text-accent-text hover:brightness-90 disabled:hover:brightness-100',
|
|
10
|
+
// A step that forgets something rather than destroys it: softer than the filled button of a deletion that cannot come back.
|
|
11
|
+
'danger-outline': 'border border-status-error/35 text-status-error hover:bg-status-error/10 disabled:hover:bg-transparent',
|
|
12
|
+
positive: 'bg-positive text-positive-text hover:brightness-90 disabled:hover:brightness-100',
|
|
13
|
+
// Dark on a light theme, light on a dark one, matching what Apple's and GitHub's sign-in buttons ask for.
|
|
14
|
+
inverse: 'bg-text text-bg hover:opacity-90 disabled:hover:opacity-100'
|
|
15
|
+
};
|
|
16
|
+
/* 28 and 32 pixels are the compact and the normal control; 24 fits a header of 32. */
|
|
17
|
+
const SIZE = {
|
|
18
|
+
xs: 'h-6 px-2',
|
|
19
|
+
sm: 'h-7 px-2.5',
|
|
20
|
+
md: 'h-8 px-3'
|
|
21
|
+
};
|
|
22
|
+
/* A pill is one size of its own, for a control that sits in a round row, so it takes the place of `size`. */
|
|
23
|
+
const SHAPE = {
|
|
24
|
+
default: 'rounded-md text-xs',
|
|
25
|
+
pill: 'h-9 rounded-full px-3.5 text-sm'
|
|
26
|
+
};
|
|
27
|
+
export function buttonClass(variant, size, shape) {
|
|
28
|
+
return clsx(BASE, SHAPE[shape], VARIANT[variant], shape === 'default' && SIZE[size]);
|
|
29
|
+
}
|
|
30
|
+
/*
|
|
31
|
+
* A split button is one shape: the frame draws the radius, the outline of an outlined variant and the
|
|
32
|
+
* height, and clips both halves, which carry the ground and its hover each on their own.
|
|
33
|
+
*/
|
|
34
|
+
const SPLIT_FRAME = {
|
|
35
|
+
primary: '',
|
|
36
|
+
secondary: 'border border-field-border',
|
|
37
|
+
ghost: '',
|
|
38
|
+
danger: '',
|
|
39
|
+
'danger-outline': 'border border-status-error/35',
|
|
40
|
+
positive: '',
|
|
41
|
+
inverse: ''
|
|
42
|
+
};
|
|
43
|
+
const SPLIT_HALF = {
|
|
44
|
+
primary: VARIANT.primary,
|
|
45
|
+
secondary: 'bg-field text-text hover:bg-surface-hover disabled:hover:bg-field',
|
|
46
|
+
ghost: VARIANT.ghost,
|
|
47
|
+
danger: VARIANT.danger,
|
|
48
|
+
'danger-outline': 'text-status-error hover:bg-status-error/10 disabled:hover:bg-transparent',
|
|
49
|
+
positive: VARIANT.positive,
|
|
50
|
+
inverse: VARIANT.inverse
|
|
51
|
+
};
|
|
52
|
+
/* The short line between the halves: a light alpha of the text over a filled ground, the border's alpha elsewhere. */
|
|
53
|
+
const SPLIT_SEPARATOR = {
|
|
54
|
+
primary: 'before:bg-current/30',
|
|
55
|
+
secondary: 'before:bg-border-strong',
|
|
56
|
+
ghost: 'before:bg-border-strong',
|
|
57
|
+
danger: 'before:bg-current/30',
|
|
58
|
+
'danger-outline': 'before:bg-status-error/35',
|
|
59
|
+
positive: 'before:bg-current/30',
|
|
60
|
+
inverse: 'before:bg-current/30'
|
|
61
|
+
};
|
|
62
|
+
const SPLIT_HEIGHT = {
|
|
63
|
+
xs: 'h-6',
|
|
64
|
+
sm: 'h-7',
|
|
65
|
+
md: 'h-8'
|
|
66
|
+
};
|
|
67
|
+
/* Half the height of the button, centered by auto margins, which land on whole pixels in the bordered frames too. */
|
|
68
|
+
const SPLIT_SEPARATOR_HEIGHT = {
|
|
69
|
+
xs: 'before:h-3',
|
|
70
|
+
sm: 'before:h-3.5',
|
|
71
|
+
md: 'before:h-4'
|
|
72
|
+
};
|
|
73
|
+
const SPLIT_PADDING = {
|
|
74
|
+
action: {
|
|
75
|
+
xs: 'px-2',
|
|
76
|
+
sm: 'px-2.5',
|
|
77
|
+
md: 'px-3'
|
|
78
|
+
},
|
|
79
|
+
// The chevron half is narrower than a half with a word in it.
|
|
80
|
+
menu: {
|
|
81
|
+
xs: 'px-1',
|
|
82
|
+
sm: 'px-1.5',
|
|
83
|
+
md: 'px-2'
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
export function splitFrameClass(variant, size) {
|
|
87
|
+
return clsx('inline-flex shrink-0 items-stretch overflow-clip rounded-md', SPLIT_HEIGHT[size], SPLIT_FRAME[variant]);
|
|
88
|
+
}
|
|
89
|
+
/* The frame clips the focus outline, so a half draws it inside its own edge. */
|
|
90
|
+
export function splitHalfClass(variant, size, half) {
|
|
91
|
+
return clsx('inline-flex items-center justify-center gap-1.5 text-xs font-medium focus-visible:-outline-offset-2 disabled:opacity-50', SPLIT_HALF[variant], SPLIT_PADDING[half][size], half === 'menu' && clsx('relative before:absolute before:inset-y-0 before:left-0 before:my-auto before:w-px before:content-[""]', SPLIT_SEPARATOR_HEIGHT[size], SPLIT_SEPARATOR[variant]));
|
|
92
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FileTree, FileTreeVisibleRow } from '@pierre/trees';
|
|
2
|
+
export type FileTreeEmptyLabel = (row: FileTreeVisibleRow) => string | undefined;
|
|
3
|
+
export declare function emptyLineOf(directory: string): string;
|
|
4
|
+
export declare function isEmptyLine(path: string): boolean;
|
|
5
|
+
export declare function directoryOfEmptyLine(path: string): string;
|
|
6
|
+
export declare function emptyLineChanges(rows: readonly FileTreeVisibleRow[], labelOf: FileTreeEmptyLabel, has: (path: string) => boolean, added: Iterable<string>): {
|
|
7
|
+
add: string[];
|
|
8
|
+
remove: string[];
|
|
9
|
+
};
|
|
10
|
+
export declare function pastEmptyLine(model: FileTree, key: string): string | null;
|
|
11
|
+
export declare function skipEmptyLine(model: FileTree, key: string): boolean;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { pathOfRow } from './helpers.js';
|
|
2
|
+
/*
|
|
3
|
+
* The engine draws only rows it holds, so the line under an open, empty directory is a child the
|
|
4
|
+
* wrapper adds and removes itself. U+FDD0 is a noncharacter, which no real file name carries.
|
|
5
|
+
*/
|
|
6
|
+
const EMPTY_LINE_NAME = 'empty';
|
|
7
|
+
export function emptyLineOf(directory) {
|
|
8
|
+
return `${directory}${EMPTY_LINE_NAME}`;
|
|
9
|
+
}
|
|
10
|
+
export function isEmptyLine(path) {
|
|
11
|
+
return path.endsWith(`/${EMPTY_LINE_NAME}`);
|
|
12
|
+
}
|
|
13
|
+
export function directoryOfEmptyLine(path) {
|
|
14
|
+
return path.slice(0, -EMPTY_LINE_NAME.length);
|
|
15
|
+
}
|
|
16
|
+
/*
|
|
17
|
+
* The lines to add and the ones to take away: an open directory whose label is not undefined and
|
|
18
|
+
* that holds nothing besides its own line gets one, every other line the wrapper added goes.
|
|
19
|
+
*/
|
|
20
|
+
export function emptyLineChanges(rows, labelOf, has, added) {
|
|
21
|
+
const wanted = new Set();
|
|
22
|
+
rows.forEach((row, index) => {
|
|
23
|
+
if (row.kind !== 'directory' || !row.isExpanded) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const line = emptyLineOf(row.path);
|
|
27
|
+
for (let next = index + 1; next < rows.length && rows[next].depth > row.depth; next++) {
|
|
28
|
+
if (rows[next].path !== line) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (labelOf(row) !== undefined) {
|
|
33
|
+
wanted.add(line);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
add: [...wanted].filter(line => !has(line)),
|
|
38
|
+
remove: [...added].filter(line => !wanted.has(line) && has(line))
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/*
|
|
42
|
+
* Where an arrow, Home or End goes instead when the row it would land on is a line: past it, or
|
|
43
|
+
* nowhere (the focused path itself) where nothing lies past it. Null when no line is in the way.
|
|
44
|
+
*/
|
|
45
|
+
export function pastEmptyLine(model, key) {
|
|
46
|
+
const index = model.getFocusedIndex();
|
|
47
|
+
const focused = model.getFocusedPath();
|
|
48
|
+
if (index < 0 || focused === null) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const count = model.getVisibleCount();
|
|
52
|
+
const at = position => position >= 0 && position < count ? model.getVisibleRows(position, position)[0] ?? null : null;
|
|
53
|
+
const lineAt = position => {
|
|
54
|
+
const row = at(position);
|
|
55
|
+
return row !== null && isEmptyLine(row.path);
|
|
56
|
+
};
|
|
57
|
+
const pathAt = position => {
|
|
58
|
+
const row = at(position);
|
|
59
|
+
return row === null ? focused : pathOfRow(row);
|
|
60
|
+
};
|
|
61
|
+
if ((key === 'ArrowDown' || key === 'ArrowRight') && lineAt(index + 1)) {
|
|
62
|
+
return key === 'ArrowDown' ? pathAt(index + 2) : focused;
|
|
63
|
+
}
|
|
64
|
+
if (key === 'ArrowUp' && lineAt(index - 1)) {
|
|
65
|
+
return pathAt(index - 2);
|
|
66
|
+
}
|
|
67
|
+
if (key === 'End' && lineAt(count - 1)) {
|
|
68
|
+
return pathAt(count - 2);
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/* Focus that still lands on a line, through a key with a modifier or the engine giving focus back to the line's element, goes on the way the last key went. */
|
|
73
|
+
export function skipEmptyLine(model, key) {
|
|
74
|
+
const path = model.getFocusedPath();
|
|
75
|
+
if (path === null || !isEmptyLine(path)) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
if (key === 'ArrowUp' || key === 'PageUp') {
|
|
79
|
+
model.focusPreviousItem();
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
if (key === 'ArrowDown' || key === 'PageDown' || key === 'End') {
|
|
83
|
+
model.focusNextItem();
|
|
84
|
+
}
|
|
85
|
+
if (model.getFocusedPath() === path) {
|
|
86
|
+
model.focusPath(directoryOfEmptyLine(path));
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const MATCH_HIGHLIGHT = "adecore-tree-match";
|
|
2
|
+
export declare function matchHighlight(): Highlight | null;
|
|
3
|
+
export declare function matchRanges(text: string, query: string): [number, number][];
|
|
4
|
+
export declare function rangesOfMatches(rows: readonly HTMLElement[], query: string): Range[];
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/* The name a tree's shadow stylesheet colors with `::highlight()`. One registry entry is shared by every tree on the page. */
|
|
2
|
+
export const MATCH_HIGHLIGHT = 'adecore-tree-match';
|
|
3
|
+
// The copies of a name the engine keeps for truncation, which the tree's stylesheet hides.
|
|
4
|
+
const HIDDEN_PARTS = '[data-truncate-content="overflow"], [data-truncate-marker-cell], [data-truncate-fill]';
|
|
5
|
+
let shared = null;
|
|
6
|
+
/* Null where the browser has no CSS Custom Highlight API; the names are then drawn without a mark. */
|
|
7
|
+
export function matchHighlight() {
|
|
8
|
+
if (typeof CSS === 'undefined' || !('highlights' in CSS) || typeof Highlight === 'undefined') {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
if (shared === null) {
|
|
12
|
+
shared = new Highlight();
|
|
13
|
+
}
|
|
14
|
+
if (CSS.highlights.get(MATCH_HIGHLIGHT) !== shared) {
|
|
15
|
+
CSS.highlights.set(MATCH_HIGHLIGHT, shared);
|
|
16
|
+
}
|
|
17
|
+
return shared;
|
|
18
|
+
}
|
|
19
|
+
/* Every place `query` occurs in `text`, ignoring case, as `[start, end)` pairs that do not overlap. */
|
|
20
|
+
export function matchRanges(text, query) {
|
|
21
|
+
const wanted = query.toLowerCase();
|
|
22
|
+
const haystack = text.toLowerCase();
|
|
23
|
+
// A letter that lowercases to more than one would shift every offset after it.
|
|
24
|
+
if (wanted === '' || haystack.length !== text.length) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
const found = [];
|
|
28
|
+
let at = haystack.indexOf(wanted);
|
|
29
|
+
while (at !== -1) {
|
|
30
|
+
found.push([at, at + wanted.length]);
|
|
31
|
+
at = haystack.indexOf(wanted, at + wanted.length);
|
|
32
|
+
}
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
35
|
+
/* The ranges over the visible text of each row's name where `query` occurs. */
|
|
36
|
+
export function rangesOfMatches(rows, query) {
|
|
37
|
+
const ranges = [];
|
|
38
|
+
if (query === '') {
|
|
39
|
+
return ranges;
|
|
40
|
+
}
|
|
41
|
+
for (const row of rows) {
|
|
42
|
+
const content = row.querySelector('[data-item-section="content"]');
|
|
43
|
+
if (content === null) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const nodes = [];
|
|
47
|
+
const walker = row.ownerDocument.createTreeWalker(content, NodeFilter.SHOW_TEXT);
|
|
48
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
49
|
+
if (node.parentElement?.closest(HIDDEN_PARTS) === null) {
|
|
50
|
+
nodes.push(node);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const starts = [];
|
|
54
|
+
let text = '';
|
|
55
|
+
for (const node of nodes) {
|
|
56
|
+
starts.push(text.length);
|
|
57
|
+
text += node.data;
|
|
58
|
+
}
|
|
59
|
+
// The node an offset falls in, and the offset inside it; an end may sit at the end of a node.
|
|
60
|
+
const locate = (offset, end) => {
|
|
61
|
+
let index = nodes.length - 1;
|
|
62
|
+
while (index > 0 && (end ? starts[index] >= offset : starts[index] > offset)) {
|
|
63
|
+
index--;
|
|
64
|
+
}
|
|
65
|
+
return [nodes[index], offset - starts[index]];
|
|
66
|
+
};
|
|
67
|
+
for (const [start, end] of matchRanges(text, query)) {
|
|
68
|
+
const range = row.ownerDocument.createRange();
|
|
69
|
+
range.setStart(...locate(start, false));
|
|
70
|
+
range.setEnd(...locate(end, true));
|
|
71
|
+
ranges.push(range);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return ranges;
|
|
75
|
+
}
|
|
@@ -2,6 +2,9 @@ import { type ReactNode } from 'react';
|
|
|
2
2
|
import { useRender } from '@base-ui/react/use-render';
|
|
3
3
|
import { type FileTreeProps as PierreProps } from '@pierre/trees/react';
|
|
4
4
|
import type { FileTree, FileTreeVisibleRow } from '@pierre/trees';
|
|
5
|
+
import type { LucideIcon } from 'lucide-react';
|
|
6
|
+
import type { FileTreeEmptyLabel } from './empty-lines.ts';
|
|
7
|
+
import { type FileTreeDecorationOrder } from './use-slots.ts';
|
|
5
8
|
export type FileTreeRootProps = useRender.ComponentProps<'div'> & {
|
|
6
9
|
model: FileTree;
|
|
7
10
|
label?: string;
|
|
@@ -15,6 +18,11 @@ export type FileTreeRootProps = useRender.ComponentProps<'div'> & {
|
|
|
15
18
|
onExpandedPathsChange?(paths: readonly string[]): void;
|
|
16
19
|
renderControl?(row: FileTreeVisibleRow): ReactNode;
|
|
17
20
|
renderDecoration?(row: FileTreeVisibleRow): ReactNode;
|
|
21
|
+
decorationOrder?: FileTreeDecorationOrder;
|
|
22
|
+
rowIcon?(row: FileTreeVisibleRow): LucideIcon | undefined;
|
|
23
|
+
fitContent?: boolean;
|
|
24
|
+
highlight?: string;
|
|
25
|
+
emptyLabel?: FileTreeEmptyLabel;
|
|
18
26
|
treeProps?: Omit<PierreProps, 'model'>;
|
|
19
27
|
};
|
|
20
|
-
export declare function FileTreeRoot({ model, label, resetKey, selectionFollowsFocus, onActivate, onFocusMove, onLoadChildren, onExpandedPathsChange, onRowContextMenu, onRowDragStart, renderControl, renderDecoration, treeProps, render, className, ref, children, onClick, onKeyDownCapture, ...props }: FileTreeRootProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
28
|
+
export declare function FileTreeRoot({ model, label, resetKey, selectionFollowsFocus, onActivate, onFocusMove, onLoadChildren, onExpandedPathsChange, onRowContextMenu, onRowDragStart, renderControl, renderDecoration, decorationOrder, rowIcon, fitContent, highlight, emptyLabel, treeProps, render, className, ref, children, onClick, onKeyDownCapture, style, ...props }: FileTreeRootProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|