@softize/opus 19.0.0 → 20.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.
Files changed (148) hide show
  1. package/CHANGELOG.md +239 -0
  2. package/bin/cli.mjs +5 -0
  3. package/bin/lib/check.mjs +198 -10
  4. package/bin/lib/copy.mjs +29 -0
  5. package/bin/lib/mcp.mjs +1 -1
  6. package/docs/adr/0020-icon-tiles-are-explicitly-composed.md +8 -0
  7. package/docs/adr/0021-content-carries-structure-and-semantic-element.md +7 -0
  8. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +19 -1
  9. package/docs/adr/0024-fill-height-propagates-by-context.md +118 -0
  10. package/docs/adr/0025-content-frame-treatment-does-not-change-semantics.md +134 -0
  11. package/docs/data-products.md +15 -9
  12. package/docs/protocol.md +3 -4
  13. package/docs/releasing.md +2 -2
  14. package/package.json +2 -2
  15. package/registry/skills/create-opus-action/scripts/scaffold.mjs +2 -1
  16. package/registry/templates/app/package.json +1 -1
  17. package/src/core/runtime.ts +96 -8
  18. package/src/core/types.ts +5 -2
  19. package/src/ui/components/patterns/action-form-card.tsx +11 -1
  20. package/src/ui/components/patterns/content-header.tsx +109 -18
  21. package/src/ui/components/patterns/fill-region.ts +150 -0
  22. package/src/ui/components/patterns/list.tsx +91 -92
  23. package/src/ui/components/patterns/page.tsx +20 -4
  24. package/src/ui/components/patterns/presentation.tsx +52 -37
  25. package/src/ui/components/patterns/split.tsx +44 -4
  26. package/src/ui/components/patterns/surface-anatomy.ts +35 -8
  27. package/src/ui/components/patterns/surface-header.tsx +58 -25
  28. package/src/ui/components/patterns/trigger.tsx +12 -2
  29. package/src/ui/components/primitives/alert.tsx +9 -1
  30. package/src/ui/components/primitives/avatar.tsx +7 -3
  31. package/src/ui/components/primitives/card.tsx +1 -1
  32. package/src/ui/components/primitives/carousel.tsx +34 -5
  33. package/src/ui/components/primitives/checkbox.tsx +11 -3
  34. package/src/ui/components/primitives/command.tsx +2 -1
  35. package/src/ui/components/primitives/copyable.tsx +120 -10
  36. package/src/ui/components/primitives/dialog.tsx +42 -11
  37. package/src/ui/components/primitives/drawer.tsx +10 -2
  38. package/src/ui/components/primitives/empty.tsx +1 -1
  39. package/src/ui/components/primitives/icon-picker.tsx +6 -2
  40. package/src/ui/components/primitives/icon-tile.tsx +7 -0
  41. package/src/ui/components/primitives/input-group.tsx +9 -2
  42. package/src/ui/components/primitives/input-otp.tsx +1 -1
  43. package/src/ui/components/primitives/kbd.tsx +1 -1
  44. package/src/ui/components/primitives/label.tsx +3 -0
  45. package/src/ui/components/primitives/menu.tsx +5 -0
  46. package/src/ui/components/primitives/popover.tsx +1 -1
  47. package/src/ui/components/primitives/progress.tsx +9 -1
  48. package/src/ui/components/primitives/slider.tsx +62 -11
  49. package/src/ui/components/primitives/truncate.tsx +10 -1
  50. package/src/ui/docs/content/accordion.md +74 -27
  51. package/src/ui/docs/content/action-form-card.md +24 -15
  52. package/src/ui/docs/content/action-form-dialog.md +17 -7
  53. package/src/ui/docs/content/action-form.md +92 -77
  54. package/src/ui/docs/content/action-list-dialog.md +75 -38
  55. package/src/ui/docs/content/action-list.md +141 -58
  56. package/src/ui/docs/content/action-trigger.md +27 -10
  57. package/src/ui/docs/content/action-view.md +14 -14
  58. package/src/ui/docs/content/actions.md +161 -66
  59. package/src/ui/docs/content/ai.md +43 -44
  60. package/src/ui/docs/content/alert.md +83 -29
  61. package/src/ui/docs/content/ask.md +51 -26
  62. package/src/ui/docs/content/aspect-ratio.md +22 -24
  63. package/src/ui/docs/content/audit.md +23 -19
  64. package/src/ui/docs/content/auth.md +38 -19
  65. package/src/ui/docs/content/avatar.md +54 -5
  66. package/src/ui/docs/content/badge.md +29 -9
  67. package/src/ui/docs/content/breadcrumb.md +21 -8
  68. package/src/ui/docs/content/button.md +73 -43
  69. package/src/ui/docs/content/calendar.md +134 -26
  70. package/src/ui/docs/content/card.md +41 -15
  71. package/src/ui/docs/content/carousel.md +37 -6
  72. package/src/ui/docs/content/chat.md +118 -37
  73. package/src/ui/docs/content/checkbox.md +73 -7
  74. package/src/ui/docs/content/cli.md +94 -55
  75. package/src/ui/docs/content/collapsible.md +43 -19
  76. package/src/ui/docs/content/command.md +30 -8
  77. package/src/ui/docs/content/composer.md +23 -8
  78. package/src/ui/docs/content/content.md +119 -27
  79. package/src/ui/docs/content/copyable.md +39 -8
  80. package/src/ui/docs/content/customization.md +55 -46
  81. package/src/ui/docs/content/cycle.md +33 -18
  82. package/src/ui/docs/content/data-products.md +115 -0
  83. package/src/ui/docs/content/data-state.md +20 -6
  84. package/src/ui/docs/content/data.md +20 -19
  85. package/src/ui/docs/content/detail.md +12 -5
  86. package/src/ui/docs/content/dialog.md +107 -24
  87. package/src/ui/docs/content/dictionary-value.md +4 -4
  88. package/src/ui/docs/content/dock.md +54 -19
  89. package/src/ui/docs/content/dot.md +35 -5
  90. package/src/ui/docs/content/drawer.md +63 -19
  91. package/src/ui/docs/content/empty-value.md +20 -6
  92. package/src/ui/docs/content/empty.md +24 -3
  93. package/src/ui/docs/content/events.md +76 -16
  94. package/src/ui/docs/content/executions.md +9 -9
  95. package/src/ui/docs/content/field.md +21 -6
  96. package/src/ui/docs/content/getting-started.md +44 -34
  97. package/src/ui/docs/content/icon-picker.md +19 -13
  98. package/src/ui/docs/content/icon-tile.md +38 -15
  99. package/src/ui/docs/content/input-otp.md +58 -7
  100. package/src/ui/docs/content/input.md +22 -3
  101. package/src/ui/docs/content/item.md +53 -6
  102. package/src/ui/docs/content/kbd.md +5 -1
  103. package/src/ui/docs/content/label.md +13 -4
  104. package/src/ui/docs/content/log.md +5 -5
  105. package/src/ui/docs/content/markdown.md +5 -4
  106. package/src/ui/docs/content/mcp.md +49 -23
  107. package/src/ui/docs/content/menu.md +95 -10
  108. package/src/ui/docs/content/metric-card.md +12 -4
  109. package/src/ui/docs/content/observability.md +10 -9
  110. package/src/ui/docs/content/page.md +118 -12
  111. package/src/ui/docs/content/pagination.md +13 -5
  112. package/src/ui/docs/content/pane.md +55 -5
  113. package/src/ui/docs/content/popover.md +42 -5
  114. package/src/ui/docs/content/presentation.md +91 -47
  115. package/src/ui/docs/content/progress.md +27 -14
  116. package/src/ui/docs/content/queue.md +39 -21
  117. package/src/ui/docs/content/radio-group.md +26 -4
  118. package/src/ui/docs/content/router.md +29 -10
  119. package/src/ui/docs/content/runtime.md +90 -54
  120. package/src/ui/docs/content/scheduler.md +53 -21
  121. package/src/ui/docs/content/scroll-area.md +42 -35
  122. package/src/ui/docs/content/select.md +78 -33
  123. package/src/ui/docs/content/semantic-context.md +21 -2
  124. package/src/ui/docs/content/separator.md +15 -9
  125. package/src/ui/docs/content/sidebar.md +3 -2
  126. package/src/ui/docs/content/skeleton.md +6 -1
  127. package/src/ui/docs/content/slider.md +37 -9
  128. package/src/ui/docs/content/spinner.md +4 -3
  129. package/src/ui/docs/content/split.md +19 -0
  130. package/src/ui/docs/content/storage.md +8 -9
  131. package/src/ui/docs/content/switch.md +10 -7
  132. package/src/ui/docs/content/table.md +46 -8
  133. package/src/ui/docs/content/tabs.md +14 -4
  134. package/src/ui/docs/content/testing.md +76 -31
  135. package/src/ui/docs/content/textarea.md +5 -4
  136. package/src/ui/docs/content/toast.md +48 -7
  137. package/src/ui/docs/content/toggle.md +3 -3
  138. package/src/ui/docs/content/tokens.md +15 -26
  139. package/src/ui/docs/content/tooltip.md +18 -2
  140. package/src/ui/docs/content/truncate.md +36 -6
  141. package/src/ui/docs/content/ui.md +74 -22
  142. package/src/ui/docs/content/upgrading.md +45 -36
  143. package/src/ui/docs/plugin.ts +36 -2
  144. package/src/ui/docs/registry.tsx +6 -0
  145. package/src/ui/drivers/react.tsx +16 -8
  146. package/src/ui/meta.ts +32 -32
  147. package/src/ui/react.tsx +1 -0
  148. package/src/vite/design.ts +16 -4
