@coopdigital/react 1.30.0 → 1.30.2

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.
Files changed (126) hide show
  1. package/dist/components/Combobox/Combobox.js +19 -3
  2. package/dist/components/Textarea/Textarea.js +14 -4
  3. package/dist/components/icons/AccountCircleFilledIcon.d.ts +2 -1
  4. package/dist/components/icons/AccountCircleIcon.d.ts +2 -1
  5. package/dist/components/icons/AccountFilledIcon.d.ts +2 -1
  6. package/dist/components/icons/AccountIcon.d.ts +2 -1
  7. package/dist/components/icons/AddIcon.d.ts +2 -1
  8. package/dist/components/icons/AppsFilledIcon.d.ts +2 -1
  9. package/dist/components/icons/AppsIcon.d.ts +2 -1
  10. package/dist/components/icons/ArrowDownIcon.d.ts +2 -1
  11. package/dist/components/icons/ArrowLeftIcon.d.ts +2 -1
  12. package/dist/components/icons/ArrowRightIcon.d.ts +2 -1
  13. package/dist/components/icons/ArrowUpIcon.d.ts +2 -1
  14. package/dist/components/icons/BackspaceFilledIcon.d.ts +2 -1
  15. package/dist/components/icons/BackspaceIcon.d.ts +2 -1
  16. package/dist/components/icons/BasketFilledIcon.d.ts +2 -1
  17. package/dist/components/icons/BasketIcon.d.ts +2 -1
  18. package/dist/components/icons/BellFilledIcon.d.ts +2 -1
  19. package/dist/components/icons/BellIcon.d.ts +2 -1
  20. package/dist/components/icons/BroomIcon.d.ts +2 -1
  21. package/dist/components/icons/CalendarFilledIcon.d.ts +2 -1
  22. package/dist/components/icons/CalendarIcon.d.ts +2 -1
  23. package/dist/components/icons/CashFilledIcon.d.ts +2 -1
  24. package/dist/components/icons/CashIcon.d.ts +2 -1
  25. package/dist/components/icons/ChevronDownIcon.d.ts +2 -1
  26. package/dist/components/icons/ChevronLeftIcon.d.ts +2 -1
  27. package/dist/components/icons/ChevronRightIcon.d.ts +2 -1
  28. package/dist/components/icons/ChevronUpIcon.d.ts +2 -1
  29. package/dist/components/icons/CircleBurgerFilledIcon.d.ts +2 -1
  30. package/dist/components/icons/CircleBurgerIcon.d.ts +2 -1
  31. package/dist/components/icons/ClipboardFilledIcon.d.ts +2 -1
  32. package/dist/components/icons/ClipboardIcon.d.ts +2 -1
  33. package/dist/components/icons/ClockFilledIcon.d.ts +2 -1
  34. package/dist/components/icons/ClockIcon.d.ts +2 -1
  35. package/dist/components/icons/CloseAltFilledIcon.d.ts +2 -1
  36. package/dist/components/icons/CloseAltIcon.d.ts +2 -1
  37. package/dist/components/icons/CogIcon.d.ts +2 -1
  38. package/dist/components/icons/CommunityFilledIcon.d.ts +2 -1
  39. package/dist/components/icons/CommunityIcon.d.ts +2 -1
  40. package/dist/components/icons/ComputerIcon.d.ts +2 -1
  41. package/dist/components/icons/ContactFilledIcon.d.ts +2 -1
  42. package/dist/components/icons/ContactIcon.d.ts +2 -1
  43. package/dist/components/icons/ContactlessFilledIcon.d.ts +2 -1
  44. package/dist/components/icons/ContactlessIcon.d.ts +2 -1
  45. package/dist/components/icons/CoopLiveIcon.d.ts +2 -1
  46. package/dist/components/icons/CoopLocationFilledIcon.d.ts +2 -1
  47. package/dist/components/icons/CoopLogoIcon.d.ts +2 -1
  48. package/dist/components/icons/CoopMembershipCardFilledIcon.d.ts +2 -1
  49. package/dist/components/icons/CreditCardFilledIcon.d.ts +2 -1
  50. package/dist/components/icons/CreditCardIcon.d.ts +2 -1
  51. package/dist/components/icons/CrossIcon.d.ts +2 -1
  52. package/dist/components/icons/DocumentIcon.d.ts +2 -1
  53. package/dist/components/icons/DownloadIcon.d.ts +2 -1
  54. package/dist/components/icons/EnterStoreIcon.d.ts +2 -1
  55. package/dist/components/icons/ExitStoreIcon.d.ts +2 -1
  56. package/dist/components/icons/EyeFilledIcon.d.ts +2 -1
  57. package/dist/components/icons/EyeIcon.d.ts +2 -1
  58. package/dist/components/icons/EyeStrikeFilledIcon.d.ts +2 -1
  59. package/dist/components/icons/EyeStrikeIcon.d.ts +2 -1
  60. package/dist/components/icons/FeedbackFilledIcon.d.ts +2 -1
  61. package/dist/components/icons/FeedbackIcon.d.ts +2 -1
  62. package/dist/components/icons/FilterIcon.d.ts +2 -1
  63. package/dist/components/icons/GiftCardFilledIcon.d.ts +2 -1
  64. package/dist/components/icons/GiftCardIcon.d.ts +2 -1
  65. package/dist/components/icons/GiftFilledIcon.d.ts +2 -1
  66. package/dist/components/icons/GiftIcon.d.ts +2 -1
  67. package/dist/components/icons/HelpFilledIcon.d.ts +2 -1
  68. package/dist/components/icons/HelpIcon.d.ts +2 -1
  69. package/dist/components/icons/HomeFilledIcon.d.ts +2 -1
  70. package/dist/components/icons/HomeIcon.d.ts +2 -1
  71. package/dist/components/icons/HowDoIAssistantIcon.d.ts +2 -1
  72. package/dist/components/icons/InfoIcon.d.ts +2 -1
  73. package/dist/components/icons/KebabIcon.d.ts +2 -1
  74. package/dist/components/icons/LoadingIcon.d.ts +2 -1
  75. package/dist/components/icons/LocationFilledIcon.d.ts +2 -1
  76. package/dist/components/icons/LocationIcon.d.ts +2 -1
  77. package/dist/components/icons/LogoutIcon.d.ts +2 -1
  78. package/dist/components/icons/MailIcon.d.ts +2 -1
  79. package/dist/components/icons/MenuIcon.d.ts +2 -1
  80. package/dist/components/icons/MessageIcon.d.ts +2 -1
  81. package/dist/components/icons/MinusIcon.d.ts +2 -1
  82. package/dist/components/icons/NotesFilledIcon.d.ts +2 -1
  83. package/dist/components/icons/NotesIcon.d.ts +2 -1
  84. package/dist/components/icons/OpenNewIcon.d.ts +2 -1
  85. package/dist/components/icons/OwnBagIcon.d.ts +2 -1
  86. package/dist/components/icons/PadlockIcon.d.ts +2 -1
  87. package/dist/components/icons/PencilIcon.d.ts +2 -1
  88. package/dist/components/icons/PhoneIcon.d.ts +2 -1
  89. package/dist/components/icons/PowerIcon.d.ts +2 -1
  90. package/dist/components/icons/SaveFilledIcon.d.ts +2 -1
  91. package/dist/components/icons/SaveIcon.d.ts +2 -1
  92. package/dist/components/icons/ScooterIcon.d.ts +2 -1
  93. package/dist/components/icons/SearchFilledIcon.d.ts +2 -1
  94. package/dist/components/icons/SearchIcon.d.ts +2 -1
  95. package/dist/components/icons/SendIcon.d.ts +2 -1
  96. package/dist/components/icons/SpannerIcon.d.ts +2 -1
  97. package/dist/components/icons/StarFilledIcon.d.ts +2 -1
  98. package/dist/components/icons/StarIcon.d.ts +2 -1
  99. package/dist/components/icons/TickAltFilledIcon.d.ts +2 -1
  100. package/dist/components/icons/TickAltIcon.d.ts +2 -1
  101. package/dist/components/icons/TickIcon.d.ts +2 -1
  102. package/dist/components/icons/TruckIcon.d.ts +2 -1
  103. package/dist/components/icons/UserWipeIcon.d.ts +2 -1
  104. package/dist/components/icons/VoucherFilledIcon.d.ts +2 -1
  105. package/dist/components/icons/VoucherIcon.d.ts +2 -1
  106. package/dist/components/icons/WarningCircleFilledIcon.d.ts +2 -1
  107. package/dist/components/icons/WarningCircleIcon.d.ts +2 -1
  108. package/dist/components/icons/WarningFilledIcon.d.ts +2 -1
  109. package/dist/components/icons/WarningIcon.d.ts +2 -1
  110. package/dist/components/icons/WifiIcon.d.ts +2 -1
  111. package/dist/components/icons/WriteIcon.d.ts +2 -1
  112. package/dist/hooks/index.d.ts +2 -1
  113. package/dist/hooks/index.js +2 -1
  114. package/dist/hooks/useFormReset.d.ts +6 -0
  115. package/dist/hooks/useFormReset.js +19 -0
  116. package/dist/hooks/useSlots.d.ts +2 -1
  117. package/dist/types/index.js +4 -0
  118. package/dist/utils/slots.d.ts +1 -1
  119. package/dist/utils/slots.js +20 -12
  120. package/package.json +19 -16
  121. package/src/components/Combobox/Combobox.context.ts +3 -1
  122. package/src/components/Combobox/Combobox.tsx +28 -4
  123. package/src/components/Textarea/Textarea.tsx +19 -4
  124. package/src/hooks/index.ts +1 -0
  125. package/src/hooks/useFormReset.ts +28 -0
  126. package/src/utils/slots.ts +37 -23
