@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.
- package/CHANGELOG.md +39 -1
- package/bin/cli.mjs +1 -9
- package/bin/lib/check.mjs +1 -7
- package/bin/lib/copy.mjs +1 -1
- package/bin/lib/gen-runner.mjs +2 -0
- package/bin/lib/gen-stubs.mjs +3 -1
- package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +1 -1
- package/docs/adr/0032-tab-baseline-respects-the-container-inset.md +16 -12
- package/docs/adr/0034-tabs-can-own-a-frame-without-owning-a-surface.md +8 -6
- package/docs/adr/0036-tabs-declares-trigger-distribution.md +11 -8
- package/docs/adr/0037-design-data-lives-outside-the-contract.md +1 -1
- package/docs/adr/0038-content-level-defines-primary-presentation.md +62 -0
- package/docs/adr/0039-filter-presentation-is-independent-from-filter-type.md +62 -0
- package/docs/adr/0040-tabs-use-a-single-navigation-presentation.md +60 -0
- package/docs/code-style.md +1 -1
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/references/ui-patterns.md +1 -1
- package/registry/skills/create-opus-action/references/contract-and-binding.md +3 -3
- package/src/core/contracts.ts +1 -1
- package/src/core/runtime.ts +4 -37
- package/src/core/types.ts +5 -23
- package/src/design/index.ts +14 -3
- package/src/testing/index.ts +6 -26
- package/src/ui/components/patterns/content-header.tsx +5 -9
- package/src/ui/components/patterns/list.tsx +55 -1
- package/src/ui/components/patterns/presentation.tsx +1 -1
- package/src/ui/components/primitives/icon-picker.tsx +6 -0
- package/src/ui/components/primitives/tabs.tsx +55 -87
- package/src/ui/components/primitives/toggle.tsx +1 -1
- package/src/ui/docs/content/action-list.md +13 -4
- package/src/ui/docs/content/actions.md +6 -7
- package/src/ui/docs/content/content.md +5 -6
- package/src/ui/docs/content/semantic-context.md +0 -1
- package/src/ui/docs/content/tabs.md +68 -69
- package/src/ui/docs/content/testing.md +2 -3
- package/src/ui/docs/content/toggle.md +15 -10
- package/src/ui/drivers/react.tsx +4 -3
- package/src/ui/meta.ts +2 -2
- package/src/ui/react.tsx +2 -3
- 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
|
-
* `
|
|
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'
|
|
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>
|
package/src/design/index.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* respostas ao runtime de design.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/testing/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
*
|
|
282
|
-
*
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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" &&
|
|
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) =>
|
|
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}
|
|
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
|
|
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
|
|
96
|
-
const hasHorizontalLine =
|
|
97
|
-
normalizedVariant === "line" && orientation === "horizontal";
|
|
71
|
+
const hasHorizontalLine = orientation === "horizontal";
|
|
98
72
|
return (
|
|
99
|
-
<
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
data-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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 =
|
|
130
|
+
const hasHorizontalLine = orientation === "horizontal";
|
|
161
131
|
const content = (
|
|
162
132
|
<>
|
|
163
133
|
{icon !== undefined && (
|
|
164
134
|
<span
|
|
165
|
-
data-slot="tabs-
|
|
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-
|
|
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
|
|
181
|
-
"
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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-
|
|
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,
|
|
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-
|
|
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: {
|