@softize/opus 22.2.0 → 23.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 (40) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/bin/cli.mjs +1 -9
  3. package/bin/lib/check.mjs +1 -7
  4. package/bin/lib/copy.mjs +1 -1
  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/0032-tab-baseline-respects-the-container-inset.md +16 -12
  9. package/docs/adr/0034-tabs-can-own-a-frame-without-owning-a-surface.md +8 -6
  10. package/docs/adr/0036-tabs-declares-trigger-distribution.md +11 -8
  11. package/docs/adr/0037-design-data-lives-outside-the-contract.md +1 -1
  12. package/docs/adr/0038-content-level-defines-primary-presentation.md +62 -0
  13. package/docs/adr/0039-filter-presentation-is-independent-from-filter-type.md +62 -0
  14. package/docs/adr/0040-tabs-use-a-single-navigation-presentation.md +60 -0
  15. package/docs/code-style.md +1 -1
  16. package/package.json +1 -1
  17. package/registry/skills/build-opus-ui/references/ui-patterns.md +1 -1
  18. package/registry/skills/create-opus-action/references/contract-and-binding.md +3 -3
  19. package/src/core/contracts.ts +1 -1
  20. package/src/core/runtime.ts +4 -37
  21. package/src/core/types.ts +5 -23
  22. package/src/design/index.ts +14 -3
  23. package/src/testing/index.ts +6 -26
  24. package/src/ui/components/patterns/content-header.tsx +5 -9
  25. package/src/ui/components/patterns/list.tsx +55 -1
  26. package/src/ui/components/patterns/presentation.tsx +1 -1
  27. package/src/ui/components/primitives/icon-picker.tsx +6 -0
  28. package/src/ui/components/primitives/tabs.tsx +55 -87
  29. package/src/ui/components/primitives/toggle.tsx +1 -1
  30. package/src/ui/docs/content/action-list.md +13 -4
  31. package/src/ui/docs/content/actions.md +6 -7
  32. package/src/ui/docs/content/content.md +5 -6
  33. package/src/ui/docs/content/semantic-context.md +0 -1
  34. package/src/ui/docs/content/tabs.md +68 -69
  35. package/src/ui/docs/content/testing.md +2 -3
  36. package/src/ui/docs/content/toggle.md +15 -10
  37. package/src/ui/drivers/react.tsx +4 -3
  38. package/src/ui/meta.ts +2 -2
  39. package/src/ui/react.tsx +2 -3
  40. package/src/vite/design.ts +23 -21
