@noya-app/noya-designsystem 0.1.41 → 0.1.43

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.
Files changed (53) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +16 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.mts +268 -147
  5. package/dist/index.d.ts +268 -147
  6. package/dist/index.js +7618 -6908
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +6161 -5487
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +4 -5
  11. package/src/__tests__/combobox.test.ts +578 -0
  12. package/src/components/ActivityIndicator.tsx +4 -4
  13. package/src/components/AnimatePresence.tsx +5 -5
  14. package/src/components/Avatar.tsx +5 -2
  15. package/src/components/BaseToolbar.tsx +61 -0
  16. package/src/components/Button.tsx +1 -1
  17. package/src/components/Checkbox.tsx +6 -5
  18. package/src/components/Combobox.tsx +327 -337
  19. package/src/components/ComboboxMenu.tsx +88 -0
  20. package/src/components/Dialog.tsx +10 -6
  21. package/src/components/DimensionInput.tsx +4 -4
  22. package/src/components/FillPreviewBackground.tsx +51 -44
  23. package/src/components/FloatingWindow.tsx +5 -2
  24. package/src/components/GradientPicker.tsx +14 -14
  25. package/src/components/IconButton.tsx +6 -12
  26. package/src/components/Icons.tsx +3 -3
  27. package/src/components/InputField.tsx +145 -160
  28. package/src/components/Label.tsx +4 -4
  29. package/src/components/LabeledElementView.tsx +35 -41
  30. package/src/components/ListView.tsx +49 -39
  31. package/src/components/Message.tsx +75 -0
  32. package/src/components/Progress.tsx +2 -2
  33. package/src/components/ScrollArea.tsx +7 -5
  34. package/src/components/SegmentedControl.tsx +171 -0
  35. package/src/components/SelectMenu.tsx +61 -10
  36. package/src/components/Slider.tsx +5 -2
  37. package/src/components/Sortable.tsx +17 -27
  38. package/src/components/Spacer.tsx +28 -9
  39. package/src/components/Switch.tsx +8 -8
  40. package/src/components/TextArea.tsx +59 -12
  41. package/src/components/Toolbar.tsx +129 -0
  42. package/src/components/Tooltip.tsx +10 -7
  43. package/src/components/WorkspaceLayout.tsx +145 -152
  44. package/src/components/internal/Menu.tsx +5 -4
  45. package/src/contexts/DesignSystemConfiguration.tsx +15 -24
  46. package/src/contexts/DialogContext.tsx +137 -49
  47. package/src/contexts/ImageDataContext.tsx +6 -12
  48. package/src/index.css +1 -3
  49. package/src/index.tsx +12 -3
  50. package/src/utils/combobox.ts +369 -0
  51. package/tailwind.config.ts +1 -2
  52. package/src/components/RadioGroup.tsx +0 -100
  53. package/src/utils/completions.ts +0 -21
