@softize/opus 27.1.0 → 28.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 +55 -0
- package/bin/lib/check.mjs +139 -30
- package/docs/adr/0015-action-size-follows-interaction-density.md +2 -0
- package/docs/adr/0029-page-header-is-the-single-page-introduction.md +3 -1
- package/docs/adr/0060-surface-composition-separates-chrome-and-intro.md +161 -0
- package/docs/adr/README.md +2 -0
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/SKILL.md +11 -11
- package/registry/skills/build-opus-ui/references/evaluations.md +10 -0
- package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -15
- package/src/ui/components/patterns/list.tsx +62 -53
- package/src/ui/components/patterns/page.tsx +146 -38
- package/src/ui/components/patterns/presentation.tsx +44 -59
- package/src/ui/components/patterns/structural-header.tsx +42 -6
- package/src/ui/components/patterns/surface-anatomy.ts +52 -22
- package/src/ui/components/patterns/surface.tsx +2 -3
- package/src/ui/components/primitives/control.ts +2 -2
- package/src/ui/components/primitives/dialog.tsx +7 -2
- package/src/ui/components/primitives/drawer.tsx +7 -2
- package/src/ui/components/primitives/icon-picker.tsx +271 -34
- package/src/ui/components/primitives/pagination.tsx +5 -4
- package/src/ui/components/primitives/tabs.tsx +15 -2
- package/src/ui/components/primitives/toggle.tsx +1 -1
- package/src/ui/docs/content/action-list.md +11 -2
- package/src/ui/docs/content/button.md +7 -7
- package/src/ui/docs/content/compositions.md +35 -23
- package/src/ui/docs/content/dialog.md +18 -6
- package/src/ui/docs/content/drawer.md +17 -5
- package/src/ui/docs/content/icon-picker.md +37 -19
- package/src/ui/docs/content/input.md +1 -1
- package/src/ui/docs/content/page.md +169 -69
- package/src/ui/docs/content/pagination.md +7 -8
- package/src/ui/docs/content/presentation.md +4 -3
- package/src/ui/docs/content/surface.md +8 -5
- package/src/ui/docs/content/tabs.md +12 -4
- package/src/ui/meta.ts +1 -1
- package/src/ui/react.tsx +9 -1
|
@@ -9,24 +9,22 @@
|
|
|
9
9
|
lista; não reconstruir essa decisão com condicionais no componente da página.
|
|
10
10
|
- `Page` fornece o `<main>` e o container centralizado com teto padrão de `80rem`. Sua forma
|
|
11
11
|
explícita exige `PageBody`, exatamente um heading principal e aceita `PageHeader` e `PageFooter`.
|
|
12
|
-
Declare esse heading com
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Não misturar as duas formas.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
paralelo para uma `Page`. `PageActionsTarget` fica reservado a workspaces imersivos que já
|
|
22
|
-
possuam chrome próprio.
|
|
12
|
+
Declare esse heading com PageTitle na PageIntro ou no PageHeader compacto, Surface level={1}
|
|
13
|
+
ou PageState ativo. A intro abre o body; ações locais usam PageIntroActions, enquanto PageActions
|
|
14
|
+
pertence ao header. A forma curta coloca title/description na intro e actions no header.
|
|
15
|
+
Não misturar as duas formas. Para ajustar a largura interna, usar contentClassName; className pertence ao container externo.
|
|
16
|
+
- Na composição com intro, PageHeader reúne navegação contextual e ações da superfície na mesma
|
|
17
|
+
linha. PageBack recebe o destino pai explícito; para vários ancestrais relevantes, usar Breadcrumb
|
|
18
|
+
em PageNavigation. Não combinar retorno e breadcrumb, nem criar chrome paralelo. Na composição
|
|
19
|
+
compacta, título e orientação permanecem no header. PageActionsTarget fica reservado a workspaces
|
|
20
|
+
imersivos com chrome próprio.
|
|
23
21
|
- Quando shell e rota conhecem partes diferentes da mesma página, use `PageShell` ao redor da rota.
|
|
24
22
|
O shell entrega navegação e ações ao `PageHeader` descendente sem criar uma barra visual.
|
|
25
|
-
Ações com texto no cabeçalho usam
|
|
26
|
-
|
|
23
|
+
Ações com texto no cabeçalho usam `sm`, e ações somente com ícone usam `icon-sm`.
|
|
24
|
+
Ações de footer de Dialog e Drawer conservam `default`. O tamanho `sm` fica para ações operacionais em
|
|
27
25
|
toolbar, seção ou coleção densa, e `xs`/`icon-xs` para ações internas de linha ou célula. Contexto
|
|
28
26
|
e variante resolvem a hierarquia visual sem alterar essa medida. Em uma coleção, a ação de
|
|
29
|
-
criar usa botão textual `
|
|
27
|
+
criar usa botão textual `sm`, sem ícone, no extremo oposto do `SurfaceHeader`. Não monte
|
|
30
28
|
cabeçalho paralelo, portal ou seletor global para reconstruir essa composição.
|
|
31
29
|
- `PageState` substitui todo o conteúdo principal quando a página carrega, falha ou está vazia. Na
|
|
32
30
|
forma curta ele pode ser escrito como filho direto de `Page`, que cria o `PageBody`; na forma
|
|
@@ -41,7 +39,7 @@
|
|
|
41
39
|
SurfaceTitle, SurfaceDescription, SurfaceMeta, SurfaceActions) + SurfaceBody + SurfaceFooter`.
|
|
42
40
|
Os slots nunca ficam soltos. `title`, `description`, `count`, `media` e `actions` são a abreviação
|
|
43
41
|
para o caso direto e não podem ser misturados ao header explícito. Em `level={1}`, ações da
|
|
44
|
-
coleção ficam no extremo oposto ao título; a criação usa botão textual `
|
|
42
|
+
coleção ficam no extremo oposto ao título; a criação usa botão textual `sm`, sem ícone, com
|
|
45
43
|
`Criar recurso`.
|
|
46
44
|
O diálogo de criação usa `Criar recurso` e o de edição, `Editar recurso`. `level` preserva a
|
|
47
45
|
hierarquia semântica do heading. `Surface framed` aceita `layout="sectioned"`; declare
|
|
@@ -229,3 +227,7 @@ abaixo do controle. Reservar `FieldSpec.help` ao ícone com detalhe complementar
|
|
|
229
227
|
Omitir repetição da label; combinar ambos somente com funções distintas. Regra de vários campos
|
|
230
228
|
pertence ao corpo ou ao grupo. Em composição manual, `FieldDescription` e erro entram juntos no
|
|
231
229
|
`aria-describedby` do controle.
|
|
230
|
+
|
|
231
|
+
- Manter header e footer de Page fora da rolagem vertical. PageBody é o viewport em flow; em fill,
|
|
232
|
+
repassar a altura à lista quando ela assume a rolagem. Não reconstruir a fixação com sticky ou
|
|
233
|
+
fixed nem tornar PageShell uma segunda região de rolagem vertical.
|
|
@@ -362,6 +362,8 @@ export interface ActionListProps<TInput, TItem> {
|
|
|
362
362
|
layout?: "flow" | "fill";
|
|
363
363
|
/** Ações do consumidor no fim da barra de filtros, depois dos controles de exibição. */
|
|
364
364
|
toolbarActions?: ReactNode;
|
|
365
|
+
/** Controles da visualização ativa, imediatamente após seu seletor. */
|
|
366
|
+
viewControls?: ReactNode;
|
|
365
367
|
/** Tabela explícita — sobrepõe as colunas do contrato. */
|
|
366
368
|
columns?: ActionListColumn<TItem>[];
|
|
367
369
|
/** Células custom POR CIMA das colunas do contrato (chave = column.key). */
|
|
@@ -610,19 +612,18 @@ function FilterField({
|
|
|
610
612
|
);
|
|
611
613
|
const runtimeOptions =
|
|
612
614
|
lookup.onSearch !== undefined && lookup.query !== ""
|
|
613
|
-
? base.filter(
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
.
|
|
617
|
-
|
|
615
|
+
? base.filter(
|
|
616
|
+
(option) =>
|
|
617
|
+
selectedValues.has(option.value) ||
|
|
618
|
+
option.label
|
|
619
|
+
.toLocaleLowerCase()
|
|
620
|
+
.includes(lookup.query.toLocaleLowerCase()),
|
|
618
621
|
)
|
|
619
622
|
: base;
|
|
620
623
|
const selectedResolvedOptions = [...selectedValues]
|
|
621
624
|
.map((selectedValue) => resolvedOptionByValue.current.get(selectedValue))
|
|
622
625
|
.filter((option): option is SelectOption => option !== undefined);
|
|
623
|
-
const mergeUniqueOptions = (
|
|
624
|
-
...groups: SelectOption[][]
|
|
625
|
-
): SelectOption[] => {
|
|
626
|
+
const mergeUniqueOptions = (...groups: SelectOption[][]): SelectOption[] => {
|
|
626
627
|
const merged = new Map<string, SelectOption>();
|
|
627
628
|
for (const group of groups) {
|
|
628
629
|
for (const option of group) {
|
|
@@ -1781,6 +1782,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1781
1782
|
input,
|
|
1782
1783
|
layout: layoutProp,
|
|
1783
1784
|
toolbarActions,
|
|
1785
|
+
viewControls,
|
|
1784
1786
|
columns,
|
|
1785
1787
|
cells,
|
|
1786
1788
|
filterOptions,
|
|
@@ -1898,6 +1900,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1898
1900
|
filterSpecs.length > 0 ||
|
|
1899
1901
|
periods.length > 0 ||
|
|
1900
1902
|
hasSegment ||
|
|
1903
|
+
viewControls != null ||
|
|
1901
1904
|
hasTable;
|
|
1902
1905
|
|
|
1903
1906
|
// — Input efetivo: escopo base + filtros preenchidos + período (→ from/to) + q + sort —
|
|
@@ -2152,9 +2155,7 @@ export function ActionList<TInput, TItem>({
|
|
|
2152
2155
|
return label === null ? [] : [{ value: field, label }];
|
|
2153
2156
|
});
|
|
2154
2157
|
const activeSortLabel =
|
|
2155
|
-
activeSort === null
|
|
2156
|
-
? null
|
|
2157
|
-
: sortLabelFor(activeSort.field);
|
|
2158
|
+
activeSort === null ? null : sortLabelFor(activeSort.field);
|
|
2158
2159
|
const activeSortDirection =
|
|
2159
2160
|
activeSort?.dir === "asc" ? "crescente" : "decrescente";
|
|
2160
2161
|
const sortCustomized =
|
|
@@ -2219,35 +2220,6 @@ export function ActionList<TInput, TItem>({
|
|
|
2219
2220
|
refreshing={isLoading}
|
|
2220
2221
|
controls={
|
|
2221
2222
|
<>
|
|
2222
|
-
{hasSegment && (
|
|
2223
|
-
<ToggleGroup
|
|
2224
|
-
type="single"
|
|
2225
|
-
variant="outline"
|
|
2226
|
-
data-slot="action-list-views"
|
|
2227
|
-
className="shrink-0"
|
|
2228
|
-
value={activeView}
|
|
2229
|
-
onValueChange={(key) => {
|
|
2230
|
-
// No single o Radix DESMARCA ao clicar no ativo (chega '') — o segment
|
|
2231
|
-
// de views sempre tem uma ativa, então o vazio não passa.
|
|
2232
|
-
if (key !== "")
|
|
2233
|
-
setState({
|
|
2234
|
-
...state,
|
|
2235
|
-
view: key === viewEntries[0]?.[0] ? null : key,
|
|
2236
|
-
});
|
|
2237
|
-
}}
|
|
2238
|
-
>
|
|
2239
|
-
{viewEntries.map(([key, label, icon]) => (
|
|
2240
|
-
<Tooltip key={key}>
|
|
2241
|
-
<TooltipTrigger asChild>
|
|
2242
|
-
<Toggle value={key} aria-label={label}>
|
|
2243
|
-
{icon ?? label}
|
|
2244
|
-
</Toggle>
|
|
2245
|
-
</TooltipTrigger>
|
|
2246
|
-
<TooltipContent>{label}</TooltipContent>
|
|
2247
|
-
</Tooltip>
|
|
2248
|
-
))}
|
|
2249
|
-
</ToggleGroup>
|
|
2250
|
-
)}
|
|
2251
2223
|
{/* Configuração de EXIBIÇÃO — sempre presente (em qualquer view): colunas quando a
|
|
2252
2224
|
tabela está ativa, itens por página sempre, e o que vier depois. */}
|
|
2253
2225
|
<Popover>
|
|
@@ -2292,7 +2264,11 @@ export function ActionList<TInput, TItem>({
|
|
|
2292
2264
|
<button
|
|
2293
2265
|
type="button"
|
|
2294
2266
|
onClick={() =>
|
|
2295
|
-
setState({
|
|
2267
|
+
setState({
|
|
2268
|
+
...state,
|
|
2269
|
+
sort: defaultSort,
|
|
2270
|
+
page: 1,
|
|
2271
|
+
})
|
|
2296
2272
|
}
|
|
2297
2273
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
2298
2274
|
>
|
|
@@ -2324,7 +2300,12 @@ export function ActionList<TInput, TItem>({
|
|
|
2324
2300
|
}
|
|
2325
2301
|
options={[
|
|
2326
2302
|
...(defaultSort === null
|
|
2327
|
-
? [
|
|
2303
|
+
? [
|
|
2304
|
+
{
|
|
2305
|
+
value: "__none__",
|
|
2306
|
+
label: "Sem ordenação",
|
|
2307
|
+
},
|
|
2308
|
+
]
|
|
2328
2309
|
: []),
|
|
2329
2310
|
...sortableFields,
|
|
2330
2311
|
]}
|
|
@@ -2468,6 +2449,36 @@ export function ActionList<TInput, TItem>({
|
|
|
2468
2449
|
</div>
|
|
2469
2450
|
</PopoverContent>
|
|
2470
2451
|
</Popover>
|
|
2452
|
+
{hasSegment && (
|
|
2453
|
+
<ToggleGroup
|
|
2454
|
+
type="single"
|
|
2455
|
+
variant="outline"
|
|
2456
|
+
data-slot="action-list-views"
|
|
2457
|
+
className="shrink-0"
|
|
2458
|
+
value={activeView}
|
|
2459
|
+
onValueChange={(key) => {
|
|
2460
|
+
// No single o Radix DESMARCA ao clicar no ativo (chega '') — o segment
|
|
2461
|
+
// de views sempre tem uma ativa, então o vazio não passa.
|
|
2462
|
+
if (key !== "")
|
|
2463
|
+
setState({
|
|
2464
|
+
...state,
|
|
2465
|
+
view: key === viewEntries[0]?.[0] ? null : key,
|
|
2466
|
+
});
|
|
2467
|
+
}}
|
|
2468
|
+
>
|
|
2469
|
+
{viewEntries.map(([key, label, icon]) => (
|
|
2470
|
+
<Tooltip key={key}>
|
|
2471
|
+
<TooltipTrigger asChild>
|
|
2472
|
+
<Toggle value={key} aria-label={label}>
|
|
2473
|
+
{icon ?? label}
|
|
2474
|
+
</Toggle>
|
|
2475
|
+
</TooltipTrigger>
|
|
2476
|
+
<TooltipContent>{label}</TooltipContent>
|
|
2477
|
+
</Tooltip>
|
|
2478
|
+
))}
|
|
2479
|
+
</ToggleGroup>
|
|
2480
|
+
)}
|
|
2481
|
+
{viewControls}
|
|
2471
2482
|
</>
|
|
2472
2483
|
}
|
|
2473
2484
|
actions={toolbarActions}
|
|
@@ -2838,10 +2849,9 @@ export function ActionList<TInput, TItem>({
|
|
|
2838
2849
|
// Encosta na última linha: a borda de cima é o fecho da tabela, não a de um
|
|
2839
2850
|
// bloco solto, então o rodapé não entra no `gap` da pilha.
|
|
2840
2851
|
// Altura: 2.5rem de piso (a mesma do `TableHead`) para a faixa não mudar de
|
|
2841
|
-
// tamanho quando o pager aparece — ele ocupa
|
|
2842
|
-
// O padding
|
|
2843
|
-
//
|
|
2844
|
-
// crescer —
|
|
2852
|
+
// tamanho quando o pager aparece — ele ocupa 2rem e cabe dentro do piso.
|
|
2853
|
+
// O padding vertical de 0.125rem acomoda o pager de 2rem e a borda dentro
|
|
2854
|
+
// do piso, mantendo a altura igual quando não há paginação.
|
|
2845
2855
|
const pages =
|
|
2846
2856
|
total !== undefined
|
|
2847
2857
|
? Math.max(1, Math.ceil(total / (state.limit ?? pageSize)))
|
|
@@ -2872,7 +2882,7 @@ export function ActionList<TInput, TItem>({
|
|
|
2872
2882
|
total !== undefined && !isError && activeGroupBy !== null ? (
|
|
2873
2883
|
<div
|
|
2874
2884
|
data-slot="action-list-footer"
|
|
2875
|
-
className="flex min-h-10 flex-wrap items-center justify-between gap-3 border-t border-border px-3 py-
|
|
2885
|
+
className="flex min-h-10 flex-wrap items-center justify-between gap-3 border-t border-border px-3 py-0.5 text-xs text-muted-foreground"
|
|
2876
2886
|
>
|
|
2877
2887
|
{displaySummary}
|
|
2878
2888
|
<span className={cn(displaySummary === null && "ml-auto")}>
|
|
@@ -2882,7 +2892,7 @@ export function ActionList<TInput, TItem>({
|
|
|
2882
2892
|
) : total !== undefined && !isError ? (
|
|
2883
2893
|
<div
|
|
2884
2894
|
data-slot="action-list-footer"
|
|
2885
|
-
className="grid min-h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-t border-border px-3 py-
|
|
2895
|
+
className="grid min-h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-t border-border px-3 py-0.5 text-xs text-muted-foreground"
|
|
2886
2896
|
>
|
|
2887
2897
|
<span className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
|
2888
2898
|
<span className="whitespace-nowrap">
|
|
@@ -2893,7 +2903,7 @@ export function ActionList<TInput, TItem>({
|
|
|
2893
2903
|
</span>
|
|
2894
2904
|
{pages > 1 && (
|
|
2895
2905
|
// A primitiva pública na escala densa do rodapé: setas no quadrado `icon-sm`,
|
|
2896
|
-
// números `
|
|
2906
|
+
// números `sm` que crescem com os dígitos (5726 e 5727 não se colam).
|
|
2897
2907
|
<Pagination className="mx-0 w-auto">
|
|
2898
2908
|
<PaginationContent className="gap-0.5">
|
|
2899
2909
|
<PaginationItem>
|
|
@@ -2915,7 +2925,8 @@ export function ActionList<TInput, TItem>({
|
|
|
2915
2925
|
<PaginationLink
|
|
2916
2926
|
page={n}
|
|
2917
2927
|
isActive={n === state.page}
|
|
2918
|
-
|
|
2928
|
+
size="sm"
|
|
2929
|
+
className="text-xs"
|
|
2919
2930
|
onClick={() => goTo(n)}
|
|
2920
2931
|
/>
|
|
2921
2932
|
</PaginationItem>
|
|
@@ -2977,9 +2988,7 @@ export function ActionList<TInput, TItem>({
|
|
|
2977
2988
|
className={cn(
|
|
2978
2989
|
"min-h-0",
|
|
2979
2990
|
layout === "fill" && "overflow-auto",
|
|
2980
|
-
busy &&
|
|
2981
|
-
items.length > 0 &&
|
|
2982
|
-
"pointer-events-none opacity-60",
|
|
2991
|
+
busy && items.length > 0 && "pointer-events-none opacity-60",
|
|
2983
2992
|
)}
|
|
2984
2993
|
>
|
|
2985
2994
|
{body}
|
|
@@ -31,15 +31,27 @@ import {
|
|
|
31
31
|
} from "./page-heading-context.tsx";
|
|
32
32
|
import {
|
|
33
33
|
StructuralHeader,
|
|
34
|
+
validateSurfaceIntro,
|
|
34
35
|
structuralHeaderClasses,
|
|
35
36
|
} from "./structural-header.tsx";
|
|
36
37
|
import { PageState, PageStatePresenceProvider } from "./page-state.tsx";
|
|
37
|
-
import { FillRoot,
|
|
38
|
+
import { FillRoot, useFillRegion } from "./fill-region.ts";
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
import {
|
|
41
|
+
surfaceHeaderRowClasses,
|
|
42
|
+
surfaceRegionClasses,
|
|
43
|
+
type SurfaceLayout,
|
|
44
|
+
} from "./surface-anatomy.ts";
|
|
45
|
+
|
|
46
|
+
interface PageContextValue {
|
|
47
|
+
surfaceLayout: SurfaceLayout;
|
|
48
|
+
contentClassName?: string;
|
|
49
|
+
}
|
|
40
50
|
|
|
41
51
|
const PageContext = createContext<PageContextValue | null>(null);
|
|
42
52
|
const PageHeaderContext = createContext(false);
|
|
53
|
+
const PageIntroContext = createContext(false);
|
|
54
|
+
const PageBodyContext = createContext(false);
|
|
43
55
|
const PageIntegralStateContext = createContext(false);
|
|
44
56
|
const PageActionsTargetContext = createContext<HTMLElement | null>(null);
|
|
45
57
|
interface PageShellContextValue {
|
|
@@ -101,7 +113,7 @@ export function PageShell({
|
|
|
101
113
|
<PageShellContext.Provider value={shell}>
|
|
102
114
|
<div
|
|
103
115
|
data-slot="page-shell-content"
|
|
104
|
-
className="min-h-0 min-w-0 flex-1 overflow-auto [&>*]:min-w-[64rem]"
|
|
116
|
+
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-auto overflow-y-auto [&>*]:min-w-[64rem]"
|
|
105
117
|
>
|
|
106
118
|
{children}
|
|
107
119
|
</div>
|
|
@@ -136,13 +148,17 @@ interface PageBaseProps extends Omit<
|
|
|
136
148
|
HTMLAttributes<HTMLElement>,
|
|
137
149
|
"children" | "title"
|
|
138
150
|
> {
|
|
139
|
-
/** Classes do container.
|
|
151
|
+
/** Classes do container externo. O conteúdo do Body mantém o teto de 80rem. */
|
|
140
152
|
className?: string;
|
|
153
|
+
/** Classes do conteúdo centralizado, incluindo largura máxima e alinhamento. */
|
|
154
|
+
contentClassName?: string;
|
|
141
155
|
/**
|
|
142
|
-
* `
|
|
156
|
+
* `flow` mantém a rolagem no body; `fill` permite que regiões internas assumam a rolagem.
|
|
143
157
|
* A altura chega sozinha a uma `ActionList` composta por `PageBody` e `Surface` (ADR 0024).
|
|
144
158
|
*/
|
|
145
159
|
layout?: "flow" | "fill";
|
|
160
|
+
/** Divisão visual das regiões, independente da distribuição de altura. */
|
|
161
|
+
surfaceLayout?: SurfaceLayout;
|
|
146
162
|
}
|
|
147
163
|
|
|
148
164
|
interface PageShorthandProps extends PageBaseProps {
|
|
@@ -183,6 +199,7 @@ function countDirectChildrenOfType(
|
|
|
183
199
|
|
|
184
200
|
function countPageBodyHeadings(node: ReactNode): {
|
|
185
201
|
content: number;
|
|
202
|
+
intro: number;
|
|
186
203
|
state: number;
|
|
187
204
|
} {
|
|
188
205
|
return Children.toArray(node).reduce(
|
|
@@ -196,6 +213,7 @@ function countPageBodyHeadings(node: ReactNode): {
|
|
|
196
213
|
if (child.type === PageState && props.status !== "ready") {
|
|
197
214
|
return {
|
|
198
215
|
content: total.content,
|
|
216
|
+
intro: total.intro,
|
|
199
217
|
state: total.state + 1,
|
|
200
218
|
};
|
|
201
219
|
}
|
|
@@ -205,10 +223,11 @@ function countPageBodyHeadings(node: ReactNode): {
|
|
|
205
223
|
total.content +
|
|
206
224
|
(child.type === Surface && props.level === 1 ? 1 : 0) +
|
|
207
225
|
nested.content,
|
|
226
|
+
intro: total.intro + (child.type === PageIntro ? 1 : 0) + nested.intro,
|
|
208
227
|
state: total.state + nested.state,
|
|
209
228
|
};
|
|
210
229
|
},
|
|
211
|
-
{ content: 0, state: 0 },
|
|
230
|
+
{ content: 0, intro: 0, state: 0 },
|
|
212
231
|
);
|
|
213
232
|
}
|
|
214
233
|
|
|
@@ -229,12 +248,13 @@ export function Page({
|
|
|
229
248
|
description,
|
|
230
249
|
actions,
|
|
231
250
|
layout = "flow",
|
|
251
|
+
surfaceLayout = "sectioned",
|
|
252
|
+
contentClassName,
|
|
232
253
|
className,
|
|
233
254
|
children,
|
|
234
255
|
...props
|
|
235
256
|
}: PageProps): ReactElement {
|
|
236
257
|
const shell = useContext(PageShellContext);
|
|
237
|
-
const insideShell = shell !== null;
|
|
238
258
|
const shorthand = title !== undefined;
|
|
239
259
|
const nodes = flattenPageHeaderChildren(children);
|
|
240
260
|
const headers = nodes.filter(
|
|
@@ -298,6 +318,7 @@ export function Page({
|
|
|
298
318
|
);
|
|
299
319
|
}
|
|
300
320
|
const bodyHeadingSources = bodies.reduce<{
|
|
321
|
+
intro: number;
|
|
301
322
|
content: number;
|
|
302
323
|
state: number;
|
|
303
324
|
}>(
|
|
@@ -307,17 +328,20 @@ export function Page({
|
|
|
307
328
|
);
|
|
308
329
|
return {
|
|
309
330
|
content: total.content + count.content,
|
|
331
|
+
intro: total.intro + count.intro,
|
|
310
332
|
state: total.state + count.state,
|
|
311
333
|
};
|
|
312
334
|
},
|
|
313
|
-
{ content: 0, state: 0 },
|
|
335
|
+
{ content: 0, intro: 0, state: 0 },
|
|
314
336
|
);
|
|
315
337
|
const structuralHeadingSources =
|
|
316
338
|
headers.reduce<number>(
|
|
317
339
|
(total, header) =>
|
|
318
340
|
total + countDirectChildrenOfType(header as ReactElement, PageTitle),
|
|
319
341
|
0,
|
|
320
|
-
) +
|
|
342
|
+
) +
|
|
343
|
+
bodyHeadingSources.content +
|
|
344
|
+
bodyHeadingSources.intro;
|
|
321
345
|
const headingSources =
|
|
322
346
|
bodyHeadingSources.state > 0
|
|
323
347
|
? bodyHeadingSources.state + bodyHeadingSources.content
|
|
@@ -329,7 +353,7 @@ export function Page({
|
|
|
329
353
|
);
|
|
330
354
|
if (headingSources > 1 || (headingSources === 0 && !hasOpaqueBodyContent)) {
|
|
331
355
|
throw new Error(
|
|
332
|
-
"Page explícito exige exatamente um heading principal em PageHeader, Surface level={1} ou PageState ativo dentro de PageBody.",
|
|
356
|
+
"Page explícito exige exatamente um heading principal em PageHeader, PageIntro, Surface level={1} ou PageState ativo dentro de PageBody.",
|
|
333
357
|
);
|
|
334
358
|
}
|
|
335
359
|
}
|
|
@@ -338,20 +362,24 @@ export function Page({
|
|
|
338
362
|
if (integralState !== hasActiveIntegralState) return;
|
|
339
363
|
if (headingCount.current !== 1) {
|
|
340
364
|
throw new Error(
|
|
341
|
-
"Page explícito exige exatamente um heading principal em PageHeader, Surface level={1} ou PageState ativo dentro de PageBody.",
|
|
365
|
+
"Page explícito exige exatamente um heading principal em PageHeader, PageIntro, Surface level={1} ou PageState ativo dentro de PageBody.",
|
|
342
366
|
);
|
|
343
367
|
}
|
|
344
368
|
});
|
|
345
369
|
const content = shorthand ? (
|
|
346
370
|
<>
|
|
347
371
|
<PageHeader>
|
|
348
|
-
<PageTitle>{title}</PageTitle>
|
|
349
|
-
{description !== undefined && (
|
|
350
|
-
<PageDescription>{description}</PageDescription>
|
|
351
|
-
)}
|
|
352
372
|
{actions !== undefined && <PageActions>{actions}</PageActions>}
|
|
353
373
|
</PageHeader>
|
|
354
|
-
<PageBody>
|
|
374
|
+
<PageBody>
|
|
375
|
+
<PageIntro>
|
|
376
|
+
<PageTitle>{title}</PageTitle>
|
|
377
|
+
{description !== undefined && (
|
|
378
|
+
<PageDescription>{description}</PageDescription>
|
|
379
|
+
)}
|
|
380
|
+
</PageIntro>
|
|
381
|
+
{children}
|
|
382
|
+
</PageBody>
|
|
355
383
|
</>
|
|
356
384
|
) : (
|
|
357
385
|
<>
|
|
@@ -364,29 +392,22 @@ export function Page({
|
|
|
364
392
|
);
|
|
365
393
|
|
|
366
394
|
return (
|
|
367
|
-
<PageContext.Provider value={{}}>
|
|
395
|
+
<PageContext.Provider value={{ surfaceLayout, contentClassName }}>
|
|
368
396
|
<PageIntegralStateContext.Provider value={integralState}>
|
|
369
397
|
<PageStatePresenceProvider register={registerIntegralState}>
|
|
370
398
|
<PageHeadingPresenceProvider register={registerHeading}>
|
|
371
399
|
<main
|
|
372
400
|
data-slot="page"
|
|
373
401
|
data-layout={layout}
|
|
402
|
+
data-surface-layout={surfaceLayout}
|
|
374
403
|
className={cn(
|
|
375
|
-
"min-w-[64rem] flex-1",
|
|
376
|
-
insideShell && "min-h-full",
|
|
377
|
-
layout === "fill" &&
|
|
378
|
-
"flex h-full min-h-0 flex-col overflow-hidden",
|
|
379
|
-
integralState && "flex min-h-full flex-col",
|
|
404
|
+
"flex h-full min-h-0 min-w-[64rem] flex-1 flex-col overflow-hidden",
|
|
380
405
|
)}
|
|
381
406
|
{...props}
|
|
382
407
|
>
|
|
383
408
|
<div
|
|
384
409
|
className={cn(
|
|
385
|
-
"
|
|
386
|
-
layout === "fill" &&
|
|
387
|
-
"flex h-full min-h-0 w-full flex-1 flex-col",
|
|
388
|
-
integralState &&
|
|
389
|
-
"flex min-h-full w-full flex-1 flex-col space-y-0",
|
|
410
|
+
"flex h-full min-h-0 w-full flex-1 flex-col",
|
|
390
411
|
className,
|
|
391
412
|
)}
|
|
392
413
|
>
|
|
@@ -455,6 +476,7 @@ export function PageHeader({
|
|
|
455
476
|
if (
|
|
456
477
|
structuralChildren.length === 0 &&
|
|
457
478
|
mergedActions === null &&
|
|
479
|
+
localNavigation.length === 0 &&
|
|
458
480
|
shell?.navigation === undefined
|
|
459
481
|
) {
|
|
460
482
|
return <></>;
|
|
@@ -470,9 +492,25 @@ export function PageHeader({
|
|
|
470
492
|
description: PageDescription,
|
|
471
493
|
actions: PageActions,
|
|
472
494
|
}}
|
|
473
|
-
leadingPlacement=
|
|
495
|
+
leadingPlacement={
|
|
496
|
+
nodes.some(
|
|
497
|
+
(node) => isValidElement(node) && node.type === PageTitle,
|
|
498
|
+
) ||
|
|
499
|
+
(localNavigation.length === 0 &&
|
|
500
|
+
shell?.navigation === undefined &&
|
|
501
|
+
!nodes.some(
|
|
502
|
+
(node) => isValidElement(node) && node.type === PageBack,
|
|
503
|
+
))
|
|
504
|
+
? "above"
|
|
505
|
+
: "inline"
|
|
506
|
+
}
|
|
474
507
|
titleRequired={false}
|
|
475
|
-
|
|
508
|
+
rowClassName={surfaceHeaderRowClasses("page")}
|
|
509
|
+
className={cn(
|
|
510
|
+
"shrink-0",
|
|
511
|
+
surfaceRegionClasses("page", page!.surfaceLayout, "header"),
|
|
512
|
+
className,
|
|
513
|
+
)}
|
|
476
514
|
{...props}
|
|
477
515
|
>
|
|
478
516
|
{shell?.navigation === undefined ? (
|
|
@@ -487,12 +525,62 @@ export function PageHeader({
|
|
|
487
525
|
);
|
|
488
526
|
}
|
|
489
527
|
|
|
528
|
+
/** Primeiro bloco do corpo, separado dos controles do cabeçalho. */
|
|
529
|
+
export function PageIntro({
|
|
530
|
+
className,
|
|
531
|
+
children,
|
|
532
|
+
...props
|
|
533
|
+
}: HTMLAttributes<HTMLDivElement>): ReactElement {
|
|
534
|
+
requireParent(useContext(PageBodyContext), "PageIntro", "PageBody");
|
|
535
|
+
const integralState = useContext(PageIntegralStateContext);
|
|
536
|
+
if (integralState) return <></>;
|
|
537
|
+
return (
|
|
538
|
+
<PageIntroContext.Provider value>
|
|
539
|
+
<StructuralHeader
|
|
540
|
+
name="PageIntro"
|
|
541
|
+
slot="page-intro"
|
|
542
|
+
slots={{
|
|
543
|
+
title: PageTitle,
|
|
544
|
+
description: PageDescription,
|
|
545
|
+
actions: PageIntroActions,
|
|
546
|
+
}}
|
|
547
|
+
leadingPlacement="inline"
|
|
548
|
+
titleRequired
|
|
549
|
+
className={className}
|
|
550
|
+
{...props}
|
|
551
|
+
>
|
|
552
|
+
{children}
|
|
553
|
+
</StructuralHeader>
|
|
554
|
+
</PageIntroContext.Provider>
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Ações locais do conteúdo, distintas das ações da superfície em PageActions. */
|
|
559
|
+
export function PageIntroActions({
|
|
560
|
+
className,
|
|
561
|
+
...props
|
|
562
|
+
}: HTMLAttributes<HTMLDivElement>): ReactElement {
|
|
563
|
+
requireParent(useContext(PageIntroContext), "PageIntroActions", "PageIntro");
|
|
564
|
+
return (
|
|
565
|
+
<div
|
|
566
|
+
data-slot="page-intro-actions"
|
|
567
|
+
className={cn(structuralHeaderClasses.page.actions, className)}
|
|
568
|
+
{...props}
|
|
569
|
+
/>
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
490
573
|
export function PageTitle({
|
|
491
574
|
className,
|
|
492
575
|
...props
|
|
493
576
|
}: HTMLAttributes<HTMLHeadingElement>): ReactElement {
|
|
494
577
|
const insideHeader = useContext(PageHeaderContext);
|
|
495
|
-
|
|
578
|
+
const insideIntro = useContext(PageIntroContext);
|
|
579
|
+
requireParent(
|
|
580
|
+
insideHeader || insideIntro,
|
|
581
|
+
"PageTitle",
|
|
582
|
+
"PageHeader ou PageIntro",
|
|
583
|
+
);
|
|
496
584
|
usePageHeadingPresence();
|
|
497
585
|
return (
|
|
498
586
|
<h1
|
|
@@ -508,7 +596,9 @@ export function PageDescription({
|
|
|
508
596
|
className,
|
|
509
597
|
...props
|
|
510
598
|
}: HTMLAttributes<HTMLParagraphElement>): ReactElement {
|
|
511
|
-
|
|
599
|
+
const header = useContext(PageHeaderContext);
|
|
600
|
+
const intro = useContext(PageIntroContext);
|
|
601
|
+
requireParent(header || intro, "PageDescription", "PageHeader ou PageIntro");
|
|
512
602
|
return (
|
|
513
603
|
<p
|
|
514
604
|
data-slot="page-description"
|
|
@@ -574,7 +664,7 @@ export function PageBack({
|
|
|
574
664
|
typeof children === "string" ? children : "a página anterior";
|
|
575
665
|
const accessibleLabel = ariaLabel ?? `Voltar para ${destination}`;
|
|
576
666
|
const back = (
|
|
577
|
-
<Button asChild variant="ghost" size="icon">
|
|
667
|
+
<Button asChild variant="ghost" size="icon-sm">
|
|
578
668
|
<a
|
|
579
669
|
data-slot="page-back"
|
|
580
670
|
aria-label={accessibleLabel}
|
|
@@ -592,25 +682,42 @@ export function PageBack({
|
|
|
592
682
|
|
|
593
683
|
export function PageBody({
|
|
594
684
|
className,
|
|
685
|
+
contentClassName,
|
|
595
686
|
children,
|
|
596
687
|
...props
|
|
597
|
-
}: HTMLAttributes<HTMLDivElement>
|
|
688
|
+
}: HTMLAttributes<HTMLDivElement> & {
|
|
689
|
+
contentClassName?: string;
|
|
690
|
+
}): ReactElement {
|
|
598
691
|
const page = useContext(PageContext);
|
|
599
|
-
const integralState = useContext(PageIntegralStateContext);
|
|
600
692
|
const fill = useFillRegion();
|
|
601
693
|
requireParent(page !== null, "PageBody", "Page");
|
|
694
|
+
validateSurfaceIntro(children, PageIntro, "PageBody");
|
|
602
695
|
return (
|
|
603
696
|
<div
|
|
604
697
|
data-slot="page-body"
|
|
605
698
|
{...fill.attributes}
|
|
606
699
|
className={cn(
|
|
607
|
-
"flex
|
|
608
|
-
|
|
700
|
+
"flex min-h-0 flex-1 flex-col",
|
|
701
|
+
fill.filling ? "overflow-hidden" : "overflow-y-auto",
|
|
609
702
|
className,
|
|
610
703
|
)}
|
|
611
704
|
{...props}
|
|
612
705
|
>
|
|
613
|
-
|
|
706
|
+
<PageBodyContext.Provider value>
|
|
707
|
+
<div
|
|
708
|
+
data-slot="page-body-content"
|
|
709
|
+
{...fill.attributes}
|
|
710
|
+
className={cn(
|
|
711
|
+
"mx-auto w-full max-w-7xl",
|
|
712
|
+
surfaceRegionClasses("page", page!.surfaceLayout, "body"),
|
|
713
|
+
fill.filling ? "min-h-0 flex-1" : "min-h-full shrink-0",
|
|
714
|
+
page!.contentClassName,
|
|
715
|
+
contentClassName,
|
|
716
|
+
)}
|
|
717
|
+
>
|
|
718
|
+
{fill.provide(children)}
|
|
719
|
+
</div>
|
|
720
|
+
</PageBodyContext.Provider>
|
|
614
721
|
</div>
|
|
615
722
|
);
|
|
616
723
|
}
|
|
@@ -625,7 +732,8 @@ export function PageFooter({
|
|
|
625
732
|
<div
|
|
626
733
|
data-slot="page-footer"
|
|
627
734
|
className={cn(
|
|
628
|
-
"mt-auto flex h-[3.75rem] shrink-0 items-center justify-end
|
|
735
|
+
"mt-auto flex min-h-[3.75rem] shrink-0 items-center justify-end",
|
|
736
|
+
surfaceRegionClasses("page", page!.surfaceLayout, "footer"),
|
|
629
737
|
className,
|
|
630
738
|
)}
|
|
631
739
|
{...props}
|