@noya-app/noya-designsystem 0.1.45 → 0.1.46

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.
@@ -65,86 +65,87 @@ export const MenuViewport = <T extends string>({
65
65
  return (
66
66
  <>
67
67
  {items.map((item, index) => {
68
- if (item.type === "separator") {
69
- return (
70
- <Components.Separator
71
- key={index}
72
- className={styles.separatorStyle}
73
- />
74
- );
75
- }
76
-
77
- if (item.type === "sectionHeader") {
78
- return (
79
- <SectionHeader
80
- isFirst={index === 0}
81
- key={item.id}
82
- {...item}
83
- indented={hasCheckedItem}
84
- />
85
- );
86
- }
68
+ switch (item.type) {
69
+ case "separator":
70
+ return (
71
+ <Components.Separator
72
+ key={index}
73
+ className={styles.separatorStyle}
74
+ />
75
+ );
87
76
 
88
- const value = item.value ?? "";
89
-
90
- if (
91
- item.items &&
92
- Components.Sub &&
93
- Components.SubTrigger &&
94
- Components.SubContent &&
95
- Components.Portal
96
- ) {
97
- return (
98
- <Components.Sub key={value}>
99
- <Components.SubTrigger
100
- className={styles.itemStyle({ disabled: item.disabled })}
101
- asChild
77
+ case "sectionHeader":
78
+ return (
79
+ <SectionHeader
80
+ isFirst={index === 0}
81
+ key={item.id}
82
+ {...item}
83
+ indented={hasCheckedItem}
84
+ />
85
+ );
86
+ case "submenu":
87
+ if (
88
+ item.items &&
89
+ Components.Sub &&
90
+ Components.SubTrigger &&
91
+ Components.SubContent &&
92
+ Components.Portal
93
+ ) {
94
+ return (
95
+ <Components.Sub key={item.id}>
96
+ <Components.SubTrigger
97
+ className={styles.itemStyle({ disabled: item.disabled })}
98
+ asChild
99
+ onClick={(e) => {
100
+ e.stopPropagation();
101
+ e.preventDefault();
102
+ }}
103
+ >
104
+ <SelectItem
105
+ value={item.id}
106
+ icon={item.icon}
107
+ checked={item.checked}
108
+ onSelect={onSelect}
109
+ Components={Components}
110
+ indented={hasCheckedItem}
111
+ >
112
+ <div className="flex items-center flex-1">
113
+ {item.title}
114
+ </div>
115
+ <ChevronRightIcon className="-mr-1" />
116
+ </SelectItem>
117
+ </Components.SubTrigger>
118
+ <Components.Portal>
119
+ <Components.SubContent
120
+ alignOffset={-5}
121
+ className={styles.contentStyle}
122
+ >
123
+ <MenuViewport
124
+ items={item.items}
125
+ Components={Components}
126
+ onSelect={onSelect}
127
+ />
128
+ </Components.SubContent>
129
+ </Components.Portal>
130
+ </Components.Sub>
131
+ );
132
+ } else return null;
133
+ default:
134
+ return (
135
+ <SelectItem
136
+ key={item.value}
137
+ value={item.value}
138
+ icon={item.icon}
139
+ checked={item.checked}
140
+ shortcut={item.shortcut}
141
+ onSelect={onSelect}
142
+ Components={Components}
143
+ indented={hasCheckedItem}
102
144
  >
103
- <SelectItem
104
- value={value}
105
- icon={item.icon}
106
- checked={item.checked}
107
- shortcut={item.shortcut}
108
- onSelect={onSelect}
109
- Components={Components}
110
- indented={hasCheckedItem}
111
- >
112
- <div className="flex items-center flex-1">
113
- {item.title ?? value}
114
- </div>
115
- <ChevronRightIcon className="-mr-1" />
116
- </SelectItem>
117
- </Components.SubTrigger>
118
- <Components.Portal>
119
- <Components.SubContent
120
- alignOffset={-5}
121
- className={styles.contentStyle}
122
- >
123
- <MenuViewport
124
- items={item.items}
125
- Components={Components}
126
- onSelect={onSelect}
127
- />
128
- </Components.SubContent>
129
- </Components.Portal>
130
- </Components.Sub>
131
- );
145
+ {item.title ?? item.value}
146
+ </SelectItem>
147
+ );
132
148
  }
133
-
134
- return (
135
- <SelectItem
136
- key={value}
137
- value={value}
138
- icon={item.icon}
139
- checked={item.checked}
140
- shortcut={item.shortcut}
141
- onSelect={onSelect}
142
- Components={Components}
143
- indented={hasCheckedItem}
144
- >
145
- {item.title ?? value}
146
- </SelectItem>
147
- );
148
149
  })}
149
150
  </>
150
151
  );
package/src/index.tsx CHANGED
@@ -2,9 +2,11 @@
2
2
  export * from "./components/ActivityIndicator";
3
3
  export * from "./components/AnimatePresence";
4
4
  export * from "./components/Avatar";
5
+ export * from "./components/Breadcrumbs";
5
6
  export * from "./components/Button";
6
7
  export * from "./components/Checkbox";
7
8
  export * from "./components/Chip";
9
+ export * from "./components/Collection";
8
10
  export * from "./components/Combobox";
9
11
  export * from "./components/ComboboxMenu";
10
12
  export * from "./components/CommandPalette";
@@ -13,11 +15,13 @@ export * from "./components/Dialog";
13
15
  export * from "./components/Divider";
14
16
  export * from "./components/DraggableMenuButton";
15
17
  export * from "./components/DropdownMenu";
18
+ export * from "./components/EditableText";
16
19
  export * from "./components/Fade";
17
20
  export * from "./components/FillInputField";
18
21
  export * from "./components/FillPreviewBackground";
19
22
  export * from "./components/FloatingWindow";
20
23
  export * from "./components/GradientPicker";
24
+ export * from "./components/Grid";
21
25
  export * from "./components/GridView";
22
26
  export * from "./components/IconButton";
23
27
  export * from "./components/Icons";
@@ -27,14 +31,13 @@ export * from "./components/internal/Menu";
27
31
  export type {
28
32
  ExtractMenuItemType,
29
33
  MenuItem,
30
- RegularMenuItem,
31
- SectionHeaderMenuItem,
32
- SeparatorItem,
34
+ SelectableMenuItem,
33
35
  } from "./components/internal/Menu";
34
36
  export * from "./components/internal/MenuViewport";
35
37
  export * from "./components/Label";
36
38
  export * from "./components/LabeledElementView";
37
39
  export * from "./components/LabeledField";
40
+ export * from "./components/List";
38
41
  export * from "./components/ListView";
39
42
  export * from "./components/Message";
40
43
  export * from "./components/Popover";
@@ -43,7 +46,6 @@ export * from "./components/ScrollArea";
43
46
  export * from "./components/SearchCompletionMenu";
44
47
  export * from "./components/SegmentedControl";
45
48
  export * from "./components/SelectMenu";
46
- export * from "./components/SidebarList";
47
49
  export * from "./components/Slider";
48
50
  export * from "./components/Sortable";
49
51
  export type { RelativeDropPosition } from "./components/Sortable";
@@ -1,83 +1,76 @@
1
1
  import { chunkBy, partition } from "@noya-app/noya-utils";
2
- import { ReactNode, useSyncExternalStore } from "react";
2
+ import { useSyncExternalStore } from "react";
3
+ import {
4
+ isMenuItemSectionHeader,
5
+ isNonSelectableMenuItem,
6
+ isSelectableMenuItem,
7
+ MenuItem,
8
+ ScoredMenuItem,
9
+ SectionHeaderMenuItem,
10
+ SelectableMenuItem,
11
+ } from "../components/internal/Menu";
3
12
  import { fuzzyFilter, IScoredItem } from "./fuzzyScorer";
4
13
 
5
- export type ComboboxOption = {
6
- type?: undefined;
7
- id: string;
8
- name: string;
9
- icon?: ReactNode;
10
- shortcut?: string;
11
- disabled?: boolean;
12
- alwaysInclude?: boolean;
13
- checked?: boolean;
14
- };
15
-
16
- export type ComboboxSectionHeader = {
17
- type: "sectionHeader";
18
- id: string;
19
- name: string;
20
- maxVisibleItems?: number;
21
- };
22
-
23
- export type ComboboxItem = ComboboxOption | ComboboxSectionHeader;
24
-
25
- export type InternalComboboxItem =
26
- | (ComboboxOption & IScoredItem)
27
- | ComboboxSectionHeader;
28
-
29
- export type ComboboxStateSnapshot = {
14
+ export type ComboboxStateSnapshot<T extends string> = {
30
15
  filter: string;
31
16
  selectedIndex: number;
32
- filteredItems: InternalComboboxItem[];
17
+ filteredItems: ScoredMenuItem<T>[];
33
18
  lastSubmittedValue: { filter: string; itemName: string };
34
19
  };
35
20
 
36
- export class ComboboxState {
21
+ export class ComboboxState<T extends string> {
37
22
  private filter: string;
38
23
  private selectedIndex: number;
39
- private items: ComboboxItem[];
24
+ private items: MenuItem<T>[];
40
25
  private lastSubmittedValue: { filter: string; itemName: string };
41
26
  private subscribers: (() => void)[] = [];
42
27
  private version = 0;
43
- private _cachedSnapshot: [number, ComboboxStateSnapshot] | undefined;
28
+ private _cachedSnapshot: [number, ComboboxStateSnapshot<T>] | undefined;
44
29
 
45
30
  constructor(
46
- items: ComboboxItem[],
47
- initialValue: string,
31
+ items: MenuItem<T>[],
32
+ initialTitle: string,
48
33
  allowArbitraryValues: boolean
49
34
  ) {
50
35
  this.items = items;
51
36
  this.lastSubmittedValue = {
52
- filter: initialValue,
53
- itemName: initialValue,
37
+ filter: initialTitle,
38
+ itemName: initialTitle,
54
39
  };
55
40
 
56
41
  // Initialize with the initial value
57
- this.filter = initialValue;
42
+ this.filter = initialTitle;
58
43
  this.selectedIndex = 0;
59
44
 
60
45
  // Create initial snapshot
61
46
  this._cachedSnapshot = [
62
47
  0,
63
48
  {
64
- filter: initialValue,
49
+ filter: initialTitle,
65
50
  selectedIndex: 0,
66
- filteredItems: items.map((item, index) => {
67
- if (item.type === "sectionHeader") return item;
68
- return { ...item, index, score: 0 };
69
- }),
51
+
52
+ filteredItems: this.items.flatMap((item, index) =>
53
+ isMenuItemSectionHeader(item)
54
+ ? item
55
+ : isSelectableMenuItem(item)
56
+ ? {
57
+ ...item,
58
+ index,
59
+ score: 0,
60
+ }
61
+ : []
62
+ ),
70
63
  lastSubmittedValue: this.lastSubmittedValue,
71
64
  },
72
65
  ];
73
66
 
74
67
  // If there's an initial value, validate it
75
- if (initialValue) {
68
+ if (initialTitle) {
76
69
  // Check if the initial value exists in items
77
- const hasMatch = items.some(
70
+ const hasMatch = this.items.some(
78
71
  (item) =>
79
- item.type !== "sectionHeader" &&
80
- item.name.toLowerCase() === initialValue.toLowerCase()
72
+ item.type === undefined &&
73
+ item.title?.toString().toLowerCase() === initialTitle.toLowerCase()
81
74
  );
82
75
 
83
76
  // Get filtered items to check for fuzzy matches
@@ -92,10 +85,20 @@ export class ComboboxState {
92
85
  {
93
86
  filter: "",
94
87
  selectedIndex: 0,
95
- filteredItems: items.map((item, index) => {
96
- if (item.type === "sectionHeader") return item;
97
- return { ...item, index, score: 0 };
98
- }),
88
+
89
+ filteredItems: this.items.flatMap((item): ScoredMenuItem<T>[] =>
90
+ isSelectableMenuItem(item)
91
+ ? [
92
+ {
93
+ ...item,
94
+ index: 0,
95
+ score: 0,
96
+ },
97
+ ]
98
+ : item.type === "sectionHeader"
99
+ ? [item]
100
+ : []
101
+ ),
99
102
  lastSubmittedValue: this.lastSubmittedValue,
100
103
  },
101
104
  ];
@@ -116,11 +119,11 @@ export class ComboboxState {
116
119
  this.subscribers.forEach((listener) => listener());
117
120
  }
118
121
 
119
- private getFilteredItems(): InternalComboboxItem[] {
122
+ private getFilteredItems(): ScoredMenuItem<T>[] {
120
123
  return filterWithGroupedSections(this.items, this.filter);
121
124
  }
122
125
 
123
- getSnapshot = (): ComboboxStateSnapshot => {
126
+ getSnapshot = (): ComboboxStateSnapshot<T> => {
124
127
  if (this._cachedSnapshot && this._cachedSnapshot[0] === this.version) {
125
128
  return this._cachedSnapshot[1];
126
129
  }
@@ -136,7 +139,7 @@ export class ComboboxState {
136
139
  return snapshot;
137
140
  };
138
141
 
139
- setItems(items: ComboboxItem[]) {
142
+ setItems(items: MenuItem<T>[]) {
140
143
  this.items = items;
141
144
  this.notifyChange();
142
145
  }
@@ -148,13 +151,13 @@ export class ComboboxState {
148
151
  // Find exact match if it exists (case insensitive)
149
152
  const exactMatchIndex = filteredItems.findIndex(
150
153
  (item) =>
151
- item.type !== "sectionHeader" &&
152
- item.name.toLowerCase().startsWith(filter.toLowerCase())
154
+ isSelectableMenuItem(item) &&
155
+ item.title?.toString().toLowerCase().startsWith(filter.toLowerCase())
153
156
  );
154
157
 
155
158
  // Find the first non-header item
156
- const firstSelectableIndex = filteredItems.findIndex(
157
- (item) => item.type !== "sectionHeader"
159
+ const firstSelectableIndex = filteredItems.findIndex((item): boolean =>
160
+ isSelectableMenuItem(item)
158
161
  );
159
162
 
160
163
  this.selectedIndex =
@@ -169,29 +172,28 @@ export class ComboboxState {
169
172
  filteredItems,
170
173
  this.selectedIndex,
171
174
  direction === "down" ? "next" : "previous",
172
- (item) => item.type === "sectionHeader"
175
+ (item) => isNonSelectableMenuItem(item)
173
176
  );
174
177
  this.notifyChange();
175
178
  }
176
179
 
177
180
  selectCurrentItem(
178
- item?: InternalComboboxItem
179
- ): InternalComboboxItem | undefined {
181
+ item?: ScoredMenuItem<T>
182
+ ): SelectableMenuItem<T> | undefined {
180
183
  const itemToSelect = item || this.getFilteredItems()[this.selectedIndex];
181
- if (!itemToSelect || itemToSelect.type === "sectionHeader") {
184
+ if (itemToSelect && isSelectableMenuItem(itemToSelect)) {
185
+ this.lastSubmittedValue = {
186
+ filter: itemToSelect.title!.toString(),
187
+ itemName: itemToSelect.title!.toString(),
188
+ };
189
+ this.filter = itemToSelect.title!.toString();
190
+ this.notifyChange();
191
+ return itemToSelect;
192
+ } else {
182
193
  // If no valid item to select and arbitrary values aren't allowed,
183
194
  // restore the last valid value
184
195
  this.restoreLastSubmittedValue();
185
- return;
186
196
  }
187
-
188
- this.lastSubmittedValue = {
189
- filter: itemToSelect.name,
190
- itemName: itemToSelect.name,
191
- };
192
- this.filter = itemToSelect.name;
193
- this.notifyChange();
194
- return itemToSelect;
195
197
  }
196
198
 
197
199
  restoreLastSubmittedValue() {
@@ -205,25 +207,25 @@ export class ComboboxState {
205
207
  this.notifyChange();
206
208
  }
207
209
 
208
- getSelectedItem(): InternalComboboxItem | undefined {
210
+ getSelectedItem(): ScoredMenuItem<T> | undefined {
209
211
  const filteredItems = this.getFilteredItems();
210
212
  return filteredItems[this.selectedIndex];
211
213
  }
212
214
 
213
215
  getTypeaheadValue(): string | undefined {
214
216
  const selectedItem = this.getSelectedItem();
215
- if (!selectedItem || selectedItem.type === "sectionHeader") return;
216
- return selectedItem.name;
217
+ if (selectedItem && isSelectableMenuItem(selectedItem))
218
+ return selectedItem.title!.toString();
217
219
  }
218
220
 
219
221
  setSelectedIndex(index: number) {
220
222
  const filteredItems = this.getFilteredItems();
221
223
 
222
- // If trying to select a header, find the next selectable item
223
- if (filteredItems[index]?.type === "sectionHeader") {
224
- // Look for the next non-header item
224
+ // If trying to select a header or separator, find the next selectable item
225
+ if (isNonSelectableMenuItem(filteredItems[index])) {
226
+ // Look for the next selectable item
225
227
  for (let i = index + 1; i < filteredItems.length; i++) {
226
- if (filteredItems[i]?.type !== "sectionHeader") {
228
+ if (isSelectableMenuItem(filteredItems[i])) {
227
229
  this.selectedIndex = i;
228
230
  this.notifyChange();
229
231
  return;
@@ -231,7 +233,7 @@ export class ComboboxState {
231
233
  }
232
234
  // If no next item, look backwards
233
235
  for (let i = index - 1; i >= 0; i--) {
234
- if (filteredItems[i]?.type !== "sectionHeader") {
236
+ if (isSelectableMenuItem(filteredItems[i])) {
235
237
  this.selectedIndex = i;
236
238
  this.notifyChange();
237
239
  return;
@@ -255,18 +257,18 @@ export class ComboboxState {
255
257
  }
256
258
  }
257
259
 
258
- function filterWithGroupedSections(
259
- items: ComboboxItem[],
260
+ function filterWithGroupedSections<T extends string>(
261
+ items: MenuItem<T>[],
260
262
  query: string
261
- ): InternalComboboxItem[] {
262
- const sections = chunkBy(items, (_a, b) => b.type !== "sectionHeader");
263
- let result: InternalComboboxItem[] = [];
263
+ ): ScoredMenuItem<T>[] {
264
+ const sections = chunkBy(items, (_a, b) => isSelectableMenuItem(b));
265
+ let result: ScoredMenuItem<T>[] = [];
264
266
  let currentIndex = 0;
265
267
 
266
268
  const createExtraItem = (
267
- item: ComboboxOption,
269
+ item: SelectableMenuItem<T>,
268
270
  index: number
269
- ): InternalComboboxItem => ({
271
+ ): ScoredMenuItem<T> => ({
270
272
  ...item,
271
273
  index: currentIndex + index,
272
274
  score: 0,
@@ -274,56 +276,66 @@ function filterWithGroupedSections(
274
276
 
275
277
  const createScoredItem = (
276
278
  item: IScoredItem,
277
- regular: ComboboxOption[]
278
- ): InternalComboboxItem => ({
279
- ...item,
280
- ...regular[item.index],
281
- index: currentIndex + item.index,
282
- });
279
+ selectableItems: Array<SelectableMenuItem<T>>
280
+ ): ScoredMenuItem<T> => {
281
+ const menuItem = selectableItems[item.index];
282
+ return {
283
+ ...item,
284
+ ...menuItem,
285
+ index: currentIndex + item.index,
286
+ };
287
+ };
283
288
 
284
289
  for (const section of sections) {
285
- const [headers, regular] = partition(
286
- section,
287
- (item): item is ComboboxSectionHeader => item.type === "sectionHeader"
290
+ const [sectionHeaders, selectableItems] = partition(
291
+ section.filter(
292
+ (item): item is SelectableMenuItem<T> | SectionHeaderMenuItem =>
293
+ item.type !== "submenu" && item.type !== "separator"
294
+ ),
295
+ (item): item is SectionHeaderMenuItem => isMenuItemSectionHeader(item)
288
296
  );
289
297
 
290
- let newItems: InternalComboboxItem[];
298
+ let newItems: ScoredMenuItem<T>[];
291
299
 
292
300
  if (!query.trim()) {
293
301
  // For empty query, include all items with score 0
294
- newItems = regular.map((item, index) => createExtraItem(item, index));
302
+ newItems = selectableItems.map((item, index) =>
303
+ createExtraItem(item, index)
304
+ );
295
305
  } else {
296
306
  const scoredItems = fuzzyFilter({
297
- items: regular.map((item) => item.name),
307
+ items: selectableItems.map((item) => item.title.toString()),
298
308
  query,
299
309
  });
300
310
 
301
311
  const usedIndexes = new Set(scoredItems.map((item) => item.index));
302
- const extraItems = regular.flatMap(
303
- (item, index): InternalComboboxItem[] =>
304
- item.alwaysInclude && !usedIndexes.has(index)
312
+ const extraItems = selectableItems.flatMap(
313
+ (item, index): ScoredMenuItem<T>[] =>
314
+ item?.alwaysInclude && !usedIndexes.has(index)
305
315
  ? [createExtraItem(item, index)]
306
316
  : []
307
317
  );
308
318
 
309
319
  newItems = scoredItems
310
- .map((item) => createScoredItem(item, regular))
320
+ .map((item) => createScoredItem(item, selectableItems))
311
321
  .concat(extraItems);
312
322
  }
313
323
 
314
324
  // If we have items or this is an empty query
315
325
  if (newItems.length > 0 || !query.trim()) {
316
326
  const maxVisibleItems =
317
- headers.length > 0 ? headers[0].maxVisibleItems : undefined;
327
+ sectionHeaders.length > 0 && sectionHeaders[0]?.maxVisibleItems
328
+ ? sectionHeaders[0]?.maxVisibleItems
329
+ : undefined;
318
330
 
319
331
  if (maxVisibleItems !== undefined) {
320
332
  newItems = newItems.slice(0, maxVisibleItems);
321
333
  }
322
334
 
323
- result.push(...headers, ...newItems);
335
+ result.push(...sectionHeaders, ...newItems);
324
336
  }
325
337
 
326
- currentIndex += regular.length;
338
+ currentIndex += selectableItems.length;
327
339
  }
328
340
 
329
341
  return result;
@@ -363,7 +375,7 @@ export function getNextIndex<T>(
363
375
  return nextIndex;
364
376
  }
365
377
 
366
- export function useComboboxState(state: ComboboxState) {
378
+ export function useComboboxState<T extends string>(state: ComboboxState<T>) {
367
379
  return useSyncExternalStore(
368
380
  state.subscribe,
369
381
  state.getSnapshot,
@@ -1,13 +1,9 @@
1
- import {
2
- MenuItem,
3
- RegularMenuItem,
4
- SeparatorItem,
5
- } from "../components/internal/Menu";
1
+ import { MenuItem, SeparatorItem } from "../components/internal/Menu";
6
2
 
7
3
  export type Optional<T> = T | false | null | undefined;
8
4
 
9
5
  function withSeparators<T extends string>(
10
- elements: RegularMenuItem<T>[][],
6
+ elements: MenuItem<T>[][],
11
7
  separator: SeparatorItem
12
8
  ): MenuItem<T>[] {
13
9
  const result: MenuItem<T>[] = [];
@@ -23,9 +19,7 @@ function withSeparators<T extends string>(
23
19
  return result;
24
20
  }
25
21
 
26
- export type MenuConfig<T extends string> = Optional<
27
- Optional<RegularMenuItem<T>>[]
28
- >[];
22
+ export type MenuConfig<T extends string> = Optional<Optional<MenuItem<T>>[]>[];
29
23
 
30
24
  export function createSectionedMenu<T extends string>(
31
25
  ...sections: MenuConfig<T>