@adecore/ui 0.36.0 → 0.37.1
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/command-palette/context.d.ts +10 -0
- package/dist/command-palette/context.js +5 -0
- package/dist/command-palette/index.parts.d.ts +1 -0
- package/dist/command-palette/index.parts.js +2 -1
- package/dist/command-palette/parts.d.ts +1 -1
- package/dist/command-palette/parts.js +12 -3
- package/dist/command-palette/virtual.d.ts +20 -0
- package/dist/command-palette/virtual.js +155 -0
- package/dist/format/index.d.ts +1 -1
- package/dist/format/index.js +1 -1
- package/dist/format/locale.d.ts +1 -0
- package/dist/format/locale.js +4 -5
- package/dist/index.d.ts +1 -0
- package/package.json +2 -1
- package/src/command-palette/context.ts +16 -0
- package/src/command-palette/index.parts.ts +2 -0
- package/src/command-palette/parts.tsx +11 -12
- package/src/command-palette/virtual.tsx +147 -0
- package/src/format/index.ts +1 -1
- package/src/format/locale.ts +13 -7
- package/src/index.ts +2 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
interface PaletteRefs {
|
|
3
|
+
input: RefObject<HTMLInputElement | null>;
|
|
4
|
+
list: RefObject<HTMLDivElement | null>;
|
|
5
|
+
highlight(row: Element): void;
|
|
6
|
+
virtualScroll: RefObject<((index: number, reason: string) => void) | null>;
|
|
7
|
+
}
|
|
8
|
+
export declare const PaletteRefsContext: import("react").Context<PaletteRefs | null>;
|
|
9
|
+
export declare function useCommandPaletteVirtualScroll(): PaletteRefs['virtualScroll'] | null;
|
|
10
|
+
export {};
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { ComboboxCollection as Collection, ComboboxEmpty as Empty, ComboboxGroup as Group, ComboboxGroupLabel as GroupLabel, ComboboxItem as Item, ComboboxStatus as Status } from '../combobox/parts.tsx';
|
|
2
2
|
export { CommandPaletteFooter as Footer, CommandPaletteInput as Input, CommandPaletteList as List, CommandPalettePopup as Popup, CommandPaletteRoot as Root } from './parts.tsx';
|
|
3
|
+
export { CommandPaletteVirtualList as VirtualList } from './virtual.tsx';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
/* A command palette is a combobox in a dialog; its rows, groups and notes are the combobox's own. */
|
|
2
2
|
export { ComboboxCollection as Collection, ComboboxEmpty as Empty, ComboboxGroup as Group, ComboboxGroupLabel as GroupLabel, ComboboxItem as Item, ComboboxStatus as Status } from "../combobox/parts.js";
|
|
3
|
-
export { CommandPaletteFooter as Footer, CommandPaletteInput as Input, CommandPaletteList as List, CommandPalettePopup as Popup, CommandPaletteRoot as Root } from "./parts.js";
|
|
3
|
+
export { CommandPaletteFooter as Footer, CommandPaletteInput as Input, CommandPaletteList as List, CommandPalettePopup as Popup, CommandPaletteRoot as Root } from "./parts.js";
|
|
4
|
+
export { CommandPaletteVirtualList as VirtualList } from "./virtual.js";
|
|
@@ -13,7 +13,7 @@ type AutocompleteProps<ItemValue> = Omit<Autocomplete.Root.Props<ItemValue>, 'op
|
|
|
13
13
|
};
|
|
14
14
|
type DialogProps = Pick<ComponentProps<typeof DialogRoot>, 'open' | 'defaultOpen' | 'onOpenChange' | 'onOpenChangeComplete'>;
|
|
15
15
|
export type CommandPaletteRootProps<ItemValue> = AutocompleteProps<ItemValue> & DialogProps;
|
|
16
|
-
export declare function CommandPaletteRoot<ItemValue>({ open, defaultOpen, onOpenChange, onOpenChangeComplete, autoHighlight, keepHighlight, highlightItemOnHover, value, onValueChange, items, children, ...props }: CommandPaletteRootProps<ItemValue>): import("react").JSX.Element;
|
|
16
|
+
export declare function CommandPaletteRoot<ItemValue>({ open, defaultOpen, onOpenChange, onOpenChangeComplete, autoHighlight, keepHighlight, highlightItemOnHover, value, onValueChange, items, onItemHighlighted, children, ...props }: CommandPaletteRootProps<ItemValue>): import("react").JSX.Element;
|
|
17
17
|
export type CommandPalettePopupProps = Omit<DialogPopupProps, 'size'> & {
|
|
18
18
|
size?: keyof typeof SIZE;
|
|
19
19
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { c as _c } from "react/compiler-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useContext, useLayoutEffect, useRef, useState } from 'react';
|
|
3
3
|
import { flushSync } from 'react-dom';
|
|
4
4
|
import clsx from 'clsx';
|
|
5
5
|
import { Search } from 'lucide-react';
|
|
@@ -12,8 +12,8 @@ import { Icon } from "../Icon.js";
|
|
|
12
12
|
import { Kbd } from "../Kbd.js";
|
|
13
13
|
import { mergeRefs } from '../merge-refs.js';
|
|
14
14
|
import { KEY_SHORTCUTS } from '../shortcut.js';
|
|
15
|
+
import { PaletteRefsContext } from './context.js';
|
|
15
16
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
16
|
-
const PaletteRefsContext = createContext(null);
|
|
17
17
|
const NO_ITEMS = [];
|
|
18
18
|
/* A palette asks for a few words and shows rows of one line; a search whose rows are lines of source needs the wider one. */
|
|
19
19
|
const SIZE = {
|
|
@@ -36,11 +36,13 @@ export function CommandPaletteRoot({
|
|
|
36
36
|
value,
|
|
37
37
|
onValueChange,
|
|
38
38
|
items,
|
|
39
|
+
onItemHighlighted,
|
|
39
40
|
children,
|
|
40
41
|
...props
|
|
41
42
|
}) {
|
|
42
43
|
const inputRef = useRef(null);
|
|
43
44
|
const listRef = useRef(null);
|
|
45
|
+
const virtualScroll = useRef(null);
|
|
44
46
|
const [focusHighlight, setFocusHighlight] = useState(false);
|
|
45
47
|
/*
|
|
46
48
|
* Base UI moves the highlight to the first row for a typed query only. For a query the app sets,
|
|
@@ -89,7 +91,8 @@ export function CommandPaletteRoot({
|
|
|
89
91
|
value: {
|
|
90
92
|
input: inputRef,
|
|
91
93
|
list: listRef,
|
|
92
|
-
highlight
|
|
94
|
+
highlight,
|
|
95
|
+
virtualScroll
|
|
93
96
|
},
|
|
94
97
|
children: /*#__PURE__*/_jsx(Autocomplete.Root, {
|
|
95
98
|
...props,
|
|
@@ -101,6 +104,12 @@ export function CommandPaletteRoot({
|
|
|
101
104
|
onValueChange?.(next, details);
|
|
102
105
|
},
|
|
103
106
|
items: resetting ? NO_ITEMS : items,
|
|
107
|
+
onItemHighlighted: (item, details_0) => {
|
|
108
|
+
if (item !== undefined && virtualScroll.current !== null) {
|
|
109
|
+
queueMicrotask(() => virtualScroll.current?.(details_0.index, details_0.reason));
|
|
110
|
+
}
|
|
111
|
+
onItemHighlighted?.(item, details_0);
|
|
112
|
+
},
|
|
104
113
|
inline: true,
|
|
105
114
|
open: true,
|
|
106
115
|
autoHighlight: autoHighlight,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ComponentProps, type ReactNode } from 'react';
|
|
2
|
+
import { CommandPaletteList } from './parts.tsx';
|
|
3
|
+
export interface CommandPaletteVirtualItemProps {
|
|
4
|
+
index: number;
|
|
5
|
+
'aria-setsize': number;
|
|
6
|
+
'aria-posinset': number;
|
|
7
|
+
}
|
|
8
|
+
export type CommandPaletteVirtualListProps<ItemValue, Group extends {
|
|
9
|
+
items: readonly ItemValue[];
|
|
10
|
+
} = {
|
|
11
|
+
items: readonly ItemValue[];
|
|
12
|
+
}> = Omit<ComponentProps<typeof CommandPaletteList>, 'children'> & {
|
|
13
|
+
children(item: ItemValue, props: CommandPaletteVirtualItemProps): ReactNode;
|
|
14
|
+
groupLabel?(group: Group): ReactNode;
|
|
15
|
+
};
|
|
16
|
+
export declare function CommandPaletteVirtualList<ItemValue, Group extends {
|
|
17
|
+
items: readonly ItemValue[];
|
|
18
|
+
} = {
|
|
19
|
+
items: readonly ItemValue[];
|
|
20
|
+
}>({ children, groupLabel, ref, ...props }: CommandPaletteVirtualListProps<ItemValue, Group>): import("react").JSX.Element;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { Fragment, useLayoutEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
3
|
+
import { Autocomplete } from '@base-ui/react/autocomplete';
|
|
4
|
+
import { CommandPaletteList } from "./parts.js";
|
|
5
|
+
import { useCommandPaletteVirtualScroll } from './context.js';
|
|
6
|
+
import { MENU_LABEL } from "../menu/parts.js";
|
|
7
|
+
import { mergeRefs } from '../merge-refs.js';
|
|
8
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
9
|
+
function virtualRowsOf(values, labelled) {
|
|
10
|
+
const groups = [];
|
|
11
|
+
const rows = [];
|
|
12
|
+
const itemRows = [];
|
|
13
|
+
let count = 0;
|
|
14
|
+
for (const value of values) {
|
|
15
|
+
const grouped = value !== null && typeof value === 'object' && 'items' in value && Array.isArray(value.items);
|
|
16
|
+
const group = grouped ? value : null;
|
|
17
|
+
if (group !== null && group.items.length === 0) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const groupIndex = groups.length;
|
|
21
|
+
groups.push(group);
|
|
22
|
+
if (group !== null && labelled) {
|
|
23
|
+
rows.push({
|
|
24
|
+
group: groupIndex,
|
|
25
|
+
index: -1
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
for (const item of group === null ? [value] : group.items) {
|
|
29
|
+
itemRows.push(rows.length);
|
|
30
|
+
rows.push({
|
|
31
|
+
group: groupIndex,
|
|
32
|
+
index: count++,
|
|
33
|
+
item
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
groups,
|
|
39
|
+
rows,
|
|
40
|
+
itemRows,
|
|
41
|
+
count
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/* The root retains every value for keyboard navigation; only the visible rows need DOM elements. */
|
|
45
|
+
export function CommandPaletteVirtualList({
|
|
46
|
+
children,
|
|
47
|
+
groupLabel,
|
|
48
|
+
ref,
|
|
49
|
+
...props
|
|
50
|
+
}) {
|
|
51
|
+
// The virtualizer keeps a mutable handle; every render reads its current window.
|
|
52
|
+
'use no memo';
|
|
53
|
+
|
|
54
|
+
const scroll = useCommandPaletteVirtualScroll();
|
|
55
|
+
const filtered = Autocomplete.useFilteredItems();
|
|
56
|
+
const scrollRef = useRef(null);
|
|
57
|
+
const labelled = groupLabel !== undefined;
|
|
58
|
+
const {
|
|
59
|
+
groups,
|
|
60
|
+
rows,
|
|
61
|
+
itemRows,
|
|
62
|
+
count
|
|
63
|
+
} = useMemo(() => virtualRowsOf(filtered, labelled), [filtered, labelled]);
|
|
64
|
+
// oxlint-disable-next-line react/incompatible-library
|
|
65
|
+
const virtualizer = useVirtualizer({
|
|
66
|
+
count: rows.length,
|
|
67
|
+
getScrollElement: () => scrollRef.current,
|
|
68
|
+
estimateSize: index => rows[index].index === -1 ? 28 : 32,
|
|
69
|
+
overscan: 8,
|
|
70
|
+
initialRect: {
|
|
71
|
+
width: 576,
|
|
72
|
+
height: 320
|
|
73
|
+
},
|
|
74
|
+
scrollPaddingStart: 6,
|
|
75
|
+
scrollPaddingEnd: 6
|
|
76
|
+
});
|
|
77
|
+
useLayoutEffect(() => {
|
|
78
|
+
if (scroll === null) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const highlight = (index_0, reason) => {
|
|
82
|
+
const row = itemRows[index_0];
|
|
83
|
+
if (row !== undefined && reason !== 'pointer') {
|
|
84
|
+
virtualizer.scrollToIndex(row, {
|
|
85
|
+
align: reason === 'none' ? 'start' : 'auto'
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
scroll.current = highlight;
|
|
90
|
+
return () => {
|
|
91
|
+
if (scroll.current === highlight) {
|
|
92
|
+
scroll.current = null;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}, [scroll, virtualizer, itemRows]);
|
|
96
|
+
const visible = virtualizer.getVirtualItems();
|
|
97
|
+
const visibleGroups = new Map();
|
|
98
|
+
for (const item of visible) {
|
|
99
|
+
const group = rows[item.index].group;
|
|
100
|
+
const current = visibleGroups.get(group);
|
|
101
|
+
if (current === undefined) {
|
|
102
|
+
visibleGroups.set(group, [item]);
|
|
103
|
+
} else {
|
|
104
|
+
current.push(item);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const renderRows = items => items.map(item_0 => {
|
|
108
|
+
const row_0 = rows[item_0.index];
|
|
109
|
+
const group_0 = groups[row_0.group];
|
|
110
|
+
return /*#__PURE__*/_jsx("div", {
|
|
111
|
+
role: "presentation",
|
|
112
|
+
"data-index": item_0.index,
|
|
113
|
+
ref: virtualizer.measureElement,
|
|
114
|
+
className: "absolute top-0 left-0 w-full",
|
|
115
|
+
style: {
|
|
116
|
+
transform: `translateY(${item_0.start}px)`
|
|
117
|
+
},
|
|
118
|
+
children: row_0.index === -1 && group_0 !== null && group_0 !== undefined ? /*#__PURE__*/_jsx(Autocomplete.GroupLabel, {
|
|
119
|
+
className: MENU_LABEL,
|
|
120
|
+
children: groupLabel?.(group_0)
|
|
121
|
+
}) : children(row_0.item, {
|
|
122
|
+
index: row_0.index,
|
|
123
|
+
'aria-posinset': row_0.index + 1,
|
|
124
|
+
'aria-setsize': count
|
|
125
|
+
})
|
|
126
|
+
}, item_0.key);
|
|
127
|
+
});
|
|
128
|
+
return /*#__PURE__*/_jsx(CommandPaletteList, {
|
|
129
|
+
...props,
|
|
130
|
+
ref: mergeRefs(ref, scrollRef),
|
|
131
|
+
children: /*#__PURE__*/_jsx("div", {
|
|
132
|
+
role: "presentation",
|
|
133
|
+
className: "relative",
|
|
134
|
+
style: {
|
|
135
|
+
height: virtualizer.getTotalSize()
|
|
136
|
+
},
|
|
137
|
+
children: [...visibleGroups].map(([index_1, items_0]) => {
|
|
138
|
+
const group_1 = groups[index_1];
|
|
139
|
+
if (group_1 === null || group_1 === undefined) {
|
|
140
|
+
return /*#__PURE__*/_jsx(Fragment, {
|
|
141
|
+
children: renderRows(items_0)
|
|
142
|
+
}, index_1);
|
|
143
|
+
}
|
|
144
|
+
const labelVisible = items_0.some(item_1 => rows[item_1.index].index === -1);
|
|
145
|
+
return /*#__PURE__*/_jsxs(Autocomplete.Group, {
|
|
146
|
+
items: group_1.items,
|
|
147
|
+
children: [!labelVisible && groupLabel !== undefined && /*#__PURE__*/_jsx(Autocomplete.GroupLabel, {
|
|
148
|
+
className: "sr-only",
|
|
149
|
+
children: groupLabel(group_1)
|
|
150
|
+
}), renderRows(items_0)]
|
|
151
|
+
}, index_1);
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
});
|
|
155
|
+
}
|
package/dist/format/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { formatAge, formatClock, formatDateTime, formatDay, formatDayClock, formatDayClockWithYear, formatDayWithYear, formatHour, formatMoment, formatNumericDate, formatWeekday, formatWeekdayClock, formatWeekdayDay, isSameDay } from './datetime.ts';
|
|
2
2
|
export { formatAgo, formatClockDuration, formatCountdown, formatDuration, formatElapsedShort, formatPreciseDuration } from './duration.ts';
|
|
3
|
-
export { formatLocale, labelCollator, setFormatSource, systemLocale, useFormatLocale, type FormatSource } from './locale.ts';
|
|
3
|
+
export { formatLocale, labelCollator, nameCollator, setFormatSource, systemLocale, useFormatLocale, type FormatSource } from './locale.ts';
|
|
4
4
|
export { formatBytes, formatDecimal, formatMoney, formatNumber, formatNumeral, formatPercent, formatRounded, formatTokens, formatUsdSignificant } from './number.ts';
|
|
5
5
|
export { FORMAT_LANGUAGE, FORMAT_REGIONS, FORMAT_REGION_CHOICES, FORMAT_SYSTEM, formatRegionFrom, regionName } from './regions.ts';
|
|
6
6
|
export { localTimeZone } from './time-zone.ts';
|
package/dist/format/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* Every number, date, time and duration a person reads, written the way the language and the region they set ask for. */
|
|
2
2
|
export { formatAge, formatClock, formatDateTime, formatDay, formatDayClock, formatDayClockWithYear, formatDayWithYear, formatHour, formatMoment, formatNumericDate, formatWeekday, formatWeekdayClock, formatWeekdayDay, isSameDay } from './datetime.js';
|
|
3
3
|
export { formatAgo, formatClockDuration, formatCountdown, formatDuration, formatElapsedShort, formatPreciseDuration } from './duration.js';
|
|
4
|
-
export { formatLocale, labelCollator, setFormatSource, systemLocale, useFormatLocale } from './locale.js';
|
|
4
|
+
export { formatLocale, labelCollator, nameCollator, setFormatSource, systemLocale, useFormatLocale } from './locale.js';
|
|
5
5
|
export { formatBytes, formatDecimal, formatMoney, formatNumber, formatNumeral, formatPercent, formatRounded, formatTokens, formatUsdSignificant } from './number.js';
|
|
6
6
|
export { FORMAT_LANGUAGE, FORMAT_REGIONS, FORMAT_REGION_CHOICES, FORMAT_SYSTEM, formatRegionFrom, regionName } from './regions.js';
|
|
7
7
|
export { localTimeZone } from './time-zone.js';
|
package/dist/format/locale.d.ts
CHANGED
|
@@ -14,5 +14,6 @@ export declare const numberFormatter: (options: Intl.NumberFormatOptions) => Int
|
|
|
14
14
|
export declare const dateFormatter: (options: Intl.DateTimeFormatOptions) => Intl.DateTimeFormat;
|
|
15
15
|
export declare const wordFormatter: (options: Intl.DateTimeFormatOptions) => Intl.DateTimeFormat;
|
|
16
16
|
export declare const relativeFormatter: (options: Intl.RelativeTimeFormatOptions) => Intl.RelativeTimeFormat;
|
|
17
|
+
export declare const nameCollator: (options: Intl.CollatorOptions) => Intl.Collator;
|
|
17
18
|
export declare const labelCollator: () => Intl.Collator;
|
|
18
19
|
export declare const nameOrder: (left: string, right: string) => number;
|
package/dist/format/locale.js
CHANGED
|
@@ -75,11 +75,7 @@ const subscribe = onChange => source.subscribe(onChange);
|
|
|
75
75
|
export const useFormatLocale = () => {
|
|
76
76
|
return useSyncExternalStore(subscribe, formatLocale, formatLocale);
|
|
77
77
|
};
|
|
78
|
-
/*
|
|
79
|
-
* A formatter per options object, keyed on the object itself, so a module-level spec builds its
|
|
80
|
-
* formatter once and a list of a thousand rows constructs nothing. A region change throws the entry
|
|
81
|
-
* away, since the locale is part of what was cached.
|
|
82
|
-
*/
|
|
78
|
+
/* A module-level options object shares its formatter across rows; a changed locale replaces it. */
|
|
83
79
|
const cacheFor = (store, options, locale, build) => {
|
|
84
80
|
const hit = store.get(options);
|
|
85
81
|
if (hit !== undefined && hit.locale === locale) {
|
|
@@ -96,6 +92,7 @@ const numbers = new WeakMap();
|
|
|
96
92
|
const dates = new WeakMap();
|
|
97
93
|
const words = new WeakMap();
|
|
98
94
|
const relatives = new WeakMap();
|
|
95
|
+
const names = new WeakMap();
|
|
99
96
|
export const numberFormatter = options => cacheFor(numbers, options, formatLocale(), (locale, spec) => new Intl.NumberFormat(locale, spec));
|
|
100
97
|
export const dateFormatter = options => cacheFor(dates, options, formatLocale(), (locale, spec) => new Intl.DateTimeFormat(locale, spec));
|
|
101
98
|
/* The same date, written in the language the words come from. Never shown whole: only its month,
|
|
@@ -103,6 +100,8 @@ export const dateFormatter = options => cacheFor(dates, options, formatLocale(),
|
|
|
103
100
|
export const wordFormatter = options => cacheFor(words, options, wordLocale(), (locale, spec) => new Intl.DateTimeFormat(locale, spec));
|
|
104
101
|
/* `3 min. geleden` is words as much as a number, so the language writes it, the way it writes a month. */
|
|
105
102
|
export const relativeFormatter = options => cacheFor(relatives, options, wordLocale(), (locale, spec) => new Intl.RelativeTimeFormat(locale, spec));
|
|
103
|
+
/* Proper names keep the runtime locale; a shared options object reuses its collator. */
|
|
104
|
+
export const nameCollator = options => cacheFor(names, options, undefined, (locale, spec) => new Intl.Collator(locale, spec));
|
|
106
105
|
const collators = new Map();
|
|
107
106
|
/*
|
|
108
107
|
* How two labels a person reads are put in order. Labels are words, so the language writes them and
|
package/dist/index.d.ts
CHANGED
|
@@ -103,3 +103,4 @@ export { DropPreview, type DropPreviewProps } from './workspace/DropPreview.tsx'
|
|
|
103
103
|
export { rectDropPreview, sameDropPreview, tabDropPreview, type DropPreviewShape, type TabDrop } from './workspace/preview.ts';
|
|
104
104
|
export { createSplitLayout, updateSplitLayout, splitPanes, type SplitAxis, type SplitSide, type SplitPane, type SplitBranch, type SplitNode, type SplitLayout, type SplitCommand } from './workspace/model.ts';
|
|
105
105
|
export { tabGapAt, type SplitRect, type SplitEdges, type SplitMinimum } from './workspace/geometry.ts';
|
|
106
|
+
export type { CommandPaletteVirtualListProps, CommandPaletteVirtualItemProps } from './command-palette/virtual.tsx';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.1",
|
|
4
4
|
"description": "React components, a theme, formatters and a settings dialog for desktop-grade apps on React 19, Base UI and Tailwind 4.",
|
|
5
5
|
"license": "FSL-1.1-MIT",
|
|
6
6
|
"author": "Bas Milius",
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"@base-ui/react": "^1.9.0",
|
|
69
69
|
"@pierre/trees": "1.0.0-beta.6",
|
|
70
|
+
"@tanstack/react-virtual": "^3.14.13",
|
|
70
71
|
"clsx": "^2.1.1",
|
|
71
72
|
"lucide-react": "^1.47.0",
|
|
72
73
|
"zustand": "^5.0.15"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createContext, useContext, type RefObject } from 'react';
|
|
2
|
+
|
|
3
|
+
interface PaletteRefs {
|
|
4
|
+
/* For the popup to put the focus in when it opens: a control before it would take it otherwise. */
|
|
5
|
+
input: RefObject<HTMLInputElement | null>;
|
|
6
|
+
/* For the root to scroll back to the top when the app sets the query. */
|
|
7
|
+
list: RefObject<HTMLDivElement | null>;
|
|
8
|
+
highlight(row: Element): void;
|
|
9
|
+
virtualScroll: RefObject<((index: number, reason: string) => void) | null>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const PaletteRefsContext = createContext<PaletteRefs | null>(null);
|
|
13
|
+
|
|
14
|
+
export function useCommandPaletteVirtualScroll(): PaletteRefs['virtualScroll'] | null {
|
|
15
|
+
return useContext(PaletteRefsContext)?.virtualScroll ?? null;
|
|
16
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useContext, useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react';
|
|
2
2
|
import { flushSync } from 'react-dom';
|
|
3
3
|
import clsx from 'clsx';
|
|
4
4
|
import { Search } from 'lucide-react';
|
|
@@ -11,16 +11,7 @@ import { Icon } from '../Icon.tsx';
|
|
|
11
11
|
import { Kbd } from '../Kbd.tsx';
|
|
12
12
|
import { mergeRefs } from '../merge-refs.ts';
|
|
13
13
|
import { KEY_SHORTCUTS } from '../shortcut.ts';
|
|
14
|
-
|
|
15
|
-
interface PaletteRefs {
|
|
16
|
-
/* For the popup to put the focus in when it opens: a control before it would take it otherwise. */
|
|
17
|
-
input: RefObject<HTMLInputElement | null>;
|
|
18
|
-
/* For the root to scroll back to the top when the app sets the query. */
|
|
19
|
-
list: RefObject<HTMLDivElement | null>;
|
|
20
|
-
highlight(row: Element): void;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const PaletteRefsContext = createContext<PaletteRefs | null>(null);
|
|
14
|
+
import { PaletteRefsContext } from './context.ts';
|
|
24
15
|
|
|
25
16
|
const NO_ITEMS: readonly never[] = [];
|
|
26
17
|
|
|
@@ -58,11 +49,13 @@ export function CommandPaletteRoot<ItemValue>({
|
|
|
58
49
|
value,
|
|
59
50
|
onValueChange,
|
|
60
51
|
items,
|
|
52
|
+
onItemHighlighted,
|
|
61
53
|
children,
|
|
62
54
|
...props
|
|
63
55
|
}: CommandPaletteRootProps<ItemValue>) {
|
|
64
56
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
65
57
|
const listRef = useRef<HTMLDivElement>(null);
|
|
58
|
+
const virtualScroll = useRef<((index: number, reason: string) => void) | null>(null);
|
|
66
59
|
const [focusHighlight, setFocusHighlight] = useState(false);
|
|
67
60
|
/*
|
|
68
61
|
* Base UI moves the highlight to the first row for a typed query only. For a query the app sets,
|
|
@@ -104,7 +97,7 @@ export function CommandPaletteRoot<ItemValue>({
|
|
|
104
97
|
|
|
105
98
|
return (
|
|
106
99
|
<DialogRoot open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange} onOpenChangeComplete={onOpenChangeComplete}>
|
|
107
|
-
<PaletteRefsContext value={{ input: inputRef, list: listRef, highlight }}>
|
|
100
|
+
<PaletteRefsContext value={{ input: inputRef, list: listRef, highlight, virtualScroll }}>
|
|
108
101
|
{/* Its props take a flat list or groups; only its overloads split the two, which a generic caller cannot pick between. */}
|
|
109
102
|
<Autocomplete.Root<ItemValue>
|
|
110
103
|
{...props}
|
|
@@ -116,6 +109,12 @@ export function CommandPaletteRoot<ItemValue>({
|
|
|
116
109
|
onValueChange?.(next, details);
|
|
117
110
|
}}
|
|
118
111
|
items={(resetting ? NO_ITEMS : items) as readonly ItemValue[] | undefined}
|
|
112
|
+
onItemHighlighted={(item, details) => {
|
|
113
|
+
if (item !== undefined && virtualScroll.current !== null) {
|
|
114
|
+
queueMicrotask(() => virtualScroll.current?.(details.index, details.reason));
|
|
115
|
+
}
|
|
116
|
+
onItemHighlighted?.(item, details);
|
|
117
|
+
}}
|
|
119
118
|
inline
|
|
120
119
|
open
|
|
121
120
|
autoHighlight={autoHighlight}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Fragment, useLayoutEffect, useMemo, useRef, type ComponentProps, type ReactNode } from 'react';
|
|
2
|
+
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
3
|
+
import { Autocomplete } from '@base-ui/react/autocomplete';
|
|
4
|
+
import { CommandPaletteList } from './parts.tsx';
|
|
5
|
+
import { useCommandPaletteVirtualScroll } from './context.ts';
|
|
6
|
+
import { MENU_LABEL } from '../menu/parts.tsx';
|
|
7
|
+
import { mergeRefs } from '../merge-refs.ts';
|
|
8
|
+
|
|
9
|
+
export interface CommandPaletteVirtualItemProps {
|
|
10
|
+
index: number;
|
|
11
|
+
'aria-setsize': number;
|
|
12
|
+
'aria-posinset': number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type CommandPaletteVirtualListProps<ItemValue, Group extends { items: readonly ItemValue[] } = { items: readonly ItemValue[] }> = Omit<
|
|
16
|
+
ComponentProps<typeof CommandPaletteList>,
|
|
17
|
+
'children'
|
|
18
|
+
> & {
|
|
19
|
+
children(item: ItemValue, props: CommandPaletteVirtualItemProps): ReactNode;
|
|
20
|
+
groupLabel?(group: Group): ReactNode;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
interface VirtualPaletteRow<ItemValue> {
|
|
24
|
+
group: number;
|
|
25
|
+
index: number;
|
|
26
|
+
item?: ItemValue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function virtualRowsOf<ItemValue, Group extends { items: readonly ItemValue[] }>(values: readonly (ItemValue | Group)[], labelled: boolean) {
|
|
30
|
+
const groups: (Group | null)[] = [];
|
|
31
|
+
const rows: VirtualPaletteRow<ItemValue>[] = [];
|
|
32
|
+
const itemRows: number[] = [];
|
|
33
|
+
let count = 0;
|
|
34
|
+
for (const value of values) {
|
|
35
|
+
const grouped = value !== null && typeof value === 'object' && 'items' in value && Array.isArray(value.items);
|
|
36
|
+
const group = grouped ? (value as Group) : null;
|
|
37
|
+
if (group !== null && group.items.length === 0) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const groupIndex = groups.length;
|
|
41
|
+
groups.push(group);
|
|
42
|
+
if (group !== null && labelled) {
|
|
43
|
+
rows.push({ group: groupIndex, index: -1 });
|
|
44
|
+
}
|
|
45
|
+
for (const item of group === null ? [value as ItemValue] : group.items) {
|
|
46
|
+
itemRows.push(rows.length);
|
|
47
|
+
rows.push({ group: groupIndex, index: count++, item });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { groups, rows, itemRows, count };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* The root retains every value for keyboard navigation; only the visible rows need DOM elements. */
|
|
54
|
+
export function CommandPaletteVirtualList<ItemValue, Group extends { items: readonly ItemValue[] } = { items: readonly ItemValue[] }>({
|
|
55
|
+
children,
|
|
56
|
+
groupLabel,
|
|
57
|
+
ref,
|
|
58
|
+
...props
|
|
59
|
+
}: CommandPaletteVirtualListProps<ItemValue, Group>) {
|
|
60
|
+
// The virtualizer keeps a mutable handle; every render reads its current window.
|
|
61
|
+
'use no memo';
|
|
62
|
+
const scroll = useCommandPaletteVirtualScroll();
|
|
63
|
+
const filtered = Autocomplete.useFilteredItems<ItemValue | Group>();
|
|
64
|
+
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
65
|
+
const labelled = groupLabel !== undefined;
|
|
66
|
+
const { groups, rows, itemRows, count } = useMemo(() => virtualRowsOf<ItemValue, Group>(filtered, labelled), [filtered, labelled]);
|
|
67
|
+
// oxlint-disable-next-line react/incompatible-library
|
|
68
|
+
const virtualizer = useVirtualizer({
|
|
69
|
+
count: rows.length,
|
|
70
|
+
getScrollElement: () => scrollRef.current,
|
|
71
|
+
estimateSize: (index) => (rows[index]!.index === -1 ? 28 : 32),
|
|
72
|
+
overscan: 8,
|
|
73
|
+
initialRect: { width: 576, height: 320 },
|
|
74
|
+
scrollPaddingStart: 6,
|
|
75
|
+
scrollPaddingEnd: 6
|
|
76
|
+
});
|
|
77
|
+
useLayoutEffect(() => {
|
|
78
|
+
if (scroll === null) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const highlight = (index: number, reason: string): void => {
|
|
82
|
+
const row = itemRows[index];
|
|
83
|
+
if (row !== undefined && reason !== 'pointer') {
|
|
84
|
+
virtualizer.scrollToIndex(row, { align: reason === 'none' ? 'start' : 'auto' });
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
scroll.current = highlight;
|
|
88
|
+
return () => {
|
|
89
|
+
if (scroll.current === highlight) {
|
|
90
|
+
scroll.current = null;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}, [scroll, virtualizer, itemRows]);
|
|
94
|
+
const visible = virtualizer.getVirtualItems();
|
|
95
|
+
const visibleGroups = new Map<number, typeof visible>();
|
|
96
|
+
for (const item of visible) {
|
|
97
|
+
const group = rows[item.index]!.group;
|
|
98
|
+
const current = visibleGroups.get(group);
|
|
99
|
+
if (current === undefined) {
|
|
100
|
+
visibleGroups.set(group, [item]);
|
|
101
|
+
} else {
|
|
102
|
+
current.push(item);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const renderRows = (items: typeof visible): ReactNode =>
|
|
106
|
+
items.map((item) => {
|
|
107
|
+
const row = rows[item.index]!;
|
|
108
|
+
const group = groups[row.group];
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
key={item.key}
|
|
112
|
+
role="presentation"
|
|
113
|
+
data-index={item.index}
|
|
114
|
+
ref={virtualizer.measureElement}
|
|
115
|
+
className="absolute top-0 left-0 w-full"
|
|
116
|
+
style={{ transform: `translateY(${item.start}px)` }}
|
|
117
|
+
>
|
|
118
|
+
{row.index === -1 && group !== null && group !== undefined ? (
|
|
119
|
+
<Autocomplete.GroupLabel className={MENU_LABEL}>{groupLabel?.(group)}</Autocomplete.GroupLabel>
|
|
120
|
+
) : (
|
|
121
|
+
children(row.item as ItemValue, { index: row.index, 'aria-posinset': row.index + 1, 'aria-setsize': count })
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
return (
|
|
127
|
+
<CommandPaletteList {...props} ref={mergeRefs(ref, scrollRef)}>
|
|
128
|
+
<div role="presentation" className="relative" style={{ height: virtualizer.getTotalSize() }}>
|
|
129
|
+
{[...visibleGroups].map(([index, items]) => {
|
|
130
|
+
const group = groups[index];
|
|
131
|
+
if (group === null || group === undefined) {
|
|
132
|
+
return <Fragment key={index}>{renderRows(items)}</Fragment>;
|
|
133
|
+
}
|
|
134
|
+
const labelVisible = items.some((item) => rows[item.index]!.index === -1);
|
|
135
|
+
return (
|
|
136
|
+
<Autocomplete.Group key={index} items={group.items}>
|
|
137
|
+
{!labelVisible && groupLabel !== undefined && (
|
|
138
|
+
<Autocomplete.GroupLabel className="sr-only">{groupLabel(group)}</Autocomplete.GroupLabel>
|
|
139
|
+
)}
|
|
140
|
+
{renderRows(items)}
|
|
141
|
+
</Autocomplete.Group>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</div>
|
|
145
|
+
</CommandPaletteList>
|
|
146
|
+
);
|
|
147
|
+
}
|
package/src/format/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ export {
|
|
|
17
17
|
isSameDay
|
|
18
18
|
} from './datetime.ts';
|
|
19
19
|
export { formatAgo, formatClockDuration, formatCountdown, formatDuration, formatElapsedShort, formatPreciseDuration } from './duration.ts';
|
|
20
|
-
export { formatLocale, labelCollator, setFormatSource, systemLocale, useFormatLocale, type FormatSource } from './locale.ts';
|
|
20
|
+
export { formatLocale, labelCollator, nameCollator, setFormatSource, systemLocale, useFormatLocale, type FormatSource } from './locale.ts';
|
|
21
21
|
export {
|
|
22
22
|
formatBytes,
|
|
23
23
|
formatDecimal,
|
package/src/format/locale.ts
CHANGED
|
@@ -95,16 +95,17 @@ const subscribe = (onChange: () => void): (() => void) => source.subscribe(onCha
|
|
|
95
95
|
export const useFormatLocale = (): string => useSyncExternalStore(subscribe, formatLocale, formatLocale);
|
|
96
96
|
|
|
97
97
|
interface Cached<T> {
|
|
98
|
-
locale: string;
|
|
98
|
+
locale: string | undefined;
|
|
99
99
|
formatter: T;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
/*
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
102
|
+
/* A module-level options object shares its formatter across rows; a changed locale replaces it. */
|
|
103
|
+
const cacheFor = <O extends object, T>(
|
|
104
|
+
store: WeakMap<O, Cached<T>>,
|
|
105
|
+
options: O,
|
|
106
|
+
locale: string | undefined,
|
|
107
|
+
build: (locale: string | undefined, options: O) => T
|
|
108
|
+
): T => {
|
|
108
109
|
const hit = store.get(options);
|
|
109
110
|
if (hit !== undefined && hit.locale === locale) {
|
|
110
111
|
return hit.formatter;
|
|
@@ -118,6 +119,7 @@ const numbers = new WeakMap<Intl.NumberFormatOptions, Cached<Intl.NumberFormat>>
|
|
|
118
119
|
const dates = new WeakMap<Intl.DateTimeFormatOptions, Cached<Intl.DateTimeFormat>>();
|
|
119
120
|
const words = new WeakMap<Intl.DateTimeFormatOptions, Cached<Intl.DateTimeFormat>>();
|
|
120
121
|
const relatives = new WeakMap<Intl.RelativeTimeFormatOptions, Cached<Intl.RelativeTimeFormat>>();
|
|
122
|
+
const names = new WeakMap<Intl.CollatorOptions, Cached<Intl.Collator>>();
|
|
121
123
|
|
|
122
124
|
export const numberFormatter = (options: Intl.NumberFormatOptions): Intl.NumberFormat =>
|
|
123
125
|
cacheFor(numbers, options, formatLocale(), (locale, spec) => new Intl.NumberFormat(locale, spec));
|
|
@@ -134,6 +136,10 @@ export const wordFormatter = (options: Intl.DateTimeFormatOptions): Intl.DateTim
|
|
|
134
136
|
export const relativeFormatter = (options: Intl.RelativeTimeFormatOptions): Intl.RelativeTimeFormat =>
|
|
135
137
|
cacheFor(relatives, options, wordLocale(), (locale, spec) => new Intl.RelativeTimeFormat(locale, spec));
|
|
136
138
|
|
|
139
|
+
/* Proper names keep the runtime locale; a shared options object reuses its collator. */
|
|
140
|
+
export const nameCollator = (options: Intl.CollatorOptions): Intl.Collator =>
|
|
141
|
+
cacheFor(names, options, undefined, (locale, spec) => new Intl.Collator(locale, spec));
|
|
142
|
+
|
|
137
143
|
const collators = new Map<string, Intl.Collator>();
|
|
138
144
|
|
|
139
145
|
/*
|
package/src/index.ts
CHANGED
|
@@ -152,3 +152,5 @@ export {
|
|
|
152
152
|
type SplitCommand
|
|
153
153
|
} from './workspace/model.ts';
|
|
154
154
|
export { tabGapAt, type SplitRect, type SplitEdges, type SplitMinimum } from './workspace/geometry.ts';
|
|
155
|
+
|
|
156
|
+
export type { CommandPaletteVirtualListProps, CommandPaletteVirtualItemProps } from './command-palette/virtual.tsx';
|