@@ -45,10 +45,12 @@ interface SurfaceHeaderSlots {
45
45
  title: ElementType;
46
46
  count?: ElementType;
47
47
  description?: ElementType;
48
- actions: ElementType;
48
+ actions?: ElementType;
49
49
  }
50
50
 
51
51
  export interface SurfaceHeaderProps extends HTMLAttributes<HTMLDivElement> {
52
+ /** Elemento estrutural; `legend` preserva a mesma anatomia com wrappers phrasing. */
53
+ as?: "div" | "legend";
52
54
  /** Nome do componente público, para as mensagens de erro (`PageHeader`, `ContentHeader`). */
53
55
  name: string;
54
56
  /** Prefixo dos `data-slot` (`page` → `page-header`, `page-heading`). */
@@ -57,6 +59,8 @@ export interface SurfaceHeaderProps extends HTMLAttributes<HTMLDivElement> {
57
59
  slots: SurfaceHeaderSlots;
58
60
  /** Posição de um slot introdutório opcional, antes da linha ou dentro dela. */
59
61
  leadingPlacement?: "above" | "inline";
62
+ /** Mantém a mídia na linha geral ou a agrupa com o heading. */
63
+ mediaPlacement?: "row" | "heading";
60
64
  /** Classes de um container interno quando a moldura precisa ocupar toda a largura. */
61
65
  contentClassName?: string;
62
66
  /** Permite uma superfície composta apenas pelas regiões leading/actions. */
@@ -71,6 +75,11 @@ export interface SurfaceHeaderProps extends HTMLAttributes<HTMLDivElement> {
71
75
  * NOMEIAM algo (Card, Dialog, Drawer, Page, Content) continuam estritas.
72
76
  */
73
77
  allowFreeContent?: boolean;
78
+ /**
79
+ * Mensagem própria para uma composição inválida, quando a regra do header não cabe na
80
+ * mensagem genérica (a legenda de um fieldset aceita menos slots que o header comum).
81
+ */
82
+ invalidMessage?: string;
74
83
  }
75
84
 
76
85
  /** Expande fragments de primeiro nível: o shorthand monta os slots dentro de um `<>`. */
@@ -83,19 +92,23 @@ function flatten(children: ReactNode): ReactNode[] {
83
92
  }
84
93
 
85
94
  export function SurfaceHeader({
95
+ as: Root = "div",
86
96
  name,
87
97
  slot,
88
98
  slots,
89
99
  leadingPlacement = "above",
100
+ mediaPlacement = "row",
90
101
  contentClassName,
91
102
  titleRequired = true,
92
103
  rowClassName,
93
104
  trailing,
94
105
  allowFreeContent = false,
106
+ invalidMessage,
95
107
  className,
96
108
  children,
97
109
  ...props
98
110
  }: SurfaceHeaderProps): ReactElement {
111
+ const Wrapper = Root === "legend" ? "span" : "div";
99
112
  const nodes = flatten(children);
100
113
  const of = (type: ElementType) =>
101
114
  nodes.filter((node) => isValidElement(node) && node.type === type);
@@ -111,7 +124,8 @@ export function SurfaceHeader({
111
124
  const counts = slots.count === undefined ? [] : of(slots.count);
112
125
  const descriptions =
113
126
  slots.description === undefined ? [] : of(slots.description);
114
- const actionSlots = of(slots.actions);
127
+ const actionSlots =
128
+ slots.actions === undefined ? [] : of(slots.actions);
115
129
  const slotTypes = [
116
130
  ...leadingTypes,
117
131
  slots.media,
@@ -167,6 +181,7 @@ export function SurfaceHeader({
167
181
  isValidElement(node) ? [node.type as ElementType] : [],
168
182
  ),
169
183
  );
184
+ if (invalidMessage !== undefined) throw new Error(invalidMessage);
170
185
  throw new Error(
171
186
  distinctLeading.size > 1
172
187
  ? `${name} aceita ${[...distinctLeading].map(label).join(" ou ")} na região introdutória, nunca os dois na mesma superfície.`
@@ -174,24 +189,42 @@ export function SurfaceHeader({
174
189
  );
175
190
  }
176
191
 
192
+ const titleLine = titles.length + counts.length > 0 && (
193
+ <Wrapper className="flex items-baseline gap-2">
194
+ {titles}
195
+ {counts}
196
+ </Wrapper>
197
+ );
177
198
  const heading = titles.length +
178
199
  counts.length +
179
200
  descriptions.length +
180
- free.length >
201
+ free.length +
202
+ (mediaPlacement === "heading" ? medias.length : 0) >
181
203
  0 && (
182
- <div
204
+ <Wrapper
183
205
  data-slot={`${slot}-heading`}
184
206
  className={cn("min-w-0", leadingPlacement === "inline" && "flex-1")}
185
207
  >
186
- {titles.length + counts.length > 0 && (
187
- <div className="flex items-baseline gap-2">
188
- {titles}
189
- {counts}
190
- </div>
208
+ {mediaPlacement === "heading" && medias.length > 0 ? (
209
+ <Wrapper
210
+ data-slot={`${slot}-heading-row`}
211
+ className="flex items-center gap-2"
212
+ >
213
+ {medias}
214
+ <Wrapper data-slot={`${slot}-heading-content`} className="min-w-0">
215
+ {titleLine}
216
+ {descriptions}
217
+ {free}
218
+ </Wrapper>
219
+ </Wrapper>
220
+ ) : (
221
+ <>
222
+ {titleLine}
223
+ {descriptions}
224
+ {free}
225
+ </>
191
226
  )}
192
- {descriptions}
193
- {free}
194
- </div>
227
+ </Wrapper>
195
228
  );
196
229
  // A região introdutória carrega o mesmo `data-slot` nas duas apresentações: seletor de teste
197
230
  // ou de CSS que funcione no header padrão precisa funcionar na barra.
@@ -200,14 +233,14 @@ export function SurfaceHeader({
200
233
  {leadingPlacement === "inline" && leading.length > 0 && (
201
234
  // `min-w-0` aqui é o que deixa a trilha do PageNavigation ceder e truncar; sem ele o
202
235
  // wrapper assume o min-content do breadcrumb e a compressão toda cai sobre o título.
203
- <div
236
+ <Wrapper
204
237
  data-slot={`${slot}-navigation`}
205
238
  className={cn("flex min-w-0 items-center", !heading && "flex-1")}
206
239
  >
207
240
  {leading}
208
- </div>
241
+ </Wrapper>
209
242
  )}
210
- {medias}
243
+ {mediaPlacement === "row" && medias}
211
244
  {heading}
212
245
  {actionSlots}
213
246
  {trailing}
@@ -215,15 +248,15 @@ export function SurfaceHeader({
215
248
  );
216
249
  const layout =
217
250
  leadingPlacement === "inline" ? (
218
- <div className={cn("flex min-w-0 items-center gap-3", rowClassName)}>
251
+ <Wrapper className={cn("flex min-w-0 items-center gap-3", rowClassName)}>
219
252
  {row}
220
- </div>
253
+ </Wrapper>
221
254
  ) : leading.length > 0 ? (
222
255
  <>
223
- <div data-slot={`${slot}-navigation`} className="mb-4">
256
+ <Wrapper data-slot={`${slot}-navigation`} className="mb-4">
224
257
  {leading}
225
- </div>
226
- <div
258
+ </Wrapper>
259
+ <Wrapper
227
260
  data-slot={`${slot}-header-row`}
228
261
  className={cn(
229
262
  actionSlots.length > 0 &&
@@ -232,7 +265,7 @@ export function SurfaceHeader({
232
265
  >
233
266
  {heading}
234
267
  {actionSlots}
235
- </div>
268
+ </Wrapper>
236
269
  </>
237
270
  ) : (
238
271
  <>
@@ -242,7 +275,7 @@ export function SurfaceHeader({
242
275
  );
243
276
 
244
277
  return (
245
- <div
278
+ <Root
246
279
  data-slot={`${slot}-header`}
247
280
  className={cn(
248
281
  contentClassName === undefined &&
@@ -257,10 +290,10 @@ export function SurfaceHeader({
257
290
  {contentClassName === undefined ? (
258
291
  layout
259
292
  ) : (
260
- <div data-slot={`${slot}-header-content`} className={contentClassName}>
293
+ <Wrapper data-slot={`${slot}-header-content`} className={contentClassName}>
261
294
  {layout}
262
- </div>
295
+ </Wrapper>
263
296
  )}
264
- </div>
297
+ </Root>
265
298
  );
266
299
  }
@@ -66,6 +66,7 @@ export interface ActionTriggerProps<TInput, TData> {
66
66
  title: string;
67
67
  /** Conteúdo relevante apresentado antes das ações. */
68
68
  body?: ReactNode;
69
+ /** Texto do botão de confirmar. Default: `action.label`; o `label` do gatilho só sem ele. */
69
70
  actionLabel?: string;
70
71
  cancelLabel?: string;
71
72
  };
@@ -123,6 +124,15 @@ export function ActionTrigger<TInput, TData>({
123
124
 
124
125
  const buttonLabel =
125
126
  label ?? (typeof action.label === "string" ? action.label : action.name);
127
+ /**
128
+ * Botão de confirmar: `confirmLabel` do contrato, depois o `label` da action. O `label` do
129
+ * gatilho costuma ser um nome acessível que identifica o alvo ("Excluir Unidade Centro"); na
130
+ * pergunta o alvo já está no corpo, então ele só entra quando o contrato não tem rótulo.
131
+ */
132
+ const confirmActionLabel =
133
+ action.label !== undefined
134
+ ? msgText(action.label, buttonLabel)
135
+ : buttonLabel;
126
136
 
127
137
  // Prop sobrepõe; sem prop, o ConfirmSpec DECLARADO no contrato (action.confirm) vale.
128
138
  const spec = action.confirm;
@@ -135,7 +145,7 @@ export function ActionTrigger<TInput, TData>({
135
145
  ? { body: msgText(spec.message, "") }
136
146
  : {}),
137
147
  ...(spec.confirmLabel !== undefined
138
- ? { actionLabel: msgText(spec.confirmLabel, buttonLabel) }
148
+ ? { actionLabel: msgText(spec.confirmLabel, confirmActionLabel) }
139
149
  : {}),
140
150
  ...(spec.cancelLabel !== undefined
141
151
  ? { cancelLabel: msgText(spec.cancelLabel, "Cancelar") }
@@ -248,7 +258,7 @@ export function ActionTrigger<TInput, TData>({
248
258
  busy={isLoading}
249
259
  onClick={fire}
250
260
  >
251
- {confirm.actionLabel ?? buttonLabel}
261
+ {confirm.actionLabel ?? confirmActionLabel}
252
262
  </Button>
253
263
  </ButtonGroup>
254
264
  </DialogFooter>
@@ -71,6 +71,14 @@ function alertMediaClasses(context: AlertContext): string {
71
71
  );
72
72
  }
73
73
 
74
+ /**
75
+ * `role="alert"` interrompe o leitor de tela; só perigo e atenção justificam a interrupção.
76
+ * Os demais contextos são anunciados sem cortar a leitura, como `role="status"`.
77
+ */
78
+ function alertRole(context: AlertContext): "alert" | "status" {
79
+ return context === "danger" || context === "warning" ? "alert" : "status";
80
+ }
81
+
74
82
  const alertHeaderClasses =
75
83
  "col-start-2 row-start-1 flex min-w-0 flex-col justify-center gap-1";
76
84
 
@@ -147,7 +155,7 @@ export function Alert({
147
155
  data-slot="alert"
148
156
  data-context={resolved.context}
149
157
  data-variant={resolved.variant}
150
- role="alert"
158
+ role={alertRole(resolved.context)}
151
159
  className={cn(alertVariants(resolved), className)}
152
160
  {...props}
153
161
  >
@@ -8,6 +8,10 @@ import type { ControlSize } from './control.ts'
8
8
  * Ejetado (14.0.0): `size` segue a escala de control.ts — o avatar de uma linha `sm` mede o
9
9
  * mesmo que o controle `sm` ao lado (2rem); `xs` (1.5rem) é a lista densa; `default` (2.25rem)
10
10
  * e `lg` (2.5rem) pareiam com Button/Input. O fallback e o selo acompanham o tamanho.
11
+ *
12
+ * O root não recorta (20.0.0): imagem e fallback herdam o raio do root e se recortam sozinhos, e
13
+ * o AvatarBadge, que transborda o círculo no canto, aparece inteiro. A forma continua declarada
14
+ * no root (`rounded-full` por padrão, `rounded-lg` para logo) e o anel do AvatarGroup a segue.
11
15
  */
12
16
  function Avatar({
13
17
  className,
@@ -21,7 +25,7 @@ function Avatar({
21
25
  data-slot="avatar"
22
26
  data-size={size}
23
27
  className={cn(
24
- "group/avatar relative flex shrink-0 overflow-hidden rounded-full select-none data-[size=xs]:size-6 data-[size=sm]:size-8 data-[size=default]:size-9 data-[size=lg]:size-10",
28
+ "group/avatar relative flex shrink-0 rounded-full select-none data-[size=xs]:size-6 data-[size=sm]:size-8 data-[size=default]:size-9 data-[size=lg]:size-10",
25
29
  className
26
30
  )}
27
31
  {...props}
@@ -36,7 +40,7 @@ function AvatarImage({
36
40
  return (
37
41
  <AvatarPrimitive.Image
38
42
  data-slot="avatar-image"
39
- className={cn("aspect-square size-full", className)}
43
+ className={cn("aspect-square size-full rounded-[inherit] object-cover", className)}
40
44
  {...props}
41
45
  />
42
46
  )
@@ -50,7 +54,7 @@ function AvatarFallback({
50
54
  <AvatarPrimitive.Fallback
51
55
  data-slot="avatar-fallback"
52
56
  className={cn(
53
- "flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=xs]/avatar:text-xs group-data-[size=sm]/avatar:text-xs",
57
+ "flex size-full items-center justify-center overflow-hidden rounded-[inherit] bg-muted text-sm text-muted-foreground group-data-[size=xs]/avatar:text-xs group-data-[size=sm]/avatar:text-xs",
54
58
  className
55
59
  )}
56
60
  {...props}
@@ -52,7 +52,7 @@ export function Card({
52
52
  data-slot="card"
53
53
  data-layout={layout}
54
54
  className={cn(
55
- "rounded-xl border border-border bg-card text-card-foreground",
55
+ "rounded-xl border border-border bg-card text-card-foreground [--surface-background:var(--card)]",
56
56
  // Seccionado: o divisor encosta na borda, então o raio precisa recortar.
57
57
  layout === "sectioned" && "overflow-hidden",
58
58
  className,
@@ -34,12 +34,33 @@ function useCarousel() {
34
34
  const context = React.useContext(CarouselContext)
35
35
 
36
36
  if (!context) {
37
- throw new Error("useCarousel must be used within a <Carousel />")
37
+ throw new Error(
38
+ "useCarousel só funciona dentro de <Carousel />. Coloque CarouselContent, CarouselItem, CarouselPrevious e CarouselNext dentro de um <Carousel>."
39
+ )
38
40
  }
39
41
 
40
42
  return context
41
43
  }
42
44
 
45
+ // Quem usa as setas por conta própria: campos editáveis e widgets compostos que navegam por dentro
46
+ // (Slider, RadioGroup, Tabs, listas, menus; ToggleGroup e Accordion pelos itens de coleção do
47
+ // Radix). A captura do carrossel roda antes deles e, com `preventDefault`, os handlers do Radix
48
+ // desistem do evento — por isso as setas vindas daqui ficam com o widget.
49
+ const ARROW_OWNERS =
50
+ "input, textarea, select, [contenteditable]:not([contenteditable=false]), " +
51
+ "[role=slider], [role=radiogroup], [role=tablist], [role=listbox], [role=menu], [role=menubar], " +
52
+ "[role=combobox], [role=grid], [role=tree], [role=spinbutton], [role=toolbar], " +
53
+ "[data-radix-collection-item]"
54
+
55
+ // Só conta o dono que está DENTRO do carrossel (ou num portal aberto a partir dele): um carrossel
56
+ // posto dentro de uma toolbar ou de uma célula de grid continua respondendo às setas.
57
+ function ownsArrowKeys(target: EventTarget, carousel: EventTarget): boolean {
58
+ if (!(target instanceof HTMLElement)) return false
59
+ if (target.isContentEditable) return true
60
+ const owner = target.closest(ARROW_OWNERS)
61
+ return owner !== null && !(carousel instanceof Node && owner.contains(carousel))
62
+ }
63
+
43
64
  function Carousel({
44
65
  orientation = "horizontal",
45
66
  opts,
@@ -75,15 +96,22 @@ function Carousel({
75
96
 
76
97
  const handleKeyDown = React.useCallback(
77
98
  (event: React.KeyboardEvent<HTMLDivElement>) => {
78
- if (event.key === "ArrowLeft") {
99
+ // As setas de um campo editável dentro do slide movem o cursor ou mudam a opção; o
100
+ // carrossel não as rouba.
101
+ if (ownsArrowKeys(event.target, event.currentTarget)) return
102
+ const [prevKey, nextKey] =
103
+ orientation === "vertical"
104
+ ? ["ArrowUp", "ArrowDown"]
105
+ : ["ArrowLeft", "ArrowRight"]
106
+ if (event.key === prevKey) {
79
107
  event.preventDefault()
80
108
  scrollPrev()
81
- } else if (event.key === "ArrowRight") {
109
+ } else if (event.key === nextKey) {
82
110
  event.preventDefault()
83
111
  scrollNext()
84
112
  }
85
113
  },
86
- [scrollPrev, scrollNext]
114
+ [orientation, scrollPrev, scrollNext]
87
115
  )
88
116
 
89
117
  React.useEffect(() => {
@@ -98,7 +126,8 @@ function Carousel({
98
126
  api.on("select", onSelect)
99
127
 
100
128
  return () => {
101
- api?.off("select", onSelect)
129
+ api.off("reInit", onSelect)
130
+ api.off("select", onSelect)
102
131
  }
103
132
  }, [api, onSelect])
104
133
 
@@ -1,5 +1,5 @@
1
1
  import * as React from "react"
2
- import { CheckIcon } from "lucide-react"
2
+ import { CheckIcon, MinusIcon } from "lucide-react"
3
3
  import { Checkbox as CheckboxPrimitive } from "radix-ui"
4
4
 
5
5
  import { cn } from '../../lib/cn.ts'
@@ -12,7 +12,7 @@ function Checkbox({
12
12
  <CheckboxPrimitive.Root
13
13
  data-slot="checkbox"
14
14
  className={cn(
15
- "peer size-4 shrink-0 rounded-[0.25rem] border border-input transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-context-danger aria-invalid:ring-context-danger/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-context-danger/40 dark:data-[state=checked]:bg-primary",
15
+ "peer group/checkbox size-4 shrink-0 rounded-[0.25rem] border border-input transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-context-danger aria-invalid:ring-context-danger/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-context-danger/40 dark:data-[state=checked]:bg-primary dark:data-[state=indeterminate]:bg-primary",
16
16
  className
17
17
  )}
18
18
  {...props}
@@ -21,7 +21,15 @@ function Checkbox({
21
21
  data-slot="checkbox-indicator"
22
22
  className="grid place-content-center text-current transition-none"
23
23
  >
24
- <CheckIcon className="size-3.5" />
24
+ {/* Indeterminado ("alguns marcados") tem glifo próprio: o traço não se confunde com o check. */}
25
+ <CheckIcon
26
+ data-slot="checkbox-check"
27
+ className="size-3.5 group-data-[state=indeterminate]/checkbox:hidden"
28
+ />
29
+ <MinusIcon
30
+ data-slot="checkbox-indeterminate"
31
+ className="hidden size-3.5 group-data-[state=indeterminate]/checkbox:block"
32
+ />
25
33
  </CheckboxPrimitive.Indicator>
26
34
  </CheckboxPrimitive.Root>
27
35
  )
@@ -103,12 +103,13 @@ function CommandList({
103
103
  }
104
104
 
105
105
  function CommandEmpty({
106
+ className,
106
107
  ...props
107
108
  }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
108
109
  return (
109
110
  <CommandPrimitive.Empty
110
111
  data-slot="command-empty"
111
- className="py-6 text-center text-sm"
112
+ className={cn("py-6 text-center text-sm", className)}
112
113
  {...props}
113
114
  />
114
115
  );
@@ -2,6 +2,76 @@ import * as React from 'react'
2
2
  import { CheckIcon, CopyIcon } from 'lucide-react'
3
3
  import { cn } from '../../lib/cn.ts'
4
4
 
5
+ /**
6
+ * A região viva que anuncia "Copiado", uma por documento.
7
+ *
8
+ * Uma tela com dezenas de Copyable (uma tabela de IDs, por exemplo) montaria dezenas de regiões
9
+ * `role="status"` vazias no fim do `body`. Todas as instâncias compartilham a mesma: a primeira a
10
+ * montar cria o nó, a última a desmontar o remove. Quem escreve a confirmação vira dono do texto;
11
+ * só o dono o apaga, para que a expiração de uma cópia antiga não silencie a mais recente.
12
+ * Se algo fora do React tirar o nó do documento (um `body.innerHTML` de script legado, por
13
+ * exemplo), a próxima montagem ou cópia o recoloca, para que o anúncio não suma em silêncio.
14
+ */
15
+ interface SharedStatus {
16
+ node: HTMLSpanElement
17
+ users: number
18
+ owner: symbol | undefined
19
+ }
20
+
21
+ const statusByDocument = new WeakMap<Document, SharedStatus>()
22
+
23
+ function acquireStatus(doc: Document): SharedStatus {
24
+ let shared = statusByDocument.get(doc)
25
+ if (shared === undefined) {
26
+ const node = doc.createElement('span')
27
+ node.setAttribute('role', 'status')
28
+ node.setAttribute('aria-live', 'polite')
29
+ node.setAttribute('data-slot', 'copyable-status')
30
+ node.className = 'sr-only'
31
+ doc.body.appendChild(node)
32
+ shared = { node, users: 0, owner: undefined }
33
+ statusByDocument.set(doc, shared)
34
+ }
35
+ ensureConnected(shared.node, doc)
36
+ shared.users += 1
37
+ return shared
38
+ }
39
+
40
+ // Devolve `true` quando precisou recolocar a região no `body`.
41
+ function ensureConnected(node: HTMLSpanElement, doc: Document): boolean {
42
+ if (node.isConnected) return false
43
+ doc.body.appendChild(node)
44
+ return true
45
+ }
46
+
47
+ function releaseStatus(doc: Document, shared: SharedStatus, id: symbol): void {
48
+ if (shared.owner === id) clearStatus(shared, id)
49
+ shared.users -= 1
50
+ if (shared.users > 0) return
51
+ shared.node.remove()
52
+ if (statusByDocument.get(doc) === shared) statusByDocument.delete(doc)
53
+ }
54
+
55
+ function announceCopied(shared: SharedStatus, id: symbol): void {
56
+ shared.owner = id
57
+ const doc = shared.node.ownerDocument
58
+ // Um nó de texto novo, em vez de manter o anterior: uma segunda cópia enquanto "Copiado" ainda
59
+ // está na região (da mesma instância ou de outra) continua sendo uma mudança a anunciar.
60
+ const write = (): void => {
61
+ if (shared.owner === id) shared.node.replaceChildren(doc.createTextNode('Copiado'))
62
+ }
63
+ // Uma região recém-inserida já com texto costuma não ser lida: depois de recolocá-la, o texto
64
+ // entra no tick seguinte, quando o leitor de tela já a conhece.
65
+ if (ensureConnected(shared.node, doc)) setTimeout(write, 0)
66
+ else write()
67
+ }
68
+
69
+ function clearStatus(shared: SharedStatus, id: symbol): void {
70
+ if (shared.owner !== id) return
71
+ shared.owner = undefined
72
+ shared.node.replaceChildren()
73
+ }
74
+
5
75
  export interface CopyableProps extends Omit<React.ComponentProps<'button'>, 'value'> {
6
76
  /** O texto que vai pro clipboard no clique. */
7
77
  value: string
@@ -13,7 +83,16 @@ export interface CopyableProps extends Omit<React.ComponentProps<'button'>, 'val
13
83
  * Clicar-pra-copiar com feedback: copia `value` pro clipboard e troca o ícone por um
14
84
  * check por ~1.5s. Sem filhos é um botão-ícone (bom pra toolbar/célula); com filhos, o
15
85
  * rótulo visível + o ícone à direita. Pra IDs, tokens, slugs, URLs. No-op silencioso se
16
- * o clipboard não estiver disponível (contexto inseguro/SSR).
86
+ * o clipboard não estiver disponível (contexto inseguro/SSR) ou recusar a escrita (permissão
87
+ * negada, documento sem foco): o botão só confirma "copiado" depois que a escrita conclui.
88
+ *
89
+ * Nome acessível: com filhos, é o próprio texto visível (quem fala o que vê aciona o botão);
90
+ * sem filhos, "Copiar" e, na confirmação, "Copiado". Um `aria-label` do consumidor vence os
91
+ * dois. A confirmação é anunciada por uma região viva no fim de `document.body`, compartilhada
92
+ * por todas as instâncias do documento: dentro do botão, o texto entraria no nome calculado pelo
93
+ * conteúdo (e o botão não expõe descendentes como regiões); como irmã, mudaria a estrutura do
94
+ * contêiner e seletores como `:first-child` de um ButtonGroup. A região só é criada no cliente,
95
+ * depois da hidratação, e existe antes da primeira cópia para que a mudança seja anunciada.
17
96
  */
18
97
  export function Copyable({
19
98
  value,
@@ -25,26 +104,57 @@ export function Copyable({
25
104
  }: CopyableProps): React.ReactElement {
26
105
  const [copied, setCopied] = React.useState(false)
27
106
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
28
- React.useEffect(() => () => clearTimeout(timer.current), [])
107
+ const [id] = React.useState(() => Symbol('copyable'))
108
+ const status = React.useRef<SharedStatus | undefined>(undefined)
109
+ React.useEffect(() => {
110
+ const doc = document
111
+ const shared = acquireStatus(doc)
112
+ status.current = shared
113
+ return () => {
114
+ clearTimeout(timer.current)
115
+ status.current = undefined
116
+ releaseStatus(doc, shared, id)
117
+ }
118
+ }, [id])
119
+ const iconOnly = children === undefined || children === null || children === false || children === ''
29
120
  return (
30
121
  <button
31
122
  type="button"
32
123
  data-slot="copyable"
33
124
  data-copied={copied || undefined}
34
- aria-label={copied ? 'Copiado' : 'Copiar'}
35
- onClick={(e) => {
125
+ aria-label={iconOnly ? (copied ? 'Copiado' : 'Copiar') : undefined}
126
+ onClick={async (e) => {
36
127
  onClick?.(e)
37
- void navigator.clipboard?.writeText(value).then(() => {
38
- setCopied(true)
39
- clearTimeout(timer.current)
40
- timer.current = setTimeout(() => setCopied(false), feedbackMs)
41
- })
128
+ const clipboard = globalThis.navigator?.clipboard
129
+ if (clipboard === undefined) return
130
+ clearTimeout(timer.current)
131
+ try {
132
+ await clipboard.writeText(value)
133
+ } catch {
134
+ // Recusado: some com um "copiado" anterior para não afirmar o que não aconteceu.
135
+ setCopied(false)
136
+ if (status.current !== undefined) clearStatus(status.current, id)
137
+ return
138
+ }
139
+ const shared = status.current
140
+ // Desmontou enquanto a escrita corria: não há botão nem região a atualizar.
141
+ if (shared === undefined) return
142
+ setCopied(true)
143
+ announceCopied(shared, id)
144
+ timer.current = setTimeout(() => {
145
+ setCopied(false)
146
+ clearStatus(shared, id)
147
+ }, feedbackMs)
42
148
  }}
43
149
  className={cn('inline-flex items-center gap-1.5', className)}
44
150
  {...props}
45
151
  >
46
152
  {children}
47
- {copied ? <CheckIcon className="size-3.5 text-context-success-emphasis" /> : <CopyIcon className="size-3.5" />}
153
+ {copied ? (
154
+ <CheckIcon aria-hidden="true" className="size-3.5 text-context-success-emphasis" />
155
+ ) : (
156
+ <CopyIcon aria-hidden="true" className="size-3.5" />
157
+ )}
48
158
  </button>
49
159
  )
50
160
  }