@noya-app/noya-designsystem 0.1.42 → 0.1.44

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noya-app/noya-designsystem",
3
- "version": "0.1.42",
3
+ "version": "0.1.44",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -25,17 +25,12 @@ import { InputField, InputFieldSize } from "./InputField";
25
25
  import { ListView } from "./ListView";
26
26
  import { Small } from "./Text";
27
27
 
28
- export type ComboboxProps = {
28
+ export type BaseComboboxProps = {
29
29
  id?: string;
30
30
  loading?: boolean;
31
- initialValue?: string;
32
31
  placeholder?: string;
33
32
  items: ComboboxItem[];
34
- onChange?: (value: string) => void;
35
- onHoverItem?: (item: ComboboxOption | undefined) => void;
36
- onSelectItem?: (item: ComboboxOption) => void;
37
33
  onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
38
- onBlur?: (didSubmit: boolean, value: string) => void;
39
34
  onDeleteWhenEmpty?: () => void;
40
35
  size?: InputFieldSize;
41
36
  style?: React.CSSProperties;
@@ -43,15 +38,40 @@ export type ComboboxProps = {
43
38
  label?: React.ReactNode;
44
39
  children?: React.ReactNode;
45
40
  hideMenuWhenEmptyValue?: boolean;
46
- allowArbitraryValues?: boolean;
47
41
  readOnly?: boolean;
48
42
  tabBehavior?: "autocomplete" | "select";
49
43
  openMenuBehavior?: "showAllItems" | "showFilteredItems";
50
44
  };
51
45
 
46
+ export type StringModeOnlyProps = {
47
+ mode: "string";
48
+ value?: string;
49
+ onChange?: (value: string) => void;
50
+ onSelectItem?: (value: string) => void;
51
+ onBlur?: (didSubmit: boolean, value: string) => void;
52
+ onHoverItem?: (value: string | undefined) => void;
53
+ allowArbitraryValues?: boolean;
54
+ };
55
+
56
+ export type StringModeProps = BaseComboboxProps & StringModeOnlyProps;
57
+
58
+ export type OptionModeOnlyProps = {
59
+ 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;
65
+ allowArbitraryValues?: false;
66
+ };
67
+
68
+ export type OptionModeProps = BaseComboboxProps & OptionModeOnlyProps;
69
+
70
+ export type ComboboxProps = StringModeProps | OptionModeProps;
71
+
52
72
  export interface ComboboxRef {
53
73
  focus(): void;
54
- setValue(value: string): void;
74
+ setValue(value: string | ComboboxOption): void;
55
75
  selectAllInputText(): void;
56
76
  }
57
77
 
@@ -60,31 +80,75 @@ export const Combobox = memo(
60
80
  {
61
81
  id,
62
82
  loading,
63
- initialValue = "",
64
83
  placeholder,
65
84
  items,
66
85
  size,
67
- onChange,
68
- onSelectItem,
69
- onHoverItem,
70
- onFocus,
71
- onBlur,
72
- onDeleteWhenEmpty,
73
86
  style,
74
87
  className,
75
88
  label,
76
89
  children,
77
90
  hideMenuWhenEmptyValue = false,
78
- allowArbitraryValues = false,
79
91
  readOnly = false,
80
92
  tabBehavior = "select",
81
93
  openMenuBehavior = "showFilteredItems",
94
+ onFocus,
95
+ onDeleteWhenEmpty,
96
+ ...rest
82
97
  }: ComboboxProps,
83
98
  forwardedRef: ForwardedRef<ComboboxRef>
84
99
  ) {
100
+ // We make a separate props object for the mode-specific props for memoizing
101
+ // since we put the props object into hook deps
102
+ const props = useMemo((): StringModeOnlyProps | OptionModeOnlyProps => {
103
+ if (rest.mode === "string") {
104
+ return {
105
+ mode: rest.mode,
106
+ value: rest.value,
107
+ onChange: rest.onChange,
108
+ onSelectItem: rest.onSelectItem,
109
+ onHoverItem: rest.onHoverItem,
110
+ onBlur: rest.onBlur,
111
+ allowArbitraryValues: rest.allowArbitraryValues,
112
+ } satisfies StringModeOnlyProps;
113
+ } else {
114
+ return {
115
+ mode: rest.mode,
116
+ value: rest.value,
117
+ onChange: rest.onChange,
118
+ onSelectItem: rest.onSelectItem,
119
+ onHoverItem: rest.onHoverItem,
120
+ onBlur: rest.onBlur,
121
+ } satisfies OptionModeOnlyProps;
122
+ }
123
+ }, [
124
+ rest.mode,
125
+ rest.value,
126
+ rest.onChange,
127
+ rest.onSelectItem,
128
+ rest.onHoverItem,
129
+ rest.onBlur,
130
+ rest.allowArbitraryValues,
131
+ ]);
132
+
85
133
  const ref = useRef<HTMLInputElement>(null);
134
+
135
+ function getInitialValueString() {
136
+ if (props.mode === "string") {
137
+ return props.value ?? "";
138
+ } else {
139
+ return props.value?.name ?? "";
140
+ }
141
+ }
142
+
143
+ const initialValueString = getInitialValueString();
144
+
86
145
  const [comboboxState] = useState(
87
- () => new ComboboxState(items, initialValue, allowArbitraryValues)
146
+ () =>
147
+ new ComboboxState(
148
+ items,
149
+ initialValueString,
150
+ props.mode === "string" && (props.allowArbitraryValues ?? false)
151
+ )
88
152
  );
89
153
  const state = useComboboxState(comboboxState);
90
154
  const [isFocused, setIsFocused] = useState(false);
@@ -96,13 +160,14 @@ export const Combobox = memo(
96
160
  comboboxState.setItems(items);
97
161
  }, [items, comboboxState]);
98
162
 
163
+ useEffect(() => {
164
+ comboboxState.setFilter(initialValueString);
165
+ }, [comboboxState, initialValueString]);
166
+
99
167
  const { filter, selectedIndex, filteredItems } = state;
100
168
 
101
- const shouldShowMenu = useMemo(() => {
102
- if (!isFocused) return false;
103
- if (hideMenuWhenEmptyValue && filter === "") return false;
104
- return true;
105
- }, [hideMenuWhenEmptyValue, filter, isFocused]);
169
+ const shouldShowMenu =
170
+ isFocused && !(hideMenuWhenEmptyValue && filter === "");
106
171
 
107
172
  useEffect(() => {
108
173
  if (listRef.current) {
@@ -112,16 +177,29 @@ export const Combobox = memo(
112
177
 
113
178
  const handleBlur = useCallback(() => {
114
179
  ref.current?.blur();
115
- comboboxState.reset(initialValue);
116
- onBlur?.(state.filter === state.lastSubmittedValue.filter, filter);
180
+ comboboxState.reset(initialValueString);
181
+ if (props.mode === "string") {
182
+ props.onBlur?.(
183
+ state.filter === state.lastSubmittedValue.filter,
184
+ filter
185
+ );
186
+ } else {
187
+ const selectedItem = comboboxState.getSelectedItem();
188
+ if (selectedItem && selectedItem.type !== "sectionHeader") {
189
+ props.onBlur?.(
190
+ state.filter === state.lastSubmittedValue.filter,
191
+ selectedItem
192
+ );
193
+ }
194
+ }
117
195
  setIsFocused(false);
118
- }, [filter, initialValue, onBlur, state, comboboxState]);
196
+ }, [filter, initialValueString, props, state, comboboxState]);
119
197
 
120
198
  const handleFocus: FocusEventHandler<HTMLInputElement> = useCallback(
121
199
  (event) => {
122
200
  setIsFocused(true);
123
201
  comboboxState.reset(
124
- openMenuBehavior === "showAllItems" ? "" : initialValue
202
+ openMenuBehavior === "showAllItems" ? "" : initialValueString
125
203
  );
126
204
  if (ref.current) {
127
205
  const length = ref.current.value.length;
@@ -129,7 +207,7 @@ export const Combobox = memo(
129
207
  }
130
208
  onFocus?.(event);
131
209
  },
132
- [initialValue, onFocus, openMenuBehavior, comboboxState]
210
+ [initialValueString, onFocus, openMenuBehavior, comboboxState]
133
211
  );
134
212
 
135
213
  const handleUpdateSelection = useCallback(
@@ -139,11 +217,16 @@ export const Combobox = memo(
139
217
  const selectedItem = comboboxState.selectCurrentItem(item);
140
218
  if (!selectedItem || selectedItem.type === "sectionHeader") return;
141
219
 
142
- onSelectItem?.(selectedItem);
143
- onHoverItem?.(undefined);
220
+ if (props.mode === "string") {
221
+ props.onSelectItem?.(selectedItem.name);
222
+ props.onHoverItem?.(undefined);
223
+ } else {
224
+ props.onSelectItem?.(selectedItem);
225
+ props.onHoverItem?.(undefined);
226
+ }
144
227
  setShouldBlur(true);
145
228
  },
146
- [onSelectItem, onHoverItem, comboboxState]
229
+ [props, comboboxState]
147
230
  );
148
231
 
149
232
  const handleIndexChange = useCallback(
@@ -151,12 +234,32 @@ export const Combobox = memo(
151
234
  comboboxState.setSelectedIndex(index);
152
235
  const item = comboboxState.getSelectedItem();
153
236
  if (item && item.type !== "sectionHeader") {
154
- onHoverItem?.(item);
237
+ if (props.mode === "string") {
238
+ props.onHoverItem?.(item.name);
239
+ } else {
240
+ props.onHoverItem?.(item);
241
+ }
155
242
  } else {
156
- onHoverItem?.(undefined);
243
+ props.onHoverItem?.(undefined);
157
244
  }
158
245
  },
159
- [onHoverItem, comboboxState]
246
+ [props, comboboxState]
247
+ );
248
+
249
+ const handleChange = useCallback(
250
+ (value: string) => {
251
+ if (readOnly) return;
252
+ comboboxState.setFilter(value);
253
+ if (props.mode === "string") {
254
+ props.onChange?.(value);
255
+ } else {
256
+ const selectedItem = comboboxState.getSelectedItem();
257
+ if (selectedItem && selectedItem.type !== "sectionHeader") {
258
+ props.onChange?.(selectedItem);
259
+ }
260
+ }
261
+ },
262
+ [readOnly, props, comboboxState]
160
263
  );
161
264
 
162
265
  const handleKeyDown = useCallback(
@@ -177,8 +280,11 @@ export const Combobox = memo(
177
280
  if (shouldShowMenu) {
178
281
  if (item) {
179
282
  handleUpdateSelection(item);
180
- } else if (allowArbitraryValues) {
181
- // If no item is selected but arbitrary filters are allowed,
283
+ } else if (
284
+ props.mode === "string" &&
285
+ props.allowArbitraryValues
286
+ ) {
287
+ // If no item is selected but arbitrary filters are allowed in string mode,
182
288
  // submit the current filter value
183
289
  comboboxState.submitArbitraryFilter(filter);
184
290
  handleBlur();
@@ -213,8 +319,12 @@ export const Combobox = memo(
213
319
  }
214
320
  handled = true;
215
321
  }
216
- } else if (allowArbitraryValues && filter !== "") {
217
- // Allow submitting arbitrary filter on Tab
322
+ } else if (
323
+ props.mode === "string" &&
324
+ props.allowArbitraryValues &&
325
+ filter !== ""
326
+ ) {
327
+ // Allow submitting arbitrary filter on Tab only in string mode
218
328
  comboboxState.submitArbitraryFilter(filter);
219
329
  handleBlur();
220
330
  handled = true;
@@ -250,19 +360,10 @@ export const Combobox = memo(
250
360
  shouldShowMenu,
251
361
  comboboxState,
252
362
  tabBehavior,
253
- allowArbitraryValues,
363
+ props,
254
364
  ]
255
365
  );
256
366
 
257
- const handleChange = useCallback(
258
- (value: string) => {
259
- if (readOnly) return;
260
- comboboxState.setFilter(value);
261
- onChange?.(value);
262
- },
263
- [onChange, readOnly, comboboxState]
264
- );
265
-
266
367
  const typeaheadValue = comboboxState.getTypeaheadValue();
267
368
 
268
369
  useImperativeHandle(forwardedRef, () => ({
@@ -273,8 +374,12 @@ export const Combobox = memo(
273
374
  ref.current.setSelectionRange(length, length);
274
375
  }
275
376
  },
276
- setValue: (value: string) => {
277
- comboboxState.setFilter(value);
377
+ setValue: (value: string | ComboboxOption) => {
378
+ if (typeof value === "string") {
379
+ comboboxState.setFilter(value);
380
+ } else {
381
+ comboboxState.setFilter(value.name);
382
+ }
278
383
  },
279
384
  selectAllInputText: () => {
280
385
  ref.current?.setSelectionRange(0, ref.current.value.length);
@@ -292,14 +397,14 @@ export const Combobox = memo(
292
397
 
293
398
  // Use openMenuBehavior consistently for both focus and chevron click
294
399
  comboboxState.reset(
295
- openMenuBehavior === "showAllItems" ? "" : initialValue
400
+ openMenuBehavior === "showAllItems" ? "" : initialValueString
296
401
  );
297
402
  ref.current?.focus();
298
403
  },
299
404
  [
300
405
  shouldShowMenu,
301
406
  openMenuBehavior,
302
- initialValue,
407
+ initialValueString,
303
408
  handleBlur,
304
409
  comboboxState,
305
410
  ]
@@ -307,9 +412,10 @@ export const Combobox = memo(
307
412
 
308
413
  useEffect(() => {
309
414
  if (!shouldBlur) return;
415
+ if (document.activeElement !== ref.current) return;
416
+ setShouldBlur(false);
310
417
  ref.current?.focus();
311
418
  handleBlur();
312
- setShouldBlur(false);
313
419
  }, [shouldBlur, handleBlur]);
314
420
 
315
421
  return (
@@ -31,6 +31,7 @@ type Props<T extends string> = {
31
31
  label?: React.ReactNode;
32
32
  onFocus?: FocusEventHandler;
33
33
  onBlur?: FocusEventHandler;
34
+ contentStyle?: React.CSSProperties;
34
35
  } & Pick<SelectProps, "open" | "onOpenChange">;
35
36
 
36
37
  const readOnlyStyle: CSSProperties = {
@@ -68,6 +69,7 @@ export const SelectMenu = memoGeneric(function SelectMenu<
68
69
  onFocus,
69
70
  onBlur,
70
71
  onOpenChange,
72
+ contentStyle,
71
73
  }: Props<T>) {
72
74
  const selectedItem = menuItems.find(
73
75
  (item): item is RegularMenuItem<T> =>
@@ -184,6 +186,8 @@ export const SelectMenu = memoGeneric(function SelectMenu<
184
186
  align="end"
185
187
  style={{
186
188
  width: "var(--radix-select-trigger-width)",
189
+ maxHeight: "500px",
190
+ ...contentStyle,
187
191
  }}
188
192
  >
189
193
  <Select.ScrollUpButton className={scrollButtonStyles}>
@@ -1,11 +1,19 @@
1
1
  import { assignRef } from "@noya-app/react-utils";
2
- import React, { forwardRef, memo, useCallback, useEffect, useRef } from "react";
2
+ import React, {
3
+ ComponentProps,
4
+ forwardRef,
5
+ memo,
6
+ useCallback,
7
+ useEffect,
8
+ useRef,
9
+ } from "react";
3
10
  import { cx } from "../utils/classNames";
4
11
 
5
- export const useAutoResize = (value: string) => {
12
+ export const useAutoResize = (value: string | undefined) => {
6
13
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
7
14
 
8
15
  useEffect(() => {
16
+ if (value === undefined) return;
9
17
  if (!textareaRef.current) return;
10
18
 
11
19
  textareaRef.current.style.height = "auto"; // Reset the height
@@ -15,29 +23,29 @@ export const useAutoResize = (value: string) => {
15
23
  return textareaRef;
16
24
  };
17
25
 
18
- export const AutoResizingTextArea = memo(
19
- forwardRef(function AutoResizingTextArea(
26
+ export const TextArea = memo(
27
+ forwardRef(function TextArea(
20
28
  {
21
29
  value,
22
30
  onChangeText,
23
31
  className,
24
- end,
25
- endClassName,
26
32
  onChange,
33
+ autoResize = false,
27
34
  ...rest
28
35
  }: {
29
- onChangeText: (value: string) => void;
36
+ onChangeText?: (value: string) => void;
30
37
  value?: string;
31
- end?: React.ReactNode;
32
- endClassName?: string;
38
+ autoResize?: boolean;
33
39
  } & React.TextareaHTMLAttributes<HTMLTextAreaElement>,
34
40
  forwardedRef: React.ForwardedRef<HTMLTextAreaElement>
35
41
  ) {
36
- const ref = useAutoResize(value || rest.placeholder || "");
42
+ const autoResizeRef = useAutoResize(
43
+ autoResize ? value || rest.placeholder || "" : undefined
44
+ );
37
45
 
38
46
  const handleChange = useCallback(
39
47
  (event: React.ChangeEvent<HTMLTextAreaElement>) => {
40
- onChangeText(event.target.value);
48
+ onChangeText?.(event.target.value);
41
49
  onChange?.(event);
42
50
  },
43
51
  [onChangeText, onChange]
@@ -45,24 +53,53 @@ export const AutoResizingTextArea = memo(
45
53
 
46
54
  const handleRef = useCallback(
47
55
  (value: HTMLTextAreaElement | null) => {
48
- ref.current = value;
56
+ if (autoResize) {
57
+ autoResizeRef.current = value;
58
+ }
49
59
  assignRef(forwardedRef, value);
50
60
  },
51
- [ref, forwardedRef]
61
+ [autoResize, autoResizeRef, forwardedRef]
52
62
  );
53
63
 
54
64
  return (
55
- <div className="relative w-full h-full">
56
- <textarea
57
- className={cx(
58
- `font-sans text-heading5 font-normal text-text bg-input-background flex-1 py-1 px-1.5 border-none outline-none min-h-24 w-full rounded resize-none placeholder:text-text-disabled focus:ring-2 focus:ring-primary read-only:text-text-disabled focus:z-interactable transition-all`,
59
- className
60
- )}
61
- ref={handleRef}
62
- {...rest}
63
- onChange={handleChange}
64
- value={value}
65
- />
65
+ <textarea
66
+ className={cx(
67
+ `font-sans text-heading5 font-normal text-text bg-input-background flex-1 py-1 px-1.5 border-none outline-none min-h-6 w-full rounded resize-none placeholder:text-text-disabled focus:ring-2 focus:ring-primary read-only:text-text-disabled focus:z-interactable transition-all`,
68
+ className
69
+ )}
70
+ ref={handleRef}
71
+ {...rest}
72
+ onChange={handleChange}
73
+ value={value}
74
+ />
75
+ );
76
+ })
77
+ );
78
+
79
+ export const TextAreaRow = memo(
80
+ forwardRef(function TextAreaRow(
81
+ {
82
+ className,
83
+ textAreaClassName,
84
+ textAreaRef,
85
+ end,
86
+ endClassName,
87
+ ...rest
88
+ }: {
89
+ className?: string;
90
+ textAreaClassName?: string;
91
+ textAreaRef?: React.Ref<HTMLTextAreaElement>;
92
+ end?: React.ReactNode;
93
+ endClassName?: string;
94
+ } & ComponentProps<typeof TextArea>,
95
+ forwardedRef: React.ForwardedRef<HTMLDivElement>
96
+ ) {
97
+ return (
98
+ <div
99
+ ref={forwardedRef}
100
+ className={cx("relative w-full h-full", className)}
101
+ >
102
+ <TextArea className={textAreaClassName} {...rest} ref={textAreaRef} />
66
103
  <div className={cx("absolute right-1 top-4", endClassName)}>{end}</div>
67
104
  </div>
68
105
  );
@@ -88,19 +88,20 @@ export function ToolbarMenu<T extends string>({
88
88
  return (
89
89
  <Button
90
90
  key={i}
91
+ disabled={item.disabled}
91
92
  onClick={() => {
92
93
  if (onSelectMenuItem && item.value) {
93
94
  onSelectMenuItem(item.value);
94
95
  }
95
96
  }}
96
97
  >
98
+ {item.title}
97
99
  {item.icon && (
98
100
  <>
99
- {renderIcon(item.icon)}
100
101
  <Spacer.Horizontal inline size={6} />
102
+ {renderIcon(item.icon)}
101
103
  </>
102
104
  )}
103
- {item.title}
104
105
  </Button>
105
106
  );
106
107
  }
@@ -115,13 +116,7 @@ export function ToolbarMenu<T extends string>({
115
116
  }
116
117
  }}
117
118
  >
118
- <Button>
119
- {item.icon && (
120
- <>
121
- {renderIcon(item.icon)}
122
- <Spacer.Horizontal size={6} />
123
- </>
124
- )}
119
+ <Button disabled={item.disabled}>
125
120
  {item.title}
126
121
  <Spacer.Horizontal size={6} />
127
122
  <DropdownChevronIcon />