@softize/opus 23.0.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -0
- package/bin/cli.mjs +2 -0
- package/bin/lib/check.mjs +241 -42
- package/bin/lib/copy.mjs +16 -34
- package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +6 -0
- package/docs/adr/0039-filter-presentation-is-independent-from-filter-type.md +3 -3
- package/docs/adr/0040-tabs-use-a-single-navigation-presentation.md +5 -6
- package/docs/adr/0042-toggle-group-shares-the-grouped-control-grammar.md +79 -0
- package/docs/adr/0043-neutral-surfaces-and-interactions-use-opaque-role-tokens.md +73 -0
- package/docs/adr/0044-neutral-appearance-derives-from-bounded-axes-per-mode.md +133 -0
- package/docs/adr/0045-surface-replaces-content-and-card.md +230 -0
- package/docs/adr/0046-structural-body-owns-content-rhythm.md +103 -0
- package/docs/adr/0047-pane-is-only-a-split-layout-unit.md +71 -0
- package/docs/adr/0048-modal-intro-separates-content-from-chrome.md +34 -0
- package/package.json +5 -1
- package/registry/skills/build-opus-ui/SKILL.md +11 -11
- package/registry/skills/build-opus-ui/references/evaluations.md +10 -9
- package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -16
- package/registry/templates/app/src/App.tsx +24 -12
- package/src/ui/appearance.ts +149 -0
- package/src/ui/components/patterns/action-form-card.tsx +46 -33
- package/src/ui/components/patterns/action-list-dialog.tsx +2 -4
- package/src/ui/components/patterns/confirm.tsx +3 -1
- package/src/ui/components/patterns/fill-region.ts +8 -7
- package/src/ui/components/patterns/form-dialog.tsx +1 -3
- package/src/ui/components/patterns/form.tsx +8 -7
- package/src/ui/components/patterns/list.tsx +15 -19
- package/src/ui/components/patterns/page.tsx +15 -11
- package/src/ui/components/patterns/pane.tsx +9 -240
- package/src/ui/components/patterns/presentation.tsx +14 -14
- package/src/ui/components/patterns/sidebar.tsx +7 -17
- package/src/ui/components/patterns/split.tsx +111 -59
- package/src/ui/components/patterns/{surface-header.tsx → structural-header.tsx} +50 -19
- package/src/ui/components/patterns/structural-media.tsx +26 -0
- package/src/ui/components/patterns/surface-anatomy.ts +71 -61
- package/src/ui/components/patterns/surface-assistant.tsx +2 -4
- package/src/ui/components/patterns/surface.tsx +620 -0
- package/src/ui/components/patterns/trigger.tsx +11 -9
- package/src/ui/components/primitives/ask.tsx +155 -83
- package/src/ui/components/primitives/button.tsx +11 -9
- package/src/ui/components/primitives/checkbox.tsx +8 -8
- package/src/ui/components/primitives/command.tsx +1 -1
- package/src/ui/components/primitives/composer.tsx +47 -45
- package/src/ui/components/primitives/detail.tsx +9 -2
- package/src/ui/components/primitives/dialog.tsx +60 -26
- package/src/ui/components/primitives/drawer.tsx +58 -12
- package/src/ui/components/primitives/icon-picker.tsx +90 -73
- package/src/ui/components/primitives/input-group.tsx +35 -28
- package/src/ui/components/primitives/input-otp.tsx +16 -16
- package/src/ui/components/primitives/input.tsx +20 -10
- package/src/ui/components/primitives/item.tsx +9 -12
- package/src/ui/components/primitives/menu.tsx +44 -65
- package/src/ui/components/primitives/metric-card.tsx +75 -59
- package/src/ui/components/primitives/radio-group.tsx +9 -9
- package/src/ui/components/primitives/select.tsx +344 -209
- package/src/ui/components/primitives/switch.tsx +10 -10
- package/src/ui/components/primitives/table.tsx +29 -24
- package/src/ui/components/primitives/tabs.tsx +4 -2
- package/src/ui/components/primitives/textarea.tsx +6 -6
- package/src/ui/components/primitives/toggle-group-context.ts +24 -0
- package/src/ui/components/primitives/toggle-group.tsx +116 -74
- package/src/ui/components/primitives/toggle.tsx +82 -16
- package/src/ui/docs/DocBrowser.tsx +77 -69
- package/src/ui/docs/content/action-form-card.md +9 -9
- package/src/ui/docs/content/action-list.md +76 -70
- package/src/ui/docs/content/action-view.md +33 -18
- package/src/ui/docs/content/button.md +1 -1
- package/src/ui/docs/content/communication.md +1 -1
- package/src/ui/docs/content/customization.md +64 -11
- package/src/ui/docs/content/detail.md +8 -6
- package/src/ui/docs/content/dialog.md +100 -58
- package/src/ui/docs/content/drawer.md +63 -19
- package/src/ui/docs/content/field.md +14 -15
- package/src/ui/docs/content/icon-tile.md +14 -13
- package/src/ui/docs/content/page.md +34 -26
- package/src/ui/docs/content/pane.md +41 -290
- package/src/ui/docs/content/presentation.md +3 -3
- package/src/ui/docs/content/separator.md +6 -6
- package/src/ui/docs/content/sidebar.md +68 -266
- package/src/ui/docs/content/skeleton.md +20 -18
- package/src/ui/docs/content/split.md +14 -14
- package/src/ui/docs/content/surface.md +513 -0
- package/src/ui/docs/content/tabs.md +20 -20
- package/src/ui/docs/content/toggle.md +117 -65
- package/src/ui/docs/content/tokens.md +124 -40
- package/src/ui/docs/registry.tsx +4 -6
- package/src/ui/index.ts +30 -27
- package/src/ui/meta.ts +15 -27
- package/src/ui/react.tsx +40 -43
- package/src/ui/theme.css +311 -64
- package/src/ui/components/patterns/content-header.tsx +0 -528
- package/src/ui/components/patterns/surface-media.tsx +0 -69
- package/src/ui/components/primitives/card.tsx +0 -191
- package/src/ui/docs/content/card.md +0 -292
- package/src/ui/docs/content/content.md +0 -275
|
@@ -8,18 +8,6 @@ import { ChevronRight } from "lucide-react";
|
|
|
8
8
|
import { useOverflowing } from "../../lib/overflow.ts";
|
|
9
9
|
import { cn } from "../../lib/cn.ts";
|
|
10
10
|
|
|
11
|
-
// Os slots de Pane moram em `pane.tsx` desde a ADR 0023. A Sidebar continua sendo uma
|
|
12
|
-
// das superfícies que os compõe, então reexporta o que antes definia.
|
|
13
|
-
export {
|
|
14
|
-
PaneActions,
|
|
15
|
-
PaneBody,
|
|
16
|
-
PaneContent,
|
|
17
|
-
PaneDescription,
|
|
18
|
-
PaneFooter,
|
|
19
|
-
PaneHeader,
|
|
20
|
-
PaneMedia,
|
|
21
|
-
PaneTitle,
|
|
22
|
-
} from "./pane.tsx";
|
|
23
11
|
import { focusRing } from "../primitives/control.ts";
|
|
24
12
|
import {
|
|
25
13
|
Tooltip,
|
|
@@ -127,8 +115,8 @@ export function SidebarItem({
|
|
|
127
115
|
"outline-none",
|
|
128
116
|
focusRing,
|
|
129
117
|
active
|
|
130
|
-
? "bg-
|
|
131
|
-
: "text-foreground/80 hover:bg-
|
|
118
|
+
? "bg-interaction-selected font-medium text-foreground"
|
|
119
|
+
: "text-foreground/80 hover:bg-interaction-hover",
|
|
132
120
|
)}
|
|
133
121
|
>
|
|
134
122
|
<span
|
|
@@ -159,8 +147,10 @@ export function SidebarItem({
|
|
|
159
147
|
data-slot="sidebar-item"
|
|
160
148
|
className={cn(
|
|
161
149
|
"group/sidebar-item relative flex items-center rounded-md transition-colors",
|
|
162
|
-
disabled
|
|
163
|
-
|
|
150
|
+
disabled
|
|
151
|
+
? "text-muted-foreground/50 opacity-40"
|
|
152
|
+
: "hover:bg-interaction-hover",
|
|
153
|
+
active && "bg-interaction-selected",
|
|
164
154
|
dropPosition === "inside" &&
|
|
165
155
|
"bg-primary/5 ring-2 ring-inset ring-primary/60",
|
|
166
156
|
className,
|
|
@@ -333,7 +323,7 @@ export function SidebarTreeGroup({
|
|
|
333
323
|
);
|
|
334
324
|
}
|
|
335
325
|
|
|
336
|
-
/** Navegação controlada para Sidebar.
|
|
326
|
+
/** Navegação controlada para Sidebar. Chrome adicional pertence à composição hospedeira. */
|
|
337
327
|
export function SidebarNav({
|
|
338
328
|
groups,
|
|
339
329
|
activeId,
|
|
@@ -1,33 +1,43 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
cloneElement,
|
|
4
|
+
Fragment,
|
|
5
|
+
isValidElement,
|
|
6
|
+
type ReactElement,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { cn } from "../../lib/cn.ts";
|
|
10
|
+
import {
|
|
11
|
+
ResizableHandle,
|
|
12
|
+
ResizablePanel,
|
|
13
|
+
ResizablePanelGroup,
|
|
14
|
+
} from "../primitives/resizable.tsx";
|
|
15
|
+
import { Pane, paneSizeToCss, type PaneProps, type PaneSize } from "./pane.tsx";
|
|
5
16
|
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
export {
|
|
9
|
-
export type { PaneProps, PaneSize }
|
|
17
|
+
// Pane é a unidade espacial de Split; sua apresentação pertence ao filho (ADR 0047).
|
|
18
|
+
export { Pane } from "./pane.tsx";
|
|
19
|
+
export type { PaneProps, PaneSize };
|
|
10
20
|
|
|
11
|
-
export type SplitLayout = Record<string, number
|
|
21
|
+
export type SplitLayout = Record<string, number>;
|
|
12
22
|
export interface SplitLayoutChange {
|
|
13
|
-
isUserInteraction: boolean
|
|
23
|
+
isUserInteraction: boolean;
|
|
14
24
|
}
|
|
15
25
|
|
|
16
26
|
export interface SplitProps {
|
|
17
27
|
/** Identidade estável do grupo redimensionável. */
|
|
18
|
-
id?: string
|
|
19
|
-
direction?:
|
|
28
|
+
id?: string;
|
|
29
|
+
direction?: "horizontal" | "vertical";
|
|
20
30
|
/** Quando ligado, cada fronteira ganha um separador acessível e arrastável. */
|
|
21
|
-
resizable?: boolean
|
|
22
|
-
handle?: boolean
|
|
31
|
+
resizable?: boolean;
|
|
32
|
+
handle?: boolean;
|
|
23
33
|
/** Layout percentual restaurado, indexado pelos ids dos panes. */
|
|
24
|
-
defaultLayout?: SplitLayout
|
|
34
|
+
defaultLayout?: SplitLayout;
|
|
25
35
|
/** Chamado ao concluir uma mudança de layout; pode persistir o resultado em localStorage. */
|
|
26
|
-
onLayoutChanged?: (layout: SplitLayout, meta: SplitLayoutChange) => void
|
|
27
|
-
className?: string
|
|
36
|
+
onLayoutChanged?: (layout: SplitLayout, meta: SplitLayoutChange) => void;
|
|
37
|
+
className?: string;
|
|
28
38
|
/** Identificador semântico opcional para testes e composição de patterns. */
|
|
29
|
-
|
|
30
|
-
children: ReactNode
|
|
39
|
+
"data-slot"?: string;
|
|
40
|
+
children: ReactNode;
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
/**
|
|
@@ -35,27 +45,36 @@ export interface SplitProps {
|
|
|
35
45
|
* `Children.toArray` numera as chaves por nível, então o filho de um fragment herda a
|
|
36
46
|
* chave do fragment como prefixo — sem isso, `.0` de dentro colidiria com `.0` de fora.
|
|
37
47
|
*/
|
|
38
|
-
function flattenFragments(children: ReactNode, prefix =
|
|
48
|
+
function flattenFragments(children: ReactNode, prefix = ""): ReactNode[] {
|
|
39
49
|
return Children.toArray(children).flatMap((node): ReactNode[] => {
|
|
40
50
|
if (isValidElement(node) && node.type === Fragment)
|
|
41
|
-
return flattenFragments(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
return flattenFragments(
|
|
52
|
+
(node.props as { children?: ReactNode }).children,
|
|
53
|
+
`${prefix}${String(node.key)}/`,
|
|
54
|
+
);
|
|
55
|
+
if (prefix !== "" && isValidElement(node))
|
|
56
|
+
return [cloneElement(node, { key: `${prefix}${String(node.key)}` })];
|
|
57
|
+
return [node];
|
|
58
|
+
});
|
|
45
59
|
}
|
|
46
60
|
|
|
47
61
|
/** Nome legível do filho recusado, para a mensagem apontar o nó certo. */
|
|
48
62
|
function describeChild(node: ReactNode): string {
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
63
|
+
if (
|
|
64
|
+
typeof node === "string" ||
|
|
65
|
+
typeof node === "number" ||
|
|
66
|
+
typeof node === "bigint"
|
|
67
|
+
) {
|
|
68
|
+
const text = String(node).trim();
|
|
69
|
+
return `o texto "${text.length > 40 ? `${text.slice(0, 40)}…` : text}"`;
|
|
52
70
|
}
|
|
53
71
|
if (isValidElement(node)) {
|
|
54
|
-
const type = node.type as string | { displayName?: string; name?: string }
|
|
55
|
-
const name =
|
|
56
|
-
|
|
72
|
+
const type = node.type as string | { displayName?: string; name?: string };
|
|
73
|
+
const name =
|
|
74
|
+
typeof type === "string" ? type : (type.displayName ?? type.name ?? "");
|
|
75
|
+
return name === "" ? "um componente sem nome" : `<${name}>`;
|
|
57
76
|
}
|
|
58
|
-
return
|
|
77
|
+
return "um nó que não é elemento React";
|
|
59
78
|
}
|
|
60
79
|
|
|
61
80
|
/**
|
|
@@ -65,27 +84,27 @@ function describeChild(node: ReactNode): string {
|
|
|
65
84
|
*/
|
|
66
85
|
function panesOf(children: ReactNode): ReactElement<PaneProps>[] {
|
|
67
86
|
return flattenFragments(children).flatMap((child) => {
|
|
68
|
-
if (isValidElement<PaneProps>(child) && child.type === Pane) return [child]
|
|
87
|
+
if (isValidElement<PaneProps>(child) && child.type === Pane) return [child];
|
|
69
88
|
// Espaço em branco (um `{' '}` da formatação) não é conteúdo: nunca ocupou lugar.
|
|
70
|
-
if (typeof child ===
|
|
89
|
+
if (typeof child === "string" && child.trim() === "") return [];
|
|
71
90
|
throw new Error(
|
|
72
91
|
`Split recebeu ${describeChild(child)} como filho, mas só distribui <Pane>: esse conteúdo ficaria fora do layout. ` +
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
)
|
|
76
|
-
})
|
|
92
|
+
"Envolva-o em um <Pane>. Se ele vem de um componente que devolve um <Pane>, escreva o <Pane> dentro do Split e " +
|
|
93
|
+
"passe o componente como filho dele: o Split lê o tamanho de cada pane antes de renderizá-lo.",
|
|
94
|
+
);
|
|
95
|
+
});
|
|
77
96
|
}
|
|
78
97
|
|
|
79
98
|
// Números continuam percentuais por compatibilidade com Split/Pane. Strings preservam
|
|
80
99
|
// a unidade explícita para layouts que precisam de um limite físico, como `28rem`.
|
|
81
100
|
function resizableSize(value: PaneSize | undefined): string | undefined {
|
|
82
|
-
return value === undefined ? undefined : paneSizeToCss(value)
|
|
101
|
+
return value === undefined ? undefined : paneSizeToCss(value);
|
|
83
102
|
}
|
|
84
103
|
|
|
85
104
|
function percentage(value: PaneSize | undefined): number | undefined {
|
|
86
|
-
if (typeof value ===
|
|
87
|
-
if (value?.endsWith(
|
|
88
|
-
return undefined
|
|
105
|
+
if (typeof value === "number") return value;
|
|
106
|
+
if (value?.endsWith("%")) return Number.parseFloat(value);
|
|
107
|
+
return undefined;
|
|
89
108
|
}
|
|
90
109
|
|
|
91
110
|
/**
|
|
@@ -95,31 +114,47 @@ function percentage(value: PaneSize | undefined): number | undefined {
|
|
|
95
114
|
*/
|
|
96
115
|
export function Split({
|
|
97
116
|
id,
|
|
98
|
-
direction =
|
|
117
|
+
direction = "horizontal",
|
|
99
118
|
resizable = false,
|
|
100
119
|
handle = false,
|
|
101
120
|
defaultLayout,
|
|
102
121
|
onLayoutChanged,
|
|
103
122
|
className,
|
|
104
|
-
|
|
123
|
+
"data-slot": dataSlot = "split",
|
|
105
124
|
children,
|
|
106
125
|
}: SplitProps): React.ReactElement {
|
|
107
|
-
const panes = panesOf(children)
|
|
108
|
-
const vertical = direction ===
|
|
126
|
+
const panes = panesOf(children);
|
|
127
|
+
const vertical = direction === "vertical";
|
|
109
128
|
|
|
110
129
|
if (!resizable) {
|
|
111
130
|
return (
|
|
112
|
-
<div
|
|
131
|
+
<div
|
|
132
|
+
id={id === undefined ? undefined : String(id)}
|
|
133
|
+
data-slot={dataSlot}
|
|
134
|
+
data-direction={direction}
|
|
135
|
+
className={cn(
|
|
136
|
+
"flex min-h-0 min-w-0 flex-1",
|
|
137
|
+
vertical && "flex-col",
|
|
138
|
+
className,
|
|
139
|
+
)}
|
|
140
|
+
>
|
|
113
141
|
{panes}
|
|
114
142
|
</div>
|
|
115
|
-
)
|
|
143
|
+
);
|
|
116
144
|
}
|
|
117
145
|
|
|
118
|
-
const fixed = panes.reduce(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
146
|
+
const fixed = panes.reduce(
|
|
147
|
+
(sum, pane) => sum + (percentage(pane.props.initialSize) ?? 0),
|
|
148
|
+
0,
|
|
149
|
+
);
|
|
150
|
+
const hasAbsoluteSize = panes.some(
|
|
151
|
+
(pane) =>
|
|
152
|
+
typeof pane.props.initialSize === "string" &&
|
|
153
|
+
!pane.props.initialSize.endsWith("%"),
|
|
154
|
+
);
|
|
155
|
+
const growing = panes.filter((pane) => pane.props.grow);
|
|
156
|
+
const rest = Math.max(0, 100 - fixed);
|
|
157
|
+
const fallback = panes.length === 0 ? 100 : 100 / panes.length;
|
|
123
158
|
|
|
124
159
|
return (
|
|
125
160
|
<ResizablePanelGroup
|
|
@@ -128,11 +163,16 @@ export function Split({
|
|
|
128
163
|
defaultLayout={defaultLayout}
|
|
129
164
|
onLayoutChanged={onLayoutChanged}
|
|
130
165
|
data-slot={dataSlot}
|
|
131
|
-
className={cn(
|
|
166
|
+
className={cn("min-h-0 min-w-0 flex-1", className)}
|
|
132
167
|
>
|
|
133
168
|
{panes.flatMap((pane, index) => {
|
|
134
|
-
const size =
|
|
135
|
-
|
|
169
|
+
const size =
|
|
170
|
+
pane.props.initialSize ??
|
|
171
|
+
(hasAbsoluteSize
|
|
172
|
+
? undefined
|
|
173
|
+
: pane.props.grow
|
|
174
|
+
? rest / Math.max(1, growing.length)
|
|
175
|
+
: fallback);
|
|
136
176
|
const panel = (
|
|
137
177
|
<ResizablePanel
|
|
138
178
|
key={pane.key ?? index}
|
|
@@ -141,11 +181,23 @@ export function Split({
|
|
|
141
181
|
minSize={resizableSize(pane.props.minSize)}
|
|
142
182
|
maxSize={resizableSize(pane.props.maxSize)}
|
|
143
183
|
>
|
|
144
|
-
<Pane
|
|
184
|
+
<Pane
|
|
185
|
+
{...pane.props}
|
|
186
|
+
id={undefined}
|
|
187
|
+
className={cn("h-full w-full", pane.props.className)}
|
|
188
|
+
/>
|
|
145
189
|
</ResizablePanel>
|
|
146
|
-
)
|
|
147
|
-
return index === 0
|
|
190
|
+
);
|
|
191
|
+
return index === 0
|
|
192
|
+
? [panel]
|
|
193
|
+
: [
|
|
194
|
+
<ResizableHandle
|
|
195
|
+
key={`handle-${pane.key ?? index}`}
|
|
196
|
+
withHandle={handle}
|
|
197
|
+
/>,
|
|
198
|
+
panel,
|
|
199
|
+
];
|
|
148
200
|
})}
|
|
149
201
|
</ResizablePanelGroup>
|
|
150
|
-
)
|
|
202
|
+
);
|
|
151
203
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cabeçalho
|
|
2
|
+
* Cabeçalho estrutural (interno): a anatomia que Page, Surface, Dialog, Drawer e Pane
|
|
3
|
+
* compartilham. O nome público `SurfaceHeader` pertence à família Surface (ADR 0045).
|
|
3
4
|
*
|
|
4
5
|
* Um título, metadata opcional e uma região de ações que vai para o extremo oposto
|
|
5
6
|
* em larguras amplas e empilha abaixo do contexto nas estreitas. Cada família escolhe quais slots
|
|
@@ -17,11 +18,11 @@ import {
|
|
|
17
18
|
} from "react";
|
|
18
19
|
import { cn } from "../../lib/cn.ts";
|
|
19
20
|
|
|
20
|
-
export type
|
|
21
|
+
export type StructuralHeaderVariant = "page" | "section";
|
|
21
22
|
|
|
22
|
-
/** As classes por slot, por hierarquia visual — PageTitle e
|
|
23
|
-
export const
|
|
24
|
-
|
|
23
|
+
/** As classes por slot, por hierarquia visual — PageTitle e SurfaceTitle no nível 1 são o mesmo. */
|
|
24
|
+
export const structuralHeaderClasses: Record<
|
|
25
|
+
StructuralHeaderVariant,
|
|
25
26
|
{ title: string; description: string; count: string; actions: string }
|
|
26
27
|
> = {
|
|
27
28
|
page: {
|
|
@@ -38,7 +39,7 @@ export const surfaceHeaderClasses: Record<
|
|
|
38
39
|
},
|
|
39
40
|
};
|
|
40
41
|
|
|
41
|
-
interface
|
|
42
|
+
interface StructuralHeaderSlots {
|
|
42
43
|
leading?: ElementType | readonly ElementType[];
|
|
43
44
|
/** Ícone ou imagem que abre a linha, antes do heading (ADR 0023). */
|
|
44
45
|
media?: ElementType;
|
|
@@ -48,15 +49,15 @@ interface SurfaceHeaderSlots {
|
|
|
48
49
|
actions?: ElementType;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
export interface
|
|
52
|
+
export interface StructuralHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
52
53
|
/** Elemento estrutural; `legend` preserva a mesma anatomia com wrappers phrasing. */
|
|
53
54
|
as?: "div" | "legend";
|
|
54
|
-
/** Nome do componente público, para as mensagens de erro (`PageHeader`, `
|
|
55
|
+
/** Nome do componente público, para as mensagens de erro (`PageHeader`, `SurfaceHeader`). */
|
|
55
56
|
name: string;
|
|
56
57
|
/** Prefixo dos `data-slot` (`page` → `page-header`, `page-heading`). */
|
|
57
58
|
slot: string;
|
|
58
59
|
/** Os componentes de slot que este cabeçalho reconhece. */
|
|
59
|
-
slots:
|
|
60
|
+
slots: StructuralHeaderSlots;
|
|
60
61
|
/** Posição de um slot introdutório opcional, antes da linha ou dentro dela. */
|
|
61
62
|
leadingPlacement?: "above" | "inline";
|
|
62
63
|
/** Mantém a mídia na linha geral ou a agrupa com o heading. */
|
|
@@ -72,7 +73,7 @@ export interface SurfaceHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
72
73
|
/**
|
|
73
74
|
* Aceita conteúdo livre além dos slots. Vale para a superfície genérica — um pane
|
|
74
75
|
* hospeda marca, busca ou chrome que não são título nem ação. As famílias que sempre
|
|
75
|
-
* NOMEIAM algo (
|
|
76
|
+
* NOMEIAM algo (Surface, Dialog, Drawer, Page) continuam estritas.
|
|
76
77
|
*/
|
|
77
78
|
allowFreeContent?: boolean;
|
|
78
79
|
/**
|
|
@@ -91,7 +92,7 @@ function flatten(children: ReactNode): ReactNode[] {
|
|
|
91
92
|
);
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
export function
|
|
95
|
+
export function StructuralHeader({
|
|
95
96
|
as: Root = "div",
|
|
96
97
|
name,
|
|
97
98
|
slot,
|
|
@@ -107,7 +108,7 @@ export function SurfaceHeader({
|
|
|
107
108
|
className,
|
|
108
109
|
children,
|
|
109
110
|
...props
|
|
110
|
-
}:
|
|
111
|
+
}: StructuralHeaderProps): ReactElement {
|
|
111
112
|
const Wrapper = Root === "legend" ? "span" : "div";
|
|
112
113
|
const nodes = flatten(children);
|
|
113
114
|
const of = (type: ElementType) =>
|
|
@@ -124,8 +125,7 @@ export function SurfaceHeader({
|
|
|
124
125
|
const counts = slots.count === undefined ? [] : of(slots.count);
|
|
125
126
|
const descriptions =
|
|
126
127
|
slots.description === undefined ? [] : of(slots.description);
|
|
127
|
-
const actionSlots =
|
|
128
|
-
slots.actions === undefined ? [] : of(slots.actions);
|
|
128
|
+
const actionSlots = slots.actions === undefined ? [] : of(slots.actions);
|
|
129
129
|
const slotTypes = [
|
|
130
130
|
...leadingTypes,
|
|
131
131
|
slots.media,
|
|
@@ -137,7 +137,9 @@ export function SurfaceHeader({
|
|
|
137
137
|
const free = allowFreeContent
|
|
138
138
|
? nodes.filter(
|
|
139
139
|
(node) =>
|
|
140
|
-
!(
|
|
140
|
+
!(
|
|
141
|
+
isValidElement(node) && slotTypes.includes(node.type as ElementType)
|
|
142
|
+
),
|
|
141
143
|
)
|
|
142
144
|
: [];
|
|
143
145
|
const recognized =
|
|
@@ -242,16 +244,26 @@ export function SurfaceHeader({
|
|
|
242
244
|
)}
|
|
243
245
|
{mediaPlacement === "row" && medias}
|
|
244
246
|
{heading}
|
|
247
|
+
{!heading && leading.length === 0 && (
|
|
248
|
+
<Wrapper
|
|
249
|
+
data-slot={`${slot}-header-spacer`}
|
|
250
|
+
className="min-w-0 flex-1"
|
|
251
|
+
aria-hidden="true"
|
|
252
|
+
/>
|
|
253
|
+
)}
|
|
245
254
|
{actionSlots}
|
|
246
255
|
{trailing}
|
|
247
256
|
</>
|
|
248
257
|
);
|
|
249
258
|
const layout =
|
|
250
259
|
leadingPlacement === "inline" ? (
|
|
251
|
-
<Wrapper
|
|
260
|
+
<Wrapper
|
|
261
|
+
data-slot={`${slot}-header-row`}
|
|
262
|
+
className={cn("flex min-w-0 items-center gap-3", rowClassName)}
|
|
263
|
+
>
|
|
252
264
|
{row}
|
|
253
265
|
</Wrapper>
|
|
254
|
-
) : leading.length > 0 ? (
|
|
266
|
+
) : leading.length > 0 && heading ? (
|
|
255
267
|
<>
|
|
256
268
|
<Wrapper data-slot={`${slot}-navigation`} className="mb-4">
|
|
257
269
|
{leading}
|
|
@@ -267,6 +279,19 @@ export function SurfaceHeader({
|
|
|
267
279
|
{actionSlots}
|
|
268
280
|
</Wrapper>
|
|
269
281
|
</>
|
|
282
|
+
) : leading.length > 0 ? (
|
|
283
|
+
<Wrapper
|
|
284
|
+
data-slot={`${slot}-header-row`}
|
|
285
|
+
className="flex min-w-0 items-center gap-4"
|
|
286
|
+
>
|
|
287
|
+
<Wrapper
|
|
288
|
+
data-slot={`${slot}-navigation`}
|
|
289
|
+
className="min-w-0 flex-1"
|
|
290
|
+
>
|
|
291
|
+
{leading}
|
|
292
|
+
</Wrapper>
|
|
293
|
+
{actionSlots}
|
|
294
|
+
</Wrapper>
|
|
270
295
|
) : (
|
|
271
296
|
<>
|
|
272
297
|
{heading}
|
|
@@ -282,7 +307,10 @@ export function SurfaceHeader({
|
|
|
282
307
|
leadingPlacement !== "inline" &&
|
|
283
308
|
leading.length === 0 &&
|
|
284
309
|
actionSlots.length > 0 &&
|
|
285
|
-
|
|
310
|
+
cn(
|
|
311
|
+
"flex flex-row items-end gap-4",
|
|
312
|
+
heading ? "justify-between" : "justify-end",
|
|
313
|
+
),
|
|
286
314
|
className,
|
|
287
315
|
)}
|
|
288
316
|
{...props}
|
|
@@ -290,7 +318,10 @@ export function SurfaceHeader({
|
|
|
290
318
|
{contentClassName === undefined ? (
|
|
291
319
|
layout
|
|
292
320
|
) : (
|
|
293
|
-
<Wrapper
|
|
321
|
+
<Wrapper
|
|
322
|
+
data-slot={`${slot}-header-content`}
|
|
323
|
+
className={contentClassName}
|
|
324
|
+
>
|
|
294
325
|
{layout}
|
|
295
326
|
</Wrapper>
|
|
296
327
|
)}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slot de mídia estrutural (interno): abre a linha do header de Dialog, Drawer e Pane
|
|
3
|
+
* sem decidir a apresentação do conteúdo. A moldura, quando necessária, é composta
|
|
4
|
+
* explicitamente com `IconTile` pelo consumidor.
|
|
5
|
+
*/
|
|
6
|
+
import type { ReactElement } from "react";
|
|
7
|
+
import { cn } from "../../lib/cn.ts";
|
|
8
|
+
|
|
9
|
+
export interface StructuralMediaProps extends React.ComponentProps<"div"> {}
|
|
10
|
+
|
|
11
|
+
export function StructuralMedia({
|
|
12
|
+
slot,
|
|
13
|
+
className,
|
|
14
|
+
...props
|
|
15
|
+
}: StructuralMediaProps & { slot: string }): ReactElement {
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
data-slot={slot}
|
|
19
|
+
className={cn(
|
|
20
|
+
"flex shrink-0 items-center justify-center [&>svg:not([class*='size-'])]:size-4",
|
|
21
|
+
className,
|
|
22
|
+
)}
|
|
23
|
+
{...props}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|