@noya-app/noya-designsystem 0.1.42 → 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.
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.43",
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
+ initialValue?: 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
+ initialValue?: 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,68 @@ 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
+ initialValue: rest.initialValue,
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
+ initialValue: rest.initialValue,
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.initialValue,
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
+ const initialValueString =
135
+ typeof props.initialValue === "string"
136
+ ? props.initialValue
137
+ : props.initialValue?.name ?? "";
86
138
  const [comboboxState] = useState(
87
- () => new ComboboxState(items, initialValue, allowArbitraryValues)
139
+ () =>
140
+ new ComboboxState(
141
+ items,
142
+ initialValueString,
143
+ props.mode === "string" && (props.allowArbitraryValues ?? false)
144
+ )
88
145
  );
89
146
  const state = useComboboxState(comboboxState);
90
147
  const [isFocused, setIsFocused] = useState(false);
@@ -98,11 +155,8 @@ export const Combobox = memo(
98
155
 
99
156
  const { filter, selectedIndex, filteredItems } = state;
100
157
 
101
- const shouldShowMenu = useMemo(() => {
102
- if (!isFocused) return false;
103
- if (hideMenuWhenEmptyValue && filter === "") return false;
104
- return true;
105
- }, [hideMenuWhenEmptyValue, filter, isFocused]);
158
+ const shouldShowMenu =
159
+ isFocused && !(hideMenuWhenEmptyValue && filter === "");
106
160
 
107
161
  useEffect(() => {
108
162
  if (listRef.current) {
@@ -112,16 +166,29 @@ export const Combobox = memo(
112
166
 
113
167
  const handleBlur = useCallback(() => {
114
168
  ref.current?.blur();
115
- comboboxState.reset(initialValue);
116
- onBlur?.(state.filter === state.lastSubmittedValue.filter, filter);
169
+ comboboxState.reset(initialValueString);
170
+ if (props.mode === "string") {
171
+ props.onBlur?.(
172
+ state.filter === state.lastSubmittedValue.filter,
173
+ filter
174
+ );
175
+ } else {
176
+ const selectedItem = comboboxState.getSelectedItem();
177
+ if (selectedItem && selectedItem.type !== "sectionHeader") {
178
+ props.onBlur?.(
179
+ state.filter === state.lastSubmittedValue.filter,
180
+ selectedItem
181
+ );
182
+ }
183
+ }
117
184
  setIsFocused(false);
118
- }, [filter, initialValue, onBlur, state, comboboxState]);
185
+ }, [filter, initialValueString, props, state, comboboxState]);
119
186
 
120
187
  const handleFocus: FocusEventHandler<HTMLInputElement> = useCallback(
121
188
  (event) => {
122
189
  setIsFocused(true);
123
190
  comboboxState.reset(
124
- openMenuBehavior === "showAllItems" ? "" : initialValue
191
+ openMenuBehavior === "showAllItems" ? "" : initialValueString
125
192
  );
126
193
  if (ref.current) {
127
194
  const length = ref.current.value.length;
@@ -129,7 +196,7 @@ export const Combobox = memo(
129
196
  }
130
197
  onFocus?.(event);
131
198
  },
132
- [initialValue, onFocus, openMenuBehavior, comboboxState]
199
+ [initialValueString, onFocus, openMenuBehavior, comboboxState]
133
200
  );
134
201
 
135
202
  const handleUpdateSelection = useCallback(
@@ -139,11 +206,16 @@ export const Combobox = memo(
139
206
  const selectedItem = comboboxState.selectCurrentItem(item);
140
207
  if (!selectedItem || selectedItem.type === "sectionHeader") return;
141
208
 
142
- onSelectItem?.(selectedItem);
143
- onHoverItem?.(undefined);
209
+ if (props.mode === "string") {
210
+ props.onSelectItem?.(selectedItem.name);
211
+ props.onHoverItem?.(undefined);
212
+ } else {
213
+ props.onSelectItem?.(selectedItem);
214
+ props.onHoverItem?.(undefined);
215
+ }
144
216
  setShouldBlur(true);
145
217
  },
146
- [onSelectItem, onHoverItem, comboboxState]
218
+ [props, comboboxState]
147
219
  );
148
220
 
149
221
  const handleIndexChange = useCallback(
@@ -151,12 +223,32 @@ export const Combobox = memo(
151
223
  comboboxState.setSelectedIndex(index);
152
224
  const item = comboboxState.getSelectedItem();
153
225
  if (item && item.type !== "sectionHeader") {
154
- onHoverItem?.(item);
226
+ if (props.mode === "string") {
227
+ props.onHoverItem?.(item.name);
228
+ } else {
229
+ props.onHoverItem?.(item);
230
+ }
231
+ } else {
232
+ props.onHoverItem?.(undefined);
233
+ }
234
+ },
235
+ [props, comboboxState]
236
+ );
237
+
238
+ const handleChange = useCallback(
239
+ (value: string) => {
240
+ if (readOnly) return;
241
+ comboboxState.setFilter(value);
242
+ if (props.mode === "string") {
243
+ props.onChange?.(value);
155
244
  } else {
156
- onHoverItem?.(undefined);
245
+ const selectedItem = comboboxState.getSelectedItem();
246
+ if (selectedItem && selectedItem.type !== "sectionHeader") {
247
+ props.onChange?.(selectedItem);
248
+ }
157
249
  }
158
250
  },
159
- [onHoverItem, comboboxState]
251
+ [readOnly, props, comboboxState]
160
252
  );
161
253
 
162
254
  const handleKeyDown = useCallback(
@@ -177,8 +269,11 @@ export const Combobox = memo(
177
269
  if (shouldShowMenu) {
178
270
  if (item) {
179
271
  handleUpdateSelection(item);
180
- } else if (allowArbitraryValues) {
181
- // If no item is selected but arbitrary filters are allowed,
272
+ } else if (
273
+ props.mode === "string" &&
274
+ props.allowArbitraryValues
275
+ ) {
276
+ // If no item is selected but arbitrary filters are allowed in string mode,
182
277
  // submit the current filter value
183
278
  comboboxState.submitArbitraryFilter(filter);
184
279
  handleBlur();
@@ -213,8 +308,12 @@ export const Combobox = memo(
213
308
  }
214
309
  handled = true;
215
310
  }
216
- } else if (allowArbitraryValues && filter !== "") {
217
- // Allow submitting arbitrary filter on Tab
311
+ } else if (
312
+ props.mode === "string" &&
313
+ props.allowArbitraryValues &&
314
+ filter !== ""
315
+ ) {
316
+ // Allow submitting arbitrary filter on Tab only in string mode
218
317
  comboboxState.submitArbitraryFilter(filter);
219
318
  handleBlur();
220
319
  handled = true;
@@ -250,19 +349,10 @@ export const Combobox = memo(
250
349
  shouldShowMenu,
251
350
  comboboxState,
252
351
  tabBehavior,
253
- allowArbitraryValues,
352
+ props,
254
353
  ]
255
354
  );
256
355
 
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
356
  const typeaheadValue = comboboxState.getTypeaheadValue();
267
357
 
268
358
  useImperativeHandle(forwardedRef, () => ({
@@ -273,8 +363,12 @@ export const Combobox = memo(
273
363
  ref.current.setSelectionRange(length, length);
274
364
  }
275
365
  },
276
- setValue: (value: string) => {
277
- comboboxState.setFilter(value);
366
+ setValue: (value: string | ComboboxOption) => {
367
+ if (typeof value === "string") {
368
+ comboboxState.setFilter(value);
369
+ } else {
370
+ comboboxState.setFilter(value.name);
371
+ }
278
372
  },
279
373
  selectAllInputText: () => {
280
374
  ref.current?.setSelectionRange(0, ref.current.value.length);
@@ -292,14 +386,14 @@ export const Combobox = memo(
292
386
 
293
387
  // Use openMenuBehavior consistently for both focus and chevron click
294
388
  comboboxState.reset(
295
- openMenuBehavior === "showAllItems" ? "" : initialValue
389
+ openMenuBehavior === "showAllItems" ? "" : initialValueString
296
390
  );
297
391
  ref.current?.focus();
298
392
  },
299
393
  [
300
394
  shouldShowMenu,
301
395
  openMenuBehavior,
302
- initialValue,
396
+ initialValueString,
303
397
  handleBlur,
304
398
  comboboxState,
305
399
  ]
@@ -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 />