@noya-app/noya-designsystem 0.1.44 → 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.
Files changed (51) hide show
  1. package/.turbo/turbo-build.log +13 -10
  2. package/CHANGELOG.md +18 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.mts +1194 -878
  5. package/dist/index.d.ts +1194 -878
  6. package/dist/index.js +11432 -10219
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +11556 -10360
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +4 -4
  11. package/src/__tests__/combobox.test.ts +137 -89
  12. package/src/components/AnimatePresence.tsx +10 -1
  13. package/src/components/Avatar.tsx +2 -0
  14. package/src/components/Breadcrumbs.tsx +29 -0
  15. package/src/components/Checkbox.tsx +6 -1
  16. package/src/components/Collection.tsx +74 -0
  17. package/src/components/Combobox.tsx +75 -56
  18. package/src/components/ComboboxMenu.tsx +61 -28
  19. package/src/components/CommandPalette.tsx +69 -0
  20. package/src/components/ContextMenu.tsx +33 -134
  21. package/src/components/DropdownMenu.tsx +46 -155
  22. package/src/components/EditableText.tsx +203 -0
  23. package/src/components/Fade.tsx +62 -0
  24. package/src/components/Grid.tsx +243 -0
  25. package/src/components/GridView.tsx +86 -96
  26. package/src/components/InputField.tsx +109 -133
  27. package/src/components/Label.tsx +81 -7
  28. package/src/components/LabeledField.tsx +112 -0
  29. package/src/components/List.tsx +268 -0
  30. package/src/components/ListView.tsx +65 -61
  31. package/src/components/Popover.tsx +12 -1
  32. package/src/components/SearchCompletionMenu.tsx +210 -0
  33. package/src/components/SegmentedControl.tsx +12 -6
  34. package/src/components/SelectMenu.tsx +110 -126
  35. package/src/components/Slider.tsx +18 -26
  36. package/src/components/Text.tsx +1 -0
  37. package/src/components/TextArea.tsx +4 -1
  38. package/src/components/Toolbar.tsx +9 -4
  39. package/src/components/TreeView.tsx +4 -0
  40. package/src/components/WorkspaceLayout.tsx +64 -20
  41. package/src/components/internal/Menu.tsx +107 -18
  42. package/src/components/internal/MenuViewport.tsx +152 -0
  43. package/src/components/internal/SelectItem.tsx +111 -0
  44. package/src/hooks/useIndent.ts +12 -0
  45. package/src/hooks/useLabel.ts +51 -0
  46. package/src/hooks/usePreservePanelSize.tsx +50 -19
  47. package/src/index.tsx +15 -6
  48. package/src/utils/combobox.ts +116 -101
  49. package/src/utils/createSectionedMenu.ts +11 -14
  50. package/src/utils/fuzzyScorer.ts +11 -8
  51. package/src/utils/selection.ts +48 -0
@@ -1,9 +1,8 @@
1
1
  import { DropdownChevronIcon } from "@noya-app/noya-icons";