package/src/core/types.ts CHANGED
@@ -638,6 +638,9 @@ export interface FilterSpec {
638
638
 
639
639
  type: 'text' | 'select' | 'lookup' | 'date' | 'number'
640
640
  multiple?: boolean
641
+ /** Controle usado para um `select` de escolha única. `select` é o padrão; `toggle`
642
+ * mantém todas as opções curtas visíveis em um grupo conectado. */
643
+ presentation?: 'select' | 'toggle'
641
644
  /** Habilita busca local nas opções de um `select`; `lookup` já é pesquisável por natureza. */
642
645
  searchable?: boolean
643
646
  /** Quando `false`, a interface omite a opção vazia. Limpar restaura o default do filtro. */
@@ -799,7 +802,7 @@ export type LoadersSpec<In> = Record<string, LoaderFn<In>>
799
802
  * aqui.
800
803
  * - **`ParsedIn`** — o lado PARSEADO (o output do schema de input, defaults
801
804
  * aplicados). O runtime SEMPRE valida antes de executar, então `handler`,
802
- * `mockHandler`, `authorize` e `loads` recebem `ParsedIn`.
805
+ * `handler`, `authorize` e `loads` recebem `ParsedIn`.
803
806
  *
804
807
  * O default `ParsedIn = In` mantém retrocompat com anotações à mão de dois
805
808
  * genéricos; `defineAction`/`defineContract` inferem os dois lados do schema.
@@ -848,16 +851,6 @@ export interface ActionBase<In, Out, ParsedIn = In> {
848
851
  // — Execução —
849
852
  handler: HandlerFn<ParsedIn, Out>
850
853
 
851
- /**
852
- * Handler alternativo executado quando `process.env.OPUS_MODE === 'design'`.
853
- *
854
- * @deprecated Desde a 22.1.0 (ADR 0037); sai na próxima major. No modo design, a action sem
855
- * mock já responde com dado gerado a partir do `output`; quando o design precisar de uma resposta
856
- * específica, declare um cenário fora do contrato com `defineDesignScenario`, de
857
- * `@softize/opus/design`, e aponte a opção `scenarios` do `opusDesign()` para ele.
858
- */
859
- mockHandler?: HandlerFn<ParsedIn, Out>
860
-
861
854
  // — Comportamento —
862
855
  audit?: boolean | AuditConfig
863
856
  confirm?: ConfirmSpec
@@ -922,24 +915,13 @@ export interface SearchFilters {
922
915
  * envelope — handler é fonte de verdade.
923
916
  */
924
917
  export interface ListAction<In, Out, ParsedIn = In>
925
- extends Omit<ActionBase<In, Out, ParsedIn>, 'handler' | 'mockHandler'> {
918
+ extends Omit<ActionBase<In, Out, ParsedIn>, 'handler'> {
926
919
  kind: 'list'
927
920
  handler: (
928
921
  ctx: ActionContext,
929
922
  input: ParsedIn,
930
923
  loaded?: Record<string, unknown>,
931
924
  ) => Paginated<Out> | Promise<Paginated<Out>>
932
- /**
933
- * Mesma semântica de `ActionBase.mockHandler`, mas devolve `Paginated<Out>`
934
- * (search retorna lista paginada, não item solto).
935
- *
936
- * @deprecated Desde a 22.1.0 (ADR 0037); ver `ActionBase.mockHandler`.
937
- */
938
- mockHandler?: (
939
- ctx: ActionContext,
940
- input: ParsedIn,
941
- loaded?: Record<string, unknown>,
942
- ) => Paginated<Out> | Promise<Paginated<Out>>
943
925
  /** Colunas declarativas da listagem (a UI deriva a tabela; ver ListColumnSpec). */
944
926
  columns?: ListColumnSpec[]
945
927
  filters?: Record<string, FilterSpec>
@@ -9,7 +9,7 @@
9
9
  * respostas ao runtime de design.
10
10
  */
11
11
 
12
- import { z } from 'zod'
12
+ import type { ZodTypeAny } from 'zod'
13
13
  import type { ActionContext, InferOutput, Paginated } from '../core/types.ts'
14
14
  import { fake, fakeMany } from '../testing/fake.ts'
15
15
 
@@ -102,11 +102,14 @@ interface DesignActionShape {
102
102
  * - `list` devolve três páginas do tamanho pedido em `limit` ({@link DESIGN_PAGE_SIZE} quando a
103
103
  * entrada não pede) e respeita a paginação declarada; busca e filtros não filtram o dado gerado.
104
104
  * - Nas demais, a saída ecoa o `id` da entrada quando o schema da saída aceita esse valor.
105
- * - Saída que não é schema zod não tem gerador: a resposta é `null`.
105
+ * - Saída que não é schema Zod não tem gerador: a resposta é `null`.
106
106
  */
107
107
  export function designResponse(action: DesignActionShape, input: unknown): unknown {
108
108
  const output = action.output
109
- if (!(output instanceof z.ZodType)) return null
109
+ // O contrato vem do grafo do consumidor e pode carregar outra instância física do Zod.
110
+ // `instanceof` confundiria identidade de módulo com contrato; o gerador usa somente a
111
+ // estrutura pública/estável que já recebe (`safeParse`) e a definição introspectável (`_def`).
112
+ if (!isZodSchema(output)) return null
110
113
  const seed = `${action.name}:${canonicalJson(withoutPagination(input))}`
111
114
 
112
115
  if (action.kind === 'list') {
@@ -177,3 +180,11 @@ function canonicalJson(value: unknown): string {
177
180
  function isRecord(value: unknown): value is Record<string, unknown> {
178
181
  return typeof value === 'object' && value !== null && !Array.isArray(value)
179
182
  }
183
+
184
+ function isZodSchema(value: unknown): value is ZodTypeAny {
185
+ return (
186
+ isRecord(value) &&
187
+ isRecord(value._def) &&
188
+ typeof value.safeParse === 'function'
189
+ )
190
+ }
@@ -14,8 +14,6 @@
14
14
  * valida output (pulado em `list`, como no runtime). Falha estoura o MESMO
15
15
  * `ActionError` tipado do runtime (`validation.invalid_input`, `auth.unauthenticated`,
16
16
  * `auth.system_forbidden`, `auth.forbidden`…). A opção `schedule` executa como o sistema.
17
- * Em `OPUS_MODE=design` roteia pro `mockHandler` como o runtime — contrato puro
18
- * (sem handler) é testável em design; fora dele, erro claro (`testing.no_handler`).
19
17
  * • `memStorage()` — StorageAdapter em memória pra testar handler que anexa
20
18
  * arquivo, com a mesma régua de key dos drivers reais.
21
19
  *
@@ -30,9 +28,7 @@ import { evalExpression } from '../dsl/eval.ts'
30
28
  import { parseExpression } from '../dsl/parser.ts'
31
29
  import { normalizeKey } from '../storage/index.ts'
32
30
  import { error } from '../core/errors.ts'
33
- // Import direto do runtime (fora do index público): a régua handler×mockHandler
34
- // tem UMA fonte — o harness nunca pode divergir do execute().
35
- import { assertAuthenticated, isOpusDesignMode, markSystemActor } from '../core/runtime.ts'
31
+ import { assertAuthenticated, markSystemActor } from '../core/runtime.ts'
36
32
  import type {
37
33
  ActionContext,
38
34
  ActionKind,
@@ -206,14 +202,8 @@ export interface TestableAction<In, Out, ParsedIn = In> {
206
202
  authorize?: AuthorizeSpec<ParsedIn>
207
203
  input: Schema<In, ParsedIn>
208
204
  output: Schema<Out> | Schema<unknown, Out>
209
- /** Opcional: contrato puro (sem handler) só executa pelo `mockHandler` obsoleto. */
205
+ /** Opcional para produzir um erro útil quando um contrato puro chega ao harness. */
210
206
  handler?: HandlerFn<ParsedIn, Out>
211
- /**
212
- * Em `OPUS_MODE=design` vence o handler real — mesma régua do runtime.
213
- *
214
- * @deprecated Desde a 22.1.0 (ADR 0037); teste a action ligada ao handler.
215
- */
216
- mockHandler?: HandlerFn<ParsedIn, Out>
217
207
  }
218
208
 
219
209
  /** Variante list: o schema descreve o ITEM, o handler devolve `Paginated<Item>`. */
@@ -230,11 +220,6 @@ export interface TestableListAction<In, Item, ParsedIn = In> {
230
220
  input: ParsedIn,
231
221
  loaded?: Record<string, unknown>,
232
222
  ) => Paginated<Item> | Promise<Paginated<Item>>
233
- mockHandler?: (
234
- ctx: ActionContext,
235
- input: ParsedIn,
236
- loaded?: Record<string, unknown>,
237
- ) => Paginated<Item> | Promise<Paginated<Item>>
238
223
  }
239
224
 
240
225
  export interface RunActionOptions extends TestContextOptions {
@@ -278,9 +263,8 @@ async function validate<T>(
278
263
  * validado + o contexto observável (`emitted`, `logged`); falha estoura o mesmo
279
264
  * `ActionError` do runtime — afirme com `expect(...).rejects`.
280
265
  *
281
- * A escolha do executável segue a régua do `execute()`: em `OPUS_MODE=design`,
282
- * `mockHandler` presente vence; senão, `handler` real. Contrato puro (sem
283
- * handler) só roda em design — fora dele, `testing.no_handler`.
266
+ * Contrato puro sem handler termina em `testing.no_handler`: dados de design
267
+ * pertencem ao plugin `opusDesign()` e não são comportamento da action.
284
268
  */
285
269
  export async function runAction<In, Item, ParsedIn = In>(
286
270
  action: TestableListAction<In, Item, ParsedIn>,
@@ -325,18 +309,14 @@ export async function runAction<In, Out, ParsedIn = In>(
325
309
  if (decision !== true) throw decision
326
310
  }
327
311
 
328
- const handlerToRun =
329
- isOpusDesignMode() && action.mockHandler !== undefined
330
- ? action.mockHandler
331
- : action.handler
332
- if (handlerToRun === undefined) {
312
+ if (action.handler === undefined) {
333
313
  throw error({
334
314
  code: 'testing.no_handler',
335
315
  category: 'internal',
336
316
  message: `Action "${action.name}" não tem handler executável: teste o ActionDef produzido pelo bindAction. Os dados do modo design são gerados pelo plugin opusDesign() e não são comportamento da action.`,
337
317
  })
338
318
  }
339
- const raw = await handlerToRun(ctx, parsed, loaded)
319
+ const raw = await action.handler(ctx, parsed, loaded)
340
320
 
341
321
  // Mesmo skip do runtime: list devolve Paginated<Item>, o schema descreve Item.
342
322
  const output = action.kind === 'list' ? (raw as Out) : await validate<Out>(action.output, raw, 'output')
@@ -19,11 +19,9 @@ import { SurfaceMedia, type SurfaceMediaProps } from "./surface-media.tsx";
19
19
  import {
20
20
  SurfaceHeader,
21
21
  surfaceHeaderClasses,
22
- type SurfaceHeaderVariant,
23
22
  } from "./surface-header.tsx";
24
23
 
25
24
  export type ContentHeaderLevel = 1 | 2 | 3 | 4 | 5 | 6;
26
- export type ContentHeaderVariant = SurfaceHeaderVariant;
27
25
  export type ContentElement = "section" | "fieldset";
28
26
  export type ContentFrameVariant = "default" | "legend";
29
27
 
@@ -34,7 +32,7 @@ interface ContentContextValue {
34
32
  layout: SurfaceLayout;
35
33
  level: ContentHeaderLevel;
36
34
  titleId: string;
37
- variant: ContentHeaderVariant;
35
+ variant: "page" | "section";
38
36
  }
39
37
 
40
38
  const ContentContext = createContext<ContentContextValue | null>(null);
@@ -55,8 +53,6 @@ interface ContentSharedProps {
55
53
  framed?: boolean;
56
54
  /** Anatomia interna: contínua (default) ou com header separado do corpo. */
57
55
  layout?: SurfaceLayout;
58
- /** Hierarquia visual: `section` (default) ou `page`, a mesma de PageHeader. */
59
- variant?: ContentHeaderVariant;
60
56
  }
61
57
 
62
58
  interface ContentSectionBaseProps
@@ -80,7 +76,6 @@ interface ContentFieldsetBaseProps
80
76
  /** Tratamento visual da moldura: contínua (default) ou recortada pela legenda. */
81
77
  frameVariant?: ContentFrameVariant;
82
78
  level?: never;
83
- variant?: "section";
84
79
  }
85
80
 
86
81
  interface ContentSectionShorthandProps extends ContentSectionBaseProps {
@@ -128,7 +123,6 @@ export function Content({
128
123
  description,
129
124
  actions,
130
125
  level = 2,
131
- variant = "section",
132
126
  framed = false,
133
127
  layout = "continuous",
134
128
  frameVariant = "default",
@@ -138,7 +132,8 @@ export function Content({
138
132
  }: ContentProps): ReactElement {
139
133
  const titleId = useId();
140
134
  const fill = useFillRegion();
141
- const context = {
135
+ const variant = level === 1 && as === "section" ? "page" : "section";
136
+ const context: ContentContextValue = {
142
137
  element: as,
143
138
  frameVariant,
144
139
  framed,
@@ -245,7 +240,8 @@ export function Content({
245
240
  {...fill.attributes}
246
241
  aria-labelledby={ariaLabelledBy}
247
242
  className={cn(
248
- layout === "continuous" && "space-y-3",
243
+ layout === "continuous" &&
244
+ (variant === "page" ? "space-y-6" : "space-y-3"),
249
245
  framed &&
250
246
  layout === "continuous" &&
251
247
  frameVariant === "default" &&
@@ -111,6 +111,7 @@ import {
111
111
  InputGroupButton,
112
112
  InputGroupInput,
113
113
  } from "../primitives/input-group.tsx";
114
+ import { iconPickerIcons } from "../primitives/icon-picker.tsx";
114
115
  import { Separator } from "../primitives/separator.tsx";
115
116
  import {
116
117
  Field,
@@ -479,7 +480,23 @@ function dictOptionsFor(
479
480
  if (spec.options?.kind !== "dictionary") return [];
480
481
  const dict = dicts[spec.options.ref];
481
482
  if (dict === undefined) return [];
482
- return dict.options().map((o) => ({ value: o.value, label: o.label }));
483
+ return dict.options().map((o) => {
484
+ const Icon = o.icon === undefined ? undefined : iconPickerIcons[o.icon];
485
+ return {
486
+ value: o.value,
487
+ label: o.label,
488
+ ...(Icon === undefined
489
+ ? {}
490
+ : {
491
+ content: (
492
+ <>
493
+ <Icon aria-hidden="true" />
494
+ <span>{o.label}</span>
495
+ </>
496
+ ),
497
+ }),
498
+ };
499
+ });
483
500
  }
484
501
 
485
502
  /** Opções vindas de uma action de lookup (`options.kind: 'lookup'`): o Select chama
@@ -571,6 +588,43 @@ function FilterField({
571
588
  const changedFromDefault = singleValue !== defaultValue;
572
589
  const acceptsEmptyValue = spec.allowEmpty !== false;
573
590
 
591
+ if (
592
+ spec.type === "select" &&
593
+ spec.multiple !== true &&
594
+ spec.presentation === "toggle"
595
+ ) {
596
+ const ALL = "__all__";
597
+ const toggleValue = singleValue === "" ? ALL : singleValue;
598
+ return (
599
+ <ToggleGroup
600
+ type="single"
601
+ variant="outline"
602
+ id={`filter-${name}`}
603
+ value={toggleValue}
604
+ onValueChange={(next) => {
605
+ // O grupo sempre preserva uma escolha ativa. Quando vazio é permitido,
606
+ // a opção explícita representa esse estado em vez de desmarcar o item atual.
607
+ if (next === "") return;
608
+ onChange(next === ALL ? "" : next);
609
+ }}
610
+ aria-label={label}
611
+ disabled={!enabled}
612
+ className={className ?? "max-w-full"}
613
+ >
614
+ {acceptsEmptyValue && (
615
+ <ToggleGroupItem value={ALL}>
616
+ {text(spec.placeholder, "Todos")}
617
+ </ToggleGroupItem>
618
+ )}
619
+ {base.map((option) => (
620
+ <ToggleGroupItem key={option.value} value={option.value}>
621
+ {option.content ?? option.label}
622
+ </ToggleGroupItem>
623
+ ))}
624
+ </ToggleGroup>
625
+ );
626
+ }
627
+
574
628
  if (spec.multiple === true) {
575
629
  return (
576
630
  <Select
@@ -221,7 +221,7 @@ function PresentationFrame({
221
221
  if (surface === "page") {
222
222
  if (listBody) {
223
223
  const content = (
224
- <Content level={1} variant="page">
224
+ <Content level={1}>
225
225
  <ContentHeader>
226
226
  <ContentTitle>{title}</ContentTitle>
227
227
  {headerActions === undefined ? null : (
@@ -25,9 +25,12 @@ import {
25
25
  Layers,
26
26
  LayoutGrid,
27
27
  Lightbulb,
28
+ Mail,
28
29
  MapPin,
30
+ MessageCircle,
29
31
  MessageSquare,
30
32
  Package,
33
+ Phone,
31
34
  Rocket,
32
35
  Settings,
33
36
  ShoppingCart,
@@ -77,9 +80,12 @@ export const iconPickerIcons: Record<string, LucideIcon> = {
77
80
  'layers': Layers,
78
81
  'layout-grid': LayoutGrid,
79
82
  'lightbulb': Lightbulb,
83
+ 'mail': Mail,
80
84
  'map-pin': MapPin,
85
+ 'message-circle': MessageCircle,
81
86
  'message-square': MessageSquare,
82
87
  'package': Package,
88
+ 'phone': Phone,
83
89
  'rocket': Rocket,
84
90
  'settings': Settings,
85
91
  'shopping-cart': ShoppingCart,
@@ -1,12 +1,11 @@
1
1
  import * as React from "react";
2
- import { cva, type VariantProps } from "class-variance-authority";
3
2
  import { Tabs as TabsPrimitive } from "radix-ui";
4
3
 
5
4
  import { cn } from "../../lib/cn.ts";
6
5
  import { controlHeight, type ControlSize } from "./control.ts";
7
6
 
8
7
  // Divergência da casa (ejetado): o Tabs ganhou `size` — Button e Select têm `sm`, o Tabs
9
- // não tinha, e uma fileira densa ficava com o segmento 0.25rem mais alto
8
+ // não tinha, e uma fileira densa ficava com as abas 0.25rem mais altas
10
9
  // que os irmãos. O tamanho flui pra TabsList via contexto (a altura é dela) e a medida é a
11
10
  // da escala de control.ts.
12
11
  type TabsSize = Extract<ControlSize, "default" | "sm">;
@@ -18,9 +17,6 @@ const TabsFramedContext = React.createContext(false);
18
17
  type TabsOrientation = "horizontal" | "vertical";
19
18
  const TabsOrientationContext =
20
19
  React.createContext<TabsOrientation>("horizontal");
21
- const TabsListVariantContext = React.createContext<"default" | "line">(
22
- "default",
23
- );
24
20
 
25
21
  function Tabs({
26
22
  className,
@@ -62,87 +58,62 @@ function Tabs({
62
58
  );
63
59
  }
64
60
 
65
- const tabsListVariants = cva(
66
- // A altura horizontal entra como classe simples no componente (abaixo), para que um
67
- // `className="h-full"` do consumidor consiga substituí-la via tailwind-merge. Seletores
68
- // condicionais por data-size têm especificidade maior e tornavam a altura inextensível.
69
- "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[0.1875rem] text-muted-foreground group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none data-[variant=line]:p-0",
70
- {
71
- variants: {
72
- variant: {
73
- default: "bg-muted",
74
- line: "gap-1 bg-transparent",
75
- },
76
- },
77
- defaultVariants: {
78
- variant: "default",
79
- },
80
- },
81
- );
82
-
83
61
  function TabsList({
84
62
  className,
85
63
  children,
86
64
  style,
87
- variant = "default",
88
65
  ...props
89
- }: React.ComponentProps<typeof TabsPrimitive.List> &
90
- VariantProps<typeof tabsListVariants>) {
66
+ }: React.ComponentProps<typeof TabsPrimitive.List>) {
91
67
  // O size vem do <Tabs> (contexto) — a altura mora aqui, na list.
92
68
  const size = React.useContext(TabsSizeContext);
93
69
  const orientation = React.useContext(TabsOrientationContext);
94
70
  const distribution = React.useContext(TabsDistributionContext);
95
- const normalizedVariant = variant ?? "default";
96
- const hasHorizontalLine =
97
- normalizedVariant === "line" && orientation === "horizontal";
71
+ const hasHorizontalLine = orientation === "horizontal";
98
72
  return (
99
- <TabsListVariantContext.Provider value={normalizedVariant}>
100
- <TabsPrimitive.List
101
- data-slot="tabs-list"
102
- data-variant={normalizedVariant}
103
- data-size={size}
104
- data-distribution={distribution}
105
- className={cn(
106
- tabsListVariants({ variant: normalizedVariant }),
107
- controlHeight[size],
108
- distribution === "equal" && orientation === "horizontal" && "w-full",
109
- className,
110
- )}
111
- style={{
112
- ...(hasHorizontalLine
113
- ? {
114
- position: "relative",
115
- width: "100%",
116
- height: "auto",
117
- justifyContent: "flex-start",
118
- }
119
- : {}),
120
- ...style,
121
- }}
122
- {...props}
123
- >
124
- {hasHorizontalLine && (
125
- <span
126
- aria-hidden="true"
127
- data-slot="tabs-line"
128
- style={{
129
- position: "absolute",
130
- insetInline: "0rem",
131
- bottom: 0,
132
- borderBottomWidth: "0.0625rem",
133
- borderBottomStyle: "solid",
134
- borderBottomColor: "var(--border)",
135
- pointerEvents: "none",
136
- }}
137
- />
138
- )}
139
- {children}
140
- </TabsPrimitive.List>
141
- </TabsListVariantContext.Provider>
73
+ <TabsPrimitive.List
74
+ data-slot="tabs-list"
75
+ data-size={size}
76
+ data-distribution={distribution}
77
+ className={cn(
78
+ "group/tabs-list inline-flex w-fit items-center justify-center gap-1 rounded-none bg-transparent p-0 text-muted-foreground group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
79
+ controlHeight[size],
80
+ distribution === "equal" && orientation === "horizontal" && "w-full",
81
+ className,
82
+ )}
83
+ style={{
84
+ ...(hasHorizontalLine
85
+ ? {
86
+ position: "relative",
87
+ width: "100%",
88
+ height: "auto",
89
+ justifyContent: "flex-start",
90
+ }
91
+ : {}),
92
+ ...style,
93
+ }}
94
+ {...props}
95
+ >
96
+ {hasHorizontalLine && (
97
+ <span
98
+ aria-hidden="true"
99
+ data-slot="tabs-line"
100
+ style={{
101
+ position: "absolute",
102
+ insetInline: "0rem",
103
+ bottom: 0,
104
+ borderBottomWidth: "0.0625rem",
105
+ borderBottomStyle: "solid",
106
+ borderBottomColor: "var(--border)",
107
+ pointerEvents: "none",
108
+ }}
109
+ />
110
+ )}
111
+ {children}
112
+ </TabsPrimitive.List>
142
113
  );
143
114
  }
144
115
 
145
- function TabsTrigger({
116
+ function TabsListItem({
146
117
  className,
147
118
  style,
148
119
  icon,
@@ -152,17 +123,16 @@ function TabsTrigger({
152
123
  /** Ícone decorativo antes do rótulo. Em uma aba sem texto, declare também `aria-label`. */
153
124
  icon?: React.ReactNode;
154
125
  }) {
155
- const variant = React.useContext(TabsListVariantContext);
156
126
  const orientation = React.useContext(TabsOrientationContext);
157
127
  const size = React.useContext(TabsSizeContext);
158
128
  const distribution = React.useContext(TabsDistributionContext);
159
129
  const framed = React.useContext(TabsFramedContext);
160
- const hasHorizontalLine = variant === "line" && orientation === "horizontal";
130
+ const hasHorizontalLine = orientation === "horizontal";
161
131
  const content = (
162
132
  <>
163
133
  {icon !== undefined && (
164
134
  <span
165
- data-slot="tabs-trigger-icon"
135
+ data-slot="tabs-list-item-icon"
166
136
  aria-hidden="true"
167
137
  className="flex size-4 shrink-0 items-center justify-center [&_svg]:size-4"
168
138
  >
@@ -174,16 +144,14 @@ function TabsTrigger({
174
144
  );
175
145
  return (
176
146
  <TabsPrimitive.Trigger
177
- data-slot="tabs-trigger"
147
+ data-slot="tabs-list-item"
178
148
  // px: altura e indicador compensam a hairline do contêiner (um pixel físico, não escala).
179
149
  className={cn(
180
- "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
181
- "dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent",
182
- "data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:text-foreground",
183
- "after:absolute after:bg-primary after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-1px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
150
+ "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground dark:data-[state=active]:border-transparent dark:data-[state=active]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
151
+ "after:absolute after:bg-primary after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-1px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 data-[state=active]:after:opacity-100",
184
152
  hasHorizontalLine &&
185
153
  cn(
186
- "group/tab-trigger rounded-none px-0 py-0 hover:bg-transparent active:brightness-100 data-[state=active]:bg-transparent data-[state=active]:hover:bg-transparent",
154
+ "group/tab-list-item rounded-none px-0 py-0 hover:bg-transparent active:brightness-100 data-[state=active]:bg-transparent data-[state=active]:hover:bg-transparent",
187
155
  framed ? "pb-2" : "pb-1.5",
188
156
  ),
189
157
  className,
@@ -202,9 +170,9 @@ function TabsTrigger({
202
170
  >
203
171
  {hasHorizontalLine ? (
204
172
  <span
205
- data-slot="tabs-trigger-content"
173
+ data-slot="tabs-list-item-content"
206
174
  className={cn(
207
- "inline-flex items-center justify-center gap-1.5 rounded-md px-3 transition-colors hover:bg-accent hover:text-accent-foreground active:brightness-90 group-data-[state=active]/tab-trigger:text-foreground",
175
+ "inline-flex items-center justify-center gap-1.5 rounded-md px-3 transition-colors hover:bg-accent hover:text-accent-foreground active:brightness-90 group-data-[state=active]/tab-list-item:text-foreground",
208
176
  distribution === "equal" && "w-full",
209
177
  controlHeight[size],
210
178
  )}
@@ -218,17 +186,17 @@ function TabsTrigger({
218
186
  );
219
187
  }
220
188
 
221
- function TabsContent({
189
+ function TabsPanel({
222
190
  className,
223
191
  ...props
224
192
  }: React.ComponentProps<typeof TabsPrimitive.Content>) {
225
193
  return (
226
194
  <TabsPrimitive.Content
227
- data-slot="tabs-content"
195
+ data-slot="tabs-panel"
228
196
  className={cn("flex-1 outline-none", className)}
229
197
  {...props}
230
198
  />
231
199
  );
232
200
  }
233
201
 
234
- export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
202
+ export { Tabs, TabsList, TabsListItem, TabsPanel };
@@ -15,7 +15,7 @@ const toggleVariants = cva(
15
15
  variant: {
16
16
  default: "bg-transparent",
17
17
  outline:
18
- "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
18
+ "border border-input bg-transparent text-muted-foreground hover:bg-muted/50 hover:text-foreground data-[state=on]:bg-muted/50 data-[state=on]:text-foreground",
19
19
  },
20
20
  // Alturas da escala de control.ts (`sm` = o mesmo h-8 de Button e Select).
21
21
  size: {