@coopdigital/react 1.30.1 → 1.31.0

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 (176) hide show
  1. package/dist/components/Author/Author.d.ts +3 -1
  2. package/dist/components/Author/Author.js +1 -1
  3. package/dist/components/Author/index.d.ts +2 -2
  4. package/dist/components/Author/index.js +2 -2
  5. package/dist/components/Combobox/Combobox.d.ts +1 -1
  6. package/dist/components/Combobox/Combobox.js +29 -8
  7. package/dist/components/Combobox/{combobox-utils.js → combobox-internal.js} +4 -2
  8. package/dist/components/DateInput/DateInput.context.js +7 -0
  9. package/dist/components/DateInput/DateInput.d.ts +104 -0
  10. package/dist/components/DateInput/DateInput.js +128 -0
  11. package/dist/components/DateInput/dateinput-internal.d.ts +6 -0
  12. package/dist/components/DateInput/dateinput-internal.js +82 -0
  13. package/dist/components/DateInput/dateinput-utils.d.ts +66 -0
  14. package/dist/components/DateInput/dateinput-utils.js +91 -0
  15. package/dist/components/DateInput/index.d.ts +3 -0
  16. package/dist/components/DateInput/index.js +6 -0
  17. package/dist/components/Field/Field.context.js +1 -1
  18. package/dist/components/Field/Field.js +1 -1
  19. package/dist/components/FieldMarkers/{fieldmarkers-utils.js → fieldmarkers-internal.js} +1 -1
  20. package/dist/components/Fieldset/Fieldset.js +1 -1
  21. package/dist/components/Form/Form.d.ts +7 -1
  22. package/dist/components/Form/Form.js +2 -1
  23. package/dist/components/Squircle/Squircle.d.ts +3 -1
  24. package/dist/components/Squircle/Squircle.js +1 -1
  25. package/dist/components/Squircle/index.d.ts +2 -2
  26. package/dist/components/Squircle/index.js +2 -2
  27. package/dist/components/Textarea/Textarea.js +14 -4
  28. package/dist/components/ValidationSummary/ValidationSummary.d.ts +1 -1
  29. package/dist/components/ValidationSummary/ValidationSummary.js +1 -1
  30. package/dist/components/ValidationSummary/{validationsummary-utils.d.ts → validationsummary-internal.d.ts} +1 -1
  31. package/dist/components/ValidationSummary/{validationsummary-utils.js → validationsummary-internal.js} +3 -2
  32. package/dist/components/icons/AccountCircleFilledIcon.d.ts +2 -1
  33. package/dist/components/icons/AccountCircleIcon.d.ts +2 -1
  34. package/dist/components/icons/AccountFilledIcon.d.ts +2 -1
  35. package/dist/components/icons/AccountIcon.d.ts +2 -1
  36. package/dist/components/icons/AddIcon.d.ts +2 -1
  37. package/dist/components/icons/AppsFilledIcon.d.ts +2 -1
  38. package/dist/components/icons/AppsIcon.d.ts +2 -1
  39. package/dist/components/icons/ArrowDownIcon.d.ts +2 -1
  40. package/dist/components/icons/ArrowLeftIcon.d.ts +2 -1
  41. package/dist/components/icons/ArrowRightIcon.d.ts +2 -1
  42. package/dist/components/icons/ArrowUpIcon.d.ts +2 -1
  43. package/dist/components/icons/BackspaceFilledIcon.d.ts +2 -1
  44. package/dist/components/icons/BackspaceIcon.d.ts +2 -1
  45. package/dist/components/icons/BasketFilledIcon.d.ts +2 -1
  46. package/dist/components/icons/BasketIcon.d.ts +2 -1
  47. package/dist/components/icons/BellFilledIcon.d.ts +2 -1
  48. package/dist/components/icons/BellIcon.d.ts +2 -1
  49. package/dist/components/icons/BroomIcon.d.ts +2 -1
  50. package/dist/components/icons/CalendarFilledIcon.d.ts +2 -1
  51. package/dist/components/icons/CalendarIcon.d.ts +2 -1
  52. package/dist/components/icons/CashFilledIcon.d.ts +2 -1
  53. package/dist/components/icons/CashIcon.d.ts +2 -1
  54. package/dist/components/icons/ChevronDownIcon.d.ts +2 -1
  55. package/dist/components/icons/ChevronLeftIcon.d.ts +2 -1
  56. package/dist/components/icons/ChevronRightIcon.d.ts +2 -1
  57. package/dist/components/icons/ChevronUpIcon.d.ts +2 -1
  58. package/dist/components/icons/CircleBurgerFilledIcon.d.ts +2 -1
  59. package/dist/components/icons/CircleBurgerIcon.d.ts +2 -1
  60. package/dist/components/icons/ClipboardFilledIcon.d.ts +2 -1
  61. package/dist/components/icons/ClipboardIcon.d.ts +2 -1
  62. package/dist/components/icons/ClockFilledIcon.d.ts +2 -1
  63. package/dist/components/icons/ClockIcon.d.ts +2 -1
  64. package/dist/components/icons/CloseAltFilledIcon.d.ts +2 -1
  65. package/dist/components/icons/CloseAltIcon.d.ts +2 -1
  66. package/dist/components/icons/CogIcon.d.ts +2 -1
  67. package/dist/components/icons/CommunityFilledIcon.d.ts +2 -1
  68. package/dist/components/icons/CommunityIcon.d.ts +2 -1
  69. package/dist/components/icons/ComputerIcon.d.ts +2 -1
  70. package/dist/components/icons/ContactFilledIcon.d.ts +2 -1
  71. package/dist/components/icons/ContactIcon.d.ts +2 -1
  72. package/dist/components/icons/ContactlessFilledIcon.d.ts +2 -1
  73. package/dist/components/icons/ContactlessIcon.d.ts +2 -1
  74. package/dist/components/icons/CoopLiveIcon.d.ts +2 -1
  75. package/dist/components/icons/CoopLocationFilledIcon.d.ts +2 -1
  76. package/dist/components/icons/CoopLogoIcon.d.ts +2 -1
  77. package/dist/components/icons/CoopMembershipCardFilledIcon.d.ts +2 -1
  78. package/dist/components/icons/CreditCardFilledIcon.d.ts +2 -1
  79. package/dist/components/icons/CreditCardIcon.d.ts +2 -1
  80. package/dist/components/icons/CrossIcon.d.ts +2 -1
  81. package/dist/components/icons/DocumentIcon.d.ts +2 -1
  82. package/dist/components/icons/DownloadIcon.d.ts +2 -1
  83. package/dist/components/icons/EnterStoreIcon.d.ts +2 -1
  84. package/dist/components/icons/ExitStoreIcon.d.ts +2 -1
  85. package/dist/components/icons/EyeFilledIcon.d.ts +2 -1
  86. package/dist/components/icons/EyeIcon.d.ts +2 -1
  87. package/dist/components/icons/EyeStrikeFilledIcon.d.ts +2 -1
  88. package/dist/components/icons/EyeStrikeIcon.d.ts +2 -1
  89. package/dist/components/icons/FeedbackFilledIcon.d.ts +2 -1
  90. package/dist/components/icons/FeedbackIcon.d.ts +2 -1
  91. package/dist/components/icons/FilterIcon.d.ts +2 -1
  92. package/dist/components/icons/GiftCardFilledIcon.d.ts +2 -1
  93. package/dist/components/icons/GiftCardIcon.d.ts +2 -1
  94. package/dist/components/icons/GiftFilledIcon.d.ts +2 -1
  95. package/dist/components/icons/GiftIcon.d.ts +2 -1
  96. package/dist/components/icons/HelpFilledIcon.d.ts +2 -1
  97. package/dist/components/icons/HelpIcon.d.ts +2 -1
  98. package/dist/components/icons/HomeFilledIcon.d.ts +2 -1
  99. package/dist/components/icons/HomeIcon.d.ts +2 -1
  100. package/dist/components/icons/HowDoIAssistantIcon.d.ts +2 -1
  101. package/dist/components/icons/InfoIcon.d.ts +2 -1
  102. package/dist/components/icons/KebabIcon.d.ts +2 -1
  103. package/dist/components/icons/LoadingIcon.d.ts +2 -1
  104. package/dist/components/icons/LocationFilledIcon.d.ts +2 -1
  105. package/dist/components/icons/LocationIcon.d.ts +2 -1
  106. package/dist/components/icons/LogoutIcon.d.ts +2 -1
  107. package/dist/components/icons/MailIcon.d.ts +2 -1
  108. package/dist/components/icons/MenuIcon.d.ts +2 -1
  109. package/dist/components/icons/MessageIcon.d.ts +2 -1
  110. package/dist/components/icons/MinusIcon.d.ts +2 -1
  111. package/dist/components/icons/NotesFilledIcon.d.ts +2 -1
  112. package/dist/components/icons/NotesIcon.d.ts +2 -1
  113. package/dist/components/icons/OpenNewIcon.d.ts +2 -1
  114. package/dist/components/icons/OwnBagIcon.d.ts +2 -1
  115. package/dist/components/icons/PadlockIcon.d.ts +2 -1
  116. package/dist/components/icons/PencilIcon.d.ts +2 -1
  117. package/dist/components/icons/PhoneIcon.d.ts +2 -1
  118. package/dist/components/icons/PowerIcon.d.ts +2 -1
  119. package/dist/components/icons/SaveFilledIcon.d.ts +2 -1
  120. package/dist/components/icons/SaveIcon.d.ts +2 -1
  121. package/dist/components/icons/ScooterIcon.d.ts +2 -1
  122. package/dist/components/icons/SearchFilledIcon.d.ts +2 -1
  123. package/dist/components/icons/SearchIcon.d.ts +2 -1
  124. package/dist/components/icons/SendIcon.d.ts +2 -1
  125. package/dist/components/icons/SpannerIcon.d.ts +2 -1
  126. package/dist/components/icons/StarFilledIcon.d.ts +2 -1
  127. package/dist/components/icons/StarIcon.d.ts +2 -1
  128. package/dist/components/icons/TickAltFilledIcon.d.ts +2 -1
  129. package/dist/components/icons/TickAltIcon.d.ts +2 -1
  130. package/dist/components/icons/TickIcon.d.ts +2 -1
  131. package/dist/components/icons/TruckIcon.d.ts +2 -1
  132. package/dist/components/icons/UserWipeIcon.d.ts +2 -1
  133. package/dist/components/icons/VoucherFilledIcon.d.ts +2 -1
  134. package/dist/components/icons/VoucherIcon.d.ts +2 -1
  135. package/dist/components/icons/WarningCircleFilledIcon.d.ts +2 -1
  136. package/dist/components/icons/WarningCircleIcon.d.ts +2 -1
  137. package/dist/components/icons/WarningFilledIcon.d.ts +2 -1
  138. package/dist/components/icons/WarningIcon.d.ts +2 -1
  139. package/dist/components/icons/WifiIcon.d.ts +2 -1
  140. package/dist/components/icons/WriteIcon.d.ts +2 -1
  141. package/dist/hooks/index.d.ts +2 -1
  142. package/dist/hooks/index.js +2 -1
  143. package/dist/hooks/useFormReset.d.ts +6 -0
  144. package/dist/hooks/useFormReset.js +19 -0
  145. package/dist/hooks/useSlots.d.ts +2 -1
  146. package/dist/index.d.ts +6 -3
  147. package/dist/index.js +6 -4
  148. package/dist/types/index.js +4 -0
  149. package/dist/utils/slots.d.ts +1 -1
  150. package/dist/utils/slots.js +20 -12
  151. package/package.json +22 -18
  152. package/src/components/Author/Author.tsx +1 -1
  153. package/src/components/Combobox/Combobox.context.ts +3 -1
  154. package/src/components/Combobox/Combobox.tsx +57 -24
  155. package/src/components/Combobox/combobox-internal.ts +60 -0
  156. package/src/components/Combobox/combobox-utils.ts +1 -41
  157. package/src/components/Combobox/index.ts +1 -0
  158. package/src/components/DateInput/DateInput.context.ts +25 -0
  159. package/src/components/DateInput/DateInput.tsx +252 -0
  160. package/src/components/DateInput/dateinput-internal.ts +176 -0
  161. package/src/components/DateInput/dateinput-utils.ts +194 -0
  162. package/src/components/DateInput/index.ts +6 -0
  163. package/src/components/Field/Field.context.ts +1 -1
  164. package/src/components/Field/Field.tsx +1 -1
  165. package/src/components/Fieldset/Fieldset.tsx +5 -1
  166. package/src/components/Form/Form.tsx +9 -1
  167. package/src/components/Squircle/Squircle.tsx +1 -1
  168. package/src/components/Textarea/Textarea.tsx +19 -4
  169. package/src/components/ValidationSummary/ValidationSummary.context.ts +1 -1
  170. package/src/components/ValidationSummary/ValidationSummary.tsx +1 -1
  171. package/src/components/ValidationSummary/{validationsummary-utils.ts → validationsummary-internal.ts} +9 -8
  172. package/src/hooks/index.ts +1 -0
  173. package/src/hooks/useFormReset.ts +28 -0
  174. package/src/index.ts +1 -0
  175. package/src/utils/slots.ts +37 -23
  176. /package/src/components/FieldMarkers/{fieldmarkers-utils.ts → fieldmarkers-internal.ts} +0 -0
