azamat-ui-kit-cli 0.2.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 (213) hide show
  1. package/README.md +8 -0
  2. package/dist/index.js +432 -0
  3. package/package.json +34 -0
  4. package/vendor/package.json +4 -0
  5. package/vendor/src/components/actions/action-bar.tsx +35 -0
  6. package/vendor/src/components/actions/action-menu.tsx +120 -0
  7. package/vendor/src/components/actions/button-group.tsx +47 -0
  8. package/vendor/src/components/actions/copy-button.tsx +91 -0
  9. package/vendor/src/components/actions/copy-field.tsx +31 -0
  10. package/vendor/src/components/actions/floating-action-button.tsx +33 -0
  11. package/vendor/src/components/actions/index.ts +7 -0
  12. package/vendor/src/components/actions/public.ts +5 -0
  13. package/vendor/src/components/actions/quick-action-grid.tsx +162 -0
  14. package/vendor/src/components/calendar/calendar.tsx +328 -0
  15. package/vendor/src/components/calendar/date-picker.tsx +78 -0
  16. package/vendor/src/components/calendar/date-range-picker.tsx +96 -0
  17. package/vendor/src/components/calendar/date-utils.ts +89 -0
  18. package/vendor/src/components/calendar/index.ts +4 -0
  19. package/vendor/src/components/charts/charts.tsx +275 -0
  20. package/vendor/src/components/charts/horizontal-bar-chart.tsx +46 -0
  21. package/vendor/src/components/charts/index.ts +4 -0
  22. package/vendor/src/components/charts/kpi.tsx +68 -0
  23. package/vendor/src/components/charts/progress-ring.tsx +45 -0
  24. package/vendor/src/components/charts/public.ts +1 -0
  25. package/vendor/src/components/command/command-palette.tsx +375 -0
  26. package/vendor/src/components/command/index.ts +1 -0
  27. package/vendor/src/components/data-table/data-table-actions-column.tsx +58 -0
  28. package/vendor/src/components/data-table/data-table-bulk-actions.tsx +84 -0
  29. package/vendor/src/components/data-table/data-table-column-visibility-menu.tsx +79 -0
  30. package/vendor/src/components/data-table/data-table-pagination.tsx +91 -0
  31. package/vendor/src/components/data-table/data-table-row-actions.tsx +48 -0
  32. package/vendor/src/components/data-table/data-table-select-column.tsx +59 -0
  33. package/vendor/src/components/data-table/data-table-sortable-header.tsx +45 -0
  34. package/vendor/src/components/data-table/data-table-toolbar.tsx +76 -0
  35. package/vendor/src/components/data-table/data-table-view-presets.tsx +128 -0
  36. package/vendor/src/components/data-table/data-table.tsx +507 -0
  37. package/vendor/src/components/data-table/index.ts +12 -0
  38. package/vendor/src/components/data-table/public.ts +10 -0
  39. package/vendor/src/components/data-table/table-export-menu.tsx +56 -0
  40. package/vendor/src/components/data-table/table-import-button.tsx +43 -0
  41. package/vendor/src/components/display/activity-feed.tsx +97 -0
  42. package/vendor/src/components/display/avatar.tsx +131 -0
  43. package/vendor/src/components/display/code-block.tsx +33 -0
  44. package/vendor/src/components/display/data-state.tsx +63 -0
  45. package/vendor/src/components/display/description-list.tsx +119 -0
  46. package/vendor/src/components/display/descriptions.tsx +83 -0
  47. package/vendor/src/components/display/entity-card.tsx +53 -0
  48. package/vendor/src/components/display/file-card.tsx +54 -0
  49. package/vendor/src/components/display/index.ts +30 -0
  50. package/vendor/src/components/display/kanban.tsx +104 -0
  51. package/vendor/src/components/display/keyboard-shortcut.tsx +31 -0
  52. package/vendor/src/components/display/list.tsx +100 -0
  53. package/vendor/src/components/display/metric-grid.tsx +86 -0
  54. package/vendor/src/components/display/progress.tsx +162 -0
  55. package/vendor/src/components/display/property-grid.tsx +54 -0
  56. package/vendor/src/components/display/result.tsx +90 -0
  57. package/vendor/src/components/display/smart-card.tsx +168 -0
  58. package/vendor/src/components/display/statistic.tsx +107 -0
  59. package/vendor/src/components/display/status-legend.tsx +108 -0
  60. package/vendor/src/components/display/tag-list.tsx +52 -0
  61. package/vendor/src/components/display/timeline.tsx +132 -0
  62. package/vendor/src/components/display/tree-view.tsx +116 -0
  63. package/vendor/src/components/feedback/alert.tsx +69 -0
  64. package/vendor/src/components/feedback/empty-state.tsx +56 -0
  65. package/vendor/src/components/feedback/index.ts +5 -0
  66. package/vendor/src/components/feedback/loading-state.tsx +39 -0
  67. package/vendor/src/components/feedback/page-state.tsx +69 -0
  68. package/vendor/src/components/feedback/status-badge.tsx +62 -0
  69. package/vendor/src/components/filters/filter-bar.tsx +89 -0
  70. package/vendor/src/components/filters/filter-chips.tsx +69 -0
  71. package/vendor/src/components/filters/index.ts +2 -0
  72. package/vendor/src/components/form/form-actions.tsx +53 -0
  73. package/vendor/src/components/form/form-async-select.tsx +26 -0
  74. package/vendor/src/components/form/form-date-input.tsx +19 -0
  75. package/vendor/src/components/form/form-date-picker.tsx +54 -0
  76. package/vendor/src/components/form/form-date-range-input.tsx +79 -0
  77. package/vendor/src/components/form/form-date-range-picker.tsx +57 -0
  78. package/vendor/src/components/form/form-field-shell.tsx +191 -0
  79. package/vendor/src/components/form/form-input.tsx +480 -0
  80. package/vendor/src/components/form/form-number-input.tsx +19 -0
  81. package/vendor/src/components/form/form-password-input.tsx +19 -0
  82. package/vendor/src/components/form/form-phone-input.tsx +22 -0
  83. package/vendor/src/components/form/form-search-input.tsx +19 -0
  84. package/vendor/src/components/form/form-section.tsx +29 -0
  85. package/vendor/src/components/form/form-select.tsx +194 -0
  86. package/vendor/src/components/form/form-switch.tsx +145 -0
  87. package/vendor/src/components/form/form-textarea.tsx +103 -0
  88. package/vendor/src/components/form/index.ts +17 -0
  89. package/vendor/src/components/form/public.ts +14 -0
  90. package/vendor/src/components/form/smart-form-shell.tsx +59 -0
  91. package/vendor/src/components/inputs/async-select.tsx +1143 -0
  92. package/vendor/src/components/inputs/clearable-input.tsx +78 -0
  93. package/vendor/src/components/inputs/color-input.tsx +47 -0
  94. package/vendor/src/components/inputs/combobox.tsx +89 -0
  95. package/vendor/src/components/inputs/date-input.tsx +32 -0
  96. package/vendor/src/components/inputs/date-range-input.tsx +67 -0
  97. package/vendor/src/components/inputs/index.ts +19 -0
  98. package/vendor/src/components/inputs/input-chrome.tsx +37 -0
  99. package/vendor/src/components/inputs/input-decorator.tsx +64 -0
  100. package/vendor/src/components/inputs/input-value.ts +42 -0
  101. package/vendor/src/components/inputs/masked-input.tsx +51 -0
  102. package/vendor/src/components/inputs/money-input.tsx +73 -0
  103. package/vendor/src/components/inputs/number-input.tsx +87 -0
  104. package/vendor/src/components/inputs/numeric-value.ts +39 -0
  105. package/vendor/src/components/inputs/otp-input.tsx +102 -0
  106. package/vendor/src/components/inputs/password-input.tsx +85 -0
  107. package/vendor/src/components/inputs/phone-input.tsx +46 -0
  108. package/vendor/src/components/inputs/quantity-input.tsx +116 -0
  109. package/vendor/src/components/inputs/quantity-stepper.tsx +49 -0
  110. package/vendor/src/components/inputs/rating.tsx +98 -0
  111. package/vendor/src/components/inputs/search-input.tsx +26 -0
  112. package/vendor/src/components/inputs/simple-select.tsx +72 -0
  113. package/vendor/src/components/inputs/slider.tsx +149 -0
  114. package/vendor/src/components/inputs/tag-input.tsx +104 -0
  115. package/vendor/src/components/layout/app-header.tsx +46 -0
  116. package/vendor/src/components/layout/app-shell.tsx +243 -0
  117. package/vendor/src/components/layout/app-sidebar.tsx +179 -0
  118. package/vendor/src/components/layout/breadcrumbs.tsx +72 -0
  119. package/vendor/src/components/layout/index.ts +11 -0
  120. package/vendor/src/components/layout/page-container.tsx +30 -0
  121. package/vendor/src/components/layout/page-header.tsx +60 -0
  122. package/vendor/src/components/layout/public.ts +10 -0
  123. package/vendor/src/components/layout/section.tsx +76 -0
  124. package/vendor/src/components/layout/sidebar-nav.tsx +147 -0
  125. package/vendor/src/components/layout/stat-card.tsx +88 -0
  126. package/vendor/src/components/layout/sticky-footer-bar.tsx +23 -0
  127. package/vendor/src/components/layout/workspace-shell.tsx +50 -0
  128. package/vendor/src/components/navigation/anchor-nav.tsx +44 -0
  129. package/vendor/src/components/navigation/index.ts +4 -0
  130. package/vendor/src/components/navigation/page-tabs.tsx +67 -0
  131. package/vendor/src/components/navigation/pagination.tsx +179 -0
  132. package/vendor/src/components/navigation/stepper-tabs.tsx +67 -0
  133. package/vendor/src/components/notifications/index.ts +1 -0
  134. package/vendor/src/components/notifications/toast.tsx +259 -0
  135. package/vendor/src/components/overlay/confirm-dialog.tsx +66 -0
  136. package/vendor/src/components/overlay/dialog-actions.tsx +68 -0
  137. package/vendor/src/components/overlay/index.ts +4 -0
  138. package/vendor/src/components/overlay/modal-shell.tsx +93 -0
  139. package/vendor/src/components/overlay/sheet-shell.tsx +212 -0
  140. package/vendor/src/components/patterns/action-system.tsx +116 -0
  141. package/vendor/src/components/patterns/crud-system.tsx +53 -0
  142. package/vendor/src/components/patterns/data-view.tsx +84 -0
  143. package/vendor/src/components/patterns/entity-details.tsx +66 -0
  144. package/vendor/src/components/patterns/filter-builder.tsx +113 -0
  145. package/vendor/src/components/patterns/form-builder-presets.ts +131 -0
  146. package/vendor/src/components/patterns/form-builder.tsx +334 -0
  147. package/vendor/src/components/patterns/index.ts +12 -0
  148. package/vendor/src/components/patterns/public.ts +4 -0
  149. package/vendor/src/components/patterns/resource-detail-page.tsx +160 -0
  150. package/vendor/src/components/patterns/resource-page.tsx +159 -0
  151. package/vendor/src/components/patterns/resource-system.tsx +61 -0
  152. package/vendor/src/components/patterns/settings-section.tsx +46 -0
  153. package/vendor/src/components/patterns/status-system.tsx +89 -0
  154. package/vendor/src/components/theme-provider.tsx +51 -0
  155. package/vendor/src/components/ui/badge.tsx +52 -0
  156. package/vendor/src/components/ui/button.tsx +61 -0
  157. package/vendor/src/components/ui/card.tsx +103 -0
  158. package/vendor/src/components/ui/checkbox.tsx +82 -0
  159. package/vendor/src/components/ui/collapse.tsx +126 -0
  160. package/vendor/src/components/ui/command.tsx +194 -0
  161. package/vendor/src/components/ui/dialog.tsx +160 -0
  162. package/vendor/src/components/ui/divider.tsx +46 -0
  163. package/vendor/src/components/ui/dropdown-menu.tsx +266 -0
  164. package/vendor/src/components/ui/input-group.tsx +158 -0
  165. package/vendor/src/components/ui/input.tsx +20 -0
  166. package/vendor/src/components/ui/popover.tsx +90 -0
  167. package/vendor/src/components/ui/segmented-control.tsx +78 -0
  168. package/vendor/src/components/ui/select.tsx +201 -0
  169. package/vendor/src/components/ui/skeleton.tsx +75 -0
  170. package/vendor/src/components/ui/spinner.tsx +50 -0
  171. package/vendor/src/components/ui/switch.tsx +71 -0
  172. package/vendor/src/components/ui/table.tsx +114 -0
  173. package/vendor/src/components/ui/tabs.tsx +55 -0
  174. package/vendor/src/components/ui/textarea.tsx +18 -0
  175. package/vendor/src/components/ui/tooltip.tsx +38 -0
  176. package/vendor/src/components/upload/file-upload.tsx +483 -0
  177. package/vendor/src/components/upload/image-upload.tsx +118 -0
  178. package/vendor/src/components/upload/index.ts +2 -0
  179. package/vendor/src/components/wizard/index.ts +2 -0
  180. package/vendor/src/components/wizard/stepper.tsx +53 -0
  181. package/vendor/src/components/wizard/wizard.tsx +60 -0
  182. package/vendor/src/families/card-family.ts +28 -0
  183. package/vendor/src/families/catalog.ts +96 -0
  184. package/vendor/src/families/data-table-family.ts +31 -0
  185. package/vendor/src/families/docs-adoption.ts +103 -0
  186. package/vendor/src/families/docs-groups.ts +209 -0
  187. package/vendor/src/families/docs-queries.ts +84 -0
  188. package/vendor/src/families/docs-routing.ts +89 -0
  189. package/vendor/src/families/form-family.ts +45 -0
  190. package/vendor/src/families/index.ts +17 -0
  191. package/vendor/src/families/input-family.ts +61 -0
  192. package/vendor/src/families/member-metadata.ts +466 -0
  193. package/vendor/src/families/member-queries.ts +28 -0
  194. package/vendor/src/families/member-snippet-queries.ts +54 -0
  195. package/vendor/src/families/member-snippets.ts +673 -0
  196. package/vendor/src/families/migration-map.ts +79 -0
  197. package/vendor/src/families/queries.ts +63 -0
  198. package/vendor/src/families/select-family.ts +33 -0
  199. package/vendor/src/families/views.ts +81 -0
  200. package/vendor/src/hooks/index.ts +6 -0
  201. package/vendor/src/hooks/use-before-unload-when-dirty.ts +21 -0
  202. package/vendor/src/hooks/use-data-table-view-state.ts +122 -0
  203. package/vendor/src/hooks/use-debounce.ts +52 -0
  204. package/vendor/src/hooks/use-disclosure.ts +38 -0
  205. package/vendor/src/hooks/use-is-mobile.ts +28 -0
  206. package/vendor/src/hooks/use-session-storage-state.ts +85 -0
  207. package/vendor/src/index.ts +38 -0
  208. package/vendor/src/lib/utils.ts +6 -0
  209. package/vendor/templates/components/button.tsx +0 -0
  210. package/vendor/templates/components/data-table.tsx +0 -0
  211. package/vendor/templates/components/input.tsx +0 -0
  212. package/vendor/templates/lib/utils.ts +0 -0
  213. package/vendor/templates/styles/globals.css +0 -0
