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.
- package/README.md +8 -0
- package/dist/index.js +432 -0
- package/package.json +34 -0
- package/vendor/package.json +4 -0
- package/vendor/src/components/actions/action-bar.tsx +35 -0
- package/vendor/src/components/actions/action-menu.tsx +120 -0
- package/vendor/src/components/actions/button-group.tsx +47 -0
- package/vendor/src/components/actions/copy-button.tsx +91 -0
- package/vendor/src/components/actions/copy-field.tsx +31 -0
- package/vendor/src/components/actions/floating-action-button.tsx +33 -0
- package/vendor/src/components/actions/index.ts +7 -0
- package/vendor/src/components/actions/public.ts +5 -0
- package/vendor/src/components/actions/quick-action-grid.tsx +162 -0
- package/vendor/src/components/calendar/calendar.tsx +328 -0
- package/vendor/src/components/calendar/date-picker.tsx +78 -0
- package/vendor/src/components/calendar/date-range-picker.tsx +96 -0
- package/vendor/src/components/calendar/date-utils.ts +89 -0
- package/vendor/src/components/calendar/index.ts +4 -0
- package/vendor/src/components/charts/charts.tsx +275 -0
- package/vendor/src/components/charts/horizontal-bar-chart.tsx +46 -0
- package/vendor/src/components/charts/index.ts +4 -0
- package/vendor/src/components/charts/kpi.tsx +68 -0
- package/vendor/src/components/charts/progress-ring.tsx +45 -0
- package/vendor/src/components/charts/public.ts +1 -0
- package/vendor/src/components/command/command-palette.tsx +375 -0
- package/vendor/src/components/command/index.ts +1 -0
- package/vendor/src/components/data-table/data-table-actions-column.tsx +58 -0
- package/vendor/src/components/data-table/data-table-bulk-actions.tsx +84 -0
- package/vendor/src/components/data-table/data-table-column-visibility-menu.tsx +79 -0
- package/vendor/src/components/data-table/data-table-pagination.tsx +91 -0
- package/vendor/src/components/data-table/data-table-row-actions.tsx +48 -0
- package/vendor/src/components/data-table/data-table-select-column.tsx +59 -0
- package/vendor/src/components/data-table/data-table-sortable-header.tsx +45 -0
- package/vendor/src/components/data-table/data-table-toolbar.tsx +76 -0
- package/vendor/src/components/data-table/data-table-view-presets.tsx +128 -0
- package/vendor/src/components/data-table/data-table.tsx +507 -0
- package/vendor/src/components/data-table/index.ts +12 -0
- package/vendor/src/components/data-table/public.ts +10 -0
- package/vendor/src/components/data-table/table-export-menu.tsx +56 -0
- package/vendor/src/components/data-table/table-import-button.tsx +43 -0
- package/vendor/src/components/display/activity-feed.tsx +97 -0
- package/vendor/src/components/display/avatar.tsx +131 -0
- package/vendor/src/components/display/code-block.tsx +33 -0
- package/vendor/src/components/display/data-state.tsx +63 -0
- package/vendor/src/components/display/description-list.tsx +119 -0
- package/vendor/src/components/display/descriptions.tsx +83 -0
- package/vendor/src/components/display/entity-card.tsx +53 -0
- package/vendor/src/components/display/file-card.tsx +54 -0
- package/vendor/src/components/display/index.ts +30 -0
- package/vendor/src/components/display/kanban.tsx +104 -0
- package/vendor/src/components/display/keyboard-shortcut.tsx +31 -0
- package/vendor/src/components/display/list.tsx +100 -0
- package/vendor/src/components/display/metric-grid.tsx +86 -0
- package/vendor/src/components/display/progress.tsx +162 -0
- package/vendor/src/components/display/property-grid.tsx +54 -0
- package/vendor/src/components/display/result.tsx +90 -0
- package/vendor/src/components/display/smart-card.tsx +168 -0
- package/vendor/src/components/display/statistic.tsx +107 -0
- package/vendor/src/components/display/status-legend.tsx +108 -0
- package/vendor/src/components/display/tag-list.tsx +52 -0
- package/vendor/src/components/display/timeline.tsx +132 -0
- package/vendor/src/components/display/tree-view.tsx +116 -0
- package/vendor/src/components/feedback/alert.tsx +69 -0
- package/vendor/src/components/feedback/empty-state.tsx +56 -0
- package/vendor/src/components/feedback/index.ts +5 -0
- package/vendor/src/components/feedback/loading-state.tsx +39 -0
- package/vendor/src/components/feedback/page-state.tsx +69 -0
- package/vendor/src/components/feedback/status-badge.tsx +62 -0
- package/vendor/src/components/filters/filter-bar.tsx +89 -0
- package/vendor/src/components/filters/filter-chips.tsx +69 -0
- package/vendor/src/components/filters/index.ts +2 -0
- package/vendor/src/components/form/form-actions.tsx +53 -0
- package/vendor/src/components/form/form-async-select.tsx +26 -0
- package/vendor/src/components/form/form-date-input.tsx +19 -0
- package/vendor/src/components/form/form-date-picker.tsx +54 -0
- package/vendor/src/components/form/form-date-range-input.tsx +79 -0
- package/vendor/src/components/form/form-date-range-picker.tsx +57 -0
- package/vendor/src/components/form/form-field-shell.tsx +191 -0
- package/vendor/src/components/form/form-input.tsx +480 -0
- package/vendor/src/components/form/form-number-input.tsx +19 -0
- package/vendor/src/components/form/form-password-input.tsx +19 -0
- package/vendor/src/components/form/form-phone-input.tsx +22 -0
- package/vendor/src/components/form/form-search-input.tsx +19 -0
- package/vendor/src/components/form/form-section.tsx +29 -0
- package/vendor/src/components/form/form-select.tsx +194 -0
- package/vendor/src/components/form/form-switch.tsx +145 -0
- package/vendor/src/components/form/form-textarea.tsx +103 -0
- package/vendor/src/components/form/index.ts +17 -0
- package/vendor/src/components/form/public.ts +14 -0
- package/vendor/src/components/form/smart-form-shell.tsx +59 -0
- package/vendor/src/components/inputs/async-select.tsx +1143 -0
- package/vendor/src/components/inputs/clearable-input.tsx +78 -0
- package/vendor/src/components/inputs/color-input.tsx +47 -0
- package/vendor/src/components/inputs/combobox.tsx +89 -0
- package/vendor/src/components/inputs/date-input.tsx +32 -0
- package/vendor/src/components/inputs/date-range-input.tsx +67 -0
- package/vendor/src/components/inputs/index.ts +19 -0
- package/vendor/src/components/inputs/input-chrome.tsx +37 -0
- package/vendor/src/components/inputs/input-decorator.tsx +64 -0
- package/vendor/src/components/inputs/input-value.ts +42 -0
- package/vendor/src/components/inputs/masked-input.tsx +51 -0
- package/vendor/src/components/inputs/money-input.tsx +73 -0
- package/vendor/src/components/inputs/number-input.tsx +87 -0
- package/vendor/src/components/inputs/numeric-value.ts +39 -0
- package/vendor/src/components/inputs/otp-input.tsx +102 -0
- package/vendor/src/components/inputs/password-input.tsx +85 -0
- package/vendor/src/components/inputs/phone-input.tsx +46 -0
- package/vendor/src/components/inputs/quantity-input.tsx +116 -0
- package/vendor/src/components/inputs/quantity-stepper.tsx +49 -0
- package/vendor/src/components/inputs/rating.tsx +98 -0
- package/vendor/src/components/inputs/search-input.tsx +26 -0
- package/vendor/src/components/inputs/simple-select.tsx +72 -0
- package/vendor/src/components/inputs/slider.tsx +149 -0
- package/vendor/src/components/inputs/tag-input.tsx +104 -0
- package/vendor/src/components/layout/app-header.tsx +46 -0
- package/vendor/src/components/layout/app-shell.tsx +243 -0
- package/vendor/src/components/layout/app-sidebar.tsx +179 -0
- package/vendor/src/components/layout/breadcrumbs.tsx +72 -0
- package/vendor/src/components/layout/index.ts +11 -0
- package/vendor/src/components/layout/page-container.tsx +30 -0
- package/vendor/src/components/layout/page-header.tsx +60 -0
- package/vendor/src/components/layout/public.ts +10 -0
- package/vendor/src/components/layout/section.tsx +76 -0
- package/vendor/src/components/layout/sidebar-nav.tsx +147 -0
- package/vendor/src/components/layout/stat-card.tsx +88 -0
- package/vendor/src/components/layout/sticky-footer-bar.tsx +23 -0
- package/vendor/src/components/layout/workspace-shell.tsx +50 -0
- package/vendor/src/components/navigation/anchor-nav.tsx +44 -0
- package/vendor/src/components/navigation/index.ts +4 -0
- package/vendor/src/components/navigation/page-tabs.tsx +67 -0
- package/vendor/src/components/navigation/pagination.tsx +179 -0
- package/vendor/src/components/navigation/stepper-tabs.tsx +67 -0
- package/vendor/src/components/notifications/index.ts +1 -0
- package/vendor/src/components/notifications/toast.tsx +259 -0
- package/vendor/src/components/overlay/confirm-dialog.tsx +66 -0
- package/vendor/src/components/overlay/dialog-actions.tsx +68 -0
- package/vendor/src/components/overlay/index.ts +4 -0
- package/vendor/src/components/overlay/modal-shell.tsx +93 -0
- package/vendor/src/components/overlay/sheet-shell.tsx +212 -0
- package/vendor/src/components/patterns/action-system.tsx +116 -0
- package/vendor/src/components/patterns/crud-system.tsx +53 -0
- package/vendor/src/components/patterns/data-view.tsx +84 -0
- package/vendor/src/components/patterns/entity-details.tsx +66 -0
- package/vendor/src/components/patterns/filter-builder.tsx +113 -0
- package/vendor/src/components/patterns/form-builder-presets.ts +131 -0
- package/vendor/src/components/patterns/form-builder.tsx +334 -0
- package/vendor/src/components/patterns/index.ts +12 -0
- package/vendor/src/components/patterns/public.ts +4 -0
- package/vendor/src/components/patterns/resource-detail-page.tsx +160 -0
- package/vendor/src/components/patterns/resource-page.tsx +159 -0
- package/vendor/src/components/patterns/resource-system.tsx +61 -0
- package/vendor/src/components/patterns/settings-section.tsx +46 -0
- package/vendor/src/components/patterns/status-system.tsx +89 -0
- package/vendor/src/components/theme-provider.tsx +51 -0
- package/vendor/src/components/ui/badge.tsx +52 -0
- package/vendor/src/components/ui/button.tsx +61 -0
- package/vendor/src/components/ui/card.tsx +103 -0
- package/vendor/src/components/ui/checkbox.tsx +82 -0
- package/vendor/src/components/ui/collapse.tsx +126 -0
- package/vendor/src/components/ui/command.tsx +194 -0
- package/vendor/src/components/ui/dialog.tsx +160 -0
- package/vendor/src/components/ui/divider.tsx +46 -0
- package/vendor/src/components/ui/dropdown-menu.tsx +266 -0
- package/vendor/src/components/ui/input-group.tsx +158 -0
- package/vendor/src/components/ui/input.tsx +20 -0
- package/vendor/src/components/ui/popover.tsx +90 -0
- package/vendor/src/components/ui/segmented-control.tsx +78 -0
- package/vendor/src/components/ui/select.tsx +201 -0
- package/vendor/src/components/ui/skeleton.tsx +75 -0
- package/vendor/src/components/ui/spinner.tsx +50 -0
- package/vendor/src/components/ui/switch.tsx +71 -0
- package/vendor/src/components/ui/table.tsx +114 -0
- package/vendor/src/components/ui/tabs.tsx +55 -0
- package/vendor/src/components/ui/textarea.tsx +18 -0
- package/vendor/src/components/ui/tooltip.tsx +38 -0
- package/vendor/src/components/upload/file-upload.tsx +483 -0
- package/vendor/src/components/upload/image-upload.tsx +118 -0
- package/vendor/src/components/upload/index.ts +2 -0
- package/vendor/src/components/wizard/index.ts +2 -0
- package/vendor/src/components/wizard/stepper.tsx +53 -0
- package/vendor/src/components/wizard/wizard.tsx +60 -0
- package/vendor/src/families/card-family.ts +28 -0
- package/vendor/src/families/catalog.ts +96 -0
- package/vendor/src/families/data-table-family.ts +31 -0
- package/vendor/src/families/docs-adoption.ts +103 -0
- package/vendor/src/families/docs-groups.ts +209 -0
- package/vendor/src/families/docs-queries.ts +84 -0
- package/vendor/src/families/docs-routing.ts +89 -0
- package/vendor/src/families/form-family.ts +45 -0
- package/vendor/src/families/index.ts +17 -0
- package/vendor/src/families/input-family.ts +61 -0
- package/vendor/src/families/member-metadata.ts +466 -0
- package/vendor/src/families/member-queries.ts +28 -0
- package/vendor/src/families/member-snippet-queries.ts +54 -0
- package/vendor/src/families/member-snippets.ts +673 -0
- package/vendor/src/families/migration-map.ts +79 -0
- package/vendor/src/families/queries.ts +63 -0
- package/vendor/src/families/select-family.ts +33 -0
- package/vendor/src/families/views.ts +81 -0
- package/vendor/src/hooks/index.ts +6 -0
- package/vendor/src/hooks/use-before-unload-when-dirty.ts +21 -0
- package/vendor/src/hooks/use-data-table-view-state.ts +122 -0
- package/vendor/src/hooks/use-debounce.ts +52 -0
- package/vendor/src/hooks/use-disclosure.ts +38 -0
- package/vendor/src/hooks/use-is-mobile.ts +28 -0
- package/vendor/src/hooks/use-session-storage-state.ts +85 -0
- package/vendor/src/index.ts +38 -0
- package/vendor/src/lib/utils.ts +6 -0
- package/vendor/templates/components/button.tsx +0 -0
- package/vendor/templates/components/data-table.tsx +0 -0
- package/vendor/templates/components/input.tsx +0 -0
- package/vendor/templates/lib/utils.ts +0 -0
- 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 }
|