@@ -2,8 +2,8 @@ import { LazyComponent, SlotConfig } from "../types/index.js";
2
2
  import React, { ReactElement, ReactNode } from "react";
3
3
  //#region src/utils/slots.d.ts
4
4
  export declare function getSlotProp<R = unknown>(slot: React.ReactNode, prop: string): R | undefined;
5
- export declare const forEachSlot: (children: ReactNode, fn: (slot: SlotConfig, child: ReactElement) => void) => void;
6
5
  export declare function isLazy<T extends React.ComponentType<any>>(type: unknown): type is LazyComponent<T>;
6
+ export declare const forEachSlot: (children: ReactNode, fn: (slot: SlotConfig, child: ReactElement) => void) => void;
7
7
  export declare function resolveComponentName(child: React.ReactNode): string | null;
8
8
  export declare function getSlotName(node: React.ReactNode): string | false;
9
9
  //#endregion
@@ -1,26 +1,34 @@
1
+ import { REACT_LAZY_TYPE } from "../types/index.js";
1
2
  import { isValidElement } from "react";
2
3
  //#region src/utils/slots.ts
3
4
  function getSlotProp(slot, prop) {
4
5
  if (isValidElement(slot) && prop in slot.props) return slot.props[prop];
5
6
  }
7
+ function isLazy(type) {
8
+ return typeof type === "object" && type !== null && type.$$typeof === REACT_LAZY_TYPE;
9
+ }
10
+ const resolveType = (type) => {
11
+ if (!isLazy(type)) return type;
12
+ try {
13
+ return type._init(type._payload);
14
+ } catch {
15
+ return;
16
+ }
17
+ };
18
+ const getSlotConfig = (type) => {
19
+ const resolved = resolveType(type);
20
+ return typeof resolved === "function" && "slot" in resolved ? resolved.slot : void 0;
21
+ };
6
22
  const forEachSlot = (children, fn) => {
7
23
  Array.from([children].flat()).forEach((child) => {
8
- if (isValidElement(child) && typeof child.type === "function" && "slot" in child.type) fn(child.type.slot, child);
24
+ if (!isValidElement(child)) return;
25
+ const slot = getSlotConfig(child.type);
26
+ if (slot) fn(slot, child);
9
27
  });
10
28
  };
