@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.
@@ -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,
@@ -13,24 +12,26 @@ import React, {
13
12
  } from "react";
14
13
  import { useLabel } from "../hooks/useLabel";
15
14
  import { cx } from "../utils/classNames";
16
- import {
17
- ComboboxItem,
18
- ComboboxOption,
19
- ComboboxState,
20
- InternalComboboxItem,
21
- useComboboxState,
22
- } from "../utils/combobox";
15
+ import { ComboboxState, useComboboxState } from "../utils/combobox";
23
16
  import { ActivityIndicator } from "./ActivityIndicator";
24
17
  import { ComboboxMenu } from "./ComboboxMenu";
25
18
  import { InputField, InputFieldSize } from "./InputField";
19
+ import {
20
+ MenuItem,
21
+ ScoredMenuItem,
22
+ SelectableMenuItem,
23
+ isMenuItemSectionHeader,
24
+ isNonSelectableMenuItem,
25
+ isSelectableMenuItem,
26
+ } from "./internal/Menu";
26
27
  import { ListView } from "./ListView";
27
28
  import { Small } from "./Text";
28
29
 
29
- export type BaseComboboxProps = {
30
+ export type BaseComboboxProps<T extends string> = {
30
31
  id?: string;
31
32
  loading?: boolean;
32
33
  placeholder?: string;
33
- items: ComboboxItem[];
34
+ items: MenuItem<T>[];
34
35
  onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
35
36
  onDeleteWhenEmpty?: () => void;
36
37
  size?: InputFieldSize;
@@ -54,30 +55,34 @@ export type StringModeOnlyProps = {
54
55
  allowArbitraryValues?: boolean;
55
56
  };
56
57
 
57
- export type StringModeProps = BaseComboboxProps & StringModeOnlyProps;
58
+ export type StringModeProps<T extends string> = BaseComboboxProps<T> &
59
+ StringModeOnlyProps;
58
60
 
59
- export type OptionModeOnlyProps = {
61
+ export type OptionModeOnlyProps<T extends string> = {
60
62
  mode?: "option";
61
- value?: ComboboxOption;
62
- onChange?: (value: ComboboxOption) => void;
63
- onSelectItem?: (value: ComboboxOption) => void;
64
- onBlur?: (didSubmit: boolean, value: ComboboxOption) => void;
65
- 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;
66
68
  allowArbitraryValues?: false;
67
69
  };
68
70
 
69
- export type OptionModeProps = BaseComboboxProps & OptionModeOnlyProps;
71
+ export type OptionModeProps<T extends string> = BaseComboboxProps<T> &
72
+ OptionModeOnlyProps<T>;
70
73
 
71
- export type ComboboxProps = StringModeProps | OptionModeProps;
74
+ export type ComboboxProps<T extends string> =
75
+ | StringModeProps<T>
76
+ | OptionModeProps<T>;
72
77
 
73
- export interface ComboboxRef {
78
+ export interface ComboboxRef<T extends string> {
74
79
  focus(): void;
75
- setValue(value: string | ComboboxOption): void;
80
+ setValue(value: string | SelectableMenuItem<T>): void;
76
81
  selectAllInputText(): void;
77
82
  }
78
83
 
79
- export const Combobox = memo(
80
- forwardRef(function Combobox(
84
+ export const Combobox = memoGeneric(
85
+ forwardRefGeneric(function Combobox<T extends string>(
81
86
  {
82
87
  loading,
83
88
  placeholder,
@@ -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
 
@@ -188,7 +193,7 @@ export const Combobox = memo(
188
193
  );
189
194
  } else {
190
195
  const selectedItem = comboboxState.getSelectedItem();
191
- if (selectedItem && selectedItem.type !== "sectionHeader") {
196
+ if (selectedItem && isSelectableMenuItem(selectedItem)) {
192
197
  props.onBlur?.(
193
198
  state.filter === state.lastSubmittedValue.filter,
194
199
  selectedItem
@@ -214,14 +219,14 @@ export const Combobox = memo(
214
219
  );
215
220
 
216
221
  const handleUpdateSelection = useCallback(
217
- (item: InternalComboboxItem) => {
218
- if (item.type === "sectionHeader") return;
222
+ (item: ScoredMenuItem<T>) => {
223
+ if (item.type !== undefined) return;
219
224
 
220
225
  const selectedItem = comboboxState.selectCurrentItem(item);
221
- if (!selectedItem || selectedItem.type === "sectionHeader") return;
226
+ if (!selectedItem) return;
222
227
 
223
228
  if (props.mode === "string") {
224
- props.onSelectItem?.(selectedItem.name);
229
+ props.onSelectItem?.(selectedItem.value);
225
230
  props.onHoverItem?.(undefined);
226
231
  } else {
227
232
  props.onSelectItem?.(selectedItem);
@@ -236,9 +241,9 @@ export const Combobox = memo(
236
241
  (index: number) => {
237
242
  comboboxState.setSelectedIndex(index);
238
243
  const item = comboboxState.getSelectedItem();
239
- if (item && item.type !== "sectionHeader") {
244
+ if (item && isSelectableMenuItem(item)) {
240
245
  if (props.mode === "string") {
241
- props.onHoverItem?.(item.name);
246
+ props.onHoverItem?.(item.value);
242
247
  } else {
243
248
  props.onHoverItem?.(item);
244
249
  }
@@ -257,7 +262,7 @@ export const Combobox = memo(
257
262
  props.onChange?.(value);
258
263
  } else {
259
264
  const selectedItem = comboboxState.getSelectedItem();
260
- if (selectedItem && selectedItem.type !== "sectionHeader") {
265
+ if (selectedItem && isSelectableMenuItem(selectedItem)) {
261
266
  props.onChange?.(selectedItem);
262
267
  }
263
268
  }
@@ -311,14 +316,17 @@ export const Combobox = memo(
311
316
  } else {
312
317
  // In autocomplete mode:
313
318
  // 1. Always set the filter to the selected item
314
- // 2. If the filter matches the item name, submit it
319
+ // 2. If the filter matches the item value, submit it
315
320
  event.preventDefault();
316
321
  event.stopPropagation();
317
- if (item.name === filter) {
318
- handleUpdateSelection(item);
319
- } else {
320
- comboboxState.setFilter(item.name);
321
- 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
+ }
322
330
  }
323
331
  handled = true;
324
332
  }
@@ -377,11 +385,11 @@ export const Combobox = memo(
377
385
  ref.current.setSelectionRange(length, length);
378
386
  }
379
387
  },
380
- setValue: (value: string | ComboboxOption) => {
388
+ setValue: (value: string | SelectableMenuItem<T>) => {
381
389
  if (typeof value === "string") {
382
390
  comboboxState.setFilter(value);
383
391
  } else {
384
- comboboxState.setFilter(value.name);
392
+ comboboxState.setFilter(value.value);
385
393
  }
386
394
  },
387
395
  selectAllInputText: () => {
@@ -421,6 +429,18 @@ export const Combobox = memo(
421
429
  handleBlur();
422
430
  }, [shouldBlur, handleBlur]);
423
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
+
424
444
  return (
425
445
  <InputField.Root
426
446
  id={id}
@@ -428,13 +448,10 @@ export const Combobox = memo(
428
448
  sideOffset={6}
429
449
  renderPopoverContent={({ width }) => {
430
450
  const height = Math.min(
431
- ListView.calculateHeight(
432
- filteredItems.filter((item) => item.type !== "sectionHeader")
433
- .length,
434
- filteredItems.filter((item) => item.type === "sectionHeader")
435
- .length,
436
- "label"
437
- ),
451
+ ListView.calculateHeight({
452
+ menuItemsCount,
453
+ sectionHeaderCount,
454
+ }),
438
455
  ListView.rowHeight * 10.5
439
456
  );
440
457
 
@@ -1,25 +1,32 @@
1
1
  import { Size } from "@noya-app/noya-geometry";
2
2
  import { CheckIcon } from "@noya-app/noya-icons";
3
- import React, { ForwardedRef, forwardRef, memo } from "react";
3
+ import { forwardRefGeneric, memoGeneric } from "@noya-app/react-utils";
4
+ import React, { ForwardedRef } from "react";
4
5
  import { cx } from "../utils/classNames";
5
- import { InternalComboboxItem } from "../utils/combobox";
6
+
6
7
  import { fuzzyTokenize } from "../utils/fuzzyScorer";
7
8
  import { renderIcon } from "./Icons";
8
9
  import { IVirtualizedList, ListView } from "./ListView";
9
10
  import { Spacer } from "./Spacer";
10
- import { KeyboardShortcut, styles } from "./internal/Menu";
11
+ import {
12
+ CHECKBOX_RIGHT_INSET,
13
+ CHECKBOX_WIDTH,
14
+ KeyboardShortcut,
15
+ ScoredMenuItem,
16
+ styles,
17
+ } from "./internal/Menu";
11
18
 
12
- interface ComboboxMenuProps {
13
- items: InternalComboboxItem[];
19
+ interface ComboboxMenuProps<T extends string> {
20
+ items: ScoredMenuItem<T>[];
14
21
  selectedIndex: number;
15
- onSelectItem: (item: InternalComboboxItem) => void;
22
+ onSelectItem: (item: ScoredMenuItem<T>) => void;
16
23
  onHoverIndex: (index: number) => void;
17
24
  listSize: Size;
18
25
  style?: React.CSSProperties;
19
26
  }
20
27
 
21
- export const ComboboxMenu = memo(
22
- forwardRef(function ComboboxMenu(
28
+ export const ComboboxMenu = memoGeneric(
29
+ forwardRefGeneric(function ComboboxMenu<T extends string>(
23
30
  {
24
31
  items,
25
32
  selectedIndex,
@@ -27,14 +34,22 @@ export const ComboboxMenu = memo(
27
34
  onHoverIndex,
28
35
  listSize,
29
36
  style,
30
- }: ComboboxMenuProps,
37
+ }: ComboboxMenuProps<T>,
31
38
  forwardedRef: ForwardedRef<IVirtualizedList>
32
39
  ) {
40
+ const hasCheckedItems = items
41
+ .filter((item) => item.type === undefined)
42
+ .some((item) => item.checked);
43
+
33
44
  return (
34
45
  <ListView.Root
35
46
  ref={forwardedRef}
36
47
  scrollable
37
- keyExtractor={(item) => item.id}
48
+ keyExtractor={(item: ScoredMenuItem<T>) => {
49
+ if (item.type === "sectionHeader") return item.id;
50
+
51
+ return item.value;
52
+ }}
38
53
  data={items}
39
54
  virtualized={listSize}
40
55
  pressEventName="onPointerDown"
@@ -44,19 +59,19 @@ export const ComboboxMenu = memo(
44
59
  if (item.type === "sectionHeader") {
45
60
  return (
46
61
  <ListView.Row key={item.id} isSectionHeader>
47
- {item.name}
62
+ {item.title}
48
63
  </ListView.Row>
49
64
  );
50
65
  }
51
66
 
52
67
  const tokens = fuzzyTokenize({
53
- item: item.name,
68
+ item: typeof item.title === "string" ? item.title : item.value,
54
69
  itemScore: item,
55
70
  });
56
71
 
57
72
  return (
58
73
  <ListView.Row
59
- key={item.id}
74
+ key={item.value}
60
75
  selected={i === selectedIndex}
61
76
  disabled={item.disabled}
62
77
  onPress={() => !item.disabled && onSelectItem(item)}
@@ -66,18 +81,22 @@ export const ComboboxMenu = memo(
66
81
  }
67
82
  }}
68
83
  >
69
- <div className="flex flex-1 items-center">
70
- {item.checked && (
84
+ <div className="flex flex-1 min-w-0 items-center">
85
+ {item.checked ? (
71
86
  <div className={styles.itemIndicatorStyle}>
72
87
  <CheckIcon />
73
88
  </div>
74
- )}
89
+ ) : hasCheckedItems ? (
90
+ <Spacer.Horizontal
91
+ size={CHECKBOX_WIDTH - CHECKBOX_RIGHT_INSET}
92
+ />
93
+ ) : null}
75
94
  {tokens.map((token, j) => (
76
95
  <span
77
- key={`${item.id}-token-${j}`}
96
+ key={`${item.value}-token-${j}`}
78
97
  className={cx(
79
98
  token.type === "match" ? "font-bold" : "font-normal",
80
- "whitespace-pre"
99
+ "whitespace-pre truncate"
81
100
  )}
82
101
  >
83
102
  {token.text}
@@ -86,7 +105,6 @@ export const ComboboxMenu = memo(
86
105
  </div>
87
106
  {(item.shortcut || item.icon) && (
88
107
  <>
89
- <Spacer.Horizontal />
90
108
  {item.shortcut ? (
91
109
  <KeyboardShortcut shortcut={item.shortcut} />
92
110
  ) : (
@@ -44,7 +44,6 @@ const DropdownMenuRoot = forwardRefGeneric(function DropdownMenuRoot<
44
44
  items,
45
45
  children,
46
46
  onSelect,
47
-
48
47
  shouldBindKeyboardShortcuts,
49
48
  onOpenChange,
50
49
  open,
@@ -0,0 +1,203 @@
1
+ import { cx, InputField } from "@noya-app/noya-designsystem";
2
+ import React, {
3
+ forwardRef,
4
+ memo,
5
+ useCallback,
6
+ useEffect,
7
+ useImperativeHandle,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+ import { BreadcrumbText } from "./Breadcrumbs";
12
+
13
+ type RenderPreviewProps = {
14
+ children: string;
15
+ onClick: () => void;
16
+ ref: React.Ref<HTMLElement & HTMLAnchorElement & HTMLSpanElement>;
17
+ tabIndex: number;
18
+ onKeyDown: (e: React.KeyboardEvent) => void;
19
+ style?: React.CSSProperties;
20
+ className?: string;
21
+ };
22
+
23
+ type Props = {
24
+ value: string;
25
+ children?: (props: RenderPreviewProps) => React.ReactNode;
26
+ placeholder?: string;
27
+ onChange?: (value: string) => void;
28
+ onSubmit?: (value: string) => void;
29
+ style?: React.CSSProperties;
30
+ textStyle?: React.CSSProperties;
31
+ className?: string;
32
+ id?: string;
33
+ disabled?: boolean;
34
+ focused?: boolean;
35
+ textClassName?: string;
36
+ readOnly?: boolean;
37
+ onBlur?: () => void;
38
+ /**
39
+ * If true, will render an unfocused input. This should only be used for visual regression tests.
40
+ */
41
+ testFocused?: boolean;
42
+ };
43
+
44
+ export interface EditableTextRef {
45
+ startEditing: () => void;
46
+ }
47
+
48
+ const defaultRenderPreview = (props: RenderPreviewProps) => (
49
+ <BreadcrumbText {...props} />
50
+ );
51
+
52
+ export const EditableText = memo(
53
+ forwardRef(function EditableText(
54
+ {
55
+ value,
56
+ onChange,
57
+ onSubmit,
58
+ focused: focusedProp,
59
+ disabled,
60
+ style,
61
+ textStyle,
62
+ className,
63
+ textClassName,
64
+ placeholder,
65
+ children = defaultRenderPreview,
66
+ readOnly,
67
+ onBlur,
68
+ testFocused = false,
69
+ ...props
70
+ }: Props,
71
+ ref: React.ForwardedRef<EditableTextRef>
72
+ ) {
73
+ const [inputValue, setInputValue] = useState(value);
74
+
75
+ useEffect(() => {
76
+ setInputValue(value);
77
+ }, [value]);
78
+
79
+ const [focusedInternal, setFocused] = useState(false);
80
+ const focused = focusedProp ?? focusedInternal;
81
+ const inputRef = useRef<HTMLInputElement>(null);
82
+ const previewRef = useRef<HTMLElement & HTMLAnchorElement>(null);
83
+
84
+ const ignoreNextBlur = useRef(false);
85
+
86
+ const handleKeyDown = useCallback(
87
+ (event: React.KeyboardEvent<HTMLInputElement>) => {
88
+ if (event.key === "Escape") {
89
+ setInputValue(value);
90
+ ignoreNextBlur.current = true;
91
+ }
92
+
93
+ // Enter and Esc both blur the input
94
+ if (event.key === "Enter" || event.key === "Escape") {
95
+ inputRef.current?.blur();
96
+ }
97
+
98
+ if (event.key === "Escape") {
99
+ previewRef.current?.focus();
100
+ }
101
+
102
+ // If tab or shift+tab is pressed, let bubble
103
+ if (event.key === "Tab") {
104
+ return;
105
+ }
106
+
107
+ event.stopPropagation();
108
+ },
109
+ [value]
110
+ );
111
+
112
+ useImperativeHandle(ref, () => ({
113
+ startEditing: () => {
114
+ setFocused(true);
115
+ },
116
+ }));
117
+
118
+ function focusInput() {
119
+ inputRef.current?.focus();
120
+ inputRef.current?.setSelectionRange(0, inputRef.current.value.length);
121
+ }
122
+
123
+ useEffect(() => {
124
+ if (testFocused) return;
125
+ if (focused) {
126
+ // Use a timeout to focus the input after the next render.
127
+ // The input isn't available even though `focused` was already set to true
128
+ // must be a delay of 1ms or more to ensure any radix ui components have finished any focus behaviors
129
+ setTimeout(focusInput, 1);
130
+ }
131
+ }, [focused, testFocused]);
132
+
133
+ const displayValue = inputValue || placeholder || "";
134
+
135
+ const preview = children({
136
+ children: displayValue,
137
+ onClick: () => setFocused(true),
138
+ ref: previewRef,
139
+ tabIndex: 0,
140
+ onKeyDown: (e: React.KeyboardEvent) => {
141
+ if (e.key === "Enter" || e.key === " ") {
142
+ setFocused(true);
143
+ }
144
+ },
145
+ style: textStyle,
146
+ className: textClassName,
147
+ });
148
+
149
+ const input = (
150
+ <div className="flex absolute inset-0 -mx-1.5 -my-1">
151
+ <InputField.Root onFocusChange={setFocused}>
152
+ <InputField.Input
153
+ ref={inputRef}
154
+ style={{
155
+ lineHeight: "15px", // Match breadcrumb height
156
+ ...textStyle,
157
+ }}
158
+ className={textClassName}
159
+ {...props}
160
+ value={inputValue}
161
+ placeholder={placeholder}
162
+ onKeyDown={handleKeyDown}
163
+ onBlur={() => {
164
+ setFocused(false);
165
+
166
+ if (ignoreNextBlur.current) {
167
+ ignoreNextBlur.current = false;
168
+ return;
169
+ }
170
+
171
+ onSubmit?.(inputValue);
172
+ onBlur?.();
173
+ }}
174
+ {...(onChange
175
+ ? {
176
+ onChange: (value) => {
177
+ setInputValue(value);
178
+ onChange?.(value);
179
+ },
180
+ }
181
+ : {
182
+ onSubmit: (value) => {
183
+ setInputValue(value);
184
+ onSubmit?.(value);
185
+ setFocused(false);
186
+ },
187
+ onChange: (value) => {
188
+ setInputValue(value);
189
+ },
190
+ })}
191
+ />
192
+ </InputField.Root>
193
+ </div>
194
+ );
195
+
196
+ return (
197
+ <div className={cx("flex relative", className)} style={style}>
198
+ {preview}
199
+ {focused && !readOnly && input}
200
+ </div>
201
+ );
202
+ })
203
+ );