@emara/ui 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/ui/.gitkeep +0 -0
- package/components/ui/accordion.stories.tsx +231 -0
- package/components/ui/accordion.tsx +250 -0
- package/components/ui/app-shell.stories.tsx +270 -0
- package/components/ui/app-shell.tsx +491 -0
- package/components/ui/avatar.stories.tsx +174 -0
- package/components/ui/avatar.tsx +257 -0
- package/components/ui/badge.stories.tsx +127 -0
- package/components/ui/badge.tsx +146 -0
- package/components/ui/breadcrumb.stories.tsx +92 -0
- package/components/ui/breadcrumb.tsx +302 -0
- package/components/ui/button.stories.tsx +186 -0
- package/components/ui/button.tsx +128 -0
- package/components/ui/card.stories.tsx +279 -0
- package/components/ui/card.tsx +250 -0
- package/components/ui/checkbox.stories.tsx +93 -0
- package/components/ui/checkbox.tsx +131 -0
- package/components/ui/combobox.stories.tsx +489 -0
- package/components/ui/combobox.tsx +874 -0
- package/components/ui/context-menu.stories.tsx +202 -0
- package/components/ui/context-menu.tsx +309 -0
- package/components/ui/data-table.stories.tsx +227 -0
- package/components/ui/data-table.tsx +539 -0
- package/components/ui/date-picker.stories.tsx +225 -0
- package/components/ui/date-picker.tsx +597 -0
- package/components/ui/dialog.stories.tsx +193 -0
- package/components/ui/dialog.tsx +262 -0
- package/components/ui/divider.stories.tsx +84 -0
- package/components/ui/divider.tsx +135 -0
- package/components/ui/drawer.stories.tsx +218 -0
- package/components/ui/drawer.tsx +329 -0
- package/components/ui/dropdown-menu.stories.tsx +270 -0
- package/components/ui/dropdown-menu.tsx +353 -0
- package/components/ui/empty-state.stories.tsx +121 -0
- package/components/ui/empty-state.tsx +289 -0
- package/components/ui/field-group.stories.tsx +201 -0
- package/components/ui/field-group.tsx +276 -0
- package/components/ui/form.stories.tsx +219 -0
- package/components/ui/form.tsx +542 -0
- package/components/ui/input.stories.tsx +154 -0
- package/components/ui/input.tsx +208 -0
- package/components/ui/label.stories.tsx +84 -0
- package/components/ui/label.tsx +98 -0
- package/components/ui/page-header.stories.tsx +136 -0
- package/components/ui/page-header.tsx +315 -0
- package/components/ui/pagination.stories.tsx +136 -0
- package/components/ui/pagination.tsx +427 -0
- package/components/ui/popover.stories.tsx +212 -0
- package/components/ui/popover.tsx +167 -0
- package/components/ui/radio-group.stories.tsx +96 -0
- package/components/ui/radio-group.tsx +250 -0
- package/components/ui/select.stories.tsx +203 -0
- package/components/ui/select.tsx +318 -0
- package/components/ui/sidebar.stories.tsx +186 -0
- package/components/ui/sidebar.tsx +623 -0
- package/components/ui/skeleton.stories.tsx +131 -0
- package/components/ui/skeleton.tsx +311 -0
- package/components/ui/switch.stories.tsx +74 -0
- package/components/ui/switch.tsx +186 -0
- package/components/ui/table.stories.tsx +107 -0
- package/components/ui/table.tsx +285 -0
- package/components/ui/tabs.stories.tsx +222 -0
- package/components/ui/tabs.tsx +287 -0
- package/components/ui/textarea.stories.tsx +96 -0
- package/components/ui/textarea.tsx +182 -0
- package/components/ui/toast.stories.tsx +169 -0
- package/components/ui/toast.tsx +250 -0
- package/components/ui/tooltip.stories.tsx +146 -0
- package/components/ui/tooltip.tsx +156 -0
- package/components/ui/top-bar.stories.tsx +182 -0
- package/components/ui/top-bar.tsx +155 -0
- package/dist/components/ui/accordion.d.ts +45 -0
- package/dist/components/ui/accordion.d.ts.map +1 -0
- package/dist/components/ui/accordion.js +99 -0
- package/dist/components/ui/accordion.js.map +1 -0
- package/dist/components/ui/app-shell.d.ts +70 -0
- package/dist/components/ui/app-shell.d.ts.map +1 -0
- package/dist/components/ui/app-shell.js +199 -0
- package/dist/components/ui/app-shell.js.map +1 -0
- package/dist/components/ui/avatar.d.ts +41 -0
- package/dist/components/ui/avatar.d.ts.map +1 -0
- package/dist/components/ui/avatar.js +104 -0
- package/dist/components/ui/avatar.js.map +1 -0
- package/dist/components/ui/badge.d.ts +27 -0
- package/dist/components/ui/badge.d.ts.map +1 -0
- package/dist/components/ui/badge.js +65 -0
- package/dist/components/ui/badge.js.map +1 -0
- package/dist/components/ui/breadcrumb.d.ts +35 -0
- package/dist/components/ui/breadcrumb.d.ts.map +1 -0
- package/dist/components/ui/breadcrumb.js +88 -0
- package/dist/components/ui/breadcrumb.js.map +1 -0
- package/dist/components/ui/button.d.ts +26 -0
- package/dist/components/ui/button.d.ts.map +1 -0
- package/dist/components/ui/button.js +73 -0
- package/dist/components/ui/button.js.map +1 -0
- package/dist/components/ui/card.d.ts +52 -0
- package/dist/components/ui/card.d.ts.map +1 -0
- package/dist/components/ui/card.js +96 -0
- package/dist/components/ui/card.js.map +1 -0
- package/dist/components/ui/checkbox.d.ts +18 -0
- package/dist/components/ui/checkbox.d.ts.map +1 -0
- package/dist/components/ui/checkbox.js +59 -0
- package/dist/components/ui/checkbox.js.map +1 -0
- package/dist/components/ui/combobox.d.ts +194 -0
- package/dist/components/ui/combobox.d.ts.map +1 -0
- package/dist/components/ui/combobox.js +361 -0
- package/dist/components/ui/combobox.js.map +1 -0
- package/dist/components/ui/context-menu.d.ts +46 -0
- package/dist/components/ui/context-menu.d.ts.map +1 -0
- package/dist/components/ui/context-menu.js +95 -0
- package/dist/components/ui/context-menu.js.map +1 -0
- package/dist/components/ui/data-table.d.ts +53 -0
- package/dist/components/ui/data-table.d.ts.map +1 -0
- package/dist/components/ui/data-table.js +163 -0
- package/dist/components/ui/data-table.js.map +1 -0
- package/dist/components/ui/date-picker.d.ts +103 -0
- package/dist/components/ui/date-picker.d.ts.map +1 -0
- package/dist/components/ui/date-picker.js +306 -0
- package/dist/components/ui/date-picker.js.map +1 -0
- package/dist/components/ui/dialog.d.ts +40 -0
- package/dist/components/ui/dialog.d.ts.map +1 -0
- package/dist/components/ui/dialog.js +110 -0
- package/dist/components/ui/dialog.js.map +1 -0
- package/dist/components/ui/divider.d.ts +30 -0
- package/dist/components/ui/divider.d.ts.map +1 -0
- package/dist/components/ui/divider.js +62 -0
- package/dist/components/ui/divider.js.map +1 -0
- package/dist/components/ui/drawer.d.ts +56 -0
- package/dist/components/ui/drawer.d.ts.map +1 -0
- package/dist/components/ui/drawer.js +147 -0
- package/dist/components/ui/drawer.js.map +1 -0
- package/dist/components/ui/dropdown-menu.d.ts +63 -0
- package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
- package/dist/components/ui/dropdown-menu.js +116 -0
- package/dist/components/ui/dropdown-menu.js.map +1 -0
- package/dist/components/ui/empty-state.d.ts +43 -0
- package/dist/components/ui/empty-state.d.ts.map +1 -0
- package/dist/components/ui/empty-state.js +128 -0
- package/dist/components/ui/empty-state.js.map +1 -0
- package/dist/components/ui/field-group.d.ts +38 -0
- package/dist/components/ui/field-group.d.ts.map +1 -0
- package/dist/components/ui/field-group.js +107 -0
- package/dist/components/ui/field-group.js.map +1 -0
- package/dist/components/ui/form.d.ts +67 -0
- package/dist/components/ui/form.d.ts.map +1 -0
- package/dist/components/ui/form.js +286 -0
- package/dist/components/ui/form.js.map +1 -0
- package/dist/components/ui/input.d.ts +36 -0
- package/dist/components/ui/input.d.ts.map +1 -0
- package/dist/components/ui/input.js +99 -0
- package/dist/components/ui/input.js.map +1 -0
- package/dist/components/ui/label.d.ts +37 -0
- package/dist/components/ui/label.d.ts.map +1 -0
- package/dist/components/ui/label.js +34 -0
- package/dist/components/ui/label.js.map +1 -0
- package/dist/components/ui/page-header.d.ts +65 -0
- package/dist/components/ui/page-header.d.ts.map +1 -0
- package/dist/components/ui/page-header.js +140 -0
- package/dist/components/ui/page-header.js.map +1 -0
- package/dist/components/ui/pagination.d.ts +67 -0
- package/dist/components/ui/pagination.d.ts.map +1 -0
- package/dist/components/ui/pagination.js +109 -0
- package/dist/components/ui/pagination.js.map +1 -0
- package/dist/components/ui/popover.d.ts +28 -0
- package/dist/components/ui/popover.d.ts.map +1 -0
- package/dist/components/ui/popover.js +85 -0
- package/dist/components/ui/popover.js.map +1 -0
- package/dist/components/ui/radio-group.d.ts +35 -0
- package/dist/components/ui/radio-group.d.ts.map +1 -0
- package/dist/components/ui/radio-group.js +103 -0
- package/dist/components/ui/radio-group.js.map +1 -0
- package/dist/components/ui/select.d.ts +42 -0
- package/dist/components/ui/select.d.ts.map +1 -0
- package/dist/components/ui/select.js +86 -0
- package/dist/components/ui/select.js.map +1 -0
- package/dist/components/ui/sidebar.d.ts +59 -0
- package/dist/components/ui/sidebar.d.ts.map +1 -0
- package/dist/components/ui/sidebar.js +189 -0
- package/dist/components/ui/sidebar.js.map +1 -0
- package/dist/components/ui/skeleton.d.ts +77 -0
- package/dist/components/ui/skeleton.d.ts.map +1 -0
- package/dist/components/ui/skeleton.js +115 -0
- package/dist/components/ui/skeleton.js.map +1 -0
- package/dist/components/ui/switch.d.ts +26 -0
- package/dist/components/ui/switch.d.ts.map +1 -0
- package/dist/components/ui/switch.js +84 -0
- package/dist/components/ui/switch.js.map +1 -0
- package/dist/components/ui/table.d.ts +52 -0
- package/dist/components/ui/table.d.ts.map +1 -0
- package/dist/components/ui/table.js +109 -0
- package/dist/components/ui/table.js.map +1 -0
- package/dist/components/ui/tabs.d.ts +42 -0
- package/dist/components/ui/tabs.d.ts.map +1 -0
- package/dist/components/ui/tabs.js +163 -0
- package/dist/components/ui/tabs.js.map +1 -0
- package/dist/components/ui/textarea.d.ts +26 -0
- package/dist/components/ui/textarea.d.ts.map +1 -0
- package/dist/components/ui/textarea.js +96 -0
- package/dist/components/ui/textarea.js.map +1 -0
- package/dist/components/ui/toast.d.ts +77 -0
- package/dist/components/ui/toast.d.ts.map +1 -0
- package/dist/components/ui/toast.js +141 -0
- package/dist/components/ui/toast.js.map +1 -0
- package/dist/components/ui/tooltip.d.ts +31 -0
- package/dist/components/ui/tooltip.d.ts.map +1 -0
- package/dist/components/ui/tooltip.js +71 -0
- package/dist/components/ui/tooltip.js.map +1 -0
- package/dist/components/ui/top-bar.d.ts +30 -0
- package/dist/components/ui/top-bar.d.ts.map +1 -0
- package/dist/components/ui/top-bar.js +64 -0
- package/dist/components/ui/top-bar.js.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +6 -0
- package/dist/lib/utils.js.map +1 -0
- package/lib/utils.ts +6 -0
- package/package.json +112 -0
- package/styles/globals.css +685 -0
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
forwardRef,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
useEffect,
|
|
9
|
+
useId,
|
|
10
|
+
useMemo,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from "react";
|
|
14
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
15
|
+
import {
|
|
16
|
+
RiArrowDownSLine,
|
|
17
|
+
RiCheckLine,
|
|
18
|
+
RiCloseLine,
|
|
19
|
+
RiLoader2Line,
|
|
20
|
+
RiSearchLine,
|
|
21
|
+
} from "@remixicon/react";
|
|
22
|
+
import { Command as CommandPrimitive, useCommandState } from "cmdk";
|
|
23
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
24
|
+
|
|
25
|
+
import { cn } from "@/lib/utils";
|
|
26
|
+
|
|
27
|
+
// Per docs/emara-ui-phase-2-components.md §5.
|
|
28
|
+
// Modes: single (default) + multiple. + creatable + async loadOptions.
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Shape of options returned by `loadOptions`. Consumers can extend with extra
|
|
32
|
+
* fields — only `value` is required.
|
|
33
|
+
*/
|
|
34
|
+
export type ComboboxOption = {
|
|
35
|
+
value: string;
|
|
36
|
+
label?: React.ReactNode;
|
|
37
|
+
description?: React.ReactNode;
|
|
38
|
+
icon?: React.ReactNode;
|
|
39
|
+
keywords?: string[];
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// ----------------------------------------------------------------------------
|
|
44
|
+
// Context — Combobox root tracks value(s), search, open, mode flag.
|
|
45
|
+
// Discriminated union so children can branch on `mode` without ambiguity.
|
|
46
|
+
// ----------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
type ComboboxBaseContext = {
|
|
49
|
+
open: boolean;
|
|
50
|
+
setOpen: (open: boolean) => void;
|
|
51
|
+
search: string;
|
|
52
|
+
setSearch: (v: string) => void;
|
|
53
|
+
disabled: boolean;
|
|
54
|
+
invalid: boolean;
|
|
55
|
+
/** Combined loading: explicit `loading` prop OR an in-flight `loadOptions` fetch. */
|
|
56
|
+
loading: boolean;
|
|
57
|
+
contentId: string;
|
|
58
|
+
creatable: boolean;
|
|
59
|
+
onCreate: ((query: string) => void) | undefined;
|
|
60
|
+
/** Options fetched by the latest `loadOptions(search)` call. Empty array if
|
|
61
|
+
* `loadOptions` is not provided. */
|
|
62
|
+
loadedOptions: ComboboxOption[];
|
|
63
|
+
/** True when `loadOptions` is wired — children should not also render
|
|
64
|
+
* static items via `<ComboboxList>` unless they want to mix. */
|
|
65
|
+
hasLoadOptions: boolean;
|
|
66
|
+
/** Optional grouping function applied to loaded options. */
|
|
67
|
+
groupBy: ((option: ComboboxOption) => string) | undefined;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type ComboboxSingleContext = ComboboxBaseContext & {
|
|
71
|
+
mode: "single";
|
|
72
|
+
value: string;
|
|
73
|
+
setValue: (v: string) => void;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type ComboboxMultipleContext = ComboboxBaseContext & {
|
|
77
|
+
mode: "multiple";
|
|
78
|
+
values: string[];
|
|
79
|
+
toggleValue: (v: string) => void;
|
|
80
|
+
setValues: (vs: string[]) => void;
|
|
81
|
+
maxSelected: number | undefined;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type ComboboxContextValue = ComboboxSingleContext | ComboboxMultipleContext;
|
|
85
|
+
|
|
86
|
+
const ComboboxContext = createContext<ComboboxContextValue | null>(null);
|
|
87
|
+
function useCombobox(): ComboboxContextValue {
|
|
88
|
+
const ctx = useContext(ComboboxContext);
|
|
89
|
+
if (!ctx) throw new Error("Combobox sub-component must be used inside <Combobox>.");
|
|
90
|
+
return ctx;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ----------------------------------------------------------------------------
|
|
94
|
+
// Combobox root
|
|
95
|
+
// ----------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
type ComboboxRootCommonProps = {
|
|
98
|
+
open?: boolean;
|
|
99
|
+
defaultOpen?: boolean;
|
|
100
|
+
onOpenChange?: (open: boolean) => void;
|
|
101
|
+
disabled?: boolean;
|
|
102
|
+
required?: boolean;
|
|
103
|
+
name?: string;
|
|
104
|
+
invalid?: boolean;
|
|
105
|
+
loading?: boolean;
|
|
106
|
+
/** Allow creating a new option from the current search query. When set,
|
|
107
|
+
* a "+ Create '{query}'" row is appended below filtered items. The
|
|
108
|
+
* consumer is expected to add the new option to their data source +
|
|
109
|
+
* selection state in `onCreate`. */
|
|
110
|
+
creatable?: boolean;
|
|
111
|
+
/** Fired when the user picks the "+ Create" row. Receives the current
|
|
112
|
+
* search string; the component does not modify selection state itself
|
|
113
|
+
* in response — that's the consumer's responsibility. */
|
|
114
|
+
onCreate?: (query: string) => void;
|
|
115
|
+
/**
|
|
116
|
+
* Async option fetcher. When provided, the component intercepts the
|
|
117
|
+
* search input: it debounces ~200ms, calls `loadOptions(query)`, and
|
|
118
|
+
* renders the returned options as items automatically (no need to
|
|
119
|
+
* write `<ComboboxItem>` children). Stale responses are dropped via
|
|
120
|
+
* a request-id guard so out-of-order fetches don't flicker the UI.
|
|
121
|
+
* While a fetch is in flight, `loading` is `true` and cmdk's internal
|
|
122
|
+
* filter is disabled.
|
|
123
|
+
*/
|
|
124
|
+
loadOptions?: (query: string) => Promise<ComboboxOption[]>;
|
|
125
|
+
/** Debounce window for `loadOptions` in milliseconds. Default 200ms. */
|
|
126
|
+
loadOptionsDebounceMs?: number;
|
|
127
|
+
/**
|
|
128
|
+
* Auto-grouping for `loadOptions`-rendered items. When provided, the
|
|
129
|
+
* loaded options are partitioned by the function's return value and
|
|
130
|
+
* each group is rendered inside a `<ComboboxGroup heading>`. Groups
|
|
131
|
+
* preserve insertion order — sort your options array if you want a
|
|
132
|
+
* different order. No effect when `loadOptions` is not wired.
|
|
133
|
+
*/
|
|
134
|
+
groupBy?: (option: ComboboxOption) => string;
|
|
135
|
+
children: React.ReactNode;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
type ComboboxSingleProps = ComboboxRootCommonProps & {
|
|
139
|
+
multiple?: false;
|
|
140
|
+
value?: string;
|
|
141
|
+
defaultValue?: string;
|
|
142
|
+
onValueChange?: (value: string) => void;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
type ComboboxMultipleProps = ComboboxRootCommonProps & {
|
|
146
|
+
multiple: true;
|
|
147
|
+
value?: string[];
|
|
148
|
+
defaultValue?: string[];
|
|
149
|
+
onValueChange?: (values: string[]) => void;
|
|
150
|
+
/** Cap on number of items that can be selected at once. Once reached,
|
|
151
|
+
* additional items are disabled (can still deselect existing ones). */
|
|
152
|
+
maxSelected?: number;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
type ComboboxRootProps = ComboboxSingleProps | ComboboxMultipleProps;
|
|
156
|
+
|
|
157
|
+
const Combobox = function Combobox(props: ComboboxRootProps) {
|
|
158
|
+
const {
|
|
159
|
+
open: openProp,
|
|
160
|
+
defaultOpen = false,
|
|
161
|
+
onOpenChange,
|
|
162
|
+
disabled = false,
|
|
163
|
+
invalid = false,
|
|
164
|
+
loading = false,
|
|
165
|
+
creatable = false,
|
|
166
|
+
onCreate,
|
|
167
|
+
loadOptions,
|
|
168
|
+
loadOptionsDebounceMs = 200,
|
|
169
|
+
groupBy,
|
|
170
|
+
children,
|
|
171
|
+
} = props;
|
|
172
|
+
const multiple = props.multiple === true;
|
|
173
|
+
|
|
174
|
+
const isOpenControlled = openProp !== undefined;
|
|
175
|
+
const [openInternal, setOpenInternal] = useState(defaultOpen);
|
|
176
|
+
const open = isOpenControlled ? openProp : openInternal;
|
|
177
|
+
const setOpen = useCallback(
|
|
178
|
+
(next: boolean) => {
|
|
179
|
+
if (!isOpenControlled) setOpenInternal(next);
|
|
180
|
+
onOpenChange?.(next);
|
|
181
|
+
},
|
|
182
|
+
[isOpenControlled, onOpenChange],
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// ---- Single-mode value state ----
|
|
186
|
+
const singleValueProp = !multiple ? (props as ComboboxSingleProps).value : undefined;
|
|
187
|
+
const singleDefault = !multiple ? ((props as ComboboxSingleProps).defaultValue ?? "") : "";
|
|
188
|
+
const isSingleControlled = singleValueProp !== undefined;
|
|
189
|
+
const [singleInternal, setSingleInternal] = useState(singleDefault);
|
|
190
|
+
const singleValue = isSingleControlled ? (singleValueProp ?? "") : singleInternal;
|
|
191
|
+
const setSingleValue = useCallback(
|
|
192
|
+
(next: string) => {
|
|
193
|
+
if (!isSingleControlled) setSingleInternal(next);
|
|
194
|
+
if (!multiple) (props as ComboboxSingleProps).onValueChange?.(next);
|
|
195
|
+
},
|
|
196
|
+
// We deliberately do not depend on `props` (would re-create every render).
|
|
197
|
+
// Single-mode handlers don't change identity across renders in practice.
|
|
198
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
199
|
+
[isSingleControlled, multiple],
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// ---- Multi-mode value state ----
|
|
203
|
+
const multiValueProp = multiple ? (props as ComboboxMultipleProps).value : undefined;
|
|
204
|
+
const multiDefault = multiple ? ((props as ComboboxMultipleProps).defaultValue ?? []) : [];
|
|
205
|
+
const isMultiControlled = multiValueProp !== undefined;
|
|
206
|
+
const [multiInternal, setMultiInternal] = useState<string[]>(multiDefault);
|
|
207
|
+
const multiValues = isMultiControlled ? (multiValueProp ?? []) : multiInternal;
|
|
208
|
+
const setValues = useCallback(
|
|
209
|
+
(next: string[]) => {
|
|
210
|
+
if (!isMultiControlled) setMultiInternal(next);
|
|
211
|
+
if (multiple) (props as ComboboxMultipleProps).onValueChange?.(next);
|
|
212
|
+
},
|
|
213
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
214
|
+
[isMultiControlled, multiple],
|
|
215
|
+
);
|
|
216
|
+
const toggleValue = useCallback(
|
|
217
|
+
(v: string) => {
|
|
218
|
+
const current = isMultiControlled ? (multiValueProp ?? []) : multiInternal;
|
|
219
|
+
const next = current.includes(v) ? current.filter((x) => x !== v) : [...current, v];
|
|
220
|
+
setValues(next);
|
|
221
|
+
},
|
|
222
|
+
[isMultiControlled, multiValueProp, multiInternal, setValues],
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const [search, setSearch] = useState("");
|
|
226
|
+
const contentId = useId();
|
|
227
|
+
|
|
228
|
+
// ---- Async options state (loadOptions) ----
|
|
229
|
+
const [loadedOptions, setLoadedOptions] = useState<ComboboxOption[]>([]);
|
|
230
|
+
const [loadingInternal, setLoadingInternal] = useState(false);
|
|
231
|
+
const requestIdRef = useRef(0);
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (!loadOptions) return;
|
|
234
|
+
const myRequestId = ++requestIdRef.current;
|
|
235
|
+
setLoadingInternal(true);
|
|
236
|
+
const timeoutId = window.setTimeout(() => {
|
|
237
|
+
Promise.resolve(loadOptions(search))
|
|
238
|
+
.then((opts) => {
|
|
239
|
+
// Drop stale response — a newer request has been issued.
|
|
240
|
+
if (myRequestId !== requestIdRef.current) return;
|
|
241
|
+
setLoadedOptions(opts);
|
|
242
|
+
})
|
|
243
|
+
.catch(() => {
|
|
244
|
+
// Swallow — consumer's fetcher is responsible for surfacing errors.
|
|
245
|
+
if (myRequestId !== requestIdRef.current) return;
|
|
246
|
+
setLoadedOptions([]);
|
|
247
|
+
})
|
|
248
|
+
.finally(() => {
|
|
249
|
+
if (myRequestId === requestIdRef.current) setLoadingInternal(false);
|
|
250
|
+
});
|
|
251
|
+
}, loadOptionsDebounceMs);
|
|
252
|
+
return () => window.clearTimeout(timeoutId);
|
|
253
|
+
}, [search, loadOptions, loadOptionsDebounceMs]);
|
|
254
|
+
const effectiveLoading = loading || (Boolean(loadOptions) && loadingInternal);
|
|
255
|
+
|
|
256
|
+
const ctx: ComboboxContextValue = useMemo(() => {
|
|
257
|
+
const base: ComboboxBaseContext = {
|
|
258
|
+
open,
|
|
259
|
+
setOpen,
|
|
260
|
+
search,
|
|
261
|
+
setSearch,
|
|
262
|
+
disabled,
|
|
263
|
+
invalid,
|
|
264
|
+
loading: effectiveLoading,
|
|
265
|
+
contentId,
|
|
266
|
+
creatable,
|
|
267
|
+
onCreate,
|
|
268
|
+
loadedOptions,
|
|
269
|
+
hasLoadOptions: Boolean(loadOptions),
|
|
270
|
+
groupBy,
|
|
271
|
+
};
|
|
272
|
+
if (multiple) {
|
|
273
|
+
return {
|
|
274
|
+
...base,
|
|
275
|
+
mode: "multiple",
|
|
276
|
+
values: multiValues,
|
|
277
|
+
toggleValue,
|
|
278
|
+
setValues,
|
|
279
|
+
maxSelected: (props as ComboboxMultipleProps).maxSelected,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
return { ...base, mode: "single", value: singleValue, setValue: setSingleValue };
|
|
283
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
284
|
+
}, [
|
|
285
|
+
multiple,
|
|
286
|
+
open,
|
|
287
|
+
setOpen,
|
|
288
|
+
search,
|
|
289
|
+
disabled,
|
|
290
|
+
invalid,
|
|
291
|
+
effectiveLoading,
|
|
292
|
+
contentId,
|
|
293
|
+
creatable,
|
|
294
|
+
onCreate,
|
|
295
|
+
loadOptions,
|
|
296
|
+
loadedOptions,
|
|
297
|
+
groupBy,
|
|
298
|
+
singleValue,
|
|
299
|
+
setSingleValue,
|
|
300
|
+
multiValues,
|
|
301
|
+
toggleValue,
|
|
302
|
+
setValues,
|
|
303
|
+
]);
|
|
304
|
+
|
|
305
|
+
return (
|
|
306
|
+
<ComboboxContext.Provider value={ctx}>
|
|
307
|
+
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
|
308
|
+
{children}
|
|
309
|
+
</PopoverPrimitive.Root>
|
|
310
|
+
</ComboboxContext.Provider>
|
|
311
|
+
);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// ----------------------------------------------------------------------------
|
|
315
|
+
// ComboboxTrigger
|
|
316
|
+
// ----------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
const triggerVariants = cva(
|
|
319
|
+
[
|
|
320
|
+
"flex items-center justify-between w-full gap-2",
|
|
321
|
+
"rounded-md border border-input bg-background text-foreground",
|
|
322
|
+
"transition-colors",
|
|
323
|
+
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
324
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
325
|
+
"[&>span:first-child]:line-clamp-1 [&>span:first-child]:text-start",
|
|
326
|
+
].join(" "),
|
|
327
|
+
{
|
|
328
|
+
variants: {
|
|
329
|
+
size: {
|
|
330
|
+
xs: "h-7 ps-2.5 pe-2 text-xs",
|
|
331
|
+
sm: "h-8 ps-3 pe-2 text-xs",
|
|
332
|
+
md: "h-9 ps-3 pe-2 text-sm",
|
|
333
|
+
lg: "h-10 ps-3.5 pe-2.5 text-base",
|
|
334
|
+
xl: "h-12 ps-4 pe-3 text-base",
|
|
335
|
+
},
|
|
336
|
+
invalid: {
|
|
337
|
+
true: "border-destructive focus-visible:ring-destructive",
|
|
338
|
+
false: "",
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
defaultVariants: { size: "md", invalid: false },
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
type ComboboxTriggerProps = Omit<
|
|
346
|
+
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
347
|
+
"type" | "children"
|
|
348
|
+
> &
|
|
349
|
+
VariantProps<typeof triggerVariants> & {
|
|
350
|
+
placeholder?: string;
|
|
351
|
+
clearable?: boolean;
|
|
352
|
+
onClear?: () => void;
|
|
353
|
+
/** Map a value to a display label. Used for pill text in multiple mode,
|
|
354
|
+
* and as the trigger label in single mode when no `children` is given. */
|
|
355
|
+
getOptionLabel?: (value: string) => React.ReactNode;
|
|
356
|
+
/** Multi-mode only: max pills to render in the trigger before collapsing
|
|
357
|
+
* the remainder into a "+N more" badge. Defaults to 3. */
|
|
358
|
+
maxVisiblePills?: number;
|
|
359
|
+
/**
|
|
360
|
+
* Single mode: what to render when the Combobox has a value. Consumers
|
|
361
|
+
* compute this from their own options array (matching shadcn's recipe).
|
|
362
|
+
* Ignored in multiple mode — pills are rendered instead.
|
|
363
|
+
*/
|
|
364
|
+
children?: React.ReactNode;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const ComboboxTrigger = forwardRef<HTMLButtonElement, ComboboxTriggerProps>(
|
|
368
|
+
function ComboboxTrigger(
|
|
369
|
+
{
|
|
370
|
+
className,
|
|
371
|
+
size,
|
|
372
|
+
invalid: invalidProp,
|
|
373
|
+
placeholder,
|
|
374
|
+
clearable = false,
|
|
375
|
+
onClear,
|
|
376
|
+
getOptionLabel,
|
|
377
|
+
maxVisiblePills = 3,
|
|
378
|
+
children,
|
|
379
|
+
...props
|
|
380
|
+
},
|
|
381
|
+
ref,
|
|
382
|
+
) {
|
|
383
|
+
const ctx = useCombobox();
|
|
384
|
+
const invalid = invalidProp ?? ctx.invalid;
|
|
385
|
+
const isMulti = ctx.mode === "multiple";
|
|
386
|
+
const hasSelection = isMulti ? ctx.values.length > 0 : Boolean(ctx.value);
|
|
387
|
+
const showClear = clearable && !ctx.disabled && !ctx.loading && hasSelection;
|
|
388
|
+
|
|
389
|
+
// ---- Multi-mode pill rendering ----
|
|
390
|
+
const visiblePills = isMulti ? ctx.values.slice(0, maxVisiblePills) : [];
|
|
391
|
+
const overflowCount = isMulti ? Math.max(0, ctx.values.length - maxVisiblePills) : 0;
|
|
392
|
+
const labelFor = (v: string): React.ReactNode => (getOptionLabel ? getOptionLabel(v) : v);
|
|
393
|
+
|
|
394
|
+
// ---- Single-mode display fallback ----
|
|
395
|
+
const singleDisplay =
|
|
396
|
+
!isMulti && ctx.value
|
|
397
|
+
? (children ?? (getOptionLabel ? getOptionLabel(ctx.value) : ctx.value))
|
|
398
|
+
: (placeholder ?? "Select…");
|
|
399
|
+
|
|
400
|
+
// WAI-ARIA accessible name fallback.
|
|
401
|
+
const consumerLabelled =
|
|
402
|
+
props["aria-label"] !== undefined || props["aria-labelledby"] !== undefined;
|
|
403
|
+
const ariaLabelFallback = consumerLabelled
|
|
404
|
+
? undefined
|
|
405
|
+
: isMulti
|
|
406
|
+
? hasSelection
|
|
407
|
+
? `${ctx.values.length} selected`
|
|
408
|
+
: (placeholder ?? "Select…")
|
|
409
|
+
: typeof singleDisplay === "string"
|
|
410
|
+
? singleDisplay
|
|
411
|
+
: (placeholder ?? "Select…");
|
|
412
|
+
|
|
413
|
+
const handleClear = (e: React.MouseEvent) => {
|
|
414
|
+
e.preventDefault();
|
|
415
|
+
e.stopPropagation();
|
|
416
|
+
if (ctx.mode === "multiple") ctx.setValues([]);
|
|
417
|
+
else ctx.setValue("");
|
|
418
|
+
onClear?.();
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
return (
|
|
422
|
+
<PopoverPrimitive.Trigger asChild>
|
|
423
|
+
<button
|
|
424
|
+
ref={ref}
|
|
425
|
+
type="button"
|
|
426
|
+
role="combobox"
|
|
427
|
+
aria-expanded={ctx.open}
|
|
428
|
+
aria-controls={ctx.open ? ctx.contentId : undefined}
|
|
429
|
+
aria-invalid={invalid || undefined}
|
|
430
|
+
aria-label={ariaLabelFallback}
|
|
431
|
+
disabled={ctx.disabled}
|
|
432
|
+
className={cn(
|
|
433
|
+
triggerVariants({ size, invalid }),
|
|
434
|
+
// In multi mode the trigger may grow to fit pills; relax height.
|
|
435
|
+
isMulti && hasSelection && "h-auto min-h-9 flex-wrap py-1",
|
|
436
|
+
className,
|
|
437
|
+
)}
|
|
438
|
+
{...props}
|
|
439
|
+
>
|
|
440
|
+
{isMulti ? (
|
|
441
|
+
<span className="flex flex-1 flex-wrap items-center gap-1">
|
|
442
|
+
{!hasSelection ? (
|
|
443
|
+
<span className="text-muted-foreground">{placeholder ?? "Select…"}</span>
|
|
444
|
+
) : (
|
|
445
|
+
<>
|
|
446
|
+
{visiblePills.map((v) => (
|
|
447
|
+
<span
|
|
448
|
+
key={v}
|
|
449
|
+
className="bg-secondary text-secondary-foreground inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs"
|
|
450
|
+
>
|
|
451
|
+
<span>{labelFor(v)}</span>
|
|
452
|
+
{!ctx.disabled ? (
|
|
453
|
+
<button
|
|
454
|
+
type="button"
|
|
455
|
+
aria-label={`Remove ${typeof labelFor(v) === "string" ? labelFor(v) : v}`}
|
|
456
|
+
onClick={(e) => {
|
|
457
|
+
e.preventDefault();
|
|
458
|
+
e.stopPropagation();
|
|
459
|
+
if (ctx.mode === "multiple") ctx.toggleValue(v);
|
|
460
|
+
}}
|
|
461
|
+
className="focus-visible:ring-ring hover:text-foreground inline-flex items-center justify-center rounded-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
462
|
+
>
|
|
463
|
+
<RiCloseLine className="size-3" />
|
|
464
|
+
</button>
|
|
465
|
+
) : null}
|
|
466
|
+
</span>
|
|
467
|
+
))}
|
|
468
|
+
{overflowCount > 0 ? (
|
|
469
|
+
<span className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-xs">
|
|
470
|
+
+{overflowCount} more
|
|
471
|
+
</span>
|
|
472
|
+
) : null}
|
|
473
|
+
</>
|
|
474
|
+
)}
|
|
475
|
+
</span>
|
|
476
|
+
) : (
|
|
477
|
+
<span className={cn(!hasSelection && "text-muted-foreground")}>{singleDisplay}</span>
|
|
478
|
+
)}
|
|
479
|
+
<span className="text-muted-foreground ms-auto inline-flex shrink-0 items-center gap-1 [&_svg]:size-4 [&_svg]:shrink-0">
|
|
480
|
+
{showClear ? (
|
|
481
|
+
<button
|
|
482
|
+
type="button"
|
|
483
|
+
aria-label="Clear"
|
|
484
|
+
onClick={handleClear}
|
|
485
|
+
className={cn(
|
|
486
|
+
"inline-flex items-center justify-center rounded p-0.5",
|
|
487
|
+
"hover:text-foreground",
|
|
488
|
+
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
|
|
489
|
+
)}
|
|
490
|
+
>
|
|
491
|
+
<RiCloseLine />
|
|
492
|
+
</button>
|
|
493
|
+
) : null}
|
|
494
|
+
{ctx.loading ? <RiLoader2Line className="size-4 animate-spin" /> : <RiArrowDownSLine />}
|
|
495
|
+
</span>
|
|
496
|
+
</button>
|
|
497
|
+
</PopoverPrimitive.Trigger>
|
|
498
|
+
);
|
|
499
|
+
},
|
|
500
|
+
);
|
|
501
|
+
ComboboxTrigger.displayName = "ComboboxTrigger";
|
|
502
|
+
|
|
503
|
+
// ----------------------------------------------------------------------------
|
|
504
|
+
// ComboboxContent — Popover content hosting the cmdk Command palette.
|
|
505
|
+
// ----------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Screen-reader-only result counter. Mounted inside the Command tree so
|
|
509
|
+
* `useCommandState` can read cmdk's filtered count. Only renders while the
|
|
510
|
+
* user is actively searching — otherwise it would announce on every open.
|
|
511
|
+
* Spec: docs/emara-ui-phase-2-components.md §5 Accessibility ("Live region
|
|
512
|
+
* announces result count").
|
|
513
|
+
*/
|
|
514
|
+
/**
|
|
515
|
+
* Renders options fetched by `loadOptions` as ComboboxItems automatically.
|
|
516
|
+
* Mounted inside ComboboxContent only when the root has loadOptions wired.
|
|
517
|
+
* Consumers can still mix static items via a ComboboxList sibling — those
|
|
518
|
+
* are rendered alongside (with cmdk filtering disabled while loading so
|
|
519
|
+
* the async items never get hidden by the search input).
|
|
520
|
+
*/
|
|
521
|
+
function ComboboxAsyncOptions(): React.ReactElement | null {
|
|
522
|
+
const ctx = useCombobox();
|
|
523
|
+
if (!ctx.hasLoadOptions) return null;
|
|
524
|
+
|
|
525
|
+
const renderItem = (opt: ComboboxOption) => (
|
|
526
|
+
<ComboboxItem
|
|
527
|
+
key={opt.value}
|
|
528
|
+
value={opt.value}
|
|
529
|
+
{...(opt.description !== undefined && { description: opt.description })}
|
|
530
|
+
{...(opt.icon !== undefined && { icon: opt.icon })}
|
|
531
|
+
{...(opt.keywords !== undefined && { keywords: opt.keywords })}
|
|
532
|
+
{...(opt.disabled !== undefined && { disabled: opt.disabled })}
|
|
533
|
+
>
|
|
534
|
+
{opt.label ?? opt.value}
|
|
535
|
+
</ComboboxItem>
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
if (!ctx.groupBy) {
|
|
539
|
+
return (
|
|
540
|
+
<CommandPrimitive.List className="max-h-64 overflow-y-auto p-1">
|
|
541
|
+
{ctx.loadedOptions.map(renderItem)}
|
|
542
|
+
</CommandPrimitive.List>
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Partition into groups preserving first-seen insertion order.
|
|
547
|
+
const groups = new Map<string, ComboboxOption[]>();
|
|
548
|
+
for (const opt of ctx.loadedOptions) {
|
|
549
|
+
const key = ctx.groupBy(opt);
|
|
550
|
+
const arr = groups.get(key);
|
|
551
|
+
if (arr) arr.push(opt);
|
|
552
|
+
else groups.set(key, [opt]);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return (
|
|
556
|
+
<CommandPrimitive.List className="max-h-64 overflow-y-auto p-1">
|
|
557
|
+
{Array.from(groups.entries()).map(([heading, opts]) => (
|
|
558
|
+
<ComboboxGroup key={heading} heading={heading}>
|
|
559
|
+
{opts.map(renderItem)}
|
|
560
|
+
</ComboboxGroup>
|
|
561
|
+
))}
|
|
562
|
+
</CommandPrimitive.List>
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function ComboboxResultCount(): React.ReactElement | null {
|
|
567
|
+
const count = useCommandState((s) => s.filtered.count) ?? 0;
|
|
568
|
+
const search = useCommandState((s) => s.search) ?? "";
|
|
569
|
+
if (!search) return null;
|
|
570
|
+
const label = count === 0 ? "No results" : count === 1 ? "1 result" : `${count} results`;
|
|
571
|
+
return (
|
|
572
|
+
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
|
|
573
|
+
{label}
|
|
574
|
+
</div>
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Renders a "+ Create '{search}'" row when the consumer has set `creatable`
|
|
580
|
+
* on the root. The row's cmdk `value` is the search string itself so it
|
|
581
|
+
* always survives cmdk's filter while the user is typing.
|
|
582
|
+
*
|
|
583
|
+
* On select: calls the consumer's `onCreate(search)` and clears the search.
|
|
584
|
+
* In single mode, the popover closes (matching ComboboxItem). In multi
|
|
585
|
+
* mode it stays open so the user can keep adding.
|
|
586
|
+
*
|
|
587
|
+
* The component does not mutate selection state itself — adding the new
|
|
588
|
+
* option to the data source AND the value array is the consumer's job
|
|
589
|
+
* inside `onCreate`. See Storybook for the canonical pattern.
|
|
590
|
+
*/
|
|
591
|
+
function ComboboxCreatable(): React.ReactElement | null {
|
|
592
|
+
const ctx = useCombobox();
|
|
593
|
+
const search = useCommandState((s) => s.search) ?? "";
|
|
594
|
+
if (!ctx.creatable || !search.trim()) return null;
|
|
595
|
+
return (
|
|
596
|
+
<CommandPrimitive.Item
|
|
597
|
+
// Use a sentinel value prefixed to avoid clashes with consumer options.
|
|
598
|
+
value={`__create__::${search}`}
|
|
599
|
+
// Always match — searches change as user types but cmdk filters by
|
|
600
|
+
// value vs. search. Forcing this item to match the current search is
|
|
601
|
+
// the cleanest way to keep it visible.
|
|
602
|
+
keywords={[search]}
|
|
603
|
+
onSelect={() => {
|
|
604
|
+
ctx.onCreate?.(search);
|
|
605
|
+
ctx.setSearch("");
|
|
606
|
+
if (ctx.mode === "single") ctx.setOpen(false);
|
|
607
|
+
}}
|
|
608
|
+
className={cn(
|
|
609
|
+
"relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none select-none",
|
|
610
|
+
"transition-colors",
|
|
611
|
+
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground",
|
|
612
|
+
"border-border/60 mt-1 border-t pt-2",
|
|
613
|
+
)}
|
|
614
|
+
>
|
|
615
|
+
<span
|
|
616
|
+
aria-hidden="true"
|
|
617
|
+
className="absolute start-2 inline-flex h-4 w-4 items-center justify-center"
|
|
618
|
+
>
|
|
619
|
+
+
|
|
620
|
+
</span>
|
|
621
|
+
<span>
|
|
622
|
+
Create <span className="font-medium">"{search}"</span>
|
|
623
|
+
</span>
|
|
624
|
+
</CommandPrimitive.Item>
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
type ComboboxContentProps = React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
|
|
629
|
+
emptyMessage?: React.ReactNode;
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
const ComboboxContent = forwardRef<
|
|
633
|
+
React.ElementRef<typeof PopoverPrimitive.Content>,
|
|
634
|
+
ComboboxContentProps
|
|
635
|
+
>(function ComboboxContent(
|
|
636
|
+
{ className, align = "start", sideOffset = 4, emptyMessage, children, ...props },
|
|
637
|
+
ref,
|
|
638
|
+
) {
|
|
639
|
+
const ctx = useCombobox();
|
|
640
|
+
return (
|
|
641
|
+
<PopoverPrimitive.Portal>
|
|
642
|
+
<PopoverPrimitive.Content
|
|
643
|
+
ref={ref}
|
|
644
|
+
id={ctx.contentId}
|
|
645
|
+
align={align}
|
|
646
|
+
sideOffset={sideOffset}
|
|
647
|
+
className={cn(
|
|
648
|
+
"border-border bg-popover text-popover-foreground z-popover w-(--radix-popover-trigger-width) overflow-hidden rounded-md border shadow-md outline-none",
|
|
649
|
+
"data-[state=open]:animate-[scale-in_var(--duration-fast)_var(--ease-out)]",
|
|
650
|
+
"data-[state=closed]:animate-[scale-out_var(--duration-fast)_var(--ease-in)]",
|
|
651
|
+
className,
|
|
652
|
+
)}
|
|
653
|
+
{...props}
|
|
654
|
+
>
|
|
655
|
+
<CommandPrimitive shouldFilter={!ctx.loading && !ctx.hasLoadOptions}>
|
|
656
|
+
{children}
|
|
657
|
+
<ComboboxAsyncOptions />
|
|
658
|
+
{emptyMessage !== undefined ? (
|
|
659
|
+
<CommandPrimitive.Empty className="text-muted-foreground px-3 py-6 text-center text-sm">
|
|
660
|
+
{emptyMessage}
|
|
661
|
+
</CommandPrimitive.Empty>
|
|
662
|
+
) : null}
|
|
663
|
+
<ComboboxCreatable />
|
|
664
|
+
<ComboboxResultCount />
|
|
665
|
+
</CommandPrimitive>
|
|
666
|
+
</PopoverPrimitive.Content>
|
|
667
|
+
</PopoverPrimitive.Portal>
|
|
668
|
+
);
|
|
669
|
+
});
|
|
670
|
+
ComboboxContent.displayName = "ComboboxContent";
|
|
671
|
+
|
|
672
|
+
// ----------------------------------------------------------------------------
|
|
673
|
+
// ComboboxInput — cmdk's input, wired to the root's search state.
|
|
674
|
+
// ----------------------------------------------------------------------------
|
|
675
|
+
|
|
676
|
+
const ComboboxInput = forwardRef<
|
|
677
|
+
React.ElementRef<typeof CommandPrimitive.Input>,
|
|
678
|
+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
|
679
|
+
>(function ComboboxInput({ className, placeholder = "Search…", ...props }, ref) {
|
|
680
|
+
const ctx = useCombobox();
|
|
681
|
+
return (
|
|
682
|
+
<div className="border-border flex items-center gap-2 border-b ps-3 pe-2">
|
|
683
|
+
<RiSearchLine className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
|
|
684
|
+
<CommandPrimitive.Input
|
|
685
|
+
ref={ref}
|
|
686
|
+
value={ctx.search}
|
|
687
|
+
onValueChange={ctx.setSearch}
|
|
688
|
+
placeholder={placeholder}
|
|
689
|
+
dir="auto"
|
|
690
|
+
className={cn(
|
|
691
|
+
"text-foreground placeholder:text-muted-foreground h-9 flex-1 bg-transparent text-sm outline-none",
|
|
692
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
693
|
+
className,
|
|
694
|
+
)}
|
|
695
|
+
{...props}
|
|
696
|
+
/>
|
|
697
|
+
</div>
|
|
698
|
+
);
|
|
699
|
+
});
|
|
700
|
+
ComboboxInput.displayName = "ComboboxInput";
|
|
701
|
+
|
|
702
|
+
// ----------------------------------------------------------------------------
|
|
703
|
+
// ComboboxEmpty
|
|
704
|
+
// ----------------------------------------------------------------------------
|
|
705
|
+
|
|
706
|
+
const ComboboxEmpty = forwardRef<
|
|
707
|
+
React.ElementRef<typeof CommandPrimitive.Empty>,
|
|
708
|
+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
|
709
|
+
>(function ComboboxEmpty({ className, ...props }, ref) {
|
|
710
|
+
return (
|
|
711
|
+
<CommandPrimitive.Empty
|
|
712
|
+
ref={ref}
|
|
713
|
+
className={cn("text-muted-foreground px-3 py-6 text-center text-sm", className)}
|
|
714
|
+
{...props}
|
|
715
|
+
/>
|
|
716
|
+
);
|
|
717
|
+
});
|
|
718
|
+
ComboboxEmpty.displayName = "ComboboxEmpty";
|
|
719
|
+
|
|
720
|
+
// ----------------------------------------------------------------------------
|
|
721
|
+
// ComboboxList — the scrollable list region. Required by cmdk.
|
|
722
|
+
// ----------------------------------------------------------------------------
|
|
723
|
+
|
|
724
|
+
const ComboboxList = forwardRef<
|
|
725
|
+
React.ElementRef<typeof CommandPrimitive.List>,
|
|
726
|
+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
|
727
|
+
>(function ComboboxList({ className, ...props }, ref) {
|
|
728
|
+
return (
|
|
729
|
+
<CommandPrimitive.List
|
|
730
|
+
ref={ref}
|
|
731
|
+
className={cn("max-h-64 overflow-y-auto p-1", className)}
|
|
732
|
+
{...props}
|
|
733
|
+
/>
|
|
734
|
+
);
|
|
735
|
+
});
|
|
736
|
+
ComboboxList.displayName = "ComboboxList";
|
|
737
|
+
|
|
738
|
+
// ----------------------------------------------------------------------------
|
|
739
|
+
// ComboboxGroup / ComboboxLabel
|
|
740
|
+
// ----------------------------------------------------------------------------
|
|
741
|
+
|
|
742
|
+
const ComboboxGroup = forwardRef<
|
|
743
|
+
React.ElementRef<typeof CommandPrimitive.Group>,
|
|
744
|
+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
|
745
|
+
>(function ComboboxGroup({ className, ...props }, ref) {
|
|
746
|
+
return (
|
|
747
|
+
<CommandPrimitive.Group
|
|
748
|
+
ref={ref}
|
|
749
|
+
className={cn(
|
|
750
|
+
"text-foreground overflow-hidden",
|
|
751
|
+
"[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:tracking-wide [&_[cmdk-group-heading]]:uppercase",
|
|
752
|
+
className,
|
|
753
|
+
)}
|
|
754
|
+
{...props}
|
|
755
|
+
/>
|
|
756
|
+
);
|
|
757
|
+
});
|
|
758
|
+
ComboboxGroup.displayName = "ComboboxGroup";
|
|
759
|
+
|
|
760
|
+
// ----------------------------------------------------------------------------
|
|
761
|
+
// ComboboxItem
|
|
762
|
+
// ----------------------------------------------------------------------------
|
|
763
|
+
|
|
764
|
+
type ComboboxItemProps = Omit<
|
|
765
|
+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>,
|
|
766
|
+
"onSelect" | "value"
|
|
767
|
+
> & {
|
|
768
|
+
value: string;
|
|
769
|
+
description?: React.ReactNode;
|
|
770
|
+
icon?: React.ReactNode;
|
|
771
|
+
/** Extra search terms the item matches against, in addition to `value`
|
|
772
|
+
* and rendered children. Example: a "New York" item with
|
|
773
|
+
* `keywords={["nyc", "ny"]}` will match either abbreviation. */
|
|
774
|
+
keywords?: string[];
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
const ComboboxItem = forwardRef<React.ElementRef<typeof CommandPrimitive.Item>, ComboboxItemProps>(
|
|
778
|
+
function ComboboxItem(
|
|
779
|
+
{ className, value, description, icon, disabled, children, ...props },
|
|
780
|
+
ref,
|
|
781
|
+
) {
|
|
782
|
+
const ctx = useCombobox();
|
|
783
|
+
const selected = ctx.mode === "multiple" ? ctx.values.includes(value) : ctx.value === value;
|
|
784
|
+
// Multi mode: when at maxSelected, items not already selected are gated.
|
|
785
|
+
const blockedByMax =
|
|
786
|
+
ctx.mode === "multiple" &&
|
|
787
|
+
ctx.maxSelected !== undefined &&
|
|
788
|
+
!selected &&
|
|
789
|
+
ctx.values.length >= ctx.maxSelected;
|
|
790
|
+
const effectiveDisabled = disabled || blockedByMax;
|
|
791
|
+
|
|
792
|
+
return (
|
|
793
|
+
<CommandPrimitive.Item
|
|
794
|
+
ref={ref}
|
|
795
|
+
value={value}
|
|
796
|
+
disabled={effectiveDisabled}
|
|
797
|
+
onSelect={() => {
|
|
798
|
+
if (effectiveDisabled) return;
|
|
799
|
+
if (ctx.mode === "multiple") {
|
|
800
|
+
ctx.toggleValue(value);
|
|
801
|
+
// Keep popover open + clear search after a successful toggle so
|
|
802
|
+
// the user can pick another option immediately.
|
|
803
|
+
ctx.setSearch("");
|
|
804
|
+
} else {
|
|
805
|
+
ctx.setValue(value);
|
|
806
|
+
ctx.setSearch("");
|
|
807
|
+
ctx.setOpen(false);
|
|
808
|
+
}
|
|
809
|
+
}}
|
|
810
|
+
className={cn(
|
|
811
|
+
"relative flex w-full cursor-pointer items-start gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none select-none",
|
|
812
|
+
"transition-colors",
|
|
813
|
+
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground",
|
|
814
|
+
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
|
|
815
|
+
className,
|
|
816
|
+
)}
|
|
817
|
+
aria-selected={selected || undefined}
|
|
818
|
+
{...props}
|
|
819
|
+
>
|
|
820
|
+
<span className="absolute start-2 inline-flex h-4 w-4 items-center justify-center">
|
|
821
|
+
{selected ? <RiCheckLine className="size-4" /> : null}
|
|
822
|
+
</span>
|
|
823
|
+
<span className="flex flex-1 flex-col gap-0.5 leading-none">
|
|
824
|
+
<span className="inline-flex items-center gap-2">
|
|
825
|
+
{icon ? (
|
|
826
|
+
<span
|
|
827
|
+
aria-hidden="true"
|
|
828
|
+
className="text-muted-foreground inline-flex shrink-0 [&_svg]:size-4 [&_svg]:shrink-0"
|
|
829
|
+
>
|
|
830
|
+
{icon}
|
|
831
|
+
</span>
|
|
832
|
+
) : null}
|
|
833
|
+
<span>{children ?? value}</span>
|
|
834
|
+
</span>
|
|
835
|
+
{description ? (
|
|
836
|
+
<span className="text-muted-foreground text-xs">{description}</span>
|
|
837
|
+
) : null}
|
|
838
|
+
</span>
|
|
839
|
+
</CommandPrimitive.Item>
|
|
840
|
+
);
|
|
841
|
+
},
|
|
842
|
+
);
|
|
843
|
+
ComboboxItem.displayName = "ComboboxItem";
|
|
844
|
+
|
|
845
|
+
// ----------------------------------------------------------------------------
|
|
846
|
+
// ComboboxSeparator
|
|
847
|
+
// ----------------------------------------------------------------------------
|
|
848
|
+
|
|
849
|
+
const ComboboxSeparator = forwardRef<
|
|
850
|
+
React.ElementRef<typeof CommandPrimitive.Separator>,
|
|
851
|
+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
|
852
|
+
>(function ComboboxSeparator({ className, ...props }, ref) {
|
|
853
|
+
return (
|
|
854
|
+
<CommandPrimitive.Separator
|
|
855
|
+
ref={ref}
|
|
856
|
+
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
|
857
|
+
{...props}
|
|
858
|
+
/>
|
|
859
|
+
);
|
|
860
|
+
});
|
|
861
|
+
ComboboxSeparator.displayName = "ComboboxSeparator";
|
|
862
|
+
|
|
863
|
+
export {
|
|
864
|
+
Combobox,
|
|
865
|
+
ComboboxTrigger,
|
|
866
|
+
ComboboxContent,
|
|
867
|
+
ComboboxInput,
|
|
868
|
+
ComboboxList,
|
|
869
|
+
ComboboxEmpty,
|
|
870
|
+
ComboboxGroup,
|
|
871
|
+
ComboboxItem,
|
|
872
|
+
ComboboxSeparator,
|
|
873
|
+
};
|
|
874
|
+
export type { ComboboxRootProps, ComboboxTriggerProps, ComboboxContentProps, ComboboxItemProps };
|