11
- function isLazy(type) {
12
- return typeof type === "object" && type !== null && "_payload" in type && "_init" in type;
13
- }
14
29
  function resolveComponentName(child) {
15
30
  if (child && typeof child === "object" && "type" in child) {
16
- const type = child.type;
17
- if (isLazy(type)) try {
18
- const resolved = type._init(type._payload);
19
- if ("slot" in resolved) return resolved.slot.name;
20
- return resolved.displayName ?? resolved.name ?? null;
21
- } catch {
22
- return null;
23
- }
31
+ const type = resolveType(child.type);
24
32
  if (typeof type === "function") {
25
33
  if ("slot" in type) return type.slot.name;
26
34
  return type.displayName ?? type.name ?? null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@coopdigital/react",
3
3
  "type": "module",
4
- "version": "1.30.1",
4
+ "version": "1.31.0",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -12,9 +12,10 @@
12
12
  "default": "./dist/index.js"
13
13
  },
14
14
  "./components/*": {
15
- "types": "./dist/components/*/*.d.ts",
16
- "default": "./dist/components/*/*.js"
15
+ "types": "./dist/components/*/index.d.ts",
16
+ "default": "./dist/components/*/index.js"
17
17
  },
18
+ "./components/icons": null,
18
19
  "./icons": {
19
20
  "types": "./dist/components/icons/index.d.ts",
20
21
  "default": "./dist/components/icons/index.js"
@@ -25,7 +26,8 @@
25
26
  "dist",
26
27
  "src/**/*.{tsx,ts}",
27
28
  "!**/__{screenshots,tests}__",
28
- "!**/*.{stories,test}.*"
29
+ "!**/*.{stories,test}.*",
30
+ "!**/recipes/**"
29
31
  ],
30
32
  "keywords": [
31
33
  "co-op",
@@ -52,8 +54,8 @@
52
54
  "test:behaviour:ci": "npm run test:e2e:ci -- -g behaviour",
53
55
  "build": "tsx scripts build",
54
56
  "build:docs": "tsx scripts build-docs",
55
- "build:docs:all": "tsx scripts build-docs --all",
56
57
  "build:icons": "tsx scripts build-icons",
58
+ "build:pattern-images": "tsx scripts build-pattern-images",
57
59
  "build:manifest": "tsx scripts build-manifest",
58
60
  "build:storybook": "storybook build --disable-telemetry",
59
61
  "build:storybook:test": "storybook build --test --disable-telemetry --output-dir storybook-test",
@@ -67,23 +69,25 @@
67
69
  "license": "MIT",
68
70
  "description": "React components for the Experience Library design system",
69
71
  "devDependencies": {
70
- "@axe-core/playwright": "^4.13.0",
71
- "@playwright/test": "^1.64.0",
72
- "@storybook/addon-a11y": "^10.6.1",
73
- "@storybook/addon-docs": "^10.6.1",
74
- "@storybook/addon-links": "^10.6.1",
75
- "@storybook/addon-onboarding": "^10.6.1",
76
- "@storybook/react-vite": "^10.6.1",
72
+ "@axe-core/playwright": "^4.14.0",
73
+ "@playwright/test": "^1.64.1",
74
+ "@storybook/addon-a11y": "^10.6.2",
75
+ "@storybook/addon-docs": "^10.6.2",
76
+ "@storybook/addon-links": "^10.6.2",
77
+ "@storybook/addon-onboarding": "^10.6.2",
78
+ "@storybook/react-vite": "^10.6.2",
77
79
  "@testing-library/jest-dom": "^7.0.1",
78
80
  "@testing-library/react": "^16.3.3",
79
81
  "@types/react": "^19.3.0",
80
82
  "@types/react-dom": "^19.3.0",
81
83
  "@whitespace/storybook-addon-html": "^9.0.0",
82
- "react": "^19.3.0",
83
- "react-dom": "^19.3.0",
84
+ "react": "^19.3.1",
85
+ "react-dom": "^19.3.1",
86
+ "react-element-to-jsx-string": "^18.0.0",
87
+ "react-hook-form": "^7.90.0",
84
88
  "resize-observer-polyfill": "^1.5.1",
85
89
  "serve": "^14.2.6",
86
- "storybook": "^10.6.1",
90
+ "storybook": "^10.6.2",
87
91
  "storybook-addon-tag-badges": "^3.1.0"
88
92
  },
89
93
  "peerDependencies": {
@@ -107,8 +111,8 @@
107
111
  "storybook": "$storybook"
108
112
  },
109
113
  "dependencies": {
110
- "@coopdigital/styles": "^1.21.1",
111
- "@radix-ui/react-popover": "^1.2.0",
114
+ "@coopdigital/styles": "^1.22.0",
115
+ "@radix-ui/react-popover": "^1.3.1",
112
116
  "clsx": "^2.1.1",
113
117
  "date-fns": "^4.4.0",
114
118
  "downshift": "^9.4.0",
@@ -116,5 +120,5 @@
116
120
  "merge-refs": "^2.0.0",
117
121
  "react-day-picker": "^10.0.2"
118
122
  },
119
- "gitHead": "d563cb9482482375ae32570423d5db012c9ca3b3"
123
+ "gitHead": "6a649143b3381563d36754d873c1410eb307fdc4"
120
124
  }
@@ -88,4 +88,4 @@ Author.Meta = AuthorMeta
88
88
  Author.slot = { name: "Author" }
89
89
 
90
90
  export default Author
91
- export { Author }
91
+ export { Author, AuthorImage, AuthorMeta }
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
  import type { UseComboboxReturnValue } from "downshift"
3
3
 
4
- import { createContext, useContext } from "react"
4
+ import { createContext, type RefObject, useContext } from "react"
5
5
 
6
6
  import type { ComboboxItem } from "@/components/Combobox/combobox-utils"
7
7
 
@@ -11,6 +11,8 @@ interface ComboboxContextValue {
11
11
  disabled: boolean
12
12
  downshift: UseComboboxReturnValue<ComboboxItem>
13
13
  error: boolean
14
+ // The visible input, which the form reset is looked up from
15
+ inputRef: RefObject<HTMLInputElement | null>
14
16
  // The options currently shown, after filtering and the maxResults cap
15
17
  items: ComboboxItem[]
16
18
  // The number of options that match the input, before the cap
@@ -1,5 +1,5 @@
1
1
  "use client"
2
- import type { ButtonHTMLAttributes, HTMLAttributes, JSX, ReactNode, Ref } from "react"
2
+ import type { ButtonHTMLAttributes, HTMLAttributes, JSX, MouseEvent, ReactNode, Ref } from "react"
3
3
 
4
4
  import clsx from "clsx"
5
5
  import {
@@ -10,28 +10,30 @@ 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
 
16
+ import type { ComboboxItem, ComboboxOption } from "@/components/Combobox/combobox-utils"
17
+
15
18
  import {
16
- type ComboboxItem,
17
- type ComboboxOption,
18
19
  defaultFilter,
19
20
  defaultIsItemDisabled,
20
21
  defaultItemToString,
22
+ defaultMaxResultsText,
21
23
  isMatchingText,
22
24
  normalizeOptions,
23
25
  resolveOption,
24
- } from "@/components/Combobox/combobox-utils"
26
+ type SelectionSnapshot,
27
+ shouldShowSelectionText,
28
+ } from "@/components/Combobox/combobox-internal"
25
29
  import { ComboboxContext, useComboboxContext } from "@/components/Combobox/Combobox.context"
26
30
  import { getFieldError, useField } from "@/components/Field/Field.context"
27
31
  import { ChevronDownIcon } from "@/components/icons"
28
32
  import { TextInput, type TextInputProps } from "@/components/TextInput"
29
- import { useDebounce, useId, useSlots } from "@/hooks"
33
+ import { useDebounce, useFormReset, useId, useSlots } from "@/hooks"
30
34
  import { StandardSizes } from "@/types"
31
35
  import { getSlotProp } from "@/utils"
32
36
 
33
- export type { ComboboxItem, ComboboxOption } from "@/components/Combobox/combobox-utils"
34
-
35
37
  export interface ComboboxSelectEvent {
36
38
  /** Whether the selection is a custom value, entered by users when `allowCustom` is set,
37
39
  * rather than one of the options.
@@ -189,9 +191,6 @@ type ComboboxState = UseComboboxState<ComboboxItem>
189
191
  const DEFAULT_MAX_RESULTS = 50
190
192
  const ANNOUNCEMENT_DELAY = 750
191
193
 
192
- const defaultMaxResultsText = (shown: number, total: number): string =>
193
- `Showing ${shown} of ${total} results.`
194
-
195
194
  const componentSlots = {
196
195
  ComboboxControls: null,
197
196
  ComboboxOptions: null,
@@ -247,7 +246,9 @@ const Combobox = ({
247
246
  )
248
247
 
249
248
  const [initialSelectedItem] = useState(() => resolveOption(defaultSelected, items))
250
- const selectedItem = selected === undefined ? undefined : resolveOption(selected, items)
249
+ const defaultSelectedItem = resolveOption(defaultSelected, items)
250
+ const controlled = selected !== undefined
251
+ const selectedItem = controlled ? resolveOption(selected, items) : undefined
251
252
 
252
253
  // Work out the selection from the text in the input, so the two always
253
254
  // agree. Returns null when the text does not match and custom values are
@@ -304,6 +305,13 @@ const Combobox = ({
304
305
  }
305
306
 
306
307
  const downshift = useCombobox<ComboboxItem>({
308
+ // reset() goes back to the current default selection and its text, as a
309
+ // native field goes back to its current default value when its form is
310
+ // reset. A controlled selection is left to the consumer.
311
+ ...(!controlled && {
312
+ defaultInputValue: toString(defaultSelectedItem),
313
+ defaultSelectedItem,
314
+ }),
307
315
  getItemId: (index) => visibleItems[index]?.id ?? `${uid}_item_${index}`,
308
316
  id: uid,
309
317
  initialSelectedItem,
@@ -343,34 +351,42 @@ const Combobox = ({
343
351
  },
344
352
  })
345
353
 
346
- // Options can arrive after the selection, such as when they are fetched,
347
- // so show the option text once it is known. Text that users are typing is
348
- // left alone.
354
+ // Keep the input text in step with the selection
349
355
  const selection = resolveOption(downshift.selectedItem, items)
350
356
  const selectionText = toString(selection)
351
357
  const selectionValue = selection?.value
352
- const previousSelection = useRef({ text: selectionText, value: selectionValue })
353
- const { inputValue, setInputValue } = downshift
358
+ const previousSelection = useRef<SelectionSnapshot>({
359
+ text: selectionText,
360
+ value: selectionValue,
361
+ })
362
+ const { inputValue, reset, setInputValue } = downshift
354
363
 
355
364
  useEffect(() => {
356
365
  const previous = previousSelection.current
357
- previousSelection.current = { text: selectionText, value: selectionValue }
366
+ const current = { text: selectionText, value: selectionValue }
367
+ previousSelection.current = current
358
368
 
359
- if (
360
- selectionValue === previous.value &&
361
- selectionText !== previous.text &&
362
- inputValue === previous.text
363
- ) {
369
+ if (shouldShowSelectionText(previous, current, inputValue)) {
364
370
  setInputValue(selectionText)
365
371
  }
366
372
  }, [inputValue, selectionText, selectionValue, setInputValue])
367
373
 
374
+ // When the form is reset, go back to the default selection
375
+ const inputRef = useRef<HTMLInputElement>(null)
376
+
377
+ useFormReset(inputRef, () => {
378
+ if (controlled) return
379
+ reset()
380
+ setFilterText("")
381
+ })
382
+
368
383
  return (
369
384
  <ComboboxContext.Provider
370
385
  value={{
371
386
  disabled,
372
387
  downshift,
373
388
  error: hasError,
389
+ inputRef,
374
390
  items: visibleItems,
375
391
  matchCount: matchingItems.length,
376
392
  size,
@@ -407,7 +423,11 @@ const ComboboxInput = ({ error, name, size, ...props }: ComboboxInputProps): JSX
407
423
 
408
424
  // Pass the props through downshift, so it combines its handlers and ref
409
425
  // with those given here
410
- const inputProps = combobox.downshift.getInputProps({ ...props, disabled })
426
+ const inputProps = combobox.downshift.getInputProps({
427
+ ...props,
428
+ disabled,
429
+ ref: mergeRefs(combobox.inputRef, props.ref),
430
+ })
411
431
 
412
432
  // The visible input shows the option text, which can be a label, so it has
413
433
  // no name. The form submits the selected value from a hidden input
@@ -469,9 +489,22 @@ const ComboboxOptions = ({
469
489
  const message = getMessage()
470
490
  const announcement = useDebounce(message, ANNOUNCEMENT_DELAY)
471
491
 
492
+ // downshift bug: a mouse leave after the menu closes is handled with the
493
+ // props from before a new controlled selection, so it is ignored
494
+ const onMouseLeave = (event: MouseEvent<HTMLUListElement>) => {
495
+ props.onMouseLeave?.(event)
496
+ if (!isOpen) Object.assign(event, { preventDownshiftDefault: true })
497
+ }
498
+
472
499
  return (
473
500
  <>
474
- <ul {...getMenuProps({ ...props, className: clsx("coop-combobox-options", className) })}>
501
+ <ul
502
+ {...getMenuProps({
503
+ ...props,
504
+ className: clsx("coop-combobox-options", className),
505
+ onMouseLeave,
506
+ })}
507
+ >
475
508
  {isOpen &&
476
509
  items.map((item, index) => {
477
510
  // Keep the option fields off the element, and pass its handlers
@@ -0,0 +1,60 @@
1
+ import { matchSorter } from "match-sorter"
2
+
3
+ import type { ComboboxItem, ComboboxOption } from "./combobox-utils"
4
+
5
+ export const defaultFilter = (items: ComboboxItem[], inputValue: string): ComboboxItem[] =>
6
+ inputValue !== ""
7
+ ? matchSorter(items, inputValue, {
8
+ keys: [(item) => [item.label ?? item.value]],
9
+ })
10
+ : items
11
+
12
+ export const defaultIsItemDisabled = (item: ComboboxItem): boolean => item.disabled ?? false
13
+
14
+ export const defaultItemToString = (item: ComboboxItem | null): string =>
15
+ item?.label ?? item?.value ?? ""
16
+
17
+ export const defaultMaxResultsText = (shown: number, total: number): string =>
18
+ `Showing ${shown} of ${total} results.`
19
+
20
+ // Convert options given as strings or objects into a list of items
21
+ export const normalizeOptions = (options: ComboboxOption[]): ComboboxItem[] =>
22
+ options
23
+ .filter((option) => option && typeof option !== "number")
24
+ .map((option) => (typeof option === "string" ? { value: option } : option))
25
+
26
+ // Created once, as localeCompare with options creates a new collator on
27
+ // every call, which is slow when checking a long list of options
28
+ const collator = new Intl.Collator(undefined, { sensitivity: "base" })
29
+
30
+ // Check whether typed text matches an option, ignoring case, accents and
31
+ // surrounding whitespace
32
+ export const isMatchingText = (optionText: string, text: string): boolean =>
33
+ collator.compare(optionText.trim(), text.trim()) === 0
34
+
35
+ // Find the item for an option given as a value or an object. An option that
36
+ // is not in the list, such as a custom value, is kept as it is.
37
+ export const resolveOption = (
38
+ option: ComboboxOption | null | undefined,
39
+ items: ComboboxItem[]
40
+ ): ComboboxItem | null => {
41
+ if (option === null || option === undefined) return null
42
+
43
+ const item = typeof option === "string" ? { value: option } : option
44
+ return items.find(({ value }) => value === item.value) ?? item
45
+ }
46
+
47
+ export interface SelectionSnapshot {
48
+ text: string
49
+ value: string | undefined
50
+ }
51
+
52
+ // Whether the input should change to the text of the selection. Options can
53
+ // arrive after the selection, such as when they are fetched, so show the
54
+ // option text once it is known, unless users are typing.
55
+ export const shouldShowSelectionText = (
56
+ previous: SelectionSnapshot,
57
+ current: SelectionSnapshot,
58
+ inputValue: string
59
+ ): boolean =>
60
+ current.value === previous.value && current.text !== previous.text && inputValue === previous.text
@@ -1,5 +1,4 @@
1
- import { matchSorter } from "match-sorter"
2
- import { HTMLAttributes } from "react"
1
+ import type { HTMLAttributes } from "react"
3
2
 
4
3
  export interface ComboboxItem extends HTMLAttributes<HTMLLIElement> {
5
4
  /** Specify the disabled state.
@@ -19,42 +18,3 @@ export interface ComboboxItem extends HTMLAttributes<HTMLLIElement> {
19
18
  }
20
19
 
21
20
  export type ComboboxOption = ComboboxItem | string
22
-
23
- export const defaultFilter = (items: ComboboxItem[], inputValue: string): ComboboxItem[] =>
24
- inputValue !== ""
25
- ? matchSorter(items, inputValue, {
26
- keys: [(item) => [item.label ?? item.value]],
27
- })
28
- : items
29
-
30
- export const defaultIsItemDisabled = (item: ComboboxItem): boolean => item.disabled ?? false
31
-
32
- export const defaultItemToString = (item: ComboboxItem | null): string =>
33
- item?.label ?? item?.value ?? ""
34
-
35
- // Convert options given as strings or objects into a list of items
36
- export const normalizeOptions = (options: ComboboxOption[]): ComboboxItem[] =>
37
- options
38
- .filter((option) => option && typeof option !== "number")
39
- .map((option) => (typeof option === "string" ? { value: option } : option))
40
-
41
- // Created once, as localeCompare with options creates a new collator on
42
- // every call, which is slow when checking a long list of options
43
- const collator = new Intl.Collator(undefined, { sensitivity: "base" })
44
-
45
- // Check whether typed text matches an option, ignoring case, accents and
46
- // surrounding whitespace
47
- export const isMatchingText = (optionText: string, text: string): boolean =>
48
- collator.compare(optionText.trim(), text.trim()) === 0
49
-
50
- // Find the item for an option given as a value or an object. An option that
51
- // is not in the list, such as a custom value, is kept as it is.
52
- export const resolveOption = (
53
- option: ComboboxOption | null | undefined,
54
- items: ComboboxItem[]
55
- ): ComboboxItem | null => {
56
- if (option === null || option === undefined) return null
57
-
58
- const item = typeof option === "string" ? { value: option } : option
59
- return items.find(({ value }) => value === item.value) ?? item
60
- }
@@ -3,3 +3,4 @@ import Combobox from "./Combobox"
3
3
  export default Combobox
4
4
  export { Combobox }
5
5
  export * from "./Combobox"
6
+ export type * from "./combobox-utils"
@@ -0,0 +1,25 @@
1
+ "use client"
2
+ import { createContext, type RefObject, useContext } from "react"
3
+
4
+ import type { StandardSizes } from "@/types"
5
+
6
+ import type { DateInputMode, DatePart, DateParts } from "./dateinput-utils"
7
+
8
+ export interface DateInputContextValue {
9
+ changePart: (part: DatePart, value: string) => void
10
+ controlled: boolean
11
+ // The first rendered part attaches controlRef, which the form reset is
12
+ // looked up from
13
+ controlPart?: DatePart
14
+ controlRef: RefObject<HTMLInputElement | null>
15
+ defaultValue?: DateParts
16
+ id: string
17
+ mode?: DateInputMode
18
+ name?: string
19
+ parts: DateParts
20
+ size?: StandardSizes
21
+ }
22
+
23
+ export const DateInputContext = createContext<DateInputContextValue | null>(null)
24
+
25
+ export const useDateInput = () => useContext(DateInputContext)