@noya-app/noya-designsystem 0.0.14 → 0.0.16

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,6 +1,7 @@
1
- import { CaretDownIcon } from '@noya-app/noya-icons';
2
- import { memoize } from '@noya-app/noya-utils';
3
- import { Property } from 'csstype';
1
+ import { CaretDownIcon } from "@noya-app/noya-icons";
2
+ import { memoize } from "@noya-app/noya-utils";
3
+ import { assignRef } from "@noya-app/react-utils";
4
+ import { Property } from "csstype";
4
5
  import React, {
5
6
  Children,
6
7
  createContext,
@@ -14,19 +15,20 @@ import React, {
14
15
  useEffect,
15
16
  useLayoutEffect,
16
17
  useMemo,
17
- } from 'react';
18
- import styled from 'styled-components';
19
- import handleNudge from '../utils/handleNudge';
20
- import { Button } from './Button';
21
- import { DropdownMenu } from './DropdownMenu';
22
- import { MenuItem } from './internal/Menu';
23
- import TextInput, { TextInputProps } from './internal/TextInput';
24
- import { Popover } from './Popover';
25
- import { Stack } from './Stack';
18
+ useRef,
19
+ } from "react";
20
+ import styled from "styled-components";
21
+ import handleNudge from "../utils/handleNudge";
22
+ import { Button } from "./Button";
23
+ import { DropdownMenu } from "./DropdownMenu";
24
+ import { MenuItem } from "./internal/Menu";
25
+ import TextInput, { TextInputProps } from "./internal/TextInput";
26
+ import { Popover } from "./Popover";
27
+ import { Stack } from "./Stack";
26
28
 
27
- type LabelPosition = 'start' | 'end';
29
+ type LabelPosition = "start" | "end";
28
30
 
29
- export type InputFieldSize = 'small' | 'medium' | 'large';
31
+ export type InputFieldSize = "small" | "medium" | "large";
30
32
 