@@ -0,0 +1,369 @@
1
+ import { chunkBy, partition } from "@noya-app/noya-utils";
2
+ import { ReactNode, useSyncExternalStore } from "react";
3
+ import { fuzzyFilter, IScoredItem } from "./fuzzyScorer";
4
+
5
+ export type ComboboxOption = {
6
+ type?: undefined;
7
+ id: string;
8
+ name: string;
9
+ icon?: ReactNode;
10
+ alwaysInclude?: boolean;
11
+ };
12
+
13
+ export type ComboboxSectionHeader = {
14
+ type: "sectionHeader";
15
+ id: string;
16
+ name: string;
17
+ maxVisibleItems?: number;
18
+ };
19
+
20
+ export type ComboboxItem = ComboboxOption | ComboboxSectionHeader;
21
+
22
+ export type InternalComboboxItem =
23
+ | (ComboboxOption & IScoredItem)
24
+ | ComboboxSectionHeader;
25
+
26
+ export type ComboboxStateSnapshot = {
27
+ filter: string;
28
+ selectedIndex: number;
29
+ filteredItems: InternalComboboxItem[];
30
+ lastSubmittedValue: { filter: string; itemName: string };
31
+ };
32
+
33
+ export class ComboboxState {
34
+ private filter: string;
35
+ private selectedIndex: number;
36
+ private items: ComboboxItem[];
37
+ private lastSubmittedValue: { filter: string; itemName: string };
38
+ private subscribers: (() => void)[] = [];
39
+ private version = 0;
40
+ private _cachedSnapshot: [number, ComboboxStateSnapshot] | undefined;
41
+
42
+ constructor(
43
+ items: ComboboxItem[],
44
+ initialValue: string,
45
+ allowArbitraryValues: boolean
46
+ ) {
47
+ this.items = items;
48
+ this.lastSubmittedValue = {
49
+ filter: initialValue,
50
+ itemName: initialValue,
51
+ };
52
+
53
+ // Initialize with the initial value
54
+ this.filter = initialValue;
55
+ this.selectedIndex = 0;
56
+
57
+ // Create initial snapshot
58
+ this._cachedSnapshot = [
59
+ 0,
60
+ {
61
+ filter: initialValue,
62
+ selectedIndex: 0,
63
+ filteredItems: items.map((item, index) => {
64
+ if (item.type === "sectionHeader") return item;
65
+ return { ...item, index, score: 0 };
66
+ }),
67
+ lastSubmittedValue: this.lastSubmittedValue,
68
+ },
69
+ ];
70
+
71
+ // If there's an initial value, validate it
72
+ if (initialValue) {
73
+ // Check if the initial value exists in items
74
+ const hasMatch = items.some(
75
+ (item) =>
76
+ item.type !== "sectionHeader" &&
77
+ item.name.toLowerCase() === initialValue.toLowerCase()
78
+ );
79
+
80
+ // Get filtered items to check for fuzzy matches
81
+ const filteredItems = this.getFilteredItems();
82
+
83
+ // If no matches and we don't want arbitrary values, reset to empty
84
+ if (!hasMatch && !filteredItems.length && !allowArbitraryValues) {
85
+ this.filter = "";
86
+ this.selectedIndex = 0;
87
+ this._cachedSnapshot = [
88
+ 0,
89
+ {
90
+ filter: "",
91
+ selectedIndex: 0,
92
+ filteredItems: items.map((item, index) => {
93
+ if (item.type === "sectionHeader") return item;
94
+ return { ...item, index, score: 0 };
95
+ }),
96
+ lastSubmittedValue: this.lastSubmittedValue,
97
+ },
98
+ ];
99
+ }
100
+ }
101
+ }
102
+
103
+ subscribe = (listener: () => void): (() => void) => {
104
+ this.subscribers.push(listener);
105
+ return () => {
106
+ this.subscribers = this.subscribers.filter((l) => l !== listener);
107
+ };
108
+ };
109
+
110
+ private notifyChange() {
111
+ this.version++;
112
+ this._cachedSnapshot = undefined;
113
+ this.subscribers.forEach((listener) => listener());
114
+ }
115
+
116
+ private getFilteredItems(): InternalComboboxItem[] {
117
+ return filterWithGroupedSections(this.items, this.filter);
118
+ }
119
+
120
+ getSnapshot = (): ComboboxStateSnapshot => {
121
+ if (this._cachedSnapshot && this._cachedSnapshot[0] === this.version) {
122
+ return this._cachedSnapshot[1];
123
+ }
124
+
125
+ const snapshot = {
126
+ filter: this.filter,
127
+ selectedIndex: this.selectedIndex,
128
+ filteredItems: this.getFilteredItems(),
129
+ lastSubmittedValue: this.lastSubmittedValue,
130
+ };
131
+
132
+ this._cachedSnapshot = [this.version, snapshot];
133
+ return snapshot;
134
+ };
135
+
136
+ setItems(items: ComboboxItem[]) {
137
+ this.items = items;
138
+ this.notifyChange();
139
+ }
140
+
141
+ setFilter(filter: string) {
142
+ this.filter = filter;
143
+ const filteredItems = this.getFilteredItems();
144
+
145
+ // Find exact match if it exists (case insensitive)
146
+ const exactMatchIndex = filteredItems.findIndex(
147
+ (item) =>
148
+ item.type !== "sectionHeader" &&
149
+ item.name.toLowerCase().startsWith(filter.toLowerCase())
150
+ );
151
+
152
+ // Find the first non-header item
153
+ const firstSelectableIndex = filteredItems.findIndex(
154
+ (item) => item.type !== "sectionHeader"
155
+ );
156
+
157
+ this.selectedIndex =
158
+ exactMatchIndex >= 0 ? exactMatchIndex : firstSelectableIndex;
159
+
160
+ this.notifyChange();
161
+ }
162
+
163
+ moveSelection(direction: "up" | "down") {
164
+ const filteredItems = this.getFilteredItems();
165
+ this.selectedIndex = getNextIndex(
166
+ filteredItems,
167
+ this.selectedIndex,
168
+ direction === "down" ? "next" : "previous",
169
+ (item) => item.type === "sectionHeader"
170
+ );
171
+ this.notifyChange();
172
+ }
173
+
174
+ selectCurrentItem(
175
+ item?: InternalComboboxItem
176
+ ): InternalComboboxItem | undefined {
177
+ const itemToSelect = item || this.getFilteredItems()[this.selectedIndex];
178
+ if (!itemToSelect || itemToSelect.type === "sectionHeader") {
179
+ // If no valid item to select and arbitrary values aren't allowed,
180
+ // restore the last valid value
181
+ this.restoreLastSubmittedValue();
182
+ return;
183
+ }
184
+
185
+ this.lastSubmittedValue = {
186
+ filter: itemToSelect.name,
187
+ itemName: itemToSelect.name,
188
+ };
189
+ this.filter = itemToSelect.name;
190
+ this.notifyChange();
191
+ return itemToSelect;
192
+ }
193
+
194
+ restoreLastSubmittedValue() {
195
+ this.filter = this.lastSubmittedValue.itemName;
196
+ this.notifyChange();
197
+ }
198
+
199
+ reset(newFilter = "") {
200
+ this.filter = newFilter;
201
+ this.selectedIndex = 0;
202
+ this.notifyChange();
203
+ }
204
+
205
+ getSelectedItem(): InternalComboboxItem | undefined {
206
+ const filteredItems = this.getFilteredItems();
207
+ return filteredItems[this.selectedIndex];
208
+ }
209
+
210
+ getTypeaheadValue(): string | undefined {
211
+ const selectedItem = this.getSelectedItem();
212
+ if (!selectedItem || selectedItem.type === "sectionHeader") return;
213
+ return selectedItem.name;
214
+ }
215
+
216
+ setSelectedIndex(index: number) {
217
+ const filteredItems = this.getFilteredItems();
218
+
219
+ // If trying to select a header, find the next selectable item
220
+ if (filteredItems[index]?.type === "sectionHeader") {
221
+ // Look for the next non-header item
222
+ for (let i = index + 1; i < filteredItems.length; i++) {
223
+ if (filteredItems[i]?.type !== "sectionHeader") {
224
+ this.selectedIndex = i;
225
+ this.notifyChange();
226
+ return;
227
+ }
228
+ }
229
+ // If no next item, look backwards
230
+ for (let i = index - 1; i >= 0; i--) {
231
+ if (filteredItems[i]?.type !== "sectionHeader") {
232
+ this.selectedIndex = i;
233
+ this.notifyChange();
234
+ return;
235
+ }
236
+ }
237
+ // If no selectable items found, don't change selection
238
+ return;
239
+ }
240
+
241
+ this.selectedIndex = index;
242
+ this.notifyChange();
243
+ }
244
+
245
+ submitArbitraryFilter(filter: string) {
246
+ this.lastSubmittedValue = {
247
+ filter,
248
+ itemName: filter,
249
+ };
250
+ this.filter = filter;
251
+ this.notifyChange();
252
+ }
253
+ }
254
+
255
+ function filterWithGroupedSections(
256
+ items: ComboboxItem[],
257
+ query: string
258
+ ): InternalComboboxItem[] {
259
+ const sections = chunkBy(items, (_a, b) => b.type !== "sectionHeader");
260
+ let result: InternalComboboxItem[] = [];
261
+ let currentIndex = 0;
262
+
263
+ const createExtraItem = (
264
+ item: ComboboxOption,
265
+ index: number
266
+ ): InternalComboboxItem => ({
267
+ ...item,
268
+ index: currentIndex + index,
269
+ score: 0,
270
+ });
271
+
272
+ const createScoredItem = (
273
+ item: IScoredItem,
274
+ regular: ComboboxOption[]
275
+ ): InternalComboboxItem => ({
276
+ ...item,
277
+ ...regular[item.index],
278
+ index: currentIndex + item.index,
279
+ });
280
+
281
+ for (const section of sections) {
282
+ const [headers, regular] = partition(
283
+ section,
284
+ (item): item is ComboboxSectionHeader => item.type === "sectionHeader"
285
+ );
286
+
287
+ let newItems: InternalComboboxItem[];
288
+
289
+ if (!query.trim()) {
290
+ // For empty query, include all items with score 0
291
+ newItems = regular.map((item, index) => createExtraItem(item, index));
292
+ } else {
293
+ const scoredItems = fuzzyFilter({
294
+ items: regular.map((item) => item.name),
295
+ query,
296
+ });
297
+
298
+ const usedIndexes = new Set(scoredItems.map((item) => item.index));
299
+ const extraItems = regular.flatMap(
300
+ (item, index): InternalComboboxItem[] =>
301
+ item.alwaysInclude && !usedIndexes.has(index)
302
+ ? [createExtraItem(item, index)]
303
+ : []
304
+ );
305
+
306
+ newItems = scoredItems
307
+ .map((item) => createScoredItem(item, regular))
308
+ .concat(extraItems);
309
+ }
310
+
311
+ // If we have items or this is an empty query
312
+ if (newItems.length > 0 || !query.trim()) {
313
+ const maxVisibleItems =
314
+ headers.length > 0 ? headers[0].maxVisibleItems : undefined;
315
+
316
+ if (maxVisibleItems !== undefined) {
317
+ newItems = newItems.slice(0, maxVisibleItems);
318
+ }
319
+
320
+ result.push(...headers, ...newItems);
321
+ }
322
+
323
+ currentIndex += regular.length;
324
+ }
325
+
326
+ return result;
327
+ }
328
+
329
+ function getNextIndex<T>(
330
+ items: T[],
331
+ currentIndex: number,
332
+ direction: "next" | "previous",
333
+ isDisabled: (item: T) => boolean
334
+ ): number {
335
+ // Make sure the current index is within bounds
336
+ currentIndex =
337
+ currentIndex < 0
338
+ ? 0
339
+ : currentIndex >= items.length
340
+ ? items.length - 1
341
+ : currentIndex;
342
+
343
+ // If there are no valid items in the array, return -1
344
+ if (items.every(isDisabled)) return -1;
345
+
346
+ let nextIndex = currentIndex;
347
+
348
+ do {
349
+ // Move to the next or previous index, wrapping around if necessary
350
+ if (direction === "next") {
351
+ nextIndex = (nextIndex + 1) % items.length;
352
+ } else {
353
+ nextIndex = (nextIndex - 1 + items.length) % items.length;
354
+ }
355
+
356
+ // If we've looped all the way around without finding a valid item, return the current index
357
+ if (nextIndex === currentIndex) return currentIndex;
358
+ } while (isDisabled(items[nextIndex]));
359
+
360
+ return nextIndex;
361
+ }
362
+
363
+ export function useComboboxState(state: ComboboxState) {
364
+ return useSyncExternalStore(
365
+ state.subscribe,
366
+ state.getSnapshot,
367
+ state.getSnapshot
368
+ );
369
+ }
@@ -46,7 +46,7 @@ const config = {
46
46
  "listview-editing-background": "var(--listview-editing-background)",
47
47
  "slider-thumb-background": "var(--slider-thumb-background)",
48
48
  "slider-border": "var(--slider-border)",
49
- "radio-group-background": "var(--radio-group-background)",
49
+ "segmented-control-item": "var(--segmented-control-item)",
50
50
  mask: "var(--mask)",
51
51
  "transparent-checker": "var(--transparent-checker)",
52
52
  scrollbar: "var(--scrollbar)",
@@ -62,7 +62,6 @@ const config = {
62
62
  icon: "var(--icon)",
63
63
  "icon-selected": "var(--icon-selected)",
64
64
  warning: "var(--warning)",
65
- "radio-group-item": "var(--radio-group-item)",
66
65
  dot: "var(--dot)",
67
66
  "row-highlight": "var(--row-highlight)",
68
67
  "table-row-background": "var(--table-row-background)",
@@ -1,100 +0,0 @@
1
- import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
2
- import React, {
3
- createContext,
4
- forwardRef,
5
- memo,
6
- ReactNode,
7
- useCallback,
8
- useContext,
9
- useMemo,
10
- } from "react";
11
- import { Tooltip } from "./Tooltip";
12
-
13
- type RadioGroupColorScheme = "primary" | "secondary";
14
- interface ItemProps {
15
- value: string;
16
- tooltip?: ReactNode;
17
- children: ReactNode;
18
- disabled?: boolean;
19
- }
20
-
21
- const ToggleGroupItem = forwardRef(function ToggleGroupItem(
22
- { value, tooltip, children, disabled = false }: ItemProps,
23
- forwardedRef: React.ForwardedRef<HTMLButtonElement>
24
- ) {
25
- const { colorScheme } = useContext(RadioGroupContext);
26
-
27
- const itemElement = (
28
- <ToggleGroupPrimitive.Item
29
- ref={forwardedRef}
30
- value={value}
31
- disabled={disabled}
32
- className={`font-sans text-heading5 font-normal relative flex-1 appearance-none border-none bg-none text-radio-group-item p-0 m-0 rounded inline-flex items-center justify-center align-middle focus:outline-none focus:shadow-[0_0_0_1px_var(--sidebar-background),0_0_0_3px_${colorScheme === "secondary" ? "var(--secondary)" : "var(--primary)"}] aria-checked:bg-${colorScheme ? colorScheme : "radio-group-background"} aria-checked:text-${colorScheme ? "radio-group-background" : "text"} aria-checked:shadow-${colorScheme ? "none" : "0_1px_1px_rgba(0,0,0,0.1)"} focus:z-interactable`}
33
- >
34
- {children}
35
- </ToggleGroupPrimitive.Item>
36
- );
37
-
38
- return tooltip ? (
39
- <Tooltip content={tooltip}>{itemElement}</Tooltip>
40
- ) : (
41
- itemElement
42
- );
43
- });
44
-
45
- type RadioGroupContextValue = {
46
- /** @default primary */
47
- colorScheme?: RadioGroupColorScheme;
48
- };
49
-
50
- const RadioGroupContext = createContext<RadioGroupContextValue>({
51
- colorScheme: "primary",
52
- });
53
-
54
- interface Props {
55
- id?: string;
56
- value?: string;
57
- onValueChange?: (value: string) => void;
58
- /** @default primary */
59
- colorScheme?: RadioGroupColorScheme;
60
- allowEmpty?: boolean;
61
- children: ReactNode;
62
- }
63
-
64
- function ToggleGroupRoot({
65
- id,
66
- value,
67
- onValueChange,
68
- colorScheme,
69
- allowEmpty,
70
- children,
71
- }: Props) {
72
- const contextValue = useMemo(() => ({ colorScheme }), [colorScheme]);
73
-
74
- const handleValueChange = useCallback(
75
- (value: string) => {
76
- if (!allowEmpty && !value) return;
77
- onValueChange?.(value);
78
- },
79
- [allowEmpty, onValueChange]
80
- );
81
-
82
- return (
83
- <RadioGroupContext.Provider value={contextValue}>
84
- <ToggleGroupPrimitive.Root
85
- id={id}
86
- type="single"
87
- value={value}
88
- onValueChange={handleValueChange}
89
- className={`appearance-none w-0 flex-1 relative border-0 outline-none min-w-0 min-h-[27px] text-left self-stretch rounded bg-input-background items-stretch focus:shadow-[0_0_0_1px_var(--sidebar-background),0_0_0_3px_${colorScheme === "secondary" ? "var(--secondary)" : "var(--primary)"}] ${colorScheme ? "p-0" : "p-0.5"}`}
90
- >
91
- {children}
92
- </ToggleGroupPrimitive.Root>
93
- </RadioGroupContext.Provider>
94
- );
95
- }
96
-
97
- export namespace RadioGroup {
98
- export const Root = memo(ToggleGroupRoot);
99
- export const Item = memo(ToggleGroupItem);
100
- }
@@ -1,21 +0,0 @@
1
- import { ReactNode } from 'react';
2
- import { IScoredItem } from './fuzzyScorer';
3
-
4
- export type CompletionItem = {
5
- type?: undefined;
6
- id: string;
7
- name: string;
8
- icon?: ReactNode;
9
- alwaysInclude?: boolean;
10
- };
11
-
12
- export type CompletionSectionHeader = {
13
- type: 'sectionHeader';
14
- id: string;
15
- name: string;
16
- maxVisibleItems?: number;
17
- };
18
-
19
- export type CompletionListItem =
20
- | (CompletionItem & IScoredItem)
21
- | CompletionSectionHeader;