@adecore/ui 0.36.0 → 0.37.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.
@@ -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 {};
@@ -0,0 +1,5 @@
1
+ import { createContext, useContext } from 'react';
2
+ export const PaletteRefsContext = createContext(null);
3
+ export function useCommandPaletteVirtualScroll() {
4
+ return useContext(PaletteRefsContext)?.virtualScroll ?? null;
5
+ }
@@ -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 { createContext, useContext, useLayoutEffect, useRef, useState } from 'react';
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
+ }
@@ -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';
@@ -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';
@@ -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;
@@ -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.36.0",
3
+ "version": "0.37.0",
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
+ }
@@ -14,3 +14,5 @@ export {
14
14
  CommandPalettePopup as Popup,
15
15
  CommandPaletteRoot as Root
16
16
  } from './parts.tsx';
17
+
18
+ export { CommandPaletteVirtualList as VirtualList } from './virtual.tsx';
@@ -1,4 +1,4 @@
1
- import { createContext, useContext, useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode, type RefObject } from 'react';
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
+ }
@@ -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,
@@ -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
- * A formatter per options object, keyed on the object itself, so a module-level spec builds its
104
- * formatter once and a list of a thousand rows constructs nothing. A region change throws the entry
105
- * away, since the locale is part of what was cached.
106
- */
107
- const cacheFor = <O extends object, T>(store: WeakMap<O, Cached<T>>, options: O, locale: string, build: (locale: string, options: O) => T): T => {
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';