31
33
  type InputFieldContextValue = {
32
34
  labelPosition: LabelPosition;
@@ -38,14 +40,15 @@ type InputFieldContextValue = {
38
40
  onFocusChange: (isFocused: boolean) => void;
39
41
  inputRef?: ForwardedRef<HTMLInputElement>;
40
42
  setInputRef?: (ref: ForwardedRef<HTMLInputElement>) => void;
43
+ setLabelWidth?: (width: number) => void;
41
44
  };
42
45
 
43
46
  const InputFieldContext = createContext<InputFieldContextValue>({
44
- labelPosition: 'end',
47
+ labelPosition: "end",
45
48
  labelSize: 6,
46
49
  hasLabel: false,
47
50
  hasDropdown: false,
48
- size: 'medium',
51
+ size: "medium",
49
52
  isFocused: false,
50
53
  onFocusChange: () => {},
51
54
  });
@@ -60,58 +63,79 @@ const LabelContainer = styled.label<{
60
63
  hasDropdown: boolean;
61
64
  }>(({ theme, labelPosition, hasDropdown, pointerEvents }) => ({
62
65
  color: theme.colors.textDisabled,
63
- position: 'absolute',
66
+ position: "absolute",
64
67
  top: 0,
65
- right: labelPosition === 'end' ? 0 : undefined,
68
+ right: labelPosition === "end" ? 0 : undefined,
66
69
  bottom: 0,
67
- left: labelPosition === 'start' ? 0 : undefined,
68
- display: 'flex',
69
- alignItems: 'center',
70
+ left: labelPosition === "start" ? 0 : undefined,
71
+ display: "flex",
72
+ alignItems: "center",
70
73
  pointerEvents,
71
- fontWeight: 'bold',
72
- fontSize: '60%',
73
- userSelect: 'none',
74
- ...(labelPosition === 'start'
75
- ? { justifyContent: 'flex-start', paddingLeft: '6px' }
74
+ fontWeight: "bold",
75
+ fontSize: "60%",
76
+ userSelect: "none",
77
+ ...(labelPosition === "start"
78
+ ? { justifyContent: "flex-start", paddingLeft: "6px" }
76
79
  : {
77
- justifyContent: 'flex-end',
78
- paddingRight: hasDropdown ? '16px' : '6px',
80
+ justifyContent: "flex-end",
81
+ paddingRight: hasDropdown ? "16px" : "6px",
79
82
  }),
80
83
  }));
81
84
 
82
85
  interface InputFieldLabelProps {
83
86
  children?: ReactNode;
84
87
  pointerEvents?: Property.PointerEvents;
88
+ style?: React.CSSProperties;
85
89
  }
86
90
 
87
- const InputFieldLabel = memo(function InputFieldLabel({
88
- children = false,
89
- pointerEvents = 'none',
90
- }: InputFieldLabelProps) {
91
- const { labelPosition, hasDropdown } = useContext(InputFieldContext);
91
+ const InputFieldLabel = memo(
92
+ forwardRef(function InputFieldLabel(
93
+ { children = false, pointerEvents = "none", style }: InputFieldLabelProps,
94
+ forwardedRef: ForwardedRef<HTMLLabelElement>
95
+ ) {
96
+ const { labelPosition, hasDropdown, setLabelWidth } =
97
+ useContext(InputFieldContext);
92
98
 
93
- return (
94
- <LabelContainer
95
- pointerEvents={pointerEvents}
96
- labelPosition={labelPosition}
97
- hasDropdown={hasDropdown}
98
- >
99
- {children}
100
- </LabelContainer>
101
- );
102
- });
99
+ const ref = useRef<HTMLLabelElement | null>(null);
100
+
101
+ useLayoutEffect(() => {
102
+ if (!setLabelWidth) return;
103
+
104
+ const width = ref.current?.getBoundingClientRect().width;
105
+
106
+ if (!width) return;
107
+
108
+ setLabelWidth(width);
109
+ }, [setLabelWidth]);
110
+
111
+ return (
112
+ <LabelContainer
113
+ ref={(element) => {
114
+ ref.current = element;
115
+ assignRef(forwardedRef, element);
116
+ }}
117
+ pointerEvents={pointerEvents}
118
+ labelPosition={labelPosition}
119
+ hasDropdown={hasDropdown}
120
+ style={style}
121
+ >
122
+ {children}
123
+ </LabelContainer>
124
+ );
125
+ })
126
+ );
103
127
 
104
128
  /* ----------------------------------------------------------------------------
105
129
  * Dropdown
106
130
  * ------------------------------------------------------------------------- */
107
131
 
108
132
  const DropdownContainer = styled.span(({ theme }) => ({
109
- position: 'absolute',
133
+ position: "absolute",
110
134
  top: 0,
111
135
  right: 0,
112
136
  bottom: 0,
113
- display: 'flex',
114
- flexDirection: 'column',
137
+ display: "flex",
138
+ flexDirection: "column",
115
139
  }));
116
140
 
117
141
  interface InputFieldDropdownProps<T extends string> {
@@ -128,10 +152,10 @@ const InputFieldDropdownMenu = memo(function InputFieldDropdownMenu<
128
152
 
129
153
  const contentStyle = useMemo(
130
154
  () => ({
131
- margin: '-4px 0',
132
- minHeight: size === 'small' ? '15px' : undefined,
155
+ margin: "-4px 0",
156
+ minHeight: size === "small" ? "15px" : undefined,
133
157
  }),
134
- [size],
158
+ [size]
135
159
  );
136
160
 
137
161
  return (
@@ -153,10 +177,10 @@ const InputFieldDropdownMenu = memo(function InputFieldDropdownMenu<
153
177
 
154
178
  const ButtonContainer = styled.span<{ size: InputFieldSize }>(
155
179
  ({ theme, size }) => ({
156
- position: 'absolute',
157
- right: size === 'large' ? '9px' : size === 'medium' ? '4px' : '2px',
158
- top: size === 'large' ? '8px' : size === 'medium' ? '4px' : '2px',
159
- }),
180
+ position: "absolute",
181
+ right: size === "large" ? "9px" : size === "medium" ? "4px" : "2px",
182
+ top: size === "large" ? "8px" : size === "medium" ? "4px" : "2px",
183
+ })
160
184
  );
161
185
 
162
186
  const InputFieldButton = memo(function InputFieldButton({
@@ -170,7 +194,7 @@ const InputFieldButton = memo(function InputFieldButton({
170
194
 
171
195
  const defaultHandleClick = useCallback(
172
196
  (event: React.MouseEvent) => {
173
- if (inputRef && typeof inputRef !== 'function') {
197
+ if (inputRef && typeof inputRef !== "function") {
174
198
  inputRef.current?.focus();
175
199
  // Select all text
176
200
  inputRef.current?.setSelectionRange(0, inputRef.current.value.length);
@@ -179,7 +203,7 @@ const InputFieldButton = memo(function InputFieldButton({
179
203
  event.preventDefault();
180
204
  event.stopPropagation();
181
205
  },
182
- [inputRef],
206
+ [inputRef]
183
207
  );
184
208
 
185
209
  const handlePointerDown = useCallback((event: React.PointerEvent) => {
@@ -209,19 +233,19 @@ const createCrossSVGString = memoize(
209
233
  (color: string) =>
210
234
  `<svg width='15' height='15' viewBox='0 0 15 15' fill='${color}' xmlns='http://www.w3.org/2000/svg'>
211
235
  <path d='M0.877075 7.49988C0.877075 3.84219 3.84222 0.877045 7.49991 0.877045C11.1576 0.877045 14.1227 3.84219 14.1227 7.49988C14.1227 11.1575 11.1576 14.1227 7.49991 14.1227C3.84222 14.1227 0.877075 11.1575 0.877075 7.49988ZM7.49991 1.82704C4.36689 1.82704 1.82708 4.36686 1.82708 7.49988C1.82708 10.6329 4.36689 13.1727 7.49991 13.1727C10.6329 13.1727 13.1727 10.6329 13.1727 7.49988C13.1727 4.36686 10.6329 1.82704 7.49991 1.82704ZM9.85358 5.14644C10.0488 5.3417 10.0488 5.65829 9.85358 5.85355L8.20713 7.49999L9.85358 9.14644C10.0488 9.3417 10.0488 9.65829 9.85358 9.85355C9.65832 10.0488 9.34173 10.0488 9.14647 9.85355L7.50002 8.2071L5.85358 9.85355C5.65832 10.0488 5.34173 10.0488 5.14647 9.85355C4.95121 9.65829 4.95121 9.3417 5.14647 9.14644L6.79292 7.49999L5.14647 5.85355C4.95121 5.65829 4.95121 5.3417 5.14647 5.14644C5.34173 4.95118 5.65832 4.95118 5.85358 5.14644L7.50002 6.79289L9.14647 5.14644C9.34173 4.95118 9.65832 4.95118 9.85358 5.14644Z' fill-rule='evenodd' clip-rule='evenodd'></path>
212
- </svg>`,
236
+ </svg>`
213
237
  );
214
238
 
215
- type InputFieldVariant = 'normal' | 'bare';
239
+ type InputFieldVariant = "normal" | "bare";
216
240
 
217
241
  const ignoredProps = new Set([
218
- 'labelPosition',
219
- 'labelSize',
220
- 'hasLabel',
221
- 'hasDropdown',
222
- 'textAlign',
223
- 'variant',
224
- 'onNudge',
242
+ "labelPosition",
243
+ "labelSize",
244
+ "hasLabel",
245
+ "hasDropdown",
246
+ "textAlign",
247
+ "variant",
248
+ "onNudge",
225
249
  ]);
226
250
 
227
251
  export const InputElement = styled(TextInput).withConfig({
@@ -246,76 +270,76 @@ export const InputElement = styled(TextInput).withConfig({
246
270
  disabled,
247
271
  hasLabel,
248
272
  readOnly,
249
- variant = 'normal',
273
+ variant = "normal",
250
274
  size,
251
275
  }) => ({
252
276
  // placeholder
253
- '&::placeholder': {
277
+ "&::placeholder": {
254
278
  color: theme.colors.textDisabled,
255
279
  },
256
280
  ...theme.textStyles.small,
257
281
  color: readOnly
258
282
  ? theme.colors.textMuted
259
283
  : disabled
260
- ? theme.colors.textDisabled
261
- : theme.colors.text,
262
- ...(size === 'small' && {
263
- fontSize: '11px',
284
+ ? theme.colors.textDisabled
285
+ : theme.colors.text,
286
+ ...(size === "small" && {
287
+ fontSize: "11px",
264
288
  }),
265
- width: '0px', // Reset intrinsic width
266
- flex: '1 1 0px',
267
- position: 'relative',
268
- border: '0',
269
- outline: 'none',
270
- minWidth: '0',
271
- textAlign: textAlign ?? 'left',
272
- alignSelf: 'stretch',
273
- borderRadius: '4px',
274
- paddingTop: size === 'large' ? '10px' : size === 'medium' ? '4px' : '1px',
289
+ width: "0px", // Reset intrinsic width
290
+ flex: "1 1 0px",
291
+ position: "relative",
292
+ border: "0",
293
+ outline: "none",
294
+ minWidth: "0",
295
+ textAlign: textAlign ?? "left",
296
+ alignSelf: "stretch",
297
+ borderRadius: "4px",
298
+ paddingTop: size === "large" ? "10px" : size === "medium" ? "4px" : "1px",
275
299
  paddingBottom:
276
- size === 'large' ? '10px' : size === 'medium' ? '4px' : '1px',
300
+ size === "large" ? "10px" : size === "medium" ? "4px" : "1px",
277
301
  paddingLeft:
278
- (size === 'large' ? 10 : size === 'medium' ? 6 : 4) +
279
- (hasLabel && labelPosition === 'start' ? 6 + labelSize : 0) +
280
- 'px',
302
+ (size === "large" ? 10 : size === "medium" ? 6 : 4) +
303
+ (hasLabel && labelPosition === "start" ? 6 + labelSize : 0) +
304
+ "px",
281
305
  paddingRight:
282
- (size === 'large' ? 10 : size === 'medium' ? 6 : 4) +
283
- (hasLabel && labelPosition === 'end' ? 6 + labelSize : 0) +
306
+ (size === "large" ? 10 : size === "medium" ? 6 : 4) +
307
+ (hasLabel && labelPosition === "end" ? 6 + labelSize : 0) +
284
308
  (hasDropdown ? 11 : 0) +
285
- 'px',
309
+ "px",
286
310
  background: theme.colors.inputBackground,
287
- '&:focus': {
311
+ "&:focus": {
288
312
  boxShadow: `0 0 0 2px ${theme.colors.primary}`,
289
313
  },
290
- ...(variant === 'bare' && {
291
- paddingTop: '4px',
292
- paddingRight: '4px',
293
- paddingBottom: '4px',
294
- paddingLeft: '4px',
295
- marginTop: '-4px',
296
- marginRight: '-4px',
297
- marginBottom: '-4px',
298
- marginLeft: '-4px',
314
+ ...(variant === "bare" && {
315
+ paddingTop: "4px",
316
+ paddingRight: "4px",
317
+ paddingBottom: "4px",
318
+ paddingLeft: "4px",
319
+ marginTop: "-4px",
320
+ marginRight: "-4px",
321
+ marginBottom: "-4px",
322
+ marginLeft: "-4px",
299
323
  }),
300
324
 
301
325
  '&[type="search"]::-webkit-search-cancel-button': {
302
- appearance: 'none',
303
- height: '15px',
304
- width: '15px',
326
+ appearance: "none",
327
+ height: "15px",
328
+ width: "15px",
305
329
  background: `url("data:image/svg+xml;utf8,${createCrossSVGString(
306
- theme.colors.icon,
330
+ theme.colors.icon
307
331
  )}") no-repeat`,
308
332
  },
309
- }),
333
+ })
310
334
  );
311
335
 
312
336
  const InputFieldInput = forwardRef(function InputFieldInput(
313
337
  // onFocusChange should only be passed from the root, never directly
314
338
  props: TextInputProps & {
315
339
  textAlign?: Property.TextAlign;
316
- variant?: 'bare';
340
+ variant?: "bare";
317
341
  },
318
- forwardedRef: ForwardedRef<HTMLInputElement>,
342
+ forwardedRef: ForwardedRef<HTMLInputElement>
319
343
  ) {
320
344
  const {
321
345
  labelPosition,
@@ -331,7 +355,7 @@ const InputFieldInput = forwardRef(function InputFieldInput(
331
355
  (isFocused: boolean) => {
332
356
  onFocusChange(isFocused);
333
357
  },
334
- [onFocusChange],
358
+ [onFocusChange]
335
359
  );
336
360
 
337
361
  useLayoutEffect(() => {
@@ -352,17 +376,54 @@ const InputFieldInput = forwardRef(function InputFieldInput(
352
376
  );
353
377
  });
354
378
 
379
+ const InputFieldTypeahead = (props: { value: string }) => {
380
+ const {
381
+ labelPosition,
382
+ labelSize,
383
+ hasDropdown,
384
+ hasLabel,
385
+ size,
386
+ onFocusChange,
387
+ } = useContext(InputFieldContext);
388
+
389
+ return (
390
+ <InputElement
391
+ labelPosition={labelPosition}
392
+ labelSize={labelSize}
393
+ hasLabel={hasLabel}
394
+ hasDropdown={hasDropdown}
395
+ size={size}
396
+ onFocusChange={onFocusChange}
397
+ readOnly
398
+ placeholder={props.value}
399
+ value=""
400
+ style={{
401
+ position: "absolute",
402
+ top: -1,
403
+ left: 0,
404
+ right: 0,
405
+ bottom: -1,
406
+ width: "100%",
407
+ height: "100%",
408
+ pointerEvents: "none",
409
+ background: "transparent",
410
+ boxShadow: "none",
411
+ }}
412
+ />
413
+ );
414
+ };
415
+
355
416
  /* ----------------------------------------------------------------------------
356
417
  * NumberInput
357
418
  * ------------------------------------------------------------------------- */
358
419
 
359
420
  type InputFieldNumberInputProps = Omit<
360
421
  TextInputProps,
361
- 'value' | 'onChange' | 'onKeyDown' | 'onSubmit'
422
+ "value" | "onChange" | "onKeyDown" | "onSubmit"
362
423
  > & {
363
424
  value: number | undefined;
364
425
  onNudge?: (value: number) => void;
365
- variant?: 'bare';
426
+ variant?: "bare";
366
427
  } & (
367
428
  | {
368
429
  onChange: (value: number) => void;
@@ -378,8 +439,8 @@ function parseNumber(value: string) {
378
439
 
379
440
  function InputFieldNumberInput(props: InputFieldNumberInputProps) {
380
441
  const { value, placeholder, onNudge } = props;
381
- const onSubmit = 'onSubmit' in props ? props.onSubmit : undefined;
382
- const onChange = 'onChange' in props ? props.onChange : undefined;
442
+ const onSubmit = "onSubmit" in props ? props.onSubmit : undefined;
443
+ const onChange = "onChange" in props ? props.onChange : undefined;
383
444
 
384
445
  const handleSubmit = useCallback(
385
446
  (value: string) => {
@@ -389,7 +450,7 @@ function InputFieldNumberInput(props: InputFieldNumberInputProps) {
389
450
  onSubmit?.(newValue);
390
451
  }
391
452
  },
392
- [onSubmit],
453
+ [onSubmit]
393
454
  );
394
455
 
395
456
  const handleKeyDown = useCallback(
@@ -403,23 +464,23 @@ function InputFieldNumberInput(props: InputFieldNumberInputProps) {
403
464
  event.preventDefault();
404
465
  event.stopPropagation();
405
466
  },
406
- [onNudge],
467
+ [onNudge]
407
468
  );
408
469
 
409
470
  const handleChange = useCallback(
410
471
  (value: string) => {
411
472
  onChange?.(parseNumber(value));
412
473
  },
413
- [onChange],
474
+ [onChange]
414
475
  );
415
476
 
416
477
  return (
417
478
  <InputFieldInput
418
479
  {...props}
419
- value={value === undefined ? '' : String(value)}
480
+ value={value === undefined ? "" : String(value)}
420
481
  placeholder={placeholder}
421
482
  onKeyDown={handleKeyDown}
422
- {...('onChange' in props
483
+ {...("onChange" in props
423
484
  ? { onChange: handleChange }
424
485
  : { onSubmit: handleSubmit })}
425
486
  />
@@ -432,12 +493,12 @@ function InputFieldNumberInput(props: InputFieldNumberInputProps) {
432
493
 
433
494
  const RootContainer = styled.div<{ width?: number; flex?: string }>(
434
495
  ({ theme, flex, width }) => ({
435
- flex: flex ?? '1',
436
- display: 'flex',
437
- flexDirection: 'row',
438
- position: 'relative',
439
- maxWidth: typeof width === 'number' ? `${width}px` : undefined,
440
- }),
496
+ flex: flex ?? "1",
497
+ display: "flex",
498
+ flexDirection: "row",
499
+ position: "relative",
500
+ maxWidth: typeof width === "number" ? `${width}px` : undefined,
501
+ })
441
502
  );
442
503
 
443
504
  interface InputFieldRootProps {
@@ -458,23 +519,24 @@ function InputFieldRoot({
458
519
  flex,
459
520
  children,
460
521
  width,
461
- labelPosition = 'end',
462
- labelSize = 6,
463
- size = 'medium',
522
+ labelPosition = "end",
523
+ labelSize,
524
+ size = "medium",
464
525
  renderPopoverContent,
465
526
  onFocusChange,
466
527
  }: InputFieldRootProps) {
467
528
  const childrenArray = Children.toArray(children);
468
529
 
469
530
  const hasDropdown = childrenArray.some(
470
- (child) => isValidElement(child) && child.type === InputFieldDropdownMenu,
531
+ (child) => isValidElement(child) && child.type === InputFieldDropdownMenu
471
532
  );
472
533
  const hasLabel = childrenArray.some(
473
- (child) => isValidElement(child) && child.type === InputFieldLabel,
534
+ (child) => isValidElement(child) && child.type === InputFieldLabel
474
535
  );
475
536
 
476
537
  const [isFocused, setIsFocused] = React.useState(false);
477
538
 
539
+ const [measuredLabelSize, setMeasuredLabelSize] = React.useState<number>();
478
540
  const [measuredWidth, setMeasuredWidth] = React.useState<number>();
479
541
 
480
542
  const handleFocusChange = useCallback(
@@ -482,18 +544,17 @@ function InputFieldRoot({
482
544
  setIsFocused(isFocused);
483
545
  onFocusChange?.(isFocused);
484
546
  },
485
- [onFocusChange],
547
+ [onFocusChange]
486
548
  );
487
549
 
488
550
  const [inputRef, setInputRef] =
489
551
  React.useState<ForwardedRef<HTMLInputElement>>();
490
552
 
553
+ // eslint-disable-next-line @shopify/prefer-early-return
491
554
  useEffect(() => {
492
- if (inputRef && typeof inputRef !== 'function') {
555
+ if (inputRef && typeof inputRef !== "function") {
493
556
  setMeasuredWidth?.(
494
- isFocused
495
- ? inputRef?.current?.getBoundingClientRect().width
496
- : undefined,
557
+ isFocused ? inputRef?.current?.getBoundingClientRect().width : undefined
497
558
  );
498
559
  }
499
560
  }, [inputRef, isFocused]);
@@ -501,7 +562,7 @@ function InputFieldRoot({
501
562
  const contextValue = useMemo(
502
563
  (): InputFieldContextValue => ({
503
564
  labelPosition,
504
- labelSize,
565
+ labelSize: measuredLabelSize ?? labelSize ?? 6,
505
566
  hasDropdown,
506
567
  hasLabel,
507
568
  size,
@@ -509,9 +570,11 @@ function InputFieldRoot({
509
570
  onFocusChange: handleFocusChange,
510
571
  inputRef,
511
572
  setInputRef,
573
+ setLabelWidth: setMeasuredLabelSize,
512
574
  }),
513
575
  [
514
576
  labelPosition,
577
+ measuredLabelSize,
515
578
  labelSize,
516
579
  hasDropdown,
517
580
  hasLabel,
@@ -519,7 +582,7 @@ function InputFieldRoot({
519
582
  isFocused,
520
583
  handleFocusChange,
521
584
  inputRef,
522
- ],
585
+ ]
523
586
  );
524
587
 
525
588
  const rootElement = (
@@ -561,6 +624,7 @@ function InputFieldRoot({
561
624
  export namespace InputField {
562
625
  export const Root = memo(InputFieldRoot);
563
626
  export const Input = memo(InputFieldInput);
627
+ export const Typeahead = memo(InputFieldTypeahead);
564
628
  export const NumberInput = memo(InputFieldNumberInput);
565
629
  export const DropdownMenu = InputFieldDropdownMenu;
566
630
  export const Button = InputFieldButton;