@noya-app/noya-designsystem 0.1.73 → 0.1.75

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.73",
3
+ "version": "0.1.75",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -23,7 +23,7 @@
23
23
  "@base-ui-components/react": "1.0.0-beta.4",
24
24
  "@dnd-kit/core": "6.3.1",
25
25
  "@dnd-kit/sortable": "10.0.0",
26
- "@noya-app/noya-colorpicker": "0.1.30",
26
+ "@noya-app/noya-colorpicker": "0.1.31",
27
27
  "@noya-app/noya-utils": "0.1.9",
28
28
  "@noya-app/noya-geometry": "0.1.16",
29
29
  "@noya-app/noya-icons": "0.1.16",
@@ -156,6 +156,24 @@ test("resets state", () => {
156
156
  expect(snapshot.selectedIndex).toBe(0);
157
157
  });
158
158
 
159
+ test("reset keeps selection aligned to provided title", () => {
160
+ const state = new ComboboxState(testItems, "", true);
161
+ state.setFilter("font");
162
+ state.moveSelection("down");
163
+ state.selectCurrentItem();
164
+
165
+ state.reset("", { selectedItemTitle: "font-italic" });
166
+ const snapshot = state.getSnapshot();
167
+
168
+ const matchedIndex = snapshot.filteredItems.findIndex(
169
+ (item) =>
170
+ isSelectableMenuItem(item) &&
171
+ item.title?.toString() === "font-italic"
172
+ );
173
+
174
+ expect(snapshot.selectedIndex).toBe(matchedIndex);
175
+ });
176
+
159
177
  test("handles empty query", () => {
160
178
  const state = new ComboboxState(testItems, "", true);
161
179
  state.setFilter("");
@@ -62,6 +62,23 @@ export function BaseToolbar({
62
62
  innerClassName,
63
63
  enableAbsoluteBreakpoint = true,
64
64
  }: BaseToolbarProps) {
65
+ const childrenContainerClassName = cx(
66
+ "n-flex n-items-center n-justify-center n-text-text-muted n-pointer-events-none n-inset-0 n-pl-1 n-pr-1 n-min-w-0",
67
+ "@xl/toolbar:!n-absolute @xl/toolbar:!n-pl-0 @xl/toolbar:!n-pr-0"
68
+ );
69
+
70
+ const childrenElement = children && (
71
+ <div className={childrenContainerClassName}>
72
+ <div className="n-flex n-items-center n-justify-center n-pointer-events-auto">
73
+ {children}
74
+ </div>
75
+ </div>
76
+ );
77
+
78
+ const leftElement = left && (
79
+ <div className="n-flex n-gap-toolbar-separator">{left}</div>
80
+ );
81
+
65
82
  return (
66
83
  <BaseToolbarContainer
67
84
  showDivider={showDivider}
@@ -76,23 +93,20 @@ export function BaseToolbar({
76
93
  </>
77
94
  )}
78
95
  <Spacer.Horizontal size={10} />
79
- {left && (
96
+ {childrenElement}
97
+ {leftElement && (
80
98
  <>
81
- <div className="n-flex n-gap-toolbar-separator">{left}</div>
99
+ {childrenElement && (
100
+ <>
101
+ <Spacer.Horizontal size={10} className="@xl/toolbar:!n-hidden" />
102
+ <DividerVertical className="@xl/toolbar:!n-hidden" />
103
+ </>
104
+ )}
105
+ <Spacer.Horizontal size={10} className="@xl/toolbar:!n-hidden" />
106
+ {leftElement}
82
107
  <Spacer.Horizontal size={10} />
83
108
  </>
84
109
  )}
85
- {children && (
86
- <div
87
- className={
88
- "n-flex n-items-center n-justify-center n-text-text-muted n-pointer-events-none @xl/toolbar:!n-absolute n-inset-0"
89
- }
90
- >
91
- <div className="n-flex n-items-center n-justify-center n-pointer-events-auto">
92
- {children}
93
- </div>
94
- </div>
95
- )}
96
110
  <Spacer.Horizontal />
97
111
  <Spacer.Horizontal size={10} />
98
112
  <div className="n-flex n-gap-toolbar-separator">{right}</div>
@@ -154,6 +154,17 @@ export const Combobox = memoGeneric(
154
154
 
155
155
  const initialValueString = getInitialValueString();
156
156
 
157
+ const resetSelectionOptions = useMemo(
158
+ () =>
159
+ props.mode === "string"
160
+ ? { selectedItemTitle: initialValueString }
161
+ : {
162
+ selectedItemTitle: initialValueString,
163
+ selectedItemValue: props.value?.value?.toString(),
164
+ },
165
+ [initialValueString, props.mode, props.value]
166
+ );
167
+
157
168
  const [comboboxState] = useState(
158
169
  () =>
159
170
  new ComboboxState(
@@ -165,6 +176,7 @@ export const Combobox = memoGeneric(
165
176
  const state = useComboboxState(comboboxState);
166
177
  const [isFocused, setIsFocused] = useState(false);
167
178
  const listRef = useRef<ListView.VirtualizedList>(null);
179
+ const menuOpenedRef = useRef(false);
168
180
  const [shouldBlur, setShouldBlur] = useState(false);
169
181
  const { fieldId: id } = useLabel({
170
182
  fieldId: rest.id,
@@ -190,9 +202,24 @@ export const Combobox = memoGeneric(
190
202
  }
191
203
  }, [selectedIndex]);
192
204
 
205
+ useEffect(() => {
206
+ if (!shouldShowMenu) {
207
+ menuOpenedRef.current = false;
208
+ return;
209
+ }
210
+
211
+ if (menuOpenedRef.current) return;
212
+
213
+ menuOpenedRef.current = true;
214
+
215
+ if (listRef.current) {
216
+ listRef.current.scrollToIndex(selectedIndex);
217
+ }
218
+ }, [shouldShowMenu, selectedIndex]);
219
+
193
220
  const handleBlur = useCallback(() => {
194
221
  ref.current?.blur();
195
- comboboxState.reset(initialValueString);
222
+ comboboxState.reset(initialValueString, resetSelectionOptions);
196
223
  if (props.mode === "string") {
197
224
  props.onBlur?.(
198
225
  state.filter === state.lastSubmittedValue.filter,
@@ -208,13 +235,21 @@ export const Combobox = memoGeneric(
208
235
  }
209
236
  }
210
237
  setIsFocused(false);
211
- }, [filter, initialValueString, props, state, comboboxState]);
238
+ }, [
239
+ filter,
240
+ initialValueString,
241
+ props,
242
+ resetSelectionOptions,
243
+ state,
244
+ comboboxState,
245
+ ]);
212
246
 
213
247
  const handleFocus: FocusEventHandler<HTMLInputElement> = useCallback(
214
248
  (event) => {
215
249
  setIsFocused(true);
216
250
  comboboxState.reset(
217
- openMenuBehavior === "showAllItems" ? "" : initialValueString
251
+ openMenuBehavior === "showAllItems" ? "" : initialValueString,
252
+ resetSelectionOptions
218
253
  );
219
254
  if (ref.current) {
220
255
  const length = ref.current.value.length;
@@ -222,7 +257,13 @@ export const Combobox = memoGeneric(
222
257
  }
223
258
  onFocus?.(event);
224
259
  },
225
- [initialValueString, onFocus, openMenuBehavior, comboboxState]
260
+ [
261
+ initialValueString,
262
+ onFocus,
263
+ openMenuBehavior,
264
+ resetSelectionOptions,
265
+ comboboxState,
266
+ ]
226
267
  );
227
268
 
228
269
  const handleUpdateSelection = useCallback(
@@ -415,7 +456,8 @@ export const Combobox = memoGeneric(
415
456
 
416
457
  // Use openMenuBehavior consistently for both focus and chevron click
417
458
  comboboxState.reset(
418
- openMenuBehavior === "showAllItems" ? "" : initialValueString
459
+ openMenuBehavior === "showAllItems" ? "" : initialValueString,
460
+ resetSelectionOptions
419
461
  );
420
462
  ref.current?.focus();
421
463
  },
@@ -424,6 +466,7 @@ export const Combobox = memoGeneric(
424
466
  openMenuBehavior,
425
467
  initialValueString,
426
468
  handleBlur,
469
+ resetSelectionOptions,
427
470
  comboboxState,
428
471
  ]
429
472
  );
@@ -198,6 +198,11 @@ type InputElementProps = {
198
198
  value?: string;
199
199
  onSubmit?: (value: string) => void;
200
200
  as?: "input" | "span";
201
+ /**
202
+ * If false, don't attach the shared inputRef to this element.
203
+ * Useful for decorative elements like the typeahead overlay.
204
+ */
205
+ attachInputRef?: boolean;
201
206
  };
202
207
 
203
208
  const InputElement = forwardRef(
@@ -213,13 +218,17 @@ const InputElement = forwardRef(
213
218
  onSubmit,
214
219
  as = "input",
215
220
  style,
221
+ attachInputRef = true,
216
222
  ...props
217
223
  }: InputElementProps,
218
224
  forwardedRef: ForwardedRef<any>
219
225
  ) => {
220
226
  const { endWidth, inputRef, size, id, startWidth } = useInputFieldContext();
221
227
 
222
- const ref = useMergeRefs(inputRef, forwardedRef);
228
+ const ref = useMergeRefs(
229
+ attachInputRef ? inputRef : null,
230
+ forwardedRef
231
+ );
223
232
 
224
233
  const spacingStyles = getFieldSpacing({
225
234
  endWidth,
@@ -322,6 +331,7 @@ const InputFieldTypeahead = (props: {
322
331
  return (
323
332
  <InputElement
324
333
  as="span"
334
+ attachInputRef={false}
325
335
  style={useMemo(
326
336
  () => ({
327
337
  position: "absolute",
@@ -551,8 +561,10 @@ function InputFieldRoot({
551
561
  const startRef = React.useRef<HTMLSpanElement>(null);
552
562
  const inputRef = React.useRef<HTMLInputElement>(null);
553
563
  const endRef = React.useRef<HTMLSpanElement>(null);
564
+ const rootRef = React.useRef<HTMLDivElement>(null);
554
565
 
555
566
  const measuredInputSize = useSize(inputRef, "width");
567
+ const measuredRootSize = useSize(rootRef, "width");
556
568
  const measuredStartSize = useSize(startRef, "width");
557
569
  const measuredEndSize = useSize(endRef, "width");
558
570
  const labelType = useLabelType();
@@ -596,7 +608,12 @@ function InputFieldRoot({
596
608
  );
597
609
 
598
610
  const rootElement = (
599
- <RootContainer $width={width} style={style} className={className}>
611
+ <RootContainer
612
+ ref={rootRef}
613
+ $width={width}
614
+ style={style}
615
+ className={className}
616
+ >
600
617
  {children}
601
618
  {start && (
602
619
  <span ref={startRef} className={cx(startBaseStyles, startClassName)}>
@@ -617,10 +634,25 @@ function InputFieldRoot({
617
634
  </RootContainer>
618
635
  );
619
636
 
637
+ const measuredWidth = useMemo(() => {
638
+ const inputWidth =
639
+ measuredInputSize && measuredInputSize.width > 0
640
+ ? measuredInputSize.width
641
+ : undefined;
642
+ const rootWidth =
643
+ measuredRootSize && measuredRootSize.width > 0
644
+ ? measuredRootSize.width
645
+ : undefined;
646
+
647
+ return inputWidth ?? rootWidth ?? width;
648
+ }, [measuredInputSize, measuredRootSize, width]);
649
+
620
650
  const measuredWidthObject = useMemo(
621
651
  () =>
622
- measuredInputSize ? { width: measuredInputSize.width + 4 } : undefined,
623
- [measuredInputSize]
652
+ measuredWidth && measuredWidth > 0
653
+ ? { width: measuredWidth + 4 }
654
+ : undefined,
655
+ [measuredWidth]
624
656
  );
625
657
 
626
658
  return (
@@ -1,9 +1,11 @@
1
1
  import type { ForwardedRef } from "react";
2
2
  import * as React from "react";
3
+ import { cx } from "../utils/classNames";
3
4
 
4
5
  interface Props {
5
6
  size?: number | string;
6
7
  inline?: boolean;
8
+ className?: string;
7
9
  }
8
10
 
9
11
  /* ----------------------------------------------------------------------------
@@ -11,10 +13,16 @@ interface Props {
11
13
  * ------------------------------------------------------------------------- */
12
14
 
13
15
  const SpacerVertical = React.forwardRef<HTMLSpanElement, Props>(
14
- ({ size, inline, ...props }, ref: ForwardedRef<HTMLSpanElement>) => {
16
+ (
17
+ { size, inline, className, ...props },
18
+ ref: ForwardedRef<HTMLSpanElement>
19
+ ) => {
15
20
  return (
16
21
  <span
17
- className={`${inline ? "n-inline-block" : "n-block"} ${size === undefined ? "n-flex n-flex-1" : ""}`}
22
+ className={cx(
23
+ `${inline ? "n-inline-block" : "n-block"} ${size === undefined ? "n-flex n-flex-1" : ""}`,
24
+ className
25
+ )}
18
26
  style={
19
27
  size
20
28
  ? {
@@ -34,10 +42,16 @@ const SpacerVertical = React.forwardRef<HTMLSpanElement, Props>(
34
42
  * ------------------------------------------------------------------------- */
35
43
 
36
44
  const SpacerHorizontal = React.forwardRef<HTMLSpanElement, Props>(
37
- ({ size, inline, ...props }, ref: ForwardedRef<HTMLSpanElement>) => {
45
+ (
46
+ { size, inline, className, ...props },
47
+ ref: ForwardedRef<HTMLSpanElement>
48
+ ) => {
38
49
  return (
39
50
  <span
40
- className={`${inline ? "n-inline-block" : "n-block"} ${size === undefined ? "n-flex n-flex-1" : ""}`}
51
+ className={cx(
52
+ `${inline ? "n-inline-block" : "n-block"} ${size === undefined ? "n-flex n-flex-1" : ""}`,
53
+ className
54
+ )}
41
55
  style={
42
56
  size
43
57
  ? {
@@ -203,9 +203,48 @@ export class ComboboxState<T extends string> {
203
203
  this.notifyChange();
204
204
  }
205
205
 
206
- reset(newFilter = "") {
206
+ reset(
207
+ newFilter = "",
208
+ options?: {
209
+ selectedItemTitle?: string;
210
+ selectedItemValue?: string | number;
211
+ }
212
+ ) {
207
213
  this.filter = newFilter;
208
- this.selectedIndex = 0;
214
+
215
+ const filteredItems = this.getFilteredItems();
216
+ const normalizedTitle = options?.selectedItemTitle
217
+ ? options.selectedItemTitle.toString().toLowerCase()
218
+ : undefined;
219
+ const normalizedValue = options?.selectedItemValue
220
+ ? options.selectedItemValue.toString().toLowerCase()
221
+ : undefined;
222
+
223
+ const matchedIndex =
224
+ normalizedTitle || normalizedValue
225
+ ? filteredItems.findIndex((item) => {
226
+ if (!isSelectableMenuItem(item)) return false;
227
+
228
+ const title = item.title?.toString().toLowerCase();
229
+ const value = item.value?.toString().toLowerCase();
230
+
231
+ if (normalizedValue && value === normalizedValue) return true;
232
+ if (normalizedTitle && title === normalizedTitle) return true;
233
+ return false;
234
+ })
235
+ : -1;
236
+
237
+ if (matchedIndex !== -1) {
238
+ this.selectedIndex = matchedIndex;
239
+ this.notifyChange();
240
+ return;
241
+ }
242
+
243
+ const firstSelectableIndex = filteredItems.findIndex((item): boolean =>
244
+ isSelectableMenuItem(item)
245
+ );
246
+
247
+ this.selectedIndex = firstSelectableIndex === -1 ? 0 : firstSelectableIndex;
209
248
  this.notifyChange();
210
249
  }
211
250