2
+ import { forwardRefGeneric, memoGeneric } from "@noya-app/react-utils";
2
3
  import React, {
3
4
  FocusEventHandler,
4
5
  ForwardedRef,
5
- forwardRef,
6
- memo,
7
6
  useCallback,
8
7
  useEffect,
9
8
  useImperativeHandle,
@@ -11,25 +10,28 @@ import React, {
11
10
  useRef,
12
11
  useState,
13
12
  } from "react";
13
+ import { useLabel } from "../hooks/useLabel";
14
14
  import { cx } from "../utils/classNames";
15
- import {
16
- ComboboxItem,
17
- ComboboxOption,
18
- ComboboxState,
19
- InternalComboboxItem,
20
- useComboboxState,
21
- } from "../utils/combobox";
15
+ import { ComboboxState, useComboboxState } from "../utils/combobox";
22
16
  import { ActivityIndicator } from "./ActivityIndicator";
23
17
  import { ComboboxMenu } from "./ComboboxMenu";
24
18
  import { InputField, InputFieldSize } from "./InputField";
19
+ import {
20
+ MenuItem,
21
+ ScoredMenuItem,
22
+ SelectableMenuItem,
23
+ isMenuItemSectionHeader,
24
+ isNonSelectableMenuItem,
25
+ isSelectableMenuItem,
26
+ } from "./internal/Menu";
25
27
  import { ListView } from "./ListView";
26
28
  import { Small } from "./Text";
27
29
 
28
- export type BaseComboboxProps = {
30
+ export type BaseComboboxProps<T extends string> = {
29
31
  id?: string;
30
32
  loading?: boolean;
31
33
  placeholder?: string;
32
- items: ComboboxItem[];
34
+ items: MenuItem<T>[];
33
35
  onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
34
36
  onDeleteWhenEmpty?: () => void;
35
37
  size?: InputFieldSize;
@@ -53,39 +55,42 @@ export type StringModeOnlyProps = {
53
55
  allowArbitraryValues?: boolean;
54
56
  };
55
57
 
56
- export type StringModeProps = BaseComboboxProps & StringModeOnlyProps;
58
+ export type StringModeProps<T extends string> = BaseComboboxProps<T> &
59
+ StringModeOnlyProps;
57
60
 
58
- export type OptionModeOnlyProps = {
61
+ export type OptionModeOnlyProps<T extends string> = {
59
62
  mode?: "option";
60
- value?: ComboboxOption;
61
- onChange?: (value: ComboboxOption) => void;
62
- onSelectItem?: (value: ComboboxOption) => void;
63
- onBlur?: (didSubmit: boolean, value: ComboboxOption) => void;
64
- onHoverItem?: (value: ComboboxOption | undefined) => void;
63
+ value?: SelectableMenuItem<T>;
64
+ onChange?: (value: SelectableMenuItem<T>) => void;
65
+ onSelectItem?: (value: SelectableMenuItem<T>) => void;
66
+ onBlur?: (didSubmit: boolean, value: SelectableMenuItem<T>) => void;
67
+ onHoverItem?: (value: SelectableMenuItem<T> | undefined) => void;
65
68
  allowArbitraryValues?: false;
66
69
  };
67
70
 
68
- export type OptionModeProps = BaseComboboxProps & OptionModeOnlyProps;
71
+ export type OptionModeProps<T extends string> = BaseComboboxProps<T> &
72
+ OptionModeOnlyProps<T>;
69
73
 
70
- export type ComboboxProps = StringModeProps | OptionModeProps;
74
+ export type ComboboxProps<T extends string> =
75
+ | StringModeProps<T>
76
+ | OptionModeProps<T>;
71
77
 
72
- export interface ComboboxRef {
78
+ export interface ComboboxRef<T extends string> {
73
79
  focus(): void;
74
- setValue(value: string | ComboboxOption): void;
80
+ setValue(value: string | SelectableMenuItem<T>): void;
75
81
  selectAllInputText(): void;
76
82
  }
77
83
 
78
- export const Combobox = memo(
79
- forwardRef(function Combobox(
84
+ export const Combobox = memoGeneric(
85
+ forwardRefGeneric(function Combobox<T extends string>(
80
86
  {
81
- id,
82
87
  loading,
83
88
  placeholder,
84
89
  items,
85
90
  size,
86
91
  style,
87
92
  className,
88
- label,
93
+ label: labelProp,
89
94
  children,
90
95
  hideMenuWhenEmptyValue = false,
91
96
  readOnly = false,
@@ -94,12 +99,12 @@ export const Combobox = memo(
94
99
  onFocus,
95
100
  onDeleteWhenEmpty,
96
101
  ...rest
97
- }: ComboboxProps,
98
- forwardedRef: ForwardedRef<ComboboxRef>
102
+ }: ComboboxProps<T>,
103
+ forwardedRef: ForwardedRef<ComboboxRef<T>>
99
104
  ) {
100
105
  // We make a separate props object for the mode-specific props for memoizing
101
106
  // since we put the props object into hook deps
102
- const props = useMemo((): StringModeOnlyProps | OptionModeOnlyProps => {
107
+ const props = useMemo((): StringModeOnlyProps | OptionModeOnlyProps<T> => {
103
108
  if (rest.mode === "string") {
104
109
  return {
105
110
  mode: rest.mode,
@@ -118,7 +123,7 @@ export const Combobox = memo(
118
123
  onSelectItem: rest.onSelectItem,
119
124
  onHoverItem: rest.onHoverItem,
120
125
  onBlur: rest.onBlur,
121
- } satisfies OptionModeOnlyProps;
126
+ } satisfies OptionModeOnlyProps<T>;
122
127
  }
123
128
  }, [
124
129
  rest.mode,
@@ -136,7 +141,7 @@ export const Combobox = memo(
136
141
  if (props.mode === "string") {
137
142
  return props.value ?? "";
138
143
  } else {
139
- return props.value?.name ?? "";
144
+ return props.value?.title?.toString() ?? "";
140
145
  }
141
146
  }
142
147
 
@@ -154,6 +159,9 @@ export const Combobox = memo(
154
159
  const [isFocused, setIsFocused] = useState(false);
155
160
  const listRef = useRef<ListView.VirtualizedList>(null);
156
161
  const [shouldBlur, setShouldBlur] = useState(false);
162
+ const { fieldId: id } = useLabel({
163
+ fieldId: rest.id,
164
+ });
157
165
 
158
166
  // Keep state in sync with items prop
159
167
  useEffect(() => {
@@ -185,7 +193,7 @@ export const Combobox = memo(
185
193
  );
186
194
  } else {
187
195
  const selectedItem = comboboxState.getSelectedItem();
188
- if (selectedItem && selectedItem.type !== "sectionHeader") {
196
+ if (selectedItem && isSelectableMenuItem(selectedItem)) {
189
197
  props.onBlur?.(
190
198
  state.filter === state.lastSubmittedValue.filter,
191
199
  selectedItem
@@ -211,14 +219,14 @@ export const Combobox = memo(
211
219
  );
212
220
 
213
221
  const handleUpdateSelection = useCallback(
214
- (item: InternalComboboxItem) => {
215
- if (item.type === "sectionHeader") return;
222
+ (item: ScoredMenuItem<T>) => {
223
+ if (item.type !== undefined) return;
216
224
 
217
225
  const selectedItem = comboboxState.selectCurrentItem(item);
218
- if (!selectedItem || selectedItem.type === "sectionHeader") return;
226
+ if (!selectedItem) return;
219
227
 
220
228
  if (props.mode === "string") {
221
- props.onSelectItem?.(selectedItem.name);
229
+ props.onSelectItem?.(selectedItem.value);
222
230
  props.onHoverItem?.(undefined);
223
231
  } else {
224
232
  props.onSelectItem?.(selectedItem);
@@ -233,9 +241,9 @@ export const Combobox = memo(
233
241
  (index: number) => {
234
242
  comboboxState.setSelectedIndex(index);
235
243
  const item = comboboxState.getSelectedItem();
236
- if (item && item.type !== "sectionHeader") {
244
+ if (item && isSelectableMenuItem(item)) {
237
245
  if (props.mode === "string") {
238
- props.onHoverItem?.(item.name);
246
+ props.onHoverItem?.(item.value);
239
247
  } else {
240
248
  props.onHoverItem?.(item);
241
249
  }
@@ -254,7 +262,7 @@ export const Combobox = memo(
254
262
  props.onChange?.(value);
255
263
  } else {
256
264
  const selectedItem = comboboxState.getSelectedItem();
257
- if (selectedItem && selectedItem.type !== "sectionHeader") {
265
+ if (selectedItem && isSelectableMenuItem(selectedItem)) {
258
266
  props.onChange?.(selectedItem);
259
267
  }
260
268
  }
@@ -308,14 +316,17 @@ export const Combobox = memo(
308
316
  } else {
309
317
  // In autocomplete mode:
310
318
  // 1. Always set the filter to the selected item
311
- // 2. If the filter matches the item name, submit it
319
+ // 2. If the filter matches the item value, submit it
312
320
  event.preventDefault();
313
321
  event.stopPropagation();
314
- if (item.name === filter) {
315
- handleUpdateSelection(item);
316
- } else {
317
- comboboxState.setFilter(item.name);
318
- ref.current?.focus();
322
+ if (isNonSelectableMenuItem(item)) return;
323
+ if (isSelectableMenuItem(item)) {
324
+ if (item.value === filter) {
325
+ handleUpdateSelection(item);
326
+ } else {
327
+ comboboxState.setFilter(item.value?.toString() ?? "");
328
+ ref.current?.focus();
329
+ }
319
330
  }
320
331
  handled = true;
321
332
  }
@@ -374,11 +385,11 @@ export const Combobox = memo(
374
385
  ref.current.setSelectionRange(length, length);
375
386
  }
376
387
  },
377
- setValue: (value: string | ComboboxOption) => {
388
+ setValue: (value: string | SelectableMenuItem<T>) => {
378
389
  if (typeof value === "string") {
379
390
  comboboxState.setFilter(value);
380
391
  } else {
381
- comboboxState.setFilter(value.name);
392
+ comboboxState.setFilter(value.value);
382
393
  }
383
394
  },
384
395
  selectAllInputText: () => {
@@ -418,6 +429,18 @@ export const Combobox = memo(
418
429
  handleBlur();
419
430
  }, [shouldBlur, handleBlur]);
420
431
 
432
+ const { sectionHeaderCount, menuItemsCount } = useMemo(
433
+ () => ({
434
+ sectionHeaderCount: filteredItems.filter((item) =>
435
+ isMenuItemSectionHeader(item)
436
+ ).length,
437
+ menuItemsCount: filteredItems.filter((item) =>
438
+ isSelectableMenuItem(item)
439
+ ).length,
440
+ }),
441
+ [filteredItems]
442
+ );
443
+
421
444
  return (
422
445
  <InputField.Root
423
446
  id={id}
@@ -425,13 +448,10 @@ export const Combobox = memo(
425
448
  sideOffset={6}
426
449
  renderPopoverContent={({ width }) => {
427
450
  const height = Math.min(
428
- ListView.calculateHeight(
429
- filteredItems.filter((item) => item.type !== "sectionHeader")
430
- .length,
431
- filteredItems.filter((item) => item.type === "sectionHeader")
432
- .length,
433
- "label"
434
- ),
451
+ ListView.calculateHeight({
452
+ menuItemsCount,
453
+ sectionHeaderCount,
454
+ }),
435
455
  ListView.rowHeight * 10.5
436
456
  );
437
457
 
@@ -500,11 +520,10 @@ export const Combobox = memo(
500
520
  )}
501
521
  {children}
502
522
  {loading && (
503
- <InputField.Label>
523
+ <InputField.Label htmlFor={id}>
504
524
  <ActivityIndicator />
505
525
  </InputField.Label>
506
526
  )}
507
- <InputField.Label>{label}</InputField.Label>
508
527
  {!readOnly && (
509
528
  <InputField.Button variant="floating" onClick={handleChevronClick}>
510
529
  <DropdownChevronIcon
@@ -1,22 +1,32 @@
1
1
  import { Size } from "@noya-app/noya-geometry";
2
- import React, { ForwardedRef, forwardRef, memo } from "react";
2
+ import { CheckIcon } from "@noya-app/noya-icons";
3
+ import { forwardRefGeneric, memoGeneric } from "@noya-app/react-utils";
4
+ import React, { ForwardedRef } from "react";
3
5
  import { cx } from "../utils/classNames";
4
- import { InternalComboboxItem } from "../utils/combobox";
6
+
5
7
  import { fuzzyTokenize } from "../utils/fuzzyScorer";
8
+ import { renderIcon } from "./Icons";
6
9
  import { IVirtualizedList, ListView } from "./ListView";
7
10
  import { Spacer } from "./Spacer";
11
+ import {
12
+ CHECKBOX_RIGHT_INSET,
13
+ CHECKBOX_WIDTH,
14
+ KeyboardShortcut,
15
+ ScoredMenuItem,
16
+ styles,
17
+ } from "./internal/Menu";
8
18
 
9
- interface ComboboxMenuProps {
10
- items: InternalComboboxItem[];
19
+ interface ComboboxMenuProps<T extends string> {
20
+ items: ScoredMenuItem<T>[];
11
21
  selectedIndex: number;
12
- onSelectItem: (item: InternalComboboxItem) => void;
22
+ onSelectItem: (item: ScoredMenuItem<T>) => void;
13
23
  onHoverIndex: (index: number) => void;
14
24
  listSize: Size;
15
25
  style?: React.CSSProperties;
16
26
  }
17
27
 
18
- export const ComboboxMenu = memo(
19
- forwardRef(function ComboboxMenu(
28
+ export const ComboboxMenu = memoGeneric(
29
+ forwardRefGeneric(function ComboboxMenu<T extends string>(
20
30
  {
21
31
  items,
22
32
  selectedIndex,
@@ -24,14 +34,22 @@ export const ComboboxMenu = memo(
24
34
  onHoverIndex,
25
35
  listSize,
26
36
  style,
27
- }: ComboboxMenuProps,
37
+ }: ComboboxMenuProps<T>,
28
38
  forwardedRef: ForwardedRef<IVirtualizedList>
29
39
  ) {
40
+ const hasCheckedItems = items
41
+ .filter((item) => item.type === undefined)
42
+ .some((item) => item.checked);
43
+
30
44
  return (
31
45
  <ListView.Root
32
46
  ref={forwardedRef}
33
47
  scrollable
34
- keyExtractor={(item) => item.id}
48
+ keyExtractor={(item: ScoredMenuItem<T>) => {
49
+ if (item.type === "sectionHeader") return item.id;
50
+
51
+ return item.value;
52
+ }}
35
53
  data={items}
36
54
  virtualized={listSize}
37
55
  pressEventName="onPointerDown"
@@ -41,42 +59,57 @@ export const ComboboxMenu = memo(
41
59
  if (item.type === "sectionHeader") {
42
60
  return (
43
61
  <ListView.Row key={item.id} isSectionHeader>
44
- {item.name}
62
+ {item.title}
45
63
  </ListView.Row>
46
64
  );
47
65
  }
48
66
 
49
67
  const tokens = fuzzyTokenize({
50
- item: item.name,
68
+ item: typeof item.title === "string" ? item.title : item.value,
51
69
  itemScore: item,
52
70
  });
53
71
 
54
72
  return (
55
73
  <ListView.Row
56
- key={item.id}
74
+ key={item.value}
57
75
  selected={i === selectedIndex}
58
- onPress={() => onSelectItem(item)}
76
+ disabled={item.disabled}
77
+ onPress={() => !item.disabled && onSelectItem(item)}
59
78
  onHoverChange={(hovered) => {
60
- if (hovered) {
79
+ if (hovered && !item.disabled) {
61
80
  onHoverIndex(i);
62
81
  }
63
82
  }}
64
83
  >
65
- {tokens.map((token, j) => (
66
- <span
67
- key={`${item.id}-token-${j}`}
68
- className={cx(
69
- token.type === "match" ? "font-bold" : "font-normal",
70
- "whitespace-pre"
71
- )}
72
- >
73
- {token.text}
74
- </span>
75
- ))}
76
- {item.icon && (
84
+ <div className="flex flex-1 min-w-0 items-center">
85
+ {item.checked ? (
86
+ <div className={styles.itemIndicatorStyle}>
87
+ <CheckIcon />
88
+ </div>
89
+ ) : hasCheckedItems ? (
90
+ <Spacer.Horizontal
91
+ size={CHECKBOX_WIDTH - CHECKBOX_RIGHT_INSET}
92
+ />
93
+ ) : null}
94
+ {tokens.map((token, j) => (
95
+ <span
96
+ key={`${item.value}-token-${j}`}
97
+ className={cx(
98
+ token.type === "match" ? "font-bold" : "font-normal",
99
+ "whitespace-pre truncate"
100
+ )}
101
+ >
102
+ {token.text}
103
+ </span>
104
+ ))}
105
+ </div>
106
+ {(item.shortcut || item.icon) && (
77
107
  <>
78
- <Spacer.Horizontal />
79
- {item.icon}
108
+ {item.shortcut ? (
109
+ <KeyboardShortcut shortcut={item.shortcut} />
110
+ ) : (
111
+ item.icon && renderIcon(item.icon)
112
+ )}
80
113
  </>
81
114
  )}
82
115
  </ListView.Row>
@@ -0,0 +1,69 @@
1
+ import { Dialog } from "@noya-app/noya-designsystem";
2
+ import { AutoSizer } from "@noya-app/react-utils";
3
+ import React, { memo } from "react";
4
+ import {
5
+ ISearchCompletionMenu,
6
+ SearchCompletionMenu,
7
+ } from "./SearchCompletionMenu";
8
+
9
+ export const CommandPalette = memo(function CommandPalette({
10
+ showCommandPalette,
11
+ setShowCommandPalette,
12
+ items,
13
+ onSelect,
14
+ onHover,
15
+ }: {
16
+ showCommandPalette: boolean;
17
+ setShowCommandPalette: (value: boolean) => void;
18
+ } & Pick<
19
+ React.ComponentProps<typeof SearchCompletionMenu>,
20
+ "items" | "onSelect" | "onHover"
21
+ >) {
22
+ const menuRef = React.useRef<ISearchCompletionMenu>(null);
23
+
24
+ const handleClose = React.useCallback(() => {
25
+ setShowCommandPalette(false);
26
+ }, [setShowCommandPalette]);
27
+
28
+ return (
29
+ <Dialog
30
+ style={{
31
+ display: "flex",
32
+ flexDirection: "column",
33
+ maxWidth: "500px",
34
+ minHeight: "100px",
35
+ padding: "1px",
36
+ top: "100px",
37
+ left: "50%",
38
+ transform: "translate(-50%, 0)",
39
+ }}
40
+ showCloseButton={false}
41
+ open={showCommandPalette}
42
+ onOpenChange={(value) => {
43
+ setShowCommandPalette(value);
44
+ }}
45
+ closeOnInteractOutside
46
+ onOpenAutoFocus={() => {
47
+ menuRef.current?.focus();
48
+ setTimeout(() => {
49
+ menuRef.current?.focus();
50
+ }, 0);
51
+ }}
52
+ >
53
+ <div className="flex flex-col flex-1">
54
+ <AutoSizer>
55
+ {(size) => (
56
+ <SearchCompletionMenu
57
+ ref={menuRef}
58
+ width={size.width}
59
+ items={items}
60
+ onSelect={onSelect}
61
+ onHover={onHover}
62
+ onClose={handleClose}
63
+ />
64
+ )}
65
+ </AutoSizer>
66
+ </div>
67
+ </Dialog>
68
+ );
69
+ });