@@ -0,0 +1,1143 @@
1
+ import * as React from "react"
2
+ import { CheckIcon, ChevronsUpDownIcon, Loader2Icon, PlusIcon, SearchIcon, XIcon } from "lucide-react"
3
+
4
+ import { Button } from "@/components/ui/button"
5
+ import { Input } from "@/components/ui/input"
6
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
7
+ import { cn } from "@/lib/utils"
8
+
9
+ export type AsyncSelectOption<TValue extends string = string, TData = unknown> = {
10
+ value: TValue
11
+ label: React.ReactNode
12
+ disabled?: boolean
13
+ disabledReason?: React.ReactNode
14
+ description?: React.ReactNode
15
+ data?: TData
16
+ }
17
+
18
+ export type AsyncSelectOptionGroup<
19
+ TValue extends string = string,
20
+ TData = unknown,
21
+ TOption extends AsyncSelectOption<TValue, TData> = AsyncSelectOption<TValue, TData>,
22
+ > = {
23
+ label?: React.ReactNode
24
+ options: TOption[]
25
+ }
26
+
27
+ export type AsyncSelectOptionsResult<
28
+ TValue extends string = string,
29
+ TData = unknown,
30
+ TOption extends AsyncSelectOption<TValue, TData> = AsyncSelectOption<TValue, TData>,
31
+ > = TOption[] | AsyncSelectOptionGroup<TValue, TData, TOption>[]
32
+
33
+ export type AsyncSelectRenderState<
34
+ TValue extends string = string,
35
+ TData = unknown,
36
+ TOption extends AsyncSelectOption<TValue, TData> = AsyncSelectOption<TValue, TData>,
37
+ > = {
38
+ search: string
39
+ minSearchLength: number
40
+ options: TOption[]
41
+ selectedCount?: number
42
+ maxSelected?: number
43
+ }
44
+
45
+ export type AsyncSelectStateRenderer<
46
+ TValue extends string = string,
47
+ TData = unknown,
48
+ TOption extends AsyncSelectOption<TValue, TData> = AsyncSelectOption<TValue, TData>,
49
+ > = (state: AsyncSelectRenderState<TValue, TData, TOption>) => React.ReactNode
50
+
51
+ export type AsyncSelectLabels = {
52
+ placeholder?: string
53
+ searchPlaceholder?: string
54
+ loading?: string
55
+ creating?: string
56
+ empty?: string
57
+ error?: string
58
+ clear?: string
59
+ clearAll?: string
60
+ selectAll?: string
61
+ minSearchLength?: (minSearchLength: number) => string
62
+ maxSelected?: (maxSelected: number) => string
63
+ selectedCount?: (count: number) => string
64
+ }
65
+
66
+ export type AsyncSelectProps<
67
+ TValue extends string = string,
68
+ TData = unknown,
69
+ TOption extends AsyncSelectOption<TValue, TData> = AsyncSelectOption<TValue, TData>,
70
+ > = Omit<React.ComponentProps<"div">, "onChange"> & {
71
+ value?: TValue
72
+ selectedOption?: TOption | null
73
+ onValueChange?: (value: TValue | undefined, option?: TOption) => void
74
+ loadOptions: (search: string, signal?: AbortSignal) => Promise<AsyncSelectOptionsResult<TValue, TData, TOption>>
75
+ loadSelectedOption?: (value: TValue, signal?: AbortSignal) => Promise<TOption | null | undefined>
76
+ defaultOptions?: AsyncSelectOptionsResult<TValue, TData, TOption>
77
+ disabled?: boolean
78
+ clearable?: boolean
79
+ cacheOptions?: boolean
80
+ cacheTtl?: number
81
+ debounceMs?: number
82
+ minSearchLength?: number
83
+ labels?: AsyncSelectLabels
84
+ renderOption?: (option: TOption, state: { selected: boolean }) => React.ReactNode
85
+ renderValue?: (option: TOption) => React.ReactNode
86
+ renderLoading?: AsyncSelectStateRenderer<TValue, TData, TOption>
87
+ renderEmpty?: AsyncSelectStateRenderer<TValue, TData, TOption>
88
+ renderError?: AsyncSelectStateRenderer<TValue, TData, TOption>
89
+ renderMinSearch?: AsyncSelectStateRenderer<TValue, TData, TOption>
90
+ onCreateOption?: (search: string) => Promise<TOption> | TOption
91
+ createOptionLabel?: (search: string) => React.ReactNode
92
+ showCreateOption?: (search: string, options: TOption[]) => boolean
93
+ triggerClassName?: string
94
+ contentClassName?: string
95
+ searchClassName?: string
96
+ optionClassName?: string
97
+ }
98
+
99
+ export type AsyncMultiSelectProps<
100
+ TValue extends string = string,
101
+ TData = unknown,
102
+ TOption extends AsyncSelectOption<TValue, TData> = AsyncSelectOption<TValue, TData>,
103
+ > = Omit<React.ComponentProps<"div">, "onChange"> & {
104
+ value?: TValue[]
105
+ selectedOptions?: TOption[]
106
+ onValueChange?: (value: TValue[], options: TOption[]) => void
107
+ loadOptions: (search: string, signal?: AbortSignal) => Promise<AsyncSelectOptionsResult<TValue, TData, TOption>>
108
+ loadSelectedOptions?: (values: TValue[], signal?: AbortSignal) => Promise<TOption[]>
109
+ defaultOptions?: AsyncSelectOptionsResult<TValue, TData, TOption>
110
+ disabled?: boolean
111
+ clearable?: boolean
112
+ cacheOptions?: boolean
113
+ cacheTtl?: number
114
+ closeOnSelect?: boolean
115
+ debounceMs?: number
116
+ minSearchLength?: number
117
+ maxSelected?: number
118
+ showSelectAll?: boolean
119
+ labels?: AsyncSelectLabels
120
+ renderOption?: (option: TOption, state: { selected: boolean }) => React.ReactNode
121
+ renderValue?: (option: TOption) => React.ReactNode
122
+ renderTag?: (option: TOption, state: { remove: () => void }) => React.ReactNode
123
+ renderLoading?: AsyncSelectStateRenderer<TValue, TData, TOption>
124
+ renderEmpty?: AsyncSelectStateRenderer<TValue, TData, TOption>
125
+ renderError?: AsyncSelectStateRenderer<TValue, TData, TOption>
126
+ renderMinSearch?: AsyncSelectStateRenderer<TValue, TData, TOption>
127
+ renderMaxSelected?: AsyncSelectStateRenderer<TValue, TData, TOption>
128
+ onCreateOption?: (search: string) => Promise<TOption> | TOption
129
+ createOptionLabel?: (search: string) => React.ReactNode
130
+ showCreateOption?: (search: string, options: TOption[]) => boolean
131
+ triggerClassName?: string
132
+ contentClassName?: string
133
+ searchClassName?: string
134
+ optionClassName?: string
135
+ tagClassName?: string
136
+ }
137
+
138
+ type AsyncSelectCacheEntry<
139
+ TValue extends string,
140
+ TData,
141
+ TOption extends AsyncSelectOption<TValue, TData>,
142
+ > = {
143
+ createdAt: number
144
+ groups: AsyncSelectOptionGroup<TValue, TData, TOption>[]
145
+ }
146
+
147
+ function useDebouncedValue<TValue>(value: TValue, delay: number) {
148
+ const [debouncedValue, setDebouncedValue] = React.useState(value)
149
+
150
+ React.useEffect(() => {
151
+ const timer = window.setTimeout(() => {
152
+ setDebouncedValue(value)
153
+ }, delay)
154
+
155
+ return () => {
156
+ window.clearTimeout(timer)
157
+ }
158
+ }, [delay, value])
159
+
160
+ return debouncedValue
161
+ }
162
+
163
+ function isOptionGroup<
164
+ TValue extends string,
165
+ TData,
166
+ TOption extends AsyncSelectOption<TValue, TData>,
167
+ >(
168
+ item: TOption | AsyncSelectOptionGroup<TValue, TData, TOption>
169
+ ): item is AsyncSelectOptionGroup<TValue, TData, TOption> {
170
+ return typeof item === "object" && item !== null && "options" in item
171
+ }
172
+
173
+ function normalizeOptionGroups<
174
+ TValue extends string,
175
+ TData,
176
+ TOption extends AsyncSelectOption<TValue, TData>,
177
+ >(
178
+ result?: AsyncSelectOptionsResult<TValue, TData, TOption>
179
+ ): AsyncSelectOptionGroup<TValue, TData, TOption>[] {
180
+ if (!result || result.length === 0) return []
181
+
182
+ const firstItem = result[0]
183
+
184
+ if (isOptionGroup(firstItem)) {
185
+ return result as AsyncSelectOptionGroup<TValue, TData, TOption>[]
186
+ }
187
+
188
+ return [{ options: result as TOption[] }]
189
+ }
190
+
191
+ function flattenOptionGroups<
192
+ TValue extends string,
193
+ TData,
194
+ TOption extends AsyncSelectOption<TValue, TData>,
195
+ >(groups: AsyncSelectOptionGroup<TValue, TData, TOption>[]) {
196
+ return groups.flatMap((group) => group.options)
197
+ }
198
+
199
+ function optionToComparableText(option: AsyncSelectOption) {
200
+ if (typeof option.label === "string" || typeof option.label === "number") {
201
+ return String(option.label).toLowerCase()
202
+ }
203
+
204
+ return option.value.toLowerCase()
205
+ }
206
+
207
+ function defaultShowCreateOption<
208
+ TValue extends string,
209
+ TData,
210
+ TOption extends AsyncSelectOption<TValue, TData>,
211
+ >(search: string, options: TOption[]) {
212
+ const normalizedSearch = search.trim().toLowerCase()
213
+
214
+ if (!normalizedSearch) return false
215
+
216
+ return !options.some(
217
+ (option) => option.value.toLowerCase() === normalizedSearch || optionToComparableText(option) === normalizedSearch
218
+ )
219
+ }
220
+
221
+ function findSelectedOption<
222
+ TValue extends string,
223
+ TData,
224
+ TOption extends AsyncSelectOption<TValue, TData>,
225
+ >(
226
+ value: TValue | undefined,
227
+ selectedOption: TOption | null | undefined,
228
+ options: TOption[],
229
+ defaultOptions: TOption[],
230
+ preloadedOption?: TOption | null
231
+ ) {
232
+ if (!value) return undefined
233
+ if (selectedOption?.value === value) return selectedOption
234
+ if (preloadedOption?.value === value) return preloadedOption
235
+
236
+ return [...options, ...defaultOptions].find((option) => option.value === value)
237
+ }
238
+
239
+ function mergeUniqueOptions<
240
+ TValue extends string,
241
+ TData,
242
+ TOption extends AsyncSelectOption<TValue, TData>,
243
+ >(...optionLists: (TOption[] | undefined)[]) {
244
+ const map = new Map<TValue, TOption>()
245
+
246
+ optionLists.forEach((optionList) => {
247
+ optionList?.forEach((option) => {
248
+ map.set(option.value, option)
249
+ })
250
+ })
251
+
252
+ return Array.from(map.values())
253
+ }
254
+
255
+ function getOptionsByValue<
256
+ TValue extends string,
257
+ TData,
258
+ TOption extends AsyncSelectOption<TValue, TData>,
259
+ >(values: TValue[], options: TOption[]) {
260
+ const map = new Map(options.map((option) => [option.value, option]))
261
+ return values.map((value) => map.get(value)).filter((option): option is TOption => Boolean(option))
262
+ }
263
+
264
+ function getCachedOptionGroups<
265
+ TValue extends string,
266
+ TData,
267
+ TOption extends AsyncSelectOption<TValue, TData>,
268
+ >(
269
+ cache: Map<string, AsyncSelectCacheEntry<TValue, TData, TOption>>,
270
+ key: string,
271
+ cacheTtl: number
272
+ ) {
273
+ const entry = cache.get(key)
274
+
275
+ if (!entry) return undefined
276
+
277
+ if (Number.isFinite(cacheTtl) && Date.now() - entry.createdAt > cacheTtl) {
278
+ cache.delete(key)
279
+ return undefined
280
+ }
281
+
282
+ return entry.groups
283
+ }
284
+
285
+ function setCachedOptionGroups<
286
+ TValue extends string,
287
+ TData,
288
+ TOption extends AsyncSelectOption<TValue, TData>,
289
+ >(
290
+ cache: Map<string, AsyncSelectCacheEntry<TValue, TData, TOption>>,
291
+ key: string,
292
+ groups: AsyncSelectOptionGroup<TValue, TData, TOption>[]
293
+ ) {
294
+ cache.set(key, {
295
+ groups,
296
+ createdAt: Date.now(),
297
+ })
298
+ }
299
+
300
+ function AsyncStateMessage({
301
+ className,
302
+ children,
303
+ }: {
304
+ className?: string
305
+ children: React.ReactNode
306
+ }) {
307
+ return <div className={cn("px-2 py-3 text-sm text-muted-foreground", className)}>{children}</div>
308
+ }
309
+
310
+ function getOptionLabelText<TValue extends string, TData>(option: AsyncSelectOption<TValue, TData>) {
311
+ if (typeof option.label === "string" || typeof option.label === "number") {
312
+ return String(option.label)
313
+ }
314
+
315
+ return option.value
316
+ }
317
+
318
+ function AsyncOptionButton<
319
+ TValue extends string,
320
+ TData,
321
+ TOption extends AsyncSelectOption<TValue, TData>,
322
+ >({
323
+ option,
324
+ selected,
325
+ renderOption,
326
+ optionClassName,
327
+ onSelect,
328
+ }: {
329
+ option: TOption
330
+ selected: boolean
331
+ renderOption?: (option: TOption, state: { selected: boolean }) => React.ReactNode
332
+ optionClassName?: string
333
+ onSelect: (option: TOption) => void
334
+ }) {
335
+ return (
336
+ <button
337
+ type="button"
338
+ disabled={option.disabled}
339
+ className={cn(
340
+ "flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm outline-none hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50",
341
+ selected && "bg-accent text-accent-foreground",
342
+ optionClassName
343
+ )}
344
+ onClick={() => onSelect(option)}
345
+ >
346
+ <span className="flex size-4 shrink-0 items-center justify-center">
347
+ {selected && <CheckIcon className="size-4" />}
348
+ </span>
349
+ <span className="min-w-0 flex-1">
350
+ {renderOption?.(option, { selected }) ?? (
351
+ <span className="flex min-w-0 flex-col">
352
+ <span className="truncate">{option.label}</span>
353
+ {option.description && (
354
+ <span className="truncate text-xs text-muted-foreground">{option.description}</span>
355
+ )}
356
+ {option.disabled && option.disabledReason && (
357
+ <span className="truncate text-xs text-muted-foreground">{option.disabledReason}</span>
358
+ )}
359
+ </span>
360
+ )}
361
+ </span>
362
+ </button>
363
+ )
364
+ }
365
+
366
+ function AsyncCreateButton({
367
+ search,
368
+ isCreating,
369
+ label,
370
+ onCreate,
371
+ }: {
372
+ search: string
373
+ isCreating: boolean
374
+ label?: (search: string) => React.ReactNode
375
+ onCreate: () => void
376
+ }) {
377
+ return (
378
+ <button
379
+ type="button"
380
+ disabled={isCreating}
381
+ className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm outline-none hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
382
+ onClick={onCreate}
383
+ >
384
+ <span className="flex size-4 shrink-0 items-center justify-center">
385
+ {isCreating ? <Loader2Icon className="size-4 animate-spin" /> : <PlusIcon className="size-4" />}
386
+ </span>
387
+ <span className="min-w-0 flex-1 truncate">
388
+ {label?.(search) ?? `Create "${search.trim()}"`}
389
+ </span>
390
+ </button>
391
+ )
392
+ }
393
+
394
+ function AsyncSelect<
395
+ TValue extends string = string,
396
+ TData = unknown,
397
+ TOption extends AsyncSelectOption<TValue, TData> = AsyncSelectOption<TValue, TData>,
398
+ >({
399
+ className,
400
+ value,
401
+ selectedOption,
402
+ onValueChange,
403
+ loadOptions,
404
+ loadSelectedOption,
405
+ defaultOptions,
406
+ disabled = false,
407
+ clearable = true,
408
+ cacheOptions = true,
409
+ cacheTtl = Number.POSITIVE_INFINITY,
410
+ debounceMs = 250,
411
+ minSearchLength = 0,
412
+ labels,
413
+ renderOption,
414
+ renderValue,
415
+ renderLoading,
416
+ renderEmpty,
417
+ renderError,
418
+ renderMinSearch,
419
+ onCreateOption,
420
+ createOptionLabel,
421
+ showCreateOption,
422
+ triggerClassName,
423
+ contentClassName,
424
+ searchClassName,
425
+ optionClassName,
426
+ ...props
427
+ }: AsyncSelectProps<TValue, TData, TOption>) {
428
+ const resolvedDefaultGroups = React.useMemo(() => normalizeOptionGroups(defaultOptions), [defaultOptions])
429
+ const defaultFlatOptions = React.useMemo(() => flattenOptionGroups(resolvedDefaultGroups), [resolvedDefaultGroups])
430
+ const [open, setOpen] = React.useState(false)
431
+ const [search, setSearch] = React.useState("")
432
+ const [optionGroups, setOptionGroups] = React.useState(resolvedDefaultGroups)
433
+ const [preloadedOption, setPreloadedOption] = React.useState<TOption | null>(null)
434
+ const [isLoading, setIsLoading] = React.useState(false)
435
+ const [isCreating, setIsCreating] = React.useState(false)
436
+ const [hasError, setHasError] = React.useState(false)
437
+ const cacheRef = React.useRef(new Map<string, AsyncSelectCacheEntry<TValue, TData, TOption>>())
438
+ const loadOptionsRequestRef = React.useRef(0)
439
+ const loadSelectedOptionRequestRef = React.useRef(0)
440
+ const debouncedSearch = useDebouncedValue(search, debounceMs)
441
+ const searchKey = debouncedSearch.trim()
442
+ const flatOptions = React.useMemo(() => flattenOptionGroups(optionGroups), [optionGroups])
443
+ const state = React.useMemo<AsyncSelectRenderState<TValue, TData, TOption>>(
444
+ () => ({ search, minSearchLength, options: flatOptions }),
445
+ [flatOptions, minSearchLength, search]
446
+ )
447
+ const currentOption = findSelectedOption(
448
+ value,
449
+ selectedOption,
450
+ flatOptions,
451
+ defaultFlatOptions,
452
+ preloadedOption
453
+ )
454
+ const canClear = clearable && Boolean(value) && !disabled
455
+ const searchTooShort = searchKey.length < minSearchLength
456
+ const canCreate =
457
+ !searchTooShort &&
458
+ Boolean(onCreateOption) &&
459
+ (showCreateOption ?? defaultShowCreateOption)(search, flatOptions)
460
+
461
+ React.useEffect(() => {
462
+ setOptionGroups(resolvedDefaultGroups)
463
+ }, [resolvedDefaultGroups])
464
+
465
+ React.useEffect(() => {
466
+ if (!value || currentOption || !loadSelectedOption) return
467
+
468
+ const controller = new AbortController()
469
+ const requestId = ++loadSelectedOptionRequestRef.current
470
+
471
+ async function run() {
472
+ const option = await loadSelectedOption?.(value as TValue, controller.signal)
473
+ if (!controller.signal.aborted && requestId === loadSelectedOptionRequestRef.current) {
474
+ setPreloadedOption(option ?? null)
475
+ }
476
+ }
477
+
478
+ void run()
479
+
480
+ return () => {
481
+ controller.abort()
482
+ }
483
+ }, [currentOption, loadSelectedOption, value])
484
+
485
+ React.useEffect(() => {
486
+ if (!open) return
487
+
488
+ if (searchTooShort) {
489
+ setOptionGroups(resolvedDefaultGroups)
490
+ setIsLoading(false)
491
+ setHasError(false)
492
+ return
493
+ }
494
+
495
+ const cachedGroups = cacheOptions ? getCachedOptionGroups(cacheRef.current, searchKey, cacheTtl) : undefined
496
+
497
+ if (cachedGroups) {
498
+ setOptionGroups(cachedGroups)
499
+ setIsLoading(false)
500
+ setHasError(false)
501
+ return
502
+ }
503
+
504
+ const controller = new AbortController()
505
+ const requestId = ++loadOptionsRequestRef.current
506
+
507
+ async function run() {
508
+ setIsLoading(true)
509
+ setHasError(false)
510
+
511
+ try {
512
+ const nextOptions = normalizeOptionGroups(await loadOptions(searchKey, controller.signal))
513
+ if (!controller.signal.aborted && requestId === loadOptionsRequestRef.current) {
514
+ if (cacheOptions) {
515
+ setCachedOptionGroups(cacheRef.current, searchKey, nextOptions)
516
+ }
517
+ setOptionGroups(nextOptions)
518
+ }
519
+ } catch {
520
+ if (!controller.signal.aborted && requestId === loadOptionsRequestRef.current) {
521
+ setHasError(true)
522
+ setOptionGroups([])
523
+ }
524
+ } finally {
525
+ if (!controller.signal.aborted && requestId === loadOptionsRequestRef.current) {
526
+ setIsLoading(false)
527
+ }
528
+ }
529
+ }
530
+
531
+ void run()
532
+
533
+ return () => {
534
+ controller.abort()
535
+ }
536
+ }, [cacheOptions, cacheTtl, loadOptions, open, resolvedDefaultGroups, searchKey, searchTooShort])
537
+
538
+ const handleSelect = (option: TOption) => {
539
+ if (option.disabled) return
540
+
541
+ onValueChange?.(option.value, option)
542
+ setOpen(false)
543
+ setSearch("")
544
+ }
545
+
546
+ const clearSelection = () => {
547
+ onValueChange?.(undefined)
548
+ setSearch("")
549
+ }
550
+
551
+ const handleClear = (event: React.MouseEvent<HTMLElement>) => {
552
+ event.stopPropagation()
553
+ clearSelection()
554
+ }
555
+
556
+ const handleCreate = async () => {
557
+ if (!onCreateOption || !search.trim() || searchTooShort) return
558
+
559
+ setIsCreating(true)
560
+
561
+ try {
562
+ const createdOption = await onCreateOption(search.trim())
563
+ setOptionGroups((previousGroups) => [{ options: [createdOption] }, ...previousGroups])
564
+ onValueChange?.(createdOption.value, createdOption)
565
+ setSearch("")
566
+ setOpen(false)
567
+ } finally {
568
+ setIsCreating(false)
569
+ }
570
+ }
571
+
572
+ return (
573
+ <div data-slot="async-select" className={cn("w-full", className)} {...props}>
574
+ <Popover open={open} onOpenChange={setOpen}>
575
+ <PopoverTrigger
576
+ render={
577
+ <Button
578
+ type="button"
579
+ variant="outline"
580
+ disabled={disabled}
581
+ aria-expanded={open}
582
+ className={cn("w-full justify-between", triggerClassName)}
583
+ />
584
+ }
585
+ >
586
+ <span className="min-w-0 flex-1 text-left">
587
+ {currentOption ? (
588
+ <span className="flex min-w-0 flex-col">
589
+ <span className="truncate">
590
+ {renderValue?.(currentOption) ?? currentOption.label}
591
+ </span>
592
+ {currentOption.disabled && currentOption.disabledReason && (
593
+ <span className="truncate text-xs text-muted-foreground">{currentOption.disabledReason}</span>
594
+ )}
595
+ </span>
596
+ ) : (
597
+ <span className="truncate">{labels?.placeholder ?? "Select"}</span>
598
+ )}
599
+ </span>
600
+ <span className="ml-2 flex shrink-0 items-center gap-1">
601
+ {canClear && (
602
+ <span
603
+ role="button"
604
+ tabIndex={0}
605
+ className="rounded-sm p-0.5 text-muted-foreground hover:text-foreground"
606
+ aria-label={labels?.clear ?? "Clear"}
607
+ onClick={handleClear}
608
+ onKeyDown={(event) => {
609
+ if (event.key !== "Enter" && event.key !== " ") return
610
+ event.preventDefault()
611
+ event.stopPropagation()
612
+ clearSelection()
613
+ }}
614
+ >
615
+ <XIcon className="size-3.5" />
616
+ </span>
617
+ )}
618
+ <ChevronsUpDownIcon className="size-4 opacity-60" />
619
+ </span>
620
+ </PopoverTrigger>
621
+ <PopoverContent
622
+ align="start"
623
+ className={cn("w-(--anchor-width) gap-2 p-2", contentClassName)}
624
+ >
625
+ <div className="relative">
626
+ <SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
627
+ <Input
628
+ value={search}
629
+ onChange={(event) => setSearch(event.target.value)}
630
+ placeholder={labels?.searchPlaceholder ?? "Search..."}
631
+ className={cn("pl-8", searchClassName)}
632
+ />
633
+ </div>
634
+
635
+ <div className="max-h-64 overflow-y-auto">
636
+ {searchTooShort && flatOptions.length === 0 && (
637
+ renderMinSearch?.(state) ?? (
638
+ <AsyncStateMessage>
639
+ {labels?.minSearchLength?.(minSearchLength) ?? `Type at least ${minSearchLength} characters`}
640
+ </AsyncStateMessage>
641
+ )
642
+ )}
643
+
644
+ {isLoading &&
645
+ (renderLoading?.(state) ?? (
646
+ <AsyncStateMessage>
647
+ <span className="inline-flex items-center gap-2">
648
+ <Loader2Icon className="size-4 animate-spin" />
649
+ {labels?.loading ?? "Loading..."}
650
+ </span>
651
+ </AsyncStateMessage>
652
+ ))}
653
+
654
+ {!isLoading && hasError &&
655
+ (renderError?.(state) ?? (
656
+ <AsyncStateMessage className="text-destructive">
657
+ {labels?.error ?? "Could not load options"}
658
+ </AsyncStateMessage>
659
+ ))}
660
+
661
+ {!isLoading && !hasError && !searchTooShort && flatOptions.length === 0 && !canCreate &&
662
+ (renderEmpty?.(state) ?? (
663
+ <AsyncStateMessage>{labels?.empty ?? "No options found"}</AsyncStateMessage>
664
+ ))}
665
+
666
+ {!isLoading && !hasError && canCreate && (
667
+ <AsyncCreateButton
668
+ search={search}
669
+ isCreating={isCreating}
670
+ label={createOptionLabel}
671
+ onCreate={handleCreate}
672
+ />
673
+ )}
674
+
675
+ {!isLoading &&
676
+ !hasError &&
677
+ optionGroups.map((group, groupIndex) => (
678
+ <div key={groupIndex}>
679
+ {group.label && (
680
+ <div className="sticky top-0 z-10 bg-popover px-2 py-1 text-xs font-medium text-muted-foreground">
681
+ {group.label}
682
+ </div>
683
+ )}
684
+ {group.options.map((option) => (
685
+ <AsyncOptionButton
686
+ key={option.value}
687
+ option={option}
688
+ selected={option.value === value}
689
+ renderOption={renderOption}
690
+ optionClassName={optionClassName}
691
+ onSelect={handleSelect}
692
+ />
693
+ ))}
694
+ </div>
695
+ ))}
696
+ </div>
697
+ </PopoverContent>
698
+ </Popover>
699
+ </div>
700
+ )
701
+ }
702
+
703
+ function AsyncMultiSelect<
704
+ TValue extends string = string,
705
+ TData = unknown,
706
+ TOption extends AsyncSelectOption<TValue, TData> = AsyncSelectOption<TValue, TData>,
707
+ >({
708
+ className,
709
+ value,
710
+ selectedOptions,
711
+ onValueChange,
712
+ loadOptions,
713
+ loadSelectedOptions,
714
+ defaultOptions,
715
+ disabled = false,
716
+ clearable = true,
717
+ cacheOptions = true,
718
+ cacheTtl = Number.POSITIVE_INFINITY,
719
+ closeOnSelect = false,
720
+ debounceMs = 250,
721
+ minSearchLength = 0,
722
+ maxSelected,
723
+ showSelectAll = false,
724
+ labels,
725
+ renderOption,
726
+ renderValue,
727
+ renderTag,
728
+ renderLoading,
729
+ renderEmpty,
730
+ renderError,
731
+ renderMinSearch,
732
+ renderMaxSelected,
733
+ onCreateOption,
734
+ createOptionLabel,
735
+ showCreateOption,
736
+ triggerClassName,
737
+ contentClassName,
738
+ searchClassName,
739
+ optionClassName,
740
+ tagClassName,
741
+ ...props
742
+ }: AsyncMultiSelectProps<TValue, TData, TOption>) {
743
+ const values = React.useMemo(() => value ?? [], [value])
744
+ const selectedValueKey = values.join("|")
745
+ const resolvedDefaultGroups = React.useMemo(() => normalizeOptionGroups(defaultOptions), [defaultOptions])
746
+ const defaultFlatOptions = React.useMemo(() => flattenOptionGroups(resolvedDefaultGroups), [resolvedDefaultGroups])
747
+ const [open, setOpen] = React.useState(false)
748
+ const [search, setSearch] = React.useState("")
749
+ const [optionGroups, setOptionGroups] = React.useState(resolvedDefaultGroups)
750
+ const [preloadedOptions, setPreloadedOptions] = React.useState<TOption[]>([])
751
+ const [isLoading, setIsLoading] = React.useState(false)
752
+ const [isCreating, setIsCreating] = React.useState(false)
753
+ const [hasError, setHasError] = React.useState(false)
754
+ const cacheRef = React.useRef(new Map<string, AsyncSelectCacheEntry<TValue, TData, TOption>>())
755
+ const loadOptionsRequestRef = React.useRef(0)
756
+ const loadSelectedOptionsRequestRef = React.useRef(0)
757
+ const debouncedSearch = useDebouncedValue(search, debounceMs)
758
+ const searchKey = debouncedSearch.trim()
759
+ const flatOptions = React.useMemo(() => flattenOptionGroups(optionGroups), [optionGroups])
760
+ const selectedSet = React.useMemo(() => new Set<TValue>(values), [values])
761
+ const allKnownOptions = React.useMemo(
762
+ () => mergeUniqueOptions(selectedOptions, preloadedOptions, flatOptions, defaultFlatOptions),
763
+ [defaultFlatOptions, flatOptions, preloadedOptions, selectedOptions]
764
+ )
765
+ const currentOptions = React.useMemo(
766
+ () => getOptionsByValue(values, allKnownOptions),
767
+ [allKnownOptions, values]
768
+ )
769
+ const state = React.useMemo<AsyncSelectRenderState<TValue, TData, TOption>>(
770
+ () => ({ search, minSearchLength, options: flatOptions, selectedCount: values.length, maxSelected }),
771
+ [flatOptions, maxSelected, minSearchLength, search, values.length]
772
+ )
773
+ const hasValue = values.length > 0
774
+ const canClear = clearable && hasValue && !disabled
775
+ const isMaxReached = typeof maxSelected === "number" && values.length >= maxSelected
776
+ const searchTooShort = searchKey.length < minSearchLength
777
+ const canCreate =
778
+ !searchTooShort &&
779
+ Boolean(onCreateOption) &&
780
+ (showCreateOption ?? defaultShowCreateOption)(search, flatOptions)
781
+ const visibleSelectableOptions = flatOptions.filter((option) => !option.disabled)
782
+ const unselectedVisibleOptions = visibleSelectableOptions.filter((option) => !selectedSet.has(option.value))
783
+ const canSelectAll = showSelectAll && unselectedVisibleOptions.length > 0 && !isMaxReached
784
+
785
+ React.useEffect(() => {
786
+ setOptionGroups(resolvedDefaultGroups)
787
+ }, [resolvedDefaultGroups])
788
+
789
+ React.useEffect(() => {
790
+ if (!values.length || !loadSelectedOptions) return
791
+
792
+ const knownValues = new Set(allKnownOptions.map((option) => option.value))
793
+ const missingValues = values.filter((item) => !knownValues.has(item))
794
+
795
+ if (!missingValues.length) return
796
+
797
+ const controller = new AbortController()
798
+ const requestId = ++loadSelectedOptionsRequestRef.current
799
+
800
+ async function run() {
801
+ const loadedOptions = await loadSelectedOptions?.(missingValues, controller.signal)
802
+ if (!controller.signal.aborted && requestId === loadSelectedOptionsRequestRef.current && loadedOptions) {
803
+ setPreloadedOptions((previousOptions) => mergeUniqueOptions(previousOptions, loadedOptions))
804
+ }
805
+ }
806
+
807
+ void run()
808
+
809
+ return () => {
810
+ controller.abort()
811
+ }
812
+ }, [allKnownOptions, loadSelectedOptions, selectedValueKey, values])
813
+
814
+ React.useEffect(() => {
815
+ if (!open) return
816
+
817
+ if (searchTooShort) {
818
+ setOptionGroups(resolvedDefaultGroups)
819
+ setIsLoading(false)
820
+ setHasError(false)
821
+ return
822
+ }
823
+
824
+ const cachedGroups = cacheOptions ? getCachedOptionGroups(cacheRef.current, searchKey, cacheTtl) : undefined
825
+
826
+ if (cachedGroups) {
827
+ setOptionGroups(cachedGroups)
828
+ setIsLoading(false)
829
+ setHasError(false)
830
+ return
831
+ }
832
+
833
+ const controller = new AbortController()
834
+ const requestId = ++loadOptionsRequestRef.current
835
+
836
+ async function run() {
837
+ setIsLoading(true)
838
+ setHasError(false)
839
+
840
+ try {
841
+ const nextOptions = normalizeOptionGroups(await loadOptions(searchKey, controller.signal))
842
+ if (!controller.signal.aborted && requestId === loadOptionsRequestRef.current) {
843
+ if (cacheOptions) {
844
+ setCachedOptionGroups(cacheRef.current, searchKey, nextOptions)
845
+ }
846
+ setOptionGroups(nextOptions)
847
+ }
848
+ } catch {
849
+ if (!controller.signal.aborted && requestId === loadOptionsRequestRef.current) {
850
+ setHasError(true)
851
+ setOptionGroups([])
852
+ }
853
+ } finally {
854
+ if (!controller.signal.aborted && requestId === loadOptionsRequestRef.current) {
855
+ setIsLoading(false)
856
+ }
857
+ }
858
+ }
859
+
860
+ void run()
861
+
862
+ return () => {
863
+ controller.abort()
864
+ }
865
+ }, [cacheOptions, cacheTtl, loadOptions, open, resolvedDefaultGroups, searchKey, searchTooShort])
866
+
867
+ const emitChange = (nextValues: TValue[], nextKnownOptions: TOption[]) => {
868
+ onValueChange?.(nextValues, getOptionsByValue(nextValues, nextKnownOptions))
869
+ }
870
+
871
+ const removeValue = (nextValue: TValue) => {
872
+ emitChange(values.filter((item) => item !== nextValue), allKnownOptions)
873
+ }
874
+
875
+ const handleSelect = (option: TOption) => {
876
+ if (option.disabled) return
877
+
878
+ const isSelected = selectedSet.has(option.value)
879
+ const nextValues = isSelected
880
+ ? values.filter((item) => item !== option.value)
881
+ : isMaxReached
882
+ ? values
883
+ : [...values, option.value]
884
+ const nextKnownOptions = mergeUniqueOptions(allKnownOptions, [option])
885
+
886
+ emitChange(nextValues, nextKnownOptions)
887
+
888
+ if (closeOnSelect) {
889
+ setOpen(false)
890
+ setSearch("")
891
+ }
892
+ }
893
+
894
+ const handleTagRemoveKeyDown = (event: React.KeyboardEvent<HTMLElement>, option: TOption) => {
895
+ if (event.key !== "Enter" && event.key !== " ") return
896
+
897
+ event.preventDefault()
898
+ event.stopPropagation()
899
+ removeValue(option.value)
900
+ }
901
+
902
+ const handleTriggerKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
903
+ if (disabled || search.length > 0 || values.length === 0) return
904
+
905
+ if (event.key === "Backspace" || event.key === "Delete") {
906
+ event.preventDefault()
907
+ removeValue(values[values.length - 1])
908
+ }
909
+ }
910
+
911
+ const handleSelectAllVisible = () => {
912
+ if (!unselectedVisibleOptions.length) return
913
+
914
+ const remainingCount = typeof maxSelected === "number" ? Math.max(maxSelected - values.length, 0) : unselectedVisibleOptions.length
915
+ const nextOptions = unselectedVisibleOptions.slice(0, remainingCount)
916
+ const nextValues = Array.from(new Set([...values, ...nextOptions.map((option) => option.value)]))
917
+ const nextKnownOptions = mergeUniqueOptions(allKnownOptions, nextOptions)
918
+
919
+ emitChange(nextValues, nextKnownOptions)
920
+ }
921
+
922
+ const removeOption = (option: TOption) => {
923
+ removeValue(option.value)
924
+ }
925
+
926
+ const clearAllSelection = () => {
927
+ onValueChange?.([], [])
928
+ setSearch("")
929
+ }
930
+
931
+ const handleClear = (event: React.MouseEvent<HTMLElement>) => {
932
+ event.stopPropagation()
933
+ clearAllSelection()
934
+ }
935
+
936
+ const handleCreate = async () => {
937
+ if (!onCreateOption || !search.trim() || searchTooShort) return
938
+
939
+ setIsCreating(true)
940
+
941
+ try {
942
+ const createdOption = await onCreateOption(search.trim())
943
+ const nextKnownOptions = mergeUniqueOptions(allKnownOptions, [createdOption])
944
+ const nextValues = selectedSet.has(createdOption.value)
945
+ ? values
946
+ : isMaxReached
947
+ ? values
948
+ : [...values, createdOption.value]
949
+
950
+ setOptionGroups((previousGroups) => [{ options: [createdOption] }, ...previousGroups])
951
+ emitChange(nextValues, nextKnownOptions)
952
+ setSearch("")
953
+
954
+ if (closeOnSelect) {
955
+ setOpen(false)
956
+ }
957
+ } finally {
958
+ setIsCreating(false)
959
+ }
960
+ }
961
+
962
+ return (
963
+ <div data-slot="async-multi-select" className={cn("w-full", className)} {...props}>
964
+ <Popover open={open} onOpenChange={setOpen}>
965
+ <PopoverTrigger
966
+ render={
967
+ <Button
968
+ type="button"
969
+ variant="outline"
970
+ disabled={disabled}
971
+ aria-expanded={open}
972
+ className={cn("min-h-8 w-full justify-between", triggerClassName)}
973
+ onKeyDown={handleTriggerKeyDown}
974
+ />
975
+ }
976
+ >
977
+ <span className="flex min-w-0 flex-1 flex-wrap gap-1 text-left">
978
+ {currentOptions.length > 0 ? (
979
+ currentOptions.map((option) => (
980
+ <span
981
+ key={option.value}
982
+ className={cn(
983
+ "inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-xs text-foreground",
984
+ tagClassName
985
+ )}
986
+ >
987
+ <span className="flex min-w-0 flex-col">
988
+ <span className="truncate">
989
+ {renderTag?.(option, { remove: () => removeOption(option) }) ??
990
+ renderValue?.(option) ??
991
+ option.label}
992
+ </span>
993
+ {option.disabled && option.disabledReason && (
994
+ <span className="truncate text-[11px] text-muted-foreground">{option.disabledReason}</span>
995
+ )}
996
+ </span>
997
+ {!disabled && (
998
+ <span
999
+ role="button"
1000
+ tabIndex={0}
1001
+ className="rounded-sm text-muted-foreground hover:text-foreground"
1002
+ aria-label={`Remove ${getOptionLabelText(option)}`}
1003
+ onClick={(event) => {
1004
+ event.stopPropagation()
1005
+ removeOption(option)
1006
+ }}
1007
+ onKeyDown={(event) => handleTagRemoveKeyDown(event, option)}
1008
+ >
1009
+ <XIcon className="size-3" />
1010
+ </span>
1011
+ )}
1012
+ </span>
1013
+ ))
1014
+ ) : (
1015
+ <span className="truncate text-muted-foreground">
1016
+ {labels?.placeholder ?? "Select"}
1017
+ </span>
1018
+ )}
1019
+ </span>
1020
+ <span className="ml-2 flex shrink-0 items-center gap-1">
1021
+ {canClear && (
1022
+ <span
1023
+ role="button"
1024
+ tabIndex={0}
1025
+ className="rounded-sm p-0.5 text-muted-foreground hover:text-foreground"
1026
+ aria-label={labels?.clearAll ?? labels?.clear ?? "Clear all"}
1027
+ onClick={handleClear}
1028
+ onKeyDown={(event) => {
1029
+ if (event.key !== "Enter" && event.key !== " ") return
1030
+ event.preventDefault()
1031
+ event.stopPropagation()
1032
+ clearAllSelection()
1033
+ }}
1034
+ >
1035
+ <XIcon className="size-3.5" />
1036
+ </span>
1037
+ )}
1038
+ <ChevronsUpDownIcon className="size-4 opacity-60" />
1039
+ </span>
1040
+ </PopoverTrigger>
1041
+ <PopoverContent
1042
+ align="start"
1043
+ className={cn("w-(--anchor-width) gap-2 p-2", contentClassName)}
1044
+ >
1045
+ <div className="relative">
1046
+ <SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
1047
+ <Input
1048
+ value={search}
1049
+ onChange={(event) => setSearch(event.target.value)}
1050
+ placeholder={labels?.searchPlaceholder ?? "Search..."}
1051
+ className={cn("pl-8", searchClassName)}
1052
+ />
1053
+ </div>
1054
+
1055
+ <div className="flex flex-wrap items-center justify-between gap-2 px-1 text-xs text-muted-foreground">
1056
+ {hasValue && labels?.selectedCount && <span>{labels.selectedCount(values.length)}</span>}
1057
+ {isMaxReached &&
1058
+ (renderMaxSelected?.(state) ?? (
1059
+ <span>{labels?.maxSelected?.(maxSelected ?? values.length) ?? `Maximum ${maxSelected} selected`}</span>
1060
+ ))}
1061
+ <div className="ml-auto flex items-center gap-2">
1062
+ {canSelectAll && (
1063
+ <button type="button" className="font-medium text-foreground hover:underline" onClick={handleSelectAllVisible}>
1064
+ {labels?.selectAll ?? "Select all"}
1065
+ </button>
1066
+ )}
1067
+ {canClear && (
1068
+ <button type="button" className="font-medium text-foreground hover:underline" onClick={() => onValueChange?.([], [])}>
1069
+ {labels?.clearAll ?? labels?.clear ?? "Clear all"}
1070
+ </button>
1071
+ )}
1072
+ </div>
1073
+ </div>
1074
+
1075
+ <div className="max-h-64 overflow-y-auto">
1076
+ {searchTooShort && flatOptions.length === 0 && (
1077
+ renderMinSearch?.(state) ?? (
1078
+ <AsyncStateMessage>
1079
+ {labels?.minSearchLength?.(minSearchLength) ?? `Type at least ${minSearchLength} characters`}
1080
+ </AsyncStateMessage>
1081
+ )
1082
+ )}
1083
+
1084
+ {isLoading &&
1085
+ (renderLoading?.(state) ?? (
1086
+ <AsyncStateMessage>
1087
+ <span className="inline-flex items-center gap-2">
1088
+ <Loader2Icon className="size-4 animate-spin" />
1089
+ {labels?.loading ?? "Loading..."}
1090
+ </span>
1091
+ </AsyncStateMessage>
1092
+ ))}
1093
+
1094
+ {!isLoading && hasError &&
1095
+ (renderError?.(state) ?? (
1096
+ <AsyncStateMessage className="text-destructive">
1097
+ {labels?.error ?? "Could not load options"}
1098
+ </AsyncStateMessage>
1099
+ ))}
1100
+
1101
+ {!isLoading && !hasError && !searchTooShort && flatOptions.length === 0 && !canCreate &&
1102
+ (renderEmpty?.(state) ?? (
1103
+ <AsyncStateMessage>{labels?.empty ?? "No options found"}</AsyncStateMessage>
1104
+ ))}
1105
+
1106
+ {!isLoading && !hasError && canCreate && (
1107
+ <AsyncCreateButton
1108
+ search={search}
1109
+ isCreating={isCreating}
1110
+ label={createOptionLabel}
1111
+ onCreate={handleCreate}
1112
+ />
1113
+ )}
1114
+
1115
+ {!isLoading &&
1116
+ !hasError &&
1117
+ optionGroups.map((group, groupIndex) => (
1118
+ <div key={groupIndex}>
1119
+ {group.label && (
1120
+ <div className="sticky top-0 z-10 bg-popover px-2 py-1 text-xs font-medium text-muted-foreground">
1121
+ {group.label}
1122
+ </div>
1123
+ )}
1124
+ {group.options.map((option) => (
1125
+ <AsyncOptionButton
1126
+ key={option.value}
1127
+ option={option}
1128
+ selected={selectedSet.has(option.value)}
1129
+ renderOption={renderOption}
1130
+ optionClassName={optionClassName}
1131
+ onSelect={handleSelect}
1132
+ />
1133
+ ))}
1134
+ </div>
1135
+ ))}
1136
+ </div>
1137
+ </PopoverContent>
1138
+ </Popover>
1139
+ </div>
1140
+ )
1141
+ }
1142
+
1143
+ export { AsyncSelect, AsyncMultiSelect }