@softize/opus 23.0.0 → 24.0.1
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/CHANGELOG.md +99 -0
- package/bin/cli.mjs +2 -0
- package/bin/lib/check.mjs +241 -42
- package/bin/lib/copy.mjs +16 -34
- package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +6 -0
- package/docs/adr/0039-filter-presentation-is-independent-from-filter-type.md +3 -3
- package/docs/adr/0040-tabs-use-a-single-navigation-presentation.md +5 -6
- package/docs/adr/0042-toggle-group-shares-the-grouped-control-grammar.md +79 -0
- package/docs/adr/0043-neutral-surfaces-and-interactions-use-opaque-role-tokens.md +73 -0
- package/docs/adr/0044-neutral-appearance-derives-from-bounded-axes-per-mode.md +133 -0
- package/docs/adr/0045-surface-replaces-content-and-card.md +230 -0
- package/docs/adr/0046-structural-body-owns-content-rhythm.md +103 -0
- package/docs/adr/0047-pane-is-only-a-split-layout-unit.md +71 -0
- package/docs/adr/0048-modal-intro-separates-content-from-chrome.md +34 -0
- package/docs/adr/0049-inline-navigation-groups-with-the-heading.md +95 -0
- package/package.json +5 -1
- package/registry/skills/build-opus-ui/SKILL.md +11 -11
- package/registry/skills/build-opus-ui/references/evaluations.md +10 -9
- package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -16
- package/registry/templates/app/src/App.tsx +24 -12
- package/src/ui/appearance.ts +149 -0
- package/src/ui/components/patterns/action-form-card.tsx +46 -33
- package/src/ui/components/patterns/action-list-dialog.tsx +2 -4
- package/src/ui/components/patterns/confirm.tsx +3 -1
- package/src/ui/components/patterns/fill-region.ts +8 -7
- package/src/ui/components/patterns/form-dialog.tsx +1 -3
- package/src/ui/components/patterns/form.tsx +8 -7
- package/src/ui/components/patterns/list.tsx +15 -19
- package/src/ui/components/patterns/page.tsx +15 -11
- package/src/ui/components/patterns/pane.tsx +9 -240
- package/src/ui/components/patterns/presentation.tsx +14 -14
- package/src/ui/components/patterns/sidebar.tsx +7 -17
- package/src/ui/components/patterns/split.tsx +111 -59
- package/src/ui/components/patterns/{surface-header.tsx → structural-header.tsx} +90 -27
- package/src/ui/components/patterns/structural-media.tsx +26 -0
- package/src/ui/components/patterns/surface-anatomy.ts +71 -61
- package/src/ui/components/patterns/surface-assistant.tsx +2 -4
- package/src/ui/components/patterns/surface.tsx +620 -0
- package/src/ui/components/patterns/trigger.tsx +11 -9
- package/src/ui/components/primitives/ask.tsx +155 -83
- package/src/ui/components/primitives/button.tsx +11 -9
- package/src/ui/components/primitives/checkbox.tsx +8 -8
- package/src/ui/components/primitives/command.tsx +1 -1
- package/src/ui/components/primitives/composer.tsx +47 -45
- package/src/ui/components/primitives/detail.tsx +9 -2
- package/src/ui/components/primitives/dialog.tsx +60 -26
- package/src/ui/components/primitives/drawer.tsx +58 -12
- package/src/ui/components/primitives/icon-picker.tsx +90 -73
- package/src/ui/components/primitives/input-group.tsx +35 -28
- package/src/ui/components/primitives/input-otp.tsx +16 -16
- package/src/ui/components/primitives/input.tsx +20 -10
- package/src/ui/components/primitives/item.tsx +9 -12
- package/src/ui/components/primitives/menu.tsx +44 -65
- package/src/ui/components/primitives/metric-card.tsx +75 -59
- package/src/ui/components/primitives/radio-group.tsx +9 -9
- package/src/ui/components/primitives/select.tsx +344 -209
- package/src/ui/components/primitives/switch.tsx +10 -10
- package/src/ui/components/primitives/table.tsx +29 -24
- package/src/ui/components/primitives/tabs.tsx +4 -2
- package/src/ui/components/primitives/textarea.tsx +6 -6
- package/src/ui/components/primitives/toggle-group-context.ts +24 -0
- package/src/ui/components/primitives/toggle-group.tsx +116 -74
- package/src/ui/components/primitives/toggle.tsx +82 -16
- package/src/ui/docs/DocBrowser.tsx +77 -69
- package/src/ui/docs/content/action-form-card.md +9 -9
- package/src/ui/docs/content/action-list.md +76 -70
- package/src/ui/docs/content/action-view.md +33 -18
- package/src/ui/docs/content/button.md +1 -1
- package/src/ui/docs/content/communication.md +1 -1
- package/src/ui/docs/content/compositions.md +479 -0
- package/src/ui/docs/content/customization.md +64 -11
- package/src/ui/docs/content/detail.md +8 -6
- package/src/ui/docs/content/dialog.md +105 -60
- package/src/ui/docs/content/drawer.md +68 -22
- package/src/ui/docs/content/field.md +14 -15
- package/src/ui/docs/content/icon-tile.md +14 -13
- package/src/ui/docs/content/page.md +44 -34
- package/src/ui/docs/content/pane.md +42 -290
- package/src/ui/docs/content/presentation.md +3 -3
- package/src/ui/docs/content/separator.md +6 -6
- package/src/ui/docs/content/sidebar.md +68 -266
- package/src/ui/docs/content/skeleton.md +20 -18
- package/src/ui/docs/content/split.md +14 -14
- package/src/ui/docs/content/surface.md +513 -0
- package/src/ui/docs/content/tabs.md +20 -20
- package/src/ui/docs/content/toggle.md +117 -65
- package/src/ui/docs/content/tokens.md +124 -40
- package/src/ui/docs/registry.tsx +10 -6
- package/src/ui/index.ts +30 -27
- package/src/ui/meta.ts +15 -27
- package/src/ui/react.tsx +40 -43
- package/src/ui/theme.css +311 -64
- package/src/ui/components/patterns/content-header.tsx +0 -528
- package/src/ui/components/patterns/surface-media.tsx +0 -69
- package/src/ui/components/primitives/card.tsx +0 -191
- package/src/ui/docs/content/card.md +0 -292
- package/src/ui/docs/content/content.md +0 -275
|
@@ -20,105 +20,129 @@
|
|
|
20
20
|
* Régua de escolha (achabilidade, não performance): lista curta e conhecida → `native`;
|
|
21
21
|
* lista longa ou que cresce → `searchable`; item que precisa de JSX → `content`.
|
|
22
22
|
*/
|
|
23
|
-
import * as React from
|
|
24
|
-
import { Check, ChevronDownIcon, SearchIcon, X } from
|
|
25
|
-
import { Command as CommandPrimitive } from
|
|
23
|
+
import * as React from "react";
|
|
24
|
+
import { Check, ChevronDownIcon, SearchIcon, X } from "lucide-react";
|
|
25
|
+
import { Command as CommandPrimitive } from "cmdk";
|
|
26
26
|
|
|
27
|
-
import { cn } from
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
import { cn } from "../../lib/cn.ts";
|
|
28
|
+
import {
|
|
29
|
+
focusRing,
|
|
30
|
+
focusRingWithin,
|
|
31
|
+
type ControlShape,
|
|
32
|
+
type ControlSize,
|
|
33
|
+
} from "./control.ts";
|
|
34
|
+
import {
|
|
35
|
+
CommandEmpty,
|
|
36
|
+
CommandGroup,
|
|
37
|
+
CommandItem,
|
|
38
|
+
CommandList,
|
|
39
|
+
} from "./command.tsx";
|
|
40
|
+
import { InputGroupButton } from "./input-group.tsx";
|
|
41
|
+
import { Popover, PopoverAnchor, PopoverContent } from "./popover.tsx";
|
|
32
42
|
|
|
33
43
|
export interface SelectOption {
|
|
34
|
-
value: string
|
|
44
|
+
value: string;
|
|
35
45
|
/** Texto da opção — é o que a BUSCA casa, o que o gatilho mostra e o que o leitor de
|
|
36
46
|
* tela anuncia. Sempre string (JSX rico vai em `content`). */
|
|
37
|
-
label: string
|
|
47
|
+
label: string;
|
|
38
48
|
/** Render rico do item NO MENU (ícone, avatar, duas linhas). Default: o `label`.
|
|
39
49
|
* Só no modo custom — o menu nativo do SO aceita texto puro. */
|
|
40
|
-
content?: React.ReactNode
|
|
50
|
+
content?: React.ReactNode;
|
|
41
51
|
/** Detalhe discreto à direita do item (ex.: a chave da task). */
|
|
42
|
-
hint?: string
|
|
52
|
+
hint?: string;
|
|
43
53
|
/** O que o GATILHO mostra quando esta é a escolhida (default: `label`) — pra quando o
|
|
44
54
|
* nome completo não cabe na barra e só a chave curta serve. */
|
|
45
|
-
triggerLabel?: React.ReactNode
|
|
55
|
+
triggerLabel?: React.ReactNode;
|
|
46
56
|
/** Agrupa as opções sob um rótulo (o `<optgroup>` do nativo, o grupo do menu custom). */
|
|
47
|
-
group?: string
|
|
48
|
-
disabled?: boolean
|
|
57
|
+
group?: string;
|
|
58
|
+
disabled?: boolean;
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
interface SelectBaseProps {
|
|
52
|
-
options: SelectOption[]
|
|
62
|
+
options: SelectOption[];
|
|
53
63
|
/** Texto do campo vazio. */
|
|
54
|
-
placeholder?: string
|
|
55
|
-
disabled?: boolean
|
|
64
|
+
placeholder?: string;
|
|
65
|
+
disabled?: boolean;
|
|
56
66
|
/** Classes da raiz do controle, incluindo campo, ícones e ações. */
|
|
57
|
-
className?: string
|
|
58
|
-
id?: string
|
|
67
|
+
className?: string;
|
|
68
|
+
id?: string;
|
|
59
69
|
/** Ícone LEADING dentro do campo — identifica o filtro sem jogar o ícone ao lado. */
|
|
60
|
-
icon?: React.ReactNode
|
|
61
|
-
|
|
62
|
-
|
|
70
|
+
icon?: React.ReactNode;
|
|
71
|
+
"aria-invalid"?: boolean;
|
|
72
|
+
"aria-describedby"?: string;
|
|
63
73
|
/** Altura: `default` (h-9, a do Input/Button) ou `sm` (h-8) pra toolbar densa. */
|
|
64
|
-
size?: Extract<ControlSize,
|
|
74
|
+
size?: Extract<ControlSize, "sm" | "default">;
|
|
65
75
|
/** Geometria do controle; `pill` preserva a variante visual escolhida. */
|
|
66
|
-
shape?: ControlShape
|
|
67
|
-
|
|
76
|
+
shape?: ControlShape;
|
|
77
|
+
"aria-label"?: string;
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
/** Só o que o menu do SISTEMA suporta — nada de busca, chip ou variante visual. */
|
|
71
81
|
interface SelectNativeProps extends SelectBaseProps {
|
|
72
|
-
native: true
|
|
73
|
-
value: string
|
|
74
|
-
onChange: (value: string) => void
|
|
75
|
-
searchable?: never
|
|
76
|
-
multiple?: never
|
|
77
|
-
variant?: never
|
|
78
|
-
onSearch?: never
|
|
79
|
-
loading?: never
|
|
80
|
-
clearable?: never
|
|
82
|
+
native: true;
|
|
83
|
+
value: string;
|
|
84
|
+
onChange: (value: string) => void;
|
|
85
|
+
searchable?: never;
|
|
86
|
+
multiple?: never;
|
|
87
|
+
variant?: never;
|
|
88
|
+
onSearch?: never;
|
|
89
|
+
loading?: never;
|
|
90
|
+
clearable?: never;
|
|
81
91
|
}
|
|
82
92
|
|
|
83
93
|
interface SelectCustomBase extends SelectBaseProps {
|
|
84
|
-
native?: false
|
|
94
|
+
native?: false;
|
|
85
95
|
/** Liga a busca no próprio campo (lista longa/que cresce). */
|
|
86
|
-
searchable?: boolean
|
|
96
|
+
searchable?: boolean;
|
|
87
97
|
/** Busca SERVER-SIDE (debounced): quando definida, o filtro local desliga e o pai
|
|
88
98
|
* atualiza `options`. Implica `searchable`. */
|
|
89
|
-
onSearch?: (query: string) => void
|
|
99
|
+
onSearch?: (query: string) => void;
|
|
90
100
|
/** "Buscando…" enquanto o fetch corre (use com `onSearch`). */
|
|
91
|
-
loading?: boolean
|
|
101
|
+
loading?: boolean;
|
|
92
102
|
/** X no campo quando há seleção — limpa num clique. Bom em filtro. */
|
|
93
|
-
clearable?: boolean
|
|
103
|
+
clearable?: boolean;
|
|
94
104
|
/** `outline`: compacto com borda; `ghost`: compacto sem borda nem fundo. */
|
|
95
|
-
variant?:
|
|
105
|
+
variant?: "default" | "outline" | "ghost";
|
|
96
106
|
/** Placeholder da linha de busca dentro da lista. Default: `'Buscar…'`. */
|
|
97
|
-
searchPlaceholder?: string
|
|
98
|
-
emptyText?: string
|
|
107
|
+
searchPlaceholder?: string;
|
|
108
|
+
emptyText?: string;
|
|
99
109
|
/** Ação custom no FIM do campo, dentro do controle (antes do chevron) — ex.: um botão que
|
|
100
110
|
* age sobre o valor escolhido. O clique NÃO abre a lista (o slot para a propagação). */
|
|
101
|
-
trailing?: React.ReactNode
|
|
111
|
+
trailing?: React.ReactNode;
|
|
102
112
|
}
|
|
103
113
|
|
|
104
114
|
export type SelectProps =
|
|
105
115
|
| SelectNativeProps
|
|
106
|
-
| (SelectCustomBase & {
|
|
107
|
-
|
|
116
|
+
| (SelectCustomBase & {
|
|
117
|
+
multiple?: false;
|
|
118
|
+
value: string;
|
|
119
|
+
onChange: (value: string) => void;
|
|
120
|
+
})
|
|
121
|
+
| (SelectCustomBase & {
|
|
122
|
+
multiple: true;
|
|
123
|
+
value: string[];
|
|
124
|
+
onChange: (value: string[]) => void;
|
|
125
|
+
});
|
|
108
126
|
|
|
109
127
|
/** Ordena preservando a ordem de aparição dos grupos (sem grupo primeiro). */
|
|
110
|
-
function byGroup(
|
|
111
|
-
|
|
128
|
+
function byGroup(
|
|
129
|
+
options: SelectOption[],
|
|
130
|
+
): Array<[string | undefined, SelectOption[]]> {
|
|
131
|
+
const out: Array<[string | undefined, SelectOption[]]> = [];
|
|
112
132
|
for (const o of options) {
|
|
113
|
-
const found = out.find(([g]) => g === o.group)
|
|
114
|
-
if (found) found[1].push(o)
|
|
115
|
-
else out.push([o.group, [o]])
|
|
133
|
+
const found = out.find(([g]) => g === o.group);
|
|
134
|
+
if (found) found[1].push(o);
|
|
135
|
+
else out.push([o.group, [o]]);
|
|
116
136
|
}
|
|
117
|
-
return out
|
|
137
|
+
return out;
|
|
118
138
|
}
|
|
119
139
|
|
|
120
140
|
export function Select(props: SelectProps): React.ReactElement {
|
|
121
|
-
return props.native === true ?
|
|
141
|
+
return props.native === true ? (
|
|
142
|
+
<NativeSelect {...props} />
|
|
143
|
+
) : (
|
|
144
|
+
<CustomSelect {...props} />
|
|
145
|
+
);
|
|
122
146
|
}
|
|
123
147
|
|
|
124
148
|
// ── Nativo: o <select> do sistema ───────────────────────────────────────────────────
|
|
@@ -129,16 +153,19 @@ function NativeSelect({
|
|
|
129
153
|
className,
|
|
130
154
|
id,
|
|
131
155
|
icon,
|
|
132
|
-
size =
|
|
133
|
-
shape =
|
|
156
|
+
size = "default",
|
|
157
|
+
shape = "default",
|
|
134
158
|
value,
|
|
135
159
|
onChange,
|
|
136
160
|
...rest
|
|
137
161
|
}: SelectNativeProps): React.ReactElement {
|
|
138
|
-
const groups = byGroup(options)
|
|
162
|
+
const groups = byGroup(options);
|
|
139
163
|
return (
|
|
140
164
|
<div
|
|
141
|
-
className={cn(
|
|
165
|
+
className={cn(
|
|
166
|
+
"group/select relative w-full min-w-0 has-[select:disabled]:opacity-50",
|
|
167
|
+
className,
|
|
168
|
+
)}
|
|
142
169
|
data-slot="select-wrapper"
|
|
143
170
|
>
|
|
144
171
|
{icon ? (
|
|
@@ -157,15 +184,15 @@ function NativeSelect({
|
|
|
157
184
|
id={id}
|
|
158
185
|
disabled={disabled}
|
|
159
186
|
value={value}
|
|
160
|
-
aria-label={rest[
|
|
161
|
-
aria-invalid={rest[
|
|
162
|
-
aria-describedby={rest[
|
|
187
|
+
aria-label={rest["aria-label"]}
|
|
188
|
+
aria-invalid={rest["aria-invalid"]}
|
|
189
|
+
aria-describedby={rest["aria-describedby"]}
|
|
163
190
|
onChange={(e) => onChange(e.target.value)}
|
|
164
191
|
className={cn(
|
|
165
|
-
"h-9 w-full min-w-0 appearance-none rounded-md border border-input bg-
|
|
192
|
+
"h-9 w-full min-w-0 appearance-none rounded-md border border-input bg-control px-3 py-2 pr-9 text-sm transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground hover:bg-interaction-hover disabled:pointer-events-none disabled:cursor-not-allowed data-[size=sm]:h-8 data-[size=sm]:py-1 data-[shape=pill]:rounded-full",
|
|
166
193
|
focusRing,
|
|
167
|
-
|
|
168
|
-
icon ?
|
|
194
|
+
"aria-invalid:border-context-danger aria-invalid:ring-context-danger/20 dark:aria-invalid:ring-context-danger/40",
|
|
195
|
+
icon ? "pl-9" : "",
|
|
169
196
|
)}
|
|
170
197
|
>
|
|
171
198
|
{placeholder !== undefined && (
|
|
@@ -176,14 +203,28 @@ function NativeSelect({
|
|
|
176
203
|
{groups.map(([group, items]) =>
|
|
177
204
|
group === undefined ? (
|
|
178
205
|
items.map((o) => (
|
|
179
|
-
<option
|
|
206
|
+
<option
|
|
207
|
+
key={o.value}
|
|
208
|
+
value={o.value}
|
|
209
|
+
disabled={o.disabled}
|
|
210
|
+
className="bg-[Canvas] text-[CanvasText]"
|
|
211
|
+
>
|
|
180
212
|
{o.label}
|
|
181
213
|
</option>
|
|
182
214
|
))
|
|
183
215
|
) : (
|
|
184
|
-
<optgroup
|
|
216
|
+
<optgroup
|
|
217
|
+
key={group}
|
|
218
|
+
label={group}
|
|
219
|
+
className="bg-[Canvas] text-[CanvasText]"
|
|
220
|
+
>
|
|
185
221
|
{items.map((o) => (
|
|
186
|
-
<option
|
|
222
|
+
<option
|
|
223
|
+
key={o.value}
|
|
224
|
+
value={o.value}
|
|
225
|
+
disabled={o.disabled}
|
|
226
|
+
className="bg-[Canvas] text-[CanvasText]"
|
|
227
|
+
>
|
|
187
228
|
{o.label}
|
|
188
229
|
</option>
|
|
189
230
|
))}
|
|
@@ -197,43 +238,49 @@ function NativeSelect({
|
|
|
197
238
|
data-slot="select-icon"
|
|
198
239
|
/>
|
|
199
240
|
</div>
|
|
200
|
-
)
|
|
241
|
+
);
|
|
201
242
|
}
|
|
202
243
|
|
|
203
244
|
// ── Custom: cmdk + Popover (busca, multi, item rico, ghost) ─────────────────────────
|
|
204
|
-
function CustomSelect(
|
|
245
|
+
function CustomSelect(
|
|
246
|
+
props: Exclude<SelectProps, SelectNativeProps>,
|
|
247
|
+
): React.ReactElement {
|
|
205
248
|
const {
|
|
206
249
|
options,
|
|
207
|
-
placeholder =
|
|
250
|
+
placeholder = "Selecione…",
|
|
208
251
|
searchPlaceholder,
|
|
209
|
-
emptyText =
|
|
252
|
+
emptyText = "Nada encontrado.",
|
|
210
253
|
disabled,
|
|
211
254
|
className,
|
|
212
255
|
id,
|
|
213
256
|
icon,
|
|
214
|
-
size =
|
|
215
|
-
shape =
|
|
257
|
+
size = "default",
|
|
258
|
+
shape = "default",
|
|
216
259
|
searchable = false,
|
|
217
260
|
onSearch,
|
|
218
261
|
loading,
|
|
219
262
|
clearable,
|
|
220
263
|
trailing,
|
|
221
|
-
variant =
|
|
222
|
-
} = props
|
|
264
|
+
variant = "default",
|
|
265
|
+
} = props;
|
|
223
266
|
// Busca server-side implica campo buscável (o pai filtra).
|
|
224
|
-
const canSearch = searchable || onSearch !== undefined
|
|
225
|
-
const selectedValues = props.multiple
|
|
226
|
-
|
|
267
|
+
const canSearch = searchable || onSearch !== undefined;
|
|
268
|
+
const selectedValues = props.multiple
|
|
269
|
+
? props.value
|
|
270
|
+
: props.value
|
|
271
|
+
? [props.value]
|
|
272
|
+
: [];
|
|
273
|
+
const singleValue = props.multiple ? undefined : props.value;
|
|
227
274
|
|
|
228
|
-
const [open, setOpen] = React.useState(false)
|
|
229
|
-
const [query, setQuery] = React.useState(
|
|
230
|
-
const rootRef = React.useRef<HTMLDivElement>(null)
|
|
231
|
-
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
275
|
+
const [open, setOpen] = React.useState(false);
|
|
276
|
+
const [query, setQuery] = React.useState("");
|
|
277
|
+
const rootRef = React.useRef<HTMLDivElement>(null);
|
|
278
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
232
279
|
/** Fechando por Esc: o foco volta pro gatilho, e focar abre. Marca pra ignorar UM foco. */
|
|
233
|
-
const closingRef = React.useRef(false)
|
|
234
|
-
const searchRef = React.useRef<HTMLInputElement>(null)
|
|
280
|
+
const closingRef = React.useRef(false);
|
|
281
|
+
const searchRef = React.useRef<HTMLInputElement>(null);
|
|
235
282
|
|
|
236
|
-
const selectedOption = options.find((o) => o.value === singleValue)
|
|
283
|
+
const selectedOption = options.find((o) => o.value === singleValue);
|
|
237
284
|
|
|
238
285
|
/**
|
|
239
286
|
* Abrir com busca FOCA a caixa — abrir e já poder digitar é o ponto do padrão.
|
|
@@ -244,10 +291,10 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
244
291
|
* depois deste efeito.
|
|
245
292
|
*/
|
|
246
293
|
React.useEffect(() => {
|
|
247
|
-
if (!open || !canSearch) return
|
|
248
|
-
const r = requestAnimationFrame(() => searchRef.current?.focus())
|
|
249
|
-
return () => cancelAnimationFrame(r)
|
|
250
|
-
}, [open, canSearch])
|
|
294
|
+
if (!open || !canSearch) return;
|
|
295
|
+
const r = requestAnimationFrame(() => searchRef.current?.focus());
|
|
296
|
+
return () => cancelAnimationFrame(r);
|
|
297
|
+
}, [open, canSearch]);
|
|
251
298
|
|
|
252
299
|
/**
|
|
253
300
|
* O termo de busca zera ao FECHAR.
|
|
@@ -256,51 +303,61 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
256
303
|
* reabrir filtrada por algo que a pessoa procurou minutos antes e já esqueceu.
|
|
257
304
|
*/
|
|
258
305
|
React.useEffect(() => {
|
|
259
|
-
if (!open) setQuery(
|
|
260
|
-
}, [open])
|
|
306
|
+
if (!open) setQuery("");
|
|
307
|
+
}, [open]);
|
|
261
308
|
|
|
262
309
|
// O cmdk carimba o id DELE no input (a11y interna) e descarta o nosso — reaplicamos por
|
|
263
310
|
// ref, senão o `htmlFor` do Label não acha campo nenhum (clicar no rótulo não focaria).
|
|
264
311
|
React.useEffect(() => {
|
|
265
|
-
if (id !== undefined && inputRef.current !== null) inputRef.current.id = id
|
|
266
|
-
}, [id])
|
|
312
|
+
if (id !== undefined && inputRef.current !== null) inputRef.current.id = id;
|
|
313
|
+
}, [id]);
|
|
267
314
|
|
|
268
|
-
const onSearchRef = React.useRef(onSearch)
|
|
269
|
-
onSearchRef.current = onSearch
|
|
315
|
+
const onSearchRef = React.useRef(onSearch);
|
|
316
|
+
onSearchRef.current = onSearch;
|
|
270
317
|
React.useEffect(() => {
|
|
271
|
-
if (onSearchRef.current === undefined || !open) return
|
|
272
|
-
const t = setTimeout(() => onSearchRef.current?.(query.trim()), 200)
|
|
273
|
-
return () => clearTimeout(t)
|
|
274
|
-
}, [query, open])
|
|
318
|
+
if (onSearchRef.current === undefined || !open) return;
|
|
319
|
+
const t = setTimeout(() => onSearchRef.current?.(query.trim()), 200);
|
|
320
|
+
return () => clearTimeout(t);
|
|
321
|
+
}, [query, open]);
|
|
275
322
|
|
|
276
323
|
const onSelectOption = (o: SelectOption): void => {
|
|
277
|
-
if (o.disabled === true) return
|
|
324
|
+
if (o.disabled === true) return;
|
|
278
325
|
if (props.multiple) {
|
|
279
|
-
props.onChange(
|
|
280
|
-
|
|
281
|
-
|
|
326
|
+
props.onChange(
|
|
327
|
+
props.value.includes(o.value)
|
|
328
|
+
? props.value.filter((v) => v !== o.value)
|
|
329
|
+
: [...props.value, o.value],
|
|
330
|
+
);
|
|
331
|
+
setQuery("");
|
|
332
|
+
inputRef.current?.focus();
|
|
282
333
|
} else {
|
|
283
|
-
props.onChange(o.value)
|
|
284
|
-
setQuery(o.label)
|
|
285
|
-
setOpen(false)
|
|
286
|
-
inputRef.current?.blur()
|
|
334
|
+
props.onChange(o.value);
|
|
335
|
+
setQuery(o.label);
|
|
336
|
+
setOpen(false);
|
|
337
|
+
inputRef.current?.blur();
|
|
287
338
|
}
|
|
288
|
-
}
|
|
339
|
+
};
|
|
289
340
|
const removeValue = (v: string): void => {
|
|
290
|
-
if (props.multiple) props.onChange(props.value.filter((x) => x !== v))
|
|
291
|
-
}
|
|
341
|
+
if (props.multiple) props.onChange(props.value.filter((x) => x !== v));
|
|
342
|
+
};
|
|
292
343
|
|
|
293
|
-
const allSelected =
|
|
294
|
-
|
|
344
|
+
const allSelected =
|
|
345
|
+
options.length > 0 &&
|
|
346
|
+
options.every((o) => selectedValues.includes(o.value));
|
|
347
|
+
const showToggleAll =
|
|
348
|
+
props.multiple === true &&
|
|
349
|
+
options.length > 1 &&
|
|
350
|
+
onSearch === undefined &&
|
|
351
|
+
query.trim() === "";
|
|
295
352
|
const toggleAll = (): void => {
|
|
296
|
-
if (!props.multiple) return
|
|
297
|
-
props.onChange(allSelected ? [] : options.map((o) => o.value))
|
|
298
|
-
inputRef.current?.focus()
|
|
299
|
-
}
|
|
353
|
+
if (!props.multiple) return;
|
|
354
|
+
props.onChange(allSelected ? [] : options.map((o) => o.value));
|
|
355
|
+
inputRef.current?.focus();
|
|
356
|
+
};
|
|
300
357
|
|
|
301
|
-
const ghost = variant ===
|
|
302
|
-
const outline = variant ===
|
|
303
|
-
const compact = ghost || outline
|
|
358
|
+
const ghost = variant === "ghost";
|
|
359
|
+
const outline = variant === "outline";
|
|
360
|
+
const compact = ghost || outline;
|
|
304
361
|
|
|
305
362
|
/**
|
|
306
363
|
* O GATILHO nunca é caixa de busca — em nenhum modo.
|
|
@@ -320,7 +377,7 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
320
377
|
* anterior — digitar no próprio campo — é de quem digita um VALOR (endereço, cidade),
|
|
321
378
|
* onde não existe seleção persistente pra exibir.
|
|
322
379
|
*/
|
|
323
|
-
const triggerText = selectedOption?.label ??
|
|
380
|
+
const triggerText = selectedOption?.label ?? "";
|
|
324
381
|
|
|
325
382
|
return (
|
|
326
383
|
<Popover
|
|
@@ -334,58 +391,85 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
334
391
|
// armada pra sempre e engoliria a PRÓXIMA abertura por teclado — trocando um
|
|
335
392
|
// defeito visível por um que só aparece pra quem navega por Tab.
|
|
336
393
|
if (!v) {
|
|
337
|
-
closingRef.current = true
|
|
394
|
+
closingRef.current = true;
|
|
338
395
|
requestAnimationFrame(() => {
|
|
339
|
-
closingRef.current = false
|
|
340
|
-
})
|
|
396
|
+
closingRef.current = false;
|
|
397
|
+
});
|
|
341
398
|
}
|
|
342
|
-
setOpen(v)
|
|
399
|
+
setOpen(v);
|
|
343
400
|
}}
|
|
344
401
|
>
|
|
345
|
-
<div
|
|
346
|
-
|
|
402
|
+
<div
|
|
403
|
+
ref={rootRef}
|
|
404
|
+
data-slot="select"
|
|
405
|
+
data-shape={shape}
|
|
406
|
+
className={cn(
|
|
407
|
+
compact ? "w-fit max-w-full min-w-0" : "min-w-40",
|
|
408
|
+
shape === "pill" && "rounded-full",
|
|
409
|
+
className,
|
|
410
|
+
)}
|
|
411
|
+
>
|
|
412
|
+
<CommandPrimitive
|
|
413
|
+
data-slot="select-command"
|
|
414
|
+
shouldFilter={canSearch && onSearch === undefined}
|
|
415
|
+
className="overflow-visible"
|
|
416
|
+
>
|
|
347
417
|
<PopoverAnchor asChild>
|
|
348
418
|
<div
|
|
349
419
|
data-slot="select-control"
|
|
350
420
|
data-size={size}
|
|
351
421
|
data-shape={shape}
|
|
352
422
|
data-variant={variant}
|
|
353
|
-
aria-invalid={props[
|
|
423
|
+
aria-invalid={props["aria-invalid"]}
|
|
354
424
|
onClick={() => {
|
|
355
425
|
if (disabled !== true) {
|
|
356
|
-
setOpen(true)
|
|
357
|
-
inputRef.current?.focus()
|
|
426
|
+
setOpen(true);
|
|
427
|
+
inputRef.current?.focus();
|
|
358
428
|
}
|
|
359
429
|
}}
|
|
360
430
|
className={cn(
|
|
361
|
-
|
|
431
|
+
"flex items-center gap-1.5 rounded-md outline-none transition-[color,box-shadow] data-[shape=pill]:rounded-full",
|
|
362
432
|
ghost
|
|
363
|
-
? cn(
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
433
|
+
? cn(
|
|
434
|
+
"min-h-9 px-3 py-1 text-muted-foreground hover:bg-interaction-hover hover:text-foreground data-[size=sm]:min-h-8 data-[size=sm]:px-2",
|
|
435
|
+
focusRingWithin,
|
|
436
|
+
)
|
|
437
|
+
: cn(
|
|
438
|
+
"min-h-9 border border-input bg-control px-3 py-1 aria-invalid:border-context-danger aria-invalid:ring-context-danger/20 data-[size=sm]:min-h-8 data-[size=sm]:py-0.5 dark:aria-invalid:ring-context-danger/40",
|
|
439
|
+
focusRingWithin,
|
|
440
|
+
),
|
|
441
|
+
variant === "default" && "flex-wrap",
|
|
442
|
+
outline &&
|
|
443
|
+
"hover:bg-interaction-hover hover:text-accent-foreground",
|
|
367
444
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
368
|
-
canSearch ?
|
|
369
|
-
disabled === true &&
|
|
445
|
+
canSearch ? "" : "cursor-pointer",
|
|
446
|
+
disabled === true && "cursor-not-allowed opacity-50",
|
|
370
447
|
)}
|
|
371
448
|
>
|
|
372
449
|
{icon ? (
|
|
373
|
-
<span
|
|
450
|
+
<span
|
|
451
|
+
data-slot="select-leading"
|
|
452
|
+
className="text-muted-foreground"
|
|
453
|
+
>
|
|
374
454
|
{icon}
|
|
375
455
|
</span>
|
|
376
456
|
) : null}
|
|
377
457
|
{props.multiple &&
|
|
378
458
|
selectedValues.map((v) => {
|
|
379
|
-
const o = options.find((x) => x.value === v)
|
|
459
|
+
const o = options.find((x) => x.value === v);
|
|
380
460
|
return (
|
|
381
|
-
<span
|
|
461
|
+
<span
|
|
462
|
+
key={v}
|
|
463
|
+
data-slot="select-chip"
|
|
464
|
+
className="inline-flex items-center gap-1 rounded bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground"
|
|
465
|
+
>
|
|
382
466
|
{o?.label ?? v}
|
|
383
467
|
<button
|
|
384
468
|
type="button"
|
|
385
469
|
data-slot="select-chip-remove"
|
|
386
470
|
onClick={(e) => {
|
|
387
|
-
e.stopPropagation()
|
|
388
|
-
removeValue(v)
|
|
471
|
+
e.stopPropagation();
|
|
472
|
+
removeValue(v);
|
|
389
473
|
}}
|
|
390
474
|
className="text-muted-foreground hover:text-foreground"
|
|
391
475
|
aria-label={`Remover ${o?.label ?? v}`}
|
|
@@ -393,7 +477,7 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
393
477
|
<X className="size-3" />
|
|
394
478
|
</button>
|
|
395
479
|
</span>
|
|
396
|
-
)
|
|
480
|
+
);
|
|
397
481
|
})}
|
|
398
482
|
{/* O rótulo é SEMPRE um `<span>`. Quando ele morava no `value` de um input,
|
|
399
483
|
o texto rolava pro fim ao ganhar foco (o browser leva o cursor pro final)
|
|
@@ -406,12 +490,16 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
406
490
|
<span
|
|
407
491
|
data-slot="select-trigger-label"
|
|
408
492
|
className={cn(
|
|
409
|
-
|
|
410
|
-
compact ?
|
|
411
|
-
selectedOption === undefined &&
|
|
493
|
+
"min-w-0 truncate text-left text-sm",
|
|
494
|
+
compact ? "flex-initial" : "flex-1",
|
|
495
|
+
selectedOption === undefined && "text-muted-foreground",
|
|
412
496
|
)}
|
|
413
497
|
>
|
|
414
|
-
{props.multiple
|
|
498
|
+
{props.multiple
|
|
499
|
+
? placeholder
|
|
500
|
+
: (selectedOption?.triggerLabel ??
|
|
501
|
+
selectedOption?.label ??
|
|
502
|
+
placeholder)}
|
|
415
503
|
</span>
|
|
416
504
|
)}
|
|
417
505
|
<input
|
|
@@ -422,34 +510,41 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
422
510
|
readOnly
|
|
423
511
|
role="combobox"
|
|
424
512
|
aria-expanded={open}
|
|
425
|
-
aria-label={props[
|
|
426
|
-
aria-invalid={props[
|
|
427
|
-
aria-describedby={props[
|
|
428
|
-
value={props.multiple ?
|
|
513
|
+
aria-label={props["aria-label"]}
|
|
514
|
+
aria-invalid={props["aria-invalid"]}
|
|
515
|
+
aria-describedby={props["aria-describedby"]}
|
|
516
|
+
value={props.multiple ? "" : triggerText}
|
|
429
517
|
onChange={() => {}}
|
|
430
518
|
onFocus={() => {
|
|
431
519
|
// Fechar por Esc devolve o foco pra cá — e focar abre. Sem esta guarda,
|
|
432
520
|
// Esc REABRE a lista e o seletor fica impossível de fechar pelo teclado.
|
|
433
521
|
if (closingRef.current) {
|
|
434
|
-
closingRef.current = false
|
|
435
|
-
return
|
|
522
|
+
closingRef.current = false;
|
|
523
|
+
return;
|
|
436
524
|
}
|
|
437
|
-
setOpen(true)
|
|
525
|
+
setOpen(true);
|
|
438
526
|
}}
|
|
439
527
|
onKeyDown={(e) => {
|
|
440
|
-
if (e.key ===
|
|
441
|
-
e.stopPropagation()
|
|
442
|
-
setOpen(false)
|
|
443
|
-
return
|
|
528
|
+
if (e.key === "Escape" && open) {
|
|
529
|
+
e.stopPropagation();
|
|
530
|
+
setOpen(false);
|
|
531
|
+
return;
|
|
444
532
|
}
|
|
445
533
|
// Teclado abre a lista como o clique abre: sem isto, quem navega por Tab
|
|
446
534
|
// chega no campo e não tem como escolher nada.
|
|
447
|
-
if (
|
|
448
|
-
|
|
449
|
-
|
|
535
|
+
if (
|
|
536
|
+
!open &&
|
|
537
|
+
(e.key === "Enter" ||
|
|
538
|
+
e.key === " " ||
|
|
539
|
+
e.key === "ArrowDown")
|
|
540
|
+
) {
|
|
541
|
+
e.preventDefault();
|
|
542
|
+
setOpen(true);
|
|
450
543
|
}
|
|
451
544
|
}}
|
|
452
|
-
placeholder={
|
|
545
|
+
placeholder={
|
|
546
|
+
props.multiple && selectedValues.length > 0 ? "" : placeholder
|
|
547
|
+
}
|
|
453
548
|
className="sr-only"
|
|
454
549
|
/>
|
|
455
550
|
{/* Ação custom (ex.: botão que age sobre o valor) — o clique NÃO abre a lista:
|
|
@@ -463,7 +558,10 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
463
558
|
// Botão trailing na borda: aproxima 0.375rem para equilibrar o inset.
|
|
464
559
|
// direito casar com o vertical (botão quadrado no canto), como o InputGroup.
|
|
465
560
|
// Com chevron, NÃO puxa — senão cola no chevron (o `gap-1.5` do control).
|
|
466
|
-
className={cn(
|
|
561
|
+
className={cn(
|
|
562
|
+
"flex shrink-0 items-center text-muted-foreground",
|
|
563
|
+
canSearch && "has-[button]:-mr-1.5",
|
|
564
|
+
)}
|
|
467
565
|
onClick={(e) => e.stopPropagation()}
|
|
468
566
|
onPointerDown={(e) => e.stopPropagation()}
|
|
469
567
|
>
|
|
@@ -473,27 +571,36 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
473
571
|
{/* Chevron SEMPRE: o gatilho agora abre uma lista nos dois modos, e essa é a
|
|
474
572
|
affordance que diz isso. Antes ele sumia no buscável, porque ali o sinal
|
|
475
573
|
era o cursor de texto — que não existe mais no campo. */}
|
|
476
|
-
<ChevronDownIcon
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
574
|
+
<ChevronDownIcon
|
|
575
|
+
data-slot="select-icon"
|
|
576
|
+
className={cn(
|
|
577
|
+
"size-4 shrink-0 opacity-50",
|
|
578
|
+
ghost ? "h-3.5 w-3.5 opacity-60" : "",
|
|
579
|
+
)}
|
|
580
|
+
aria-hidden="true"
|
|
581
|
+
/>
|
|
582
|
+
{clearable === true &&
|
|
583
|
+
selectedValues.length > 0 &&
|
|
584
|
+
disabled !== true && (
|
|
585
|
+
<InputGroupButton
|
|
586
|
+
type="button"
|
|
587
|
+
size="icon-xs"
|
|
588
|
+
data-slot="select-clear"
|
|
589
|
+
aria-label="Limpar seleção"
|
|
590
|
+
onPointerDown={(e) => e.preventDefault()}
|
|
591
|
+
onClick={(e) => {
|
|
592
|
+
e.stopPropagation();
|
|
593
|
+
if (props.multiple) props.onChange([]);
|
|
594
|
+
else {
|
|
595
|
+
props.onChange("");
|
|
596
|
+
setQuery("");
|
|
597
|
+
}
|
|
598
|
+
}}
|
|
599
|
+
className="-ml-1 -mr-1.5 text-muted-foreground hover:text-foreground"
|
|
600
|
+
>
|
|
601
|
+
<X className="size-3.5" />
|
|
602
|
+
</InputGroupButton>
|
|
603
|
+
)}
|
|
497
604
|
</div>
|
|
498
605
|
</PopoverAnchor>
|
|
499
606
|
<PopoverContent
|
|
@@ -502,10 +609,10 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
502
609
|
// preservar rótulos maiores sem alargar o gatilho. No ghost, o gatilho já abraça
|
|
503
610
|
// o conteúdo e a lista mantém sua faixa compacta própria.
|
|
504
611
|
className={cn(
|
|
505
|
-
|
|
612
|
+
"pointer-events-auto overflow-hidden p-0",
|
|
506
613
|
compact
|
|
507
|
-
?
|
|
508
|
-
:
|
|
614
|
+
? "w-auto min-w-56 max-w-80"
|
|
615
|
+
: "w-max min-w-(--radix-popover-trigger-width) max-w-lg",
|
|
509
616
|
)}
|
|
510
617
|
/*
|
|
511
618
|
* O foco ao abrir é SEMPRE nosso — o Radix nunca mexe.
|
|
@@ -519,10 +626,11 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
519
626
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
520
627
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
521
628
|
onInteractOutside={(e) => {
|
|
522
|
-
const target = e.target as HTMLElement
|
|
523
|
-
if (rootRef.current?.contains(target)) return e.preventDefault()
|
|
524
|
-
const label = target.closest(
|
|
525
|
-
if (id !== undefined && label !== null && label.htmlFor === id)
|
|
629
|
+
const target = e.target as HTMLElement;
|
|
630
|
+
if (rootRef.current?.contains(target)) return e.preventDefault();
|
|
631
|
+
const label = target.closest("label");
|
|
632
|
+
if (id !== undefined && label !== null && label.htmlFor === id)
|
|
633
|
+
e.preventDefault();
|
|
526
634
|
}}
|
|
527
635
|
onWheel={(e) => e.stopPropagation()}
|
|
528
636
|
onTouchMove={(e) => e.stopPropagation()}
|
|
@@ -530,21 +638,27 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
530
638
|
{/* A busca mora AQUI, e não no gatilho. Nasce vazia porque é um campo
|
|
531
639
|
próprio — não há rótulo pra herdar. */}
|
|
532
640
|
{canSearch && (
|
|
533
|
-
<div
|
|
534
|
-
|
|
641
|
+
<div
|
|
642
|
+
data-slot="select-search-row"
|
|
643
|
+
className="flex items-center gap-2 border-b px-3"
|
|
644
|
+
>
|
|
645
|
+
<SearchIcon
|
|
646
|
+
className="size-4 shrink-0 opacity-50"
|
|
647
|
+
aria-hidden="true"
|
|
648
|
+
/>
|
|
535
649
|
<CommandPrimitive.Input
|
|
536
650
|
ref={searchRef}
|
|
537
651
|
data-slot="select-search"
|
|
538
652
|
value={query}
|
|
539
653
|
onValueChange={setQuery}
|
|
540
|
-
placeholder={searchPlaceholder ??
|
|
654
|
+
placeholder={searchPlaceholder ?? "Buscar…"}
|
|
541
655
|
className="flex-1 bg-transparent py-2.5 text-sm outline-hidden placeholder:text-muted-foreground"
|
|
542
656
|
onKeyDown={(e) => {
|
|
543
|
-
if (e.key ===
|
|
544
|
-
e.stopPropagation()
|
|
545
|
-
closingRef.current = true
|
|
546
|
-
setOpen(false)
|
|
547
|
-
inputRef.current?.focus() // devolve o foco ao gatilho, senão ele se perde
|
|
657
|
+
if (e.key === "Escape") {
|
|
658
|
+
e.stopPropagation();
|
|
659
|
+
closingRef.current = true;
|
|
660
|
+
setOpen(false);
|
|
661
|
+
inputRef.current?.focus(); // devolve o foco ao gatilho, senão ele se perde
|
|
548
662
|
}
|
|
549
663
|
}}
|
|
550
664
|
/>
|
|
@@ -556,46 +670,67 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
556
670
|
type="button"
|
|
557
671
|
data-slot="select-toggle-all"
|
|
558
672
|
onClick={toggleAll}
|
|
559
|
-
className="flex w-full items-center rounded-sm px-2 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-
|
|
673
|
+
className="flex w-full items-center rounded-sm px-2 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-interaction-hover hover:text-accent-foreground"
|
|
560
674
|
>
|
|
561
|
-
{allSelected
|
|
675
|
+
{allSelected
|
|
676
|
+
? "Limpar seleção"
|
|
677
|
+
: `Selecionar tudo (${options.length})`}
|
|
562
678
|
</button>
|
|
563
679
|
</div>
|
|
564
680
|
)}
|
|
565
|
-
<CommandList
|
|
681
|
+
<CommandList
|
|
682
|
+
data-slot="select-list"
|
|
683
|
+
className="max-h-60 overflow-auto"
|
|
684
|
+
>
|
|
566
685
|
{loading === true ? (
|
|
567
|
-
<div
|
|
686
|
+
<div
|
|
687
|
+
data-slot="select-loading"
|
|
688
|
+
className="py-6 text-center text-sm text-muted-foreground"
|
|
689
|
+
>
|
|
568
690
|
Buscando…
|
|
569
691
|
</div>
|
|
570
692
|
) : (
|
|
571
693
|
<>
|
|
572
694
|
<CommandEmpty>{emptyText}</CommandEmpty>
|
|
573
695
|
{byGroup(options).map(([group, items]) => (
|
|
574
|
-
<CommandGroup key={group ??
|
|
696
|
+
<CommandGroup key={group ?? "—"} heading={group}>
|
|
575
697
|
{items.map((o) => {
|
|
576
|
-
const hasHint =
|
|
698
|
+
const hasHint =
|
|
699
|
+
o.hint !== undefined && o.hint.length > 0;
|
|
577
700
|
return (
|
|
578
701
|
<CommandItem
|
|
579
702
|
key={o.value}
|
|
580
703
|
data-slot="select-item"
|
|
581
704
|
disabled={o.disabled}
|
|
582
|
-
value={`${o.label} ${o.hint ??
|
|
705
|
+
value={`${o.label} ${o.hint ?? ""} ${o.value}`}
|
|
583
706
|
onSelect={() => onSelectOption(o)}
|
|
584
707
|
>
|
|
585
|
-
<span
|
|
708
|
+
<span
|
|
709
|
+
data-slot="select-item-label"
|
|
710
|
+
className="min-w-0 truncate"
|
|
711
|
+
>
|
|
586
712
|
{o.content ?? o.label}
|
|
587
713
|
</span>
|
|
588
714
|
{hasHint && (
|
|
589
|
-
<span
|
|
715
|
+
<span
|
|
716
|
+
data-slot="select-item-hint"
|
|
717
|
+
className="ml-auto shrink-0 text-xs text-muted-foreground"
|
|
718
|
+
>
|
|
590
719
|
{o.hint}
|
|
591
720
|
</span>
|
|
592
721
|
)}
|
|
593
722
|
<Check
|
|
594
723
|
data-slot="select-item-check"
|
|
595
|
-
className={cn(
|
|
724
|
+
className={cn(
|
|
725
|
+
"size-4 shrink-0",
|
|
726
|
+
!hasHint && "ml-auto",
|
|
727
|
+
selectedValues.includes(o.value)
|
|
728
|
+
? "opacity-100"
|
|
729
|
+
: "opacity-0",
|
|
730
|
+
)}
|
|
596
731
|
/>
|
|
597
732
|
</CommandItem>
|
|
598
|
-
)
|
|
733
|
+
);
|
|
599
734
|
})}
|
|
600
735
|
</CommandGroup>
|
|
601
736
|
))}
|
|
@@ -606,5 +741,5 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
606
741
|
</CommandPrimitive>
|
|
607
742
|
</div>
|
|
608
743
|
</Popover>
|
|
609
|
-
)
|
|
744
|
+
);
|
|
610
745
|
}
|