@softize/opus 22.2.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +120 -1
  2. package/bin/cli.mjs +3 -9
  3. package/bin/lib/check.mjs +242 -49
  4. package/bin/lib/copy.mjs +17 -35
  5. package/bin/lib/gen-runner.mjs +2 -0
  6. package/bin/lib/gen-stubs.mjs +3 -1
  7. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +1 -1
  8. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +6 -0
  9. package/docs/adr/0032-tab-baseline-respects-the-container-inset.md +16 -12
  10. package/docs/adr/0034-tabs-can-own-a-frame-without-owning-a-surface.md +8 -6
  11. package/docs/adr/0036-tabs-declares-trigger-distribution.md +11 -8
  12. package/docs/adr/0037-design-data-lives-outside-the-contract.md +1 -1
  13. package/docs/adr/0038-content-level-defines-primary-presentation.md +62 -0
  14. package/docs/adr/0039-filter-presentation-is-independent-from-filter-type.md +62 -0
  15. package/docs/adr/0040-tabs-use-a-single-navigation-presentation.md +59 -0
  16. package/docs/adr/0042-toggle-group-shares-the-grouped-control-grammar.md +79 -0
  17. package/docs/adr/0043-neutral-surfaces-and-interactions-use-opaque-role-tokens.md +73 -0
  18. package/docs/adr/0044-neutral-appearance-derives-from-bounded-axes-per-mode.md +133 -0
  19. package/docs/adr/0045-surface-replaces-content-and-card.md +230 -0
  20. package/docs/adr/0046-structural-body-owns-content-rhythm.md +103 -0
  21. package/docs/adr/0047-pane-is-only-a-split-layout-unit.md +71 -0
  22. package/docs/adr/0048-modal-intro-separates-content-from-chrome.md +34 -0
  23. package/docs/code-style.md +1 -1
  24. package/package.json +5 -1
  25. package/registry/skills/build-opus-ui/SKILL.md +11 -11
  26. package/registry/skills/build-opus-ui/references/evaluations.md +10 -9
  27. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -16
  28. package/registry/skills/create-opus-action/references/contract-and-binding.md +3 -3
  29. package/registry/templates/app/src/App.tsx +24 -12
  30. package/src/core/contracts.ts +1 -1
  31. package/src/core/runtime.ts +4 -37
  32. package/src/core/types.ts +5 -23
  33. package/src/design/index.ts +14 -3
  34. package/src/testing/index.ts +6 -26
  35. package/src/ui/appearance.ts +149 -0
  36. package/src/ui/components/patterns/action-form-card.tsx +46 -33
  37. package/src/ui/components/patterns/action-list-dialog.tsx +2 -4
  38. package/src/ui/components/patterns/confirm.tsx +3 -1
  39. package/src/ui/components/patterns/fill-region.ts +8 -7
  40. package/src/ui/components/patterns/form-dialog.tsx +1 -3
  41. package/src/ui/components/patterns/form.tsx +8 -7
  42. package/src/ui/components/patterns/list.tsx +64 -14
  43. package/src/ui/components/patterns/page.tsx +15 -11
  44. package/src/ui/components/patterns/pane.tsx +9 -240
  45. package/src/ui/components/patterns/presentation.tsx +14 -14
  46. package/src/ui/components/patterns/sidebar.tsx +7 -17
  47. package/src/ui/components/patterns/split.tsx +111 -59
  48. package/src/ui/components/patterns/{surface-header.tsx → structural-header.tsx} +50 -19
  49. package/src/ui/components/patterns/structural-media.tsx +26 -0
  50. package/src/ui/components/patterns/surface-anatomy.ts +71 -61
  51. package/src/ui/components/patterns/surface-assistant.tsx +2 -4
  52. package/src/ui/components/patterns/surface.tsx +620 -0
  53. package/src/ui/components/patterns/trigger.tsx +11 -9
  54. package/src/ui/components/primitives/ask.tsx +155 -83
  55. package/src/ui/components/primitives/button.tsx +11 -9
  56. package/src/ui/components/primitives/checkbox.tsx +8 -8
  57. package/src/ui/components/primitives/command.tsx +1 -1
  58. package/src/ui/components/primitives/composer.tsx +47 -45
  59. package/src/ui/components/primitives/detail.tsx +9 -2
  60. package/src/ui/components/primitives/dialog.tsx +60 -26
  61. package/src/ui/components/primitives/drawer.tsx +58 -12
  62. package/src/ui/components/primitives/icon-picker.tsx +93 -70
  63. package/src/ui/components/primitives/input-group.tsx +35 -28
  64. package/src/ui/components/primitives/input-otp.tsx +16 -16
  65. package/src/ui/components/primitives/input.tsx +20 -10
  66. package/src/ui/components/primitives/item.tsx +9 -12
  67. package/src/ui/components/primitives/menu.tsx +44 -65
  68. package/src/ui/components/primitives/metric-card.tsx +75 -59
  69. package/src/ui/components/primitives/radio-group.tsx +9 -9
  70. package/src/ui/components/primitives/select.tsx +344 -209
  71. package/src/ui/components/primitives/switch.tsx +10 -10
  72. package/src/ui/components/primitives/table.tsx +29 -24
  73. package/src/ui/components/primitives/tabs.tsx +58 -88
  74. package/src/ui/components/primitives/textarea.tsx +6 -6
  75. package/src/ui/components/primitives/toggle-group-context.ts +24 -0
  76. package/src/ui/components/primitives/toggle-group.tsx +116 -74
  77. package/src/ui/components/primitives/toggle.tsx +82 -16
  78. package/src/ui/docs/DocBrowser.tsx +77 -69
  79. package/src/ui/docs/content/action-form-card.md +9 -9
  80. package/src/ui/docs/content/action-list.md +85 -70
  81. package/src/ui/docs/content/action-view.md +33 -18
  82. package/src/ui/docs/content/actions.md +6 -7
  83. package/src/ui/docs/content/button.md +1 -1
  84. package/src/ui/docs/content/communication.md +1 -1
  85. package/src/ui/docs/content/customization.md +64 -11
  86. package/src/ui/docs/content/detail.md +8 -6
  87. package/src/ui/docs/content/dialog.md +100 -58
  88. package/src/ui/docs/content/drawer.md +63 -19
  89. package/src/ui/docs/content/field.md +14 -15
  90. package/src/ui/docs/content/icon-tile.md +14 -13
  91. package/src/ui/docs/content/page.md +34 -26
  92. package/src/ui/docs/content/pane.md +41 -290
  93. package/src/ui/docs/content/presentation.md +3 -3
  94. package/src/ui/docs/content/semantic-context.md +0 -1
  95. package/src/ui/docs/content/separator.md +6 -6
  96. package/src/ui/docs/content/sidebar.md +68 -266
  97. package/src/ui/docs/content/skeleton.md +20 -18
  98. package/src/ui/docs/content/split.md +14 -14
  99. package/src/ui/docs/content/surface.md +513 -0
  100. package/src/ui/docs/content/tabs.md +82 -83
  101. package/src/ui/docs/content/testing.md +2 -3
  102. package/src/ui/docs/content/toggle.md +120 -63
  103. package/src/ui/docs/content/tokens.md +124 -40
  104. package/src/ui/docs/registry.tsx +4 -6
  105. package/src/ui/drivers/react.tsx +4 -3
  106. package/src/ui/index.ts +30 -27
  107. package/src/ui/meta.ts +15 -27
  108. package/src/ui/react.tsx +42 -46
  109. package/src/ui/theme.css +311 -64
  110. package/src/vite/design.ts +23 -21
  111. package/src/ui/components/patterns/content-header.tsx +0 -532
  112. package/src/ui/components/patterns/surface-media.tsx +0 -69
  113. package/src/ui/components/primitives/card.tsx +0 -191
  114. package/src/ui/docs/content/card.md +0 -292
  115. package/src/ui/docs/content/content.md +0 -276