@@ -10,6 +10,7 @@ import {
10
10
  type UseComboboxState,
11
11
  type UseComboboxStateChange,
12
12
  } from "downshift"
13
+ import mergeRefs from "merge-refs"
13
14
  import { useEffect, useMemo, useRef, useState } from "react"
14
15
 
15
16
  import {
@@ -26,7 +27,7 @@ import { ComboboxContext, useComboboxContext } from "@/components/Combobox/Combo
26
27
  import { getFieldError, useField } from "@/components/Field/Field.context"
27
28
  import { ChevronDownIcon } from "@/components/icons"
28
29
  import { TextInput, type TextInputProps } from "@/components/TextInput"
29
- import { useDebounce, useId, useSlots } from "@/hooks"
30
+ import { useDebounce, useFormReset, useId, useSlots } from "@/hooks"
30
31
  import { StandardSizes } from "@/types"
31
32
  import { getSlotProp } from "@/utils"
32
33
 
@@ -247,7 +248,9 @@ const Combobox = ({
247
248
  )
248
249
 
249
250
  const [initialSelectedItem] = useState(() => resolveOption(defaultSelected, items))
250
- const selectedItem = selected === undefined ? undefined : resolveOption(selected, items)
251
+ const defaultSelectedItem = resolveOption(defaultSelected, items)
252
+ const controlled = selected !== undefined
253
+ const selectedItem = controlled ? resolveOption(selected, items) : undefined
251
254
 
252
255
  // Work out the selection from the text in the input, so the two always
253
256
  // agree. Returns null when the text does not match and custom values are
@@ -304,6 +307,13 @@ const Combobox = ({
304
307
  }
305
308
 
306
309
  const downshift = useCombobox<ComboboxItem>({
310
+ // reset() goes back to the current default selection and its text, as a
311
+ // native field goes back to its current default value when its form is
312
+ // reset. A controlled selection is left to the consumer.
313
+ ...(!controlled && {
314
+ defaultInputValue: toString(defaultSelectedItem),
315
+ defaultSelectedItem,
316
+ }),
307
317
  getItemId: (index) => visibleItems[index]?.id ?? `${uid}_item_${index}`,
308
318
  id: uid,
309
319
  initialSelectedItem,
@@ -350,7 +360,7 @@ const Combobox = ({
350
360
  const selectionText = toString(selection)
351
361
  const selectionValue = selection?.value
352
362
  const previousSelection = useRef({ text: selectionText, value: selectionValue })
353
- const { inputValue, setInputValue } = downshift
363
+ const { inputValue, reset, setInputValue } = downshift
354
364
 
355
365
  useEffect(() => {
356
366
  const previous = previousSelection.current
@@ -365,12 +375,22 @@ const Combobox = ({
365
375
  }
366
376
  }, [inputValue, selectionText, selectionValue, setInputValue])
367
377
 
378
+ // When the form is reset, go back to the default selection
379
+ const inputRef = useRef<HTMLInputElement>(null)
380
+
381
+ useFormReset(inputRef, () => {
382
+ if (controlled) return
383
+ reset()
384
+ setFilterText("")
385
+ })
386
+
368
387
  return (
369
388
  <ComboboxContext.Provider
370
389
  value={{
371
390
  disabled,
372
391
  downshift,
373
392
  error: hasError,
393
+ inputRef,
374
394
  items: visibleItems,
375
395
  matchCount: matchingItems.length,
376
396
  size,
@@ -407,7 +427,11 @@ const ComboboxInput = ({ error, name, size, ...props }: ComboboxInputProps): JSX
407
427
 
408
428
  // Pass the props through downshift, so it combines its handlers and ref
409
429
  // with those given here
410
- const inputProps = combobox.downshift.getInputProps({ ...props, disabled })
430
+ const inputProps = combobox.downshift.getInputProps({
431
+ ...props,
432
+ disabled,
433
+ ref: mergeRefs(combobox.inputRef, props.ref),
434
+ })
411
435
 
412
436
  // The visible input shows the option text, which can be a label, so it has
413
437
  // no name. The form submits the selected value from a hidden input
@@ -3,14 +3,19 @@
3
3
  import type { ChangeEvent, JSX, Ref, TextareaHTMLAttributes } from "react"
4
4
 
5
5
  import clsx from "clsx"
6
- import { useState } from "react"
6
+ import mergeRefs from "merge-refs"
7
+ import { useRef, useState } from "react"
7
8
 
8
9
  import { getDescribedBy, getFieldError, useField } from "@/components/Field/Field.context"
9
10
  import { useDebounce } from "@/hooks/useDebounce"
11
+ import { useFormReset } from "@/hooks/useFormReset"
10
12
  import { StandardSizes } from "@/types"
11
13
 
12
14
  const DEBOUNCE_DELAY = 750
13
15
 
16
+ const lengthOf = (text: TextareaHTMLAttributes<HTMLTextAreaElement>["value"]): number =>
17
+ String(text ?? "").length
18
+
14
19
  const charCountMessage = (remaining: number) => {
15
20
  return `You have ${Math.abs(remaining).toLocaleString()} ${Math.abs(remaining) === 1 ? "character" : "characters"} ${remaining < 0 ? "too many" : "remaining"}`
16
21
  }
@@ -94,19 +99,29 @@ const Textarea = ({
94
99
  ...props,
95
100
  }
96
101
 
97
- const [remaining, setRemaining] = useState(maxLength)
102
+ const internalRef = useRef<HTMLTextAreaElement>(null)
103
+ const controlled = props.value !== undefined
104
+ // The length typed, when not controlled, starting from the default value
105
+ const [typedLength, setTypedLength] = useState(() => lengthOf(props.defaultValue))
106
+ const length = controlled ? lengthOf(props.value) : typedLength
107
+ const remaining = maxLength == null ? undefined : maxLength - length
98
108
  const debouncedRemaining = useDebounce(remaining, DEBOUNCE_DELAY)
109
+
110
+ // The browser resets the textarea to its default value, so reset the count
111
+ useFormReset(internalRef, () => {
112
+ if (!controlled) setTypedLength(lengthOf(props.defaultValue))
113
+ })
99
114
  const showCounter =
100
115
  !disabled && counter && maxLength && remaining != null && debouncedRemaining != null
101
116
 
102
117
  const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
103
118
  userOnChange?.(e)
104
- if (maxLength && e.target) setRemaining(maxLength - e.target.value.length)
119
+ if (!controlled && e.target) setTypedLength(e.target.value.length)
105
120
  }
106
121
  // const formItemProps = { "aria-disabled": disabled ? true : undefined }
107
122
  return (
108
123
  <div className="coop-textarea-wrapper">
109
- <textarea {...componentProps} onChange={handleChange} ref={ref} />
124
+ <textarea {...componentProps} onChange={handleChange} ref={mergeRefs(internalRef, ref)} />
110
125
  {showCounter && (
111
126
  <>
112
127
  <small
@@ -1,3 +1,4 @@
1
1
  export { useDebounce } from "./useDebounce"
2
+ export { useFormReset } from "./useFormReset"
2
3
  export { useId } from "./useId"
3
4
  export { useSlots } from "./useSlots"
@@ -0,0 +1,28 @@
1
+ import { type RefObject, useEffect, useRef } from "react"
2
+
3
+ type FormControl = HTMLButtonElement | HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
4
+
5
+ // Call onReset when the form that owns the control is reset, so a component
6
+ // that keeps its own state can go back to its default value, as native fields
7
+ // do. React 19 form actions reset the form after they run. The control's form
8
+ // property also covers a control linked to its form with the form attribute.
9
+ // A reset that has been prevented is ignored, as the fields are not reset then.
10
+ export function useFormReset(ref: RefObject<FormControl | null>, onReset: () => void): void {
11
+ // Keep the latest callback, so the listener sees the latest props without
12
+ // being added again
13
+ const onResetRef = useRef(onReset)
14
+ useEffect(() => {
15
+ onResetRef.current = onReset
16
+ })
17
+
18
+ useEffect(() => {
19
+ const form = ref.current?.form
20
+ if (!form) return
21
+
22
+ const handleReset = (event: Event) => {
23
+ if (!event.defaultPrevented) onResetRef.current()
24
+ }
25
+ form.addEventListener("reset", handleReset)
26
+ return () => form.removeEventListener("reset", handleReset)
27
+ }, [ref])
28
+ }
@@ -1,6 +1,6 @@
1
1
  import React, { isValidElement, type ReactElement, type ReactNode } from "react"
2
2
 
3
- import type { LazyComponent, SlotConfig } from "@/types"
3
+ import { type LazyComponent, REACT_LAZY_TYPE, type SlotConfig } from "@/types"
4
4
 
5
5
  // oxlint-disable-next-line typescript/no-unnecessary-type-parameters
6
6
  export function getSlotProp<R = unknown>(slot: React.ReactNode, prop: string): R | undefined {
@@ -10,39 +10,53 @@ export function getSlotProp<R = unknown>(slot: React.ReactNode, prop: string): R
10
10
  return undefined
11
11
  }
12
12
 
13
+ // oxlint-disable-next-line typescript/no-explicit-any
14
+ export function isLazy<T extends React.ComponentType<any>>(
15
+ type: unknown
16
+ ): type is LazyComponent<T> {
17
+ return (
18
+ typeof type === "object" &&
19
+ type !== null &&
20
+ (type as { $$typeof?: unknown }).$$typeof === REACT_LAZY_TYPE
21
+ )
22
+ }
23
+
24
+ // The component for an element type. Lazy types, such as client components
25
+ // rendered from a server component, are resolved once loaded. Undefined while
26
+ // still loading, as _init throws a Promise, or if loading failed.
27
+ const resolveType = (type: unknown): unknown => {
28
+ if (!isLazy(type)) return type
29
+
30
+ try {
31
+ return type._init(type._payload)
32
+ } catch {
33
+ return undefined
34
+ }
35
+ }
36
+
37
+ const getSlotConfig = (type: unknown): SlotConfig | undefined => {
38
+ const resolved = resolveType(type)
39
+ return typeof resolved === "function" && "slot" in resolved
40
+ ? (resolved.slot as SlotConfig)
41
+ : undefined
42
+ }
43
+
13
44
  // Call fn for each direct child that is a slot component
14
45
  export const forEachSlot = (
15
46
  children: ReactNode,
16
47
  fn: (slot: SlotConfig, child: ReactElement) => void
17
48
  ): void => {
18
49
  Array.from([children].flat()).forEach((child) => {
19
- if (isValidElement(child) && typeof child.type === "function" && "slot" in child.type) {
20
- fn((child.type as { slot: SlotConfig }).slot, child)
21
- }
22
- })
23
- }
50
+ if (!isValidElement(child)) return
24
51
 
25
- // oxlint-disable-next-line typescript/no-explicit-any
26
- export function isLazy<T extends React.ComponentType<any>>(
27
- type: unknown
28
- ): type is LazyComponent<T> {
29
- return typeof type === "object" && type !== null && "_payload" in type && "_init" in type
52
+ const slot = getSlotConfig(child.type)
53
+ if (slot) fn(slot, child)
54
+ })
30
55
  }
31
56
 
32
57
  export function resolveComponentName(child: React.ReactNode): string | null {
33
58
  if (child && typeof child === "object" && "type" in child) {
34
- const type = child.type
35
-
36
- if (isLazy(type)) {
37
- try {
38
- const resolved = type._init(type._payload)
39
- if ("slot" in resolved) return (resolved.slot as SlotConfig).name
40
- return resolved.displayName ?? resolved.name ?? null
41
- } catch {
42
- // _init throws a Promise if the payload is still pending
43
- return null
44
- }
45
- }
59
+ const type = resolveType(child.type)
46
60
 
47
61
  if (typeof type === "function") {
48
62
  if ("slot" in type) return (type.slot as SlotConfig).name