@@ -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 'react'
24
- import { Check, ChevronDownIcon, SearchIcon, X } from 'lucide-react'
25
- import { Command as CommandPrimitive } from 'cmdk'
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 '../../lib/cn.ts'
28
- import { focusRing, focusRingWithin, type ControlShape, type ControlSize } from './control.ts'
29
- import { CommandEmpty, CommandGroup, CommandItem, CommandList } from './command.tsx'
30
- import { InputGroupButton } from './input-group.tsx'
31
- import { Popover, PopoverAnchor, PopoverContent } from './popover.tsx'
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
- 'aria-invalid'?: boolean
62
- 'aria-describedby'?: string
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, 'sm' | 'default'>
74
+ size?: Extract<ControlSize, "sm" | "default">;
65
75
  /** Geometria do controle; `pill` preserva a variante visual escolhida. */
66
- shape?: ControlShape
67
- 'aria-label'?: string
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?: 'default' | 'outline' | 'ghost'
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 & { multiple?: false; value: string; onChange: (value: string) => void })
107
- | (SelectCustomBase & { multiple: true; value: string[]; onChange: (value: string[]) => void })
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(options: SelectOption[]): Array<[string | undefined, SelectOption[]]> {
111
- const out: Array<[string | undefined, SelectOption[]]> = []
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 ? <NativeSelect {...props} /> : <CustomSelect {...props} />
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 = 'default',
133
- shape = 'default',
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('group/select relative w-full min-w-0 has-[select:disabled]:opacity-50', className)}
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['aria-label']}
161
- aria-invalid={rest['aria-invalid']}
162
- aria-describedby={rest['aria-describedby']}
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-muted/30 px-3 py-2 pr-9 text-sm transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground disabled:pointer-events-none disabled:cursor-not-allowed data-[size=sm]:h-8 data-[size=sm]:py-1 data-[shape=pill]:rounded-full dark:bg-input/30 dark:hover:bg-input/50",
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
- 'aria-invalid:border-context-danger aria-invalid:ring-context-danger/20 dark:aria-invalid:ring-context-danger/40',
168
- icon ? 'pl-9' : '',
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 key={o.value} value={o.value} disabled={o.disabled} className="bg-[Canvas] text-[CanvasText]">
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 key={group} label={group} className="bg-[Canvas] text-[CanvasText]">
216
+ <optgroup
217
+ key={group}
218
+ label={group}
219
+ className="bg-[Canvas] text-[CanvasText]"
220
+ >
185
221
  {items.map((o) => (
186
- <option key={o.value} value={o.value} disabled={o.disabled} className="bg-[Canvas] text-[CanvasText]">
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(props: Exclude<SelectProps, SelectNativeProps>): React.ReactElement {
245
+ function CustomSelect(
246
+ props: Exclude<SelectProps, SelectNativeProps>,
247
+ ): React.ReactElement {
205
248
  const {
206
249
  options,
207
- placeholder = 'Selecione…',
250
+ placeholder = "Selecione…",
208
251
  searchPlaceholder,
209
- emptyText = 'Nada encontrado.',
252
+ emptyText = "Nada encontrado.",
210
253
  disabled,
211
254
  className,
212
255
  id,
213
256
  icon,
214
- size = 'default',
215
- shape = 'default',
257
+ size = "default",
258
+ shape = "default",
216
259
  searchable = false,
217
260
  onSearch,
218
261
  loading,
219
262
  clearable,
220
263
  trailing,
221
- variant = 'default',
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 ? props.value : props.value ? [props.value] : []
226
- const singleValue = props.multiple ? undefined : props.value
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(props.value.includes(o.value) ? props.value.filter((v) => v !== o.value) : [...props.value, o.value])
280
- setQuery('')
281
- inputRef.current?.focus()
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 = options.length > 0 && options.every((o) => selectedValues.includes(o.value))
294
- const showToggleAll = props.multiple === true && options.length > 1 && onSearch === undefined && query.trim() === ''
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 === 'ghost'
302
- const outline = variant === 'outline'
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 ref={rootRef} data-slot="select" data-shape={shape} className={cn(compact ? 'w-fit max-w-full min-w-0' : 'min-w-40', shape === 'pill' && 'rounded-full', className)}>
346
- <CommandPrimitive data-slot="select-command" shouldFilter={canSearch && onSearch === undefined} className="overflow-visible">
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['aria-invalid']}
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
- 'flex items-center gap-1.5 rounded-md outline-none transition-[color,box-shadow] data-[shape=pill]:rounded-full',
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('min-h-9 px-3 py-1 text-muted-foreground hover:bg-muted hover:text-foreground data-[size=sm]:min-h-8 data-[size=sm]:px-2', focusRingWithin)
364
- : cn('min-h-9 border border-input bg-muted/30 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:bg-input/30 dark:aria-invalid:ring-context-danger/40', focusRingWithin),
365
- variant === 'default' && 'flex-wrap',
366
- outline && 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-input/50',
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 ? '' : 'cursor-pointer',
369
- disabled === true && 'cursor-not-allowed opacity-50',
445
+ canSearch ? "" : "cursor-pointer",
446
+ disabled === true && "cursor-not-allowed opacity-50",
370
447
  )}
371
448
  >
372
449
  {icon ? (
373
- <span data-slot="select-leading" className="text-muted-foreground">
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 key={v} data-slot="select-chip" className="inline-flex items-center gap-1 rounded bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground">
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
- 'min-w-0 truncate text-left text-sm',
410
- compact ? 'flex-initial' : 'flex-1',
411
- selectedOption === undefined && 'text-muted-foreground',
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 ? placeholder : (selectedOption?.triggerLabel ?? selectedOption?.label ?? placeholder)}
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['aria-label']}
426
- aria-invalid={props['aria-invalid']}
427
- aria-describedby={props['aria-describedby']}
428
- value={props.multiple ? '' : triggerText}
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 === 'Escape' && open) {
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 (!open && (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown')) {
448
- e.preventDefault()
449
- setOpen(true)
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={props.multiple && selectedValues.length > 0 ? '' : 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('flex shrink-0 items-center text-muted-foreground', canSearch && 'has-[button]:-mr-1.5')}
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 data-slot="select-icon" className={cn('size-4 shrink-0 opacity-50', ghost ? 'h-3.5 w-3.5 opacity-60' : '')} aria-hidden="true" />
477
- {clearable === true && selectedValues.length > 0 && disabled !== true && (
478
- <InputGroupButton
479
- type="button"
480
- size="icon-xs"
481
- data-slot="select-clear"
482
- aria-label="Limpar seleção"
483
- onPointerDown={(e) => e.preventDefault()}
484
- onClick={(e) => {
485
- e.stopPropagation()
486
- if (props.multiple) props.onChange([])
487
- else {
488
- props.onChange('')
489
- setQuery('')
490
- }
491
- }}
492
- className="-ml-1 -mr-1.5 text-muted-foreground hover:text-foreground"
493
- >
494
- <X className="size-3.5" />
495
- </InputGroupButton>
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
- 'pointer-events-auto overflow-hidden p-0',
612
+ "pointer-events-auto overflow-hidden p-0",
506
613
  compact
507
- ? 'w-auto min-w-56 max-w-80'
508
- : 'w-max min-w-(--radix-popover-trigger-width) max-w-lg',
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('label')
525
- if (id !== undefined && label !== null && label.htmlFor === id) e.preventDefault()
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 data-slot="select-search-row" className="flex items-center gap-2 border-b px-3">
534
- <SearchIcon className="size-4 shrink-0 opacity-50" aria-hidden="true" />
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 ?? 'Buscar…'}
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 === 'Escape') {
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-accent hover:text-accent-foreground"
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 ? 'Limpar seleção' : `Selecionar tudo (${options.length})`}
675
+ {allSelected
676
+ ? "Limpar seleção"
677
+ : `Selecionar tudo (${options.length})`}
562
678
  </button>
563
679
  </div>
564
680
  )}
565
- <CommandList data-slot="select-list" className="max-h-60 overflow-auto">
681
+ <CommandList
682
+ data-slot="select-list"
683
+ className="max-h-60 overflow-auto"
684
+ >
566
685
  {loading === true ? (
567
- <div data-slot="select-loading" className="py-6 text-center text-sm text-muted-foreground">
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 ?? '—'} heading={group}>
696
+ <CommandGroup key={group ?? "—"} heading={group}>
575
697
  {items.map((o) => {
576
- const hasHint = o.hint !== undefined && o.hint.length > 0
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 ?? ''} ${o.value}`}
705
+ value={`${o.label} ${o.hint ?? ""} ${o.value}`}
583
706
  onSelect={() => onSelectOption(o)}
584
707
  >
585
- <span data-slot="select-item-label" className="min-w-0 truncate">
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 data-slot="select-item-hint" className="ml-auto shrink-0 text-xs text-muted-foreground">
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('size-4 shrink-0', !hasHint && 'ml-auto', selectedValues.includes(o.value) ? 'opacity-100' : 'opacity-0')}
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
  }