@softize/opus 26.0.0 → 26.2.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 -0
- package/bin/lib/check.mjs +9 -32
- package/bin/lib/copy.mjs +7 -4
- package/docs/adr/0014-structural-headers-do-not-carry-description.md +1 -1
- package/docs/adr/0018-modal-header-only-names-the-surface.md +1 -1
- package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +4 -0
- package/docs/adr/0057-doc-browser-permite-busca-no-chrome-do-consumidor.md +27 -0
- package/docs/adr/0059-subheadings-orient-without-carrying-business-rules.md +51 -0
- package/docs/adr/README.md +2 -0
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/references/evaluations.md +8 -0
- package/registry/skills/build-opus-ui/references/ui-patterns.md +14 -4
- package/src/core/types.ts +3 -2
- package/src/ui/components/patterns/action-list-dialog.tsx +14 -7
- package/src/ui/components/patterns/form-dialog.tsx +18 -7
- package/src/ui/components/patterns/form.tsx +36 -8
- package/src/ui/components/patterns/page.tsx +22 -0
- package/src/ui/components/patterns/sidebar.tsx +17 -3
- package/src/ui/components/patterns/surface-anatomy.ts +1 -1
- package/src/ui/components/primitives/dialog.tsx +28 -1
- package/src/ui/components/primitives/drawer.tsx +30 -2
- package/src/ui/docs/DocBrowser.tsx +128 -81
- package/src/ui/docs/content/action-form-card.md +1 -1
- package/src/ui/docs/content/action-form-dialog.md +8 -0
- package/src/ui/docs/content/action-form.md +18 -5
- package/src/ui/docs/content/action-list-dialog.md +8 -0
- package/src/ui/docs/content/dialog.md +24 -2
- package/src/ui/docs/content/documentation-portal.md +45 -2
- package/src/ui/docs/content/drawer.md +24 -2
- package/src/ui/docs/content/page.md +23 -1
- package/src/ui/docs/content/sidebar.md +23 -0
- package/src/ui/docs/content/surface.md +8 -2
- package/src/ui/docs/types.ts +2 -0
- package/src/ui/lib/modal-layers.ts +27 -3
- package/src/ui/react.tsx +3 -0
|
@@ -147,6 +147,8 @@ interface PageBaseProps extends Omit<
|
|
|
147
147
|
|
|
148
148
|
interface PageShorthandProps extends PageBaseProps {
|
|
149
149
|
title: ReactNode;
|
|
150
|
+
/** Orientação opcional sobre a finalidade da página; regras pertencem ao corpo. */
|
|
151
|
+
description?: ReactNode;
|
|
150
152
|
actions?: ReactNode;
|
|
151
153
|
children: ReactNode;
|
|
152
154
|
}
|
|
@@ -224,6 +226,7 @@ function hasOpaquePageBodyContent(node: ReactNode): boolean {
|
|
|
224
226
|
|
|
225
227
|
export function Page({
|
|
226
228
|
title,
|
|
229
|
+
description,
|
|
227
230
|
actions,
|
|
228
231
|
layout = "flow",
|
|
229
232
|
className,
|
|
@@ -343,6 +346,9 @@ export function Page({
|
|
|
343
346
|
<>
|
|
344
347
|
<PageHeader>
|
|
345
348
|
<PageTitle>{title}</PageTitle>
|
|
349
|
+
{description !== undefined && (
|
|
350
|
+
<PageDescription>{description}</PageDescription>
|
|
351
|
+
)}
|
|
346
352
|
{actions !== undefined && <PageActions>{actions}</PageActions>}
|
|
347
353
|
</PageHeader>
|
|
348
354
|
<PageBody>{children}</PageBody>
|
|
@@ -461,6 +467,7 @@ export function PageHeader({
|
|
|
461
467
|
slots={{
|
|
462
468
|
leading: [PageBack, PageNavigation],
|
|
463
469
|
title: PageTitle,
|
|
470
|
+
description: PageDescription,
|
|
464
471
|
actions: PageActions,
|
|
465
472
|
}}
|
|
466
473
|
leadingPlacement="above"
|
|
@@ -496,6 +503,21 @@ export function PageTitle({
|
|
|
496
503
|
);
|
|
497
504
|
}
|
|
498
505
|
|
|
506
|
+
/** Orientação breve e dispensável para a decisão; não recebe regras de negócio. */
|
|
507
|
+
export function PageDescription({
|
|
508
|
+
className,
|
|
509
|
+
...props
|
|
510
|
+
}: HTMLAttributes<HTMLParagraphElement>): ReactElement {
|
|
511
|
+
requireParent(useContext(PageHeaderContext), "PageDescription", "PageHeader");
|
|
512
|
+
return (
|
|
513
|
+
<p
|
|
514
|
+
data-slot="page-description"
|
|
515
|
+
className={cn(structuralHeaderClasses.page.description, className)}
|
|
516
|
+
{...props}
|
|
517
|
+
/>
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
499
521
|
export function PageActions({
|
|
500
522
|
className,
|
|
501
523
|
...props
|
|
@@ -79,6 +79,8 @@ export interface SidebarItemProps {
|
|
|
79
79
|
className?: string;
|
|
80
80
|
/** Destino nativo. Com onClick, intercepta somente a navegação simples; preserva novas abas. */
|
|
81
81
|
href?: string;
|
|
82
|
+
/** Abre o destino em nova aba, sem interceptar a navegação nativa. */
|
|
83
|
+
newTab?: boolean;
|
|
82
84
|
onClick?: () => void;
|
|
83
85
|
}
|
|
84
86
|
|
|
@@ -98,11 +100,19 @@ export function SidebarItem({
|
|
|
98
100
|
className,
|
|
99
101
|
onClick,
|
|
100
102
|
href,
|
|
103
|
+
newTab = false,
|
|
101
104
|
}: SidebarItemProps): React.ReactElement {
|
|
102
105
|
const collapsed = useSidebarCollapsed();
|
|
103
106
|
const Target = href !== undefined && !disabled ? "a" : "button";
|
|
104
107
|
const targetProps =
|
|
105
|
-
Target === "a"
|
|
108
|
+
Target === "a"
|
|
109
|
+
? {
|
|
110
|
+
href,
|
|
111
|
+
...(newTab ? { target: "_blank", rel: "noopener noreferrer" } : {}),
|
|
112
|
+
}
|
|
113
|
+
: { type: "button" as const, disabled };
|
|
114
|
+
const accessibleLabel =
|
|
115
|
+
Target === "a" && newTab ? `${label} (abre em nova aba)` : label;
|
|
106
116
|
const activate = (
|
|
107
117
|
event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>,
|
|
108
118
|
) => {
|
|
@@ -113,6 +123,7 @@ export function SidebarItem({
|
|
|
113
123
|
event.ctrlKey ||
|
|
114
124
|
event.shiftKey ||
|
|
115
125
|
event.altKey ||
|
|
126
|
+
newTab ||
|
|
116
127
|
!onClick
|
|
117
128
|
)
|
|
118
129
|
return;
|
|
@@ -129,7 +140,7 @@ export function SidebarItem({
|
|
|
129
140
|
const button = (
|
|
130
141
|
<Target
|
|
131
142
|
{...targetProps}
|
|
132
|
-
aria-label={
|
|
143
|
+
aria-label={accessibleLabel}
|
|
133
144
|
aria-current={active ? "page" : undefined}
|
|
134
145
|
onClick={activate}
|
|
135
146
|
className={cn(
|
|
@@ -156,7 +167,7 @@ export function SidebarItem({
|
|
|
156
167
|
<Tooltip>
|
|
157
168
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
|
158
169
|
<TooltipContent side="right">
|
|
159
|
-
{
|
|
170
|
+
{accessibleLabel}
|
|
160
171
|
{tooltipHint}
|
|
161
172
|
</TooltipContent>
|
|
162
173
|
</Tooltip>
|
|
@@ -275,6 +286,9 @@ export function SidebarItem({
|
|
|
275
286
|
{badge}
|
|
276
287
|
</span>
|
|
277
288
|
)}
|
|
289
|
+
{Target === "a" && newTab && (
|
|
290
|
+
<span className="sr-only"> (abre em nova aba)</span>
|
|
291
|
+
)}
|
|
278
292
|
</Target>
|
|
279
293
|
{hasActions && (
|
|
280
294
|
<div
|
|
@@ -60,7 +60,7 @@ interface FamilyAnatomy {
|
|
|
60
60
|
regions: Record<SurfaceLayout, RegionAnatomy>;
|
|
61
61
|
/** Escala visual do título. O nível semântico do heading é escolhido à parte. */
|
|
62
62
|
title: Record<SurfaceLayout, string>;
|
|
63
|
-
/** Descrição no header, quando a família a oferece (
|
|
63
|
+
/** Descrição no header, quando a família a oferece (ADR 0059 inclui subheadings opcionais em modais). */
|
|
64
64
|
description: string;
|
|
65
65
|
/** Espaço entre mídia, título e ações na linha do header. */
|
|
66
66
|
headerGap: string;
|
|
@@ -5,6 +5,7 @@ import { cn } from "../../lib/cn.ts";
|
|
|
5
5
|
import {
|
|
6
6
|
SurfaceLayoutProvider,
|
|
7
7
|
surfaceActionsClasses,
|
|
8
|
+
surfaceDescriptionClasses,
|
|
8
9
|
surfaceHeaderRowClasses,
|
|
9
10
|
surfaceRegionClasses,
|
|
10
11
|
surfaceTitleClasses,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
exemptLayerFirst,
|
|
27
28
|
focusIsInLayer,
|
|
28
29
|
handleModalTabKey,
|
|
30
|
+
skipHelpOnAutoFocus,
|
|
29
31
|
isLayerInteraction,
|
|
30
32
|
routeEscapeToLayer,
|
|
31
33
|
useLayerPortal,
|
|
@@ -415,6 +417,7 @@ export function DialogContent({
|
|
|
415
417
|
onEscapeKeyDown,
|
|
416
418
|
onPointerDownOutside,
|
|
417
419
|
onInteractOutside,
|
|
420
|
+
onOpenAutoFocus,
|
|
418
421
|
onCloseAutoFocus,
|
|
419
422
|
onKeyDown,
|
|
420
423
|
ref,
|
|
@@ -481,6 +484,7 @@ export function DialogContent({
|
|
|
481
484
|
aria-describedby={ariaDescribedBy}
|
|
482
485
|
// O `AlertDialog` do Radix cancela sozinho o clique fora; só o Esc é composto.
|
|
483
486
|
onEscapeKeyDown={dismiss.onEscapeKeyDown}
|
|
487
|
+
onOpenAutoFocus={onOpenAutoFocus}
|
|
484
488
|
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
|
485
489
|
onKeyDown={onKeyDown}
|
|
486
490
|
{...props}
|
|
@@ -532,6 +536,10 @@ export function DialogContent({
|
|
|
532
536
|
onPointerDownOutside={dismiss.onPointerDownOutside}
|
|
533
537
|
onInteractOutside={dismiss.onInteractOutside}
|
|
534
538
|
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
|
539
|
+
onOpenAutoFocus={(event) => {
|
|
540
|
+
onOpenAutoFocus?.(event);
|
|
541
|
+
skipHelpOnAutoFocus(event);
|
|
542
|
+
}}
|
|
535
543
|
onKeyDown={handleKeyDown}
|
|
536
544
|
{...props}
|
|
537
545
|
>
|
|
@@ -584,6 +592,7 @@ export function DialogHeader({
|
|
|
584
592
|
leading: DialogNavigation,
|
|
585
593
|
media: DialogMedia,
|
|
586
594
|
title: DialogTitle,
|
|
595
|
+
description: DialogDescription,
|
|
587
596
|
actions: DialogActions,
|
|
588
597
|
}}
|
|
589
598
|
leadingPlacement="inline"
|
|
@@ -645,7 +654,11 @@ export function DialogIntro({
|
|
|
645
654
|
<StructuralHeader
|
|
646
655
|
name="DialogIntro"
|
|
647
656
|
slot="dialog-intro"
|
|
648
|
-
slots={{
|
|
657
|
+
slots={{
|
|
658
|
+
title: DialogTitle,
|
|
659
|
+
description: DialogDescription,
|
|
660
|
+
actions: DialogIntroActions,
|
|
661
|
+
}}
|
|
649
662
|
leadingPlacement="inline"
|
|
650
663
|
titleRequired
|
|
651
664
|
className={cn("text-left", className)}
|
|
@@ -770,3 +783,17 @@ export function DialogTitle({
|
|
|
770
783
|
</Title>
|
|
771
784
|
);
|
|
772
785
|
}
|
|
786
|
+
|
|
787
|
+
/** Orientação opcional sob o título; condições e consequências ficam no corpo. */
|
|
788
|
+
export function DialogDescription({
|
|
789
|
+
className,
|
|
790
|
+
...props
|
|
791
|
+
}: React.ComponentProps<"p">) {
|
|
792
|
+
return (
|
|
793
|
+
<p
|
|
794
|
+
data-slot="dialog-description"
|
|
795
|
+
className={cn("mt-1", surfaceDescriptionClasses("dialog"), className)}
|
|
796
|
+
{...props}
|
|
797
|
+
/>
|
|
798
|
+
);
|
|
799
|
+
}
|
|
@@ -5,6 +5,7 @@ import { cn } from "../../lib/cn.ts";
|
|
|
5
5
|
import {
|
|
6
6
|
SurfaceLayoutProvider,
|
|
7
7
|
surfaceActionsClasses,
|
|
8
|
+
surfaceDescriptionClasses,
|
|
8
9
|
surfaceHeaderRowClasses,
|
|
9
10
|
surfaceRegionClasses,
|
|
10
11
|
surfaceTitleClasses,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
LAYER_MODAL_ATTRIBUTE,
|
|
29
30
|
LayerPortalProvider,
|
|
30
31
|
handleModalTabKey,
|
|
32
|
+
skipHelpOnAutoFocus,
|
|
31
33
|
useLayerPortal,
|
|
32
34
|
useModalLayerEntry,
|
|
33
35
|
} from "../../lib/modal-layers.ts";
|
|
@@ -113,7 +115,9 @@ function DrawerOverlay({
|
|
|
113
115
|
return (
|
|
114
116
|
<DrawerPrimitive.Overlay
|
|
115
117
|
data-slot="drawer-overlay"
|
|
116
|
-
{...{
|
|
118
|
+
{...{
|
|
119
|
+
[LAYER_MODAL_ATTRIBUTE]: layer.container !== undefined || undefined,
|
|
120
|
+
}}
|
|
117
121
|
className={cn(overlayClasses, layer.zIndexClass, className)}
|
|
118
122
|
{...props}
|
|
119
123
|
/>
|
|
@@ -146,6 +150,7 @@ function DrawerContent({
|
|
|
146
150
|
onEscapeKeyDown,
|
|
147
151
|
onPointerDownOutside,
|
|
148
152
|
onInteractOutside,
|
|
153
|
+
onOpenAutoFocus,
|
|
149
154
|
onCloseAutoFocus,
|
|
150
155
|
onKeyDown,
|
|
151
156
|
ref,
|
|
@@ -218,6 +223,10 @@ function DrawerContent({
|
|
|
218
223
|
onPointerDownOutside={dismiss.onPointerDownOutside}
|
|
219
224
|
onInteractOutside={dismiss.onInteractOutside}
|
|
220
225
|
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
|
226
|
+
onOpenAutoFocus={(event) => {
|
|
227
|
+
onOpenAutoFocus?.(event);
|
|
228
|
+
skipHelpOnAutoFocus(event);
|
|
229
|
+
}}
|
|
221
230
|
onKeyDown={handleKeyDown}
|
|
222
231
|
{...props}
|
|
223
232
|
>
|
|
@@ -251,6 +260,7 @@ function DrawerHeader({
|
|
|
251
260
|
leading: DrawerNavigation,
|
|
252
261
|
media: DrawerMedia,
|
|
253
262
|
title: DrawerTitle,
|
|
263
|
+
description: DrawerDescription,
|
|
254
264
|
actions: DrawerActions,
|
|
255
265
|
}}
|
|
256
266
|
leadingPlacement="inline"
|
|
@@ -314,7 +324,11 @@ function DrawerIntro({
|
|
|
314
324
|
<StructuralHeader
|
|
315
325
|
name="DrawerIntro"
|
|
316
326
|
slot="drawer-intro"
|
|
317
|
-
slots={{
|
|
327
|
+
slots={{
|
|
328
|
+
title: DrawerTitle,
|
|
329
|
+
description: DrawerDescription,
|
|
330
|
+
actions: DrawerIntroActions,
|
|
331
|
+
}}
|
|
318
332
|
leadingPlacement="inline"
|
|
319
333
|
titleRequired
|
|
320
334
|
className={cn("text-left", className)}
|
|
@@ -435,3 +449,17 @@ export {
|
|
|
435
449
|
DrawerFooter,
|
|
436
450
|
DrawerTitle,
|
|
437
451
|
};
|
|
452
|
+
|
|
453
|
+
/** Orientação opcional sob o título; condições e consequências ficam no corpo. */
|
|
454
|
+
export function DrawerDescription({
|
|
455
|
+
className,
|
|
456
|
+
...props
|
|
457
|
+
}: React.ComponentProps<"p">) {
|
|
458
|
+
return (
|
|
459
|
+
<p
|
|
460
|
+
data-slot="drawer-description"
|
|
461
|
+
className={cn("mt-1", surfaceDescriptionClasses("drawer"), className)}
|
|
462
|
+
{...props}
|
|
463
|
+
/>
|
|
464
|
+
);
|
|
465
|
+
}
|
|
@@ -12,8 +12,15 @@
|
|
|
12
12
|
* Nos dois, navegar emite `popstate`: host que guarda o path por conta própria acompanha.
|
|
13
13
|
* `basePath` adapta o prefixo das URLs (`/docs` no Maestro, `/__docs` nos apps).
|
|
14
14
|
*/
|
|
15
|
-
import {
|
|
16
|
-
|
|
15
|
+
import {
|
|
16
|
+
Fragment,
|
|
17
|
+
useEffect,
|
|
18
|
+
useMemo,
|
|
19
|
+
useRef,
|
|
20
|
+
useState,
|
|
21
|
+
type ReactNode,
|
|
22
|
+
} from "react";
|
|
23
|
+
import { ArrowLeft, ArrowRight, ExternalLink, Search } from "lucide-react";
|
|
17
24
|
import type { DocEntry, DocGroup, DocSection } from "./types.ts";
|
|
18
25
|
import { Pane, Split, type PaneSize } from "../components/patterns/split.tsx";
|
|
19
26
|
import { Input } from "../components/primitives/input.tsx";
|
|
@@ -33,14 +40,28 @@ import {
|
|
|
33
40
|
} from "../components/patterns/sidebar.tsx";
|
|
34
41
|
import { navigate, usePathname } from "../router.ts";
|
|
35
42
|
|
|
43
|
+
function documentEntries(
|
|
44
|
+
sections: DocSection[],
|
|
45
|
+
): Array<{ entry: DocEntry; context: string }> {
|
|
46
|
+
const entriesFrom = (
|
|
47
|
+
groups: DocGroup[],
|
|
48
|
+
parent: string,
|
|
49
|
+
): Array<{ entry: DocEntry; context: string }> =>
|
|
50
|
+
groups.flatMap((group) => {
|
|
51
|
+
const context = group.label?.trim() || parent;
|
|
52
|
+
return [
|
|
53
|
+
...group.pages.map((entry) => ({ entry, context })),
|
|
54
|
+
...entriesFrom(group.groups ?? [], context),
|
|
55
|
+
];
|
|
56
|
+
});
|
|
57
|
+
return sections.flatMap((section) =>
|
|
58
|
+
entriesFrom(section.groups, section.label),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
36
62
|
function findPage(sections: DocSection[], slug: string): DocEntry | undefined {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const p = g.pages.find((p) => p.slug === slug);
|
|
40
|
-
if (p) return p;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
return undefined;
|
|
63
|
+
return documentEntries(sections).find(({ entry }) => entry.slug === slug)
|
|
64
|
+
?.entry;
|
|
44
65
|
}
|
|
45
66
|
|
|
46
67
|
interface DocBrowserSearchCopy {
|
|
@@ -58,7 +79,7 @@ export interface DocBrowserProps {
|
|
|
58
79
|
basePath?: string;
|
|
59
80
|
/** Path controlado pelo host (modo Maestro). Ausente = standalone (gerencia o próprio). */
|
|
60
81
|
path?: string;
|
|
61
|
-
/** Seções a exibir
|
|
82
|
+
/** Seções a exibir; grupos podem conter páginas e subgrupos. */
|
|
62
83
|
sections: DocSection[];
|
|
63
84
|
/** Slugs antigos que devem abrir e substituir a URL pela página canônica. */
|
|
64
85
|
redirects?: Readonly<Record<string, string>>;
|
|
@@ -79,8 +100,13 @@ export interface DocBrowserProps {
|
|
|
79
100
|
additionalPages?: DocEntry[];
|
|
80
101
|
/** Substitui a leitura, mantendo a árvore; o host resolve conteúdo, título e rota. */
|
|
81
102
|
content?: ReactNode;
|
|
82
|
-
/** Destinos fora dos capítulos;
|
|
83
|
-
sidebarLinks?: Array<{
|
|
103
|
+
/** Destinos fora dos capítulos; `newTab` abre em nova aba com ícone e descrição acessível. */
|
|
104
|
+
sidebarLinks?: Array<{
|
|
105
|
+
label: string;
|
|
106
|
+
href: string;
|
|
107
|
+
active?: boolean;
|
|
108
|
+
newTab?: boolean;
|
|
109
|
+
}>;
|
|
84
110
|
/** Seções e grupos começam recolhidos; a busca revela resultados sem mudar a escolha manual. */
|
|
85
111
|
sidebarInitiallyCollapsed?: boolean;
|
|
86
112
|
}
|
|
@@ -116,14 +142,7 @@ function EditorialDocument({
|
|
|
116
142
|
const [headings, setHeadings] = useState<
|
|
117
143
|
Array<{ id: string; title: string }>
|
|
118
144
|
>([]);
|
|
119
|
-
const entries = sections
|
|
120
|
-
section.groups.flatMap((group) =>
|
|
121
|
-
group.pages.map((entry) => ({
|
|
122
|
-
entry,
|
|
123
|
-
context: group.label || section.label,
|
|
124
|
-
})),
|
|
125
|
-
),
|
|
126
|
-
);
|
|
145
|
+
const entries = documentEntries(sections);
|
|
127
146
|
const index = entries.findIndex(({ entry }) => entry.slug === page.slug);
|
|
128
147
|
const previous = index > 0 ? entries[index - 1]?.entry : undefined;
|
|
129
148
|
const next = index >= 0 ? entries[index + 1]?.entry : undefined;
|
|
@@ -271,9 +290,14 @@ function searchable(value: string): string {
|
|
|
271
290
|
.toLocaleLowerCase("pt-BR");
|
|
272
291
|
}
|
|
273
292
|
|
|
293
|
+
type SidebarGroup = Omit<DocGroup, "groups"> & {
|
|
294
|
+
navigationKey: string;
|
|
295
|
+
groups: SidebarGroup[];
|
|
296
|
+
};
|
|
297
|
+
|
|
274
298
|
type SidebarSection = Omit<DocSection, "groups"> & {
|
|
275
299
|
navigationKey: string;
|
|
276
|
-
groups:
|
|
300
|
+
groups: SidebarGroup[];
|
|
277
301
|
};
|
|
278
302
|
|
|
279
303
|
function filterSections(
|
|
@@ -282,25 +306,35 @@ function filterSections(
|
|
|
282
306
|
): SidebarSection[] {
|
|
283
307
|
const terms = searchable(query).split(/\s+/).filter(Boolean);
|
|
284
308
|
// Identidade vem do manifesto completo, antes de remover ramos pela busca.
|
|
309
|
+
const indexGroups = (groups: DocGroup[], parent: string): SidebarGroup[] =>
|
|
310
|
+
groups.map((group, index) => {
|
|
311
|
+
const navigationKey = `${parent}:${index}`;
|
|
312
|
+
return {
|
|
313
|
+
...group,
|
|
314
|
+
navigationKey,
|
|
315
|
+
groups: indexGroups(group.groups ?? [], navigationKey),
|
|
316
|
+
};
|
|
317
|
+
});
|
|
285
318
|
const indexedSections = sections.map((section, sectionIndex) => ({
|
|
286
319
|
...section,
|
|
287
320
|
navigationKey: `section:${sectionIndex}`,
|
|
288
|
-
groups: section.groups
|
|
289
|
-
...group,
|
|
290
|
-
navigationKey: `group:${sectionIndex}:${groupIndex}`,
|
|
291
|
-
})),
|
|
321
|
+
groups: indexGroups(section.groups, `group:${sectionIndex}`),
|
|
292
322
|
}));
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const groups = section.groups.flatMap((group) => {
|
|
323
|
+
const matchingGroups = (groups: SidebarGroup[]): SidebarGroup[] =>
|
|
324
|
+
groups.flatMap((group) => {
|
|
296
325
|
const pages = group.pages.filter((page) => {
|
|
297
326
|
const text = searchable(
|
|
298
327
|
`${page.title}\n${page.navigationTitle ?? ""}\n${page.badge ?? ""}\n${page.searchText ?? ""}`,
|
|
299
328
|
);
|
|
300
329
|
return terms.every((term) => text.includes(term));
|
|
301
330
|
});
|
|
302
|
-
|
|
331
|
+
const children = matchingGroups(group.groups);
|
|
332
|
+
return pages.length === 0 && children.length === 0
|
|
333
|
+
? []
|
|
334
|
+
: [{ ...group, pages, groups: children }];
|
|
303
335
|
});
|
|
336
|
+
return indexedSections.flatMap((section) => {
|
|
337
|
+
const groups = matchingGroups(section.groups);
|
|
304
338
|
return groups.length === 0 ? [] : [{ ...section, groups }];
|
|
305
339
|
});
|
|
306
340
|
}
|
|
@@ -327,22 +361,21 @@ function DocSidebarTree({
|
|
|
327
361
|
|
|
328
362
|
useEffect(() => {
|
|
329
363
|
if (forceExpanded || initiallyCollapsed) return;
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (activeGroup === undefined) return;
|
|
364
|
+
const activePath = (groups: SidebarGroup[]): string[] => {
|
|
365
|
+
for (const group of groups) {
|
|
366
|
+
if (group.pages.some((page) => page.slug === activeSlug))
|
|
367
|
+
return [group.navigationKey];
|
|
368
|
+
const children = activePath(group.groups);
|
|
369
|
+
if (children.length > 0) return [group.navigationKey, ...children];
|
|
370
|
+
}
|
|
371
|
+
return [];
|
|
372
|
+
};
|
|
373
|
+
const keys = sections.flatMap((section) => activePath(section.groups));
|
|
374
|
+
if (keys.length === 0) return;
|
|
342
375
|
setToggledBranches((current) => {
|
|
343
|
-
if (!current.has(
|
|
376
|
+
if (!keys.some((key) => current.has(key))) return current;
|
|
344
377
|
const next = new Set(current);
|
|
345
|
-
next.delete(
|
|
378
|
+
keys.forEach((key) => next.delete(key));
|
|
346
379
|
return next;
|
|
347
380
|
});
|
|
348
381
|
}, [activeSlug, forceExpanded, sections, initiallyCollapsed]);
|
|
@@ -372,6 +405,36 @@ function DocSidebarTree({
|
|
|
372
405
|
/>
|
|
373
406
|
);
|
|
374
407
|
|
|
408
|
+
const renderGroup = (group: SidebarGroup): ReactNode => {
|
|
409
|
+
const key = group.navigationKey;
|
|
410
|
+
const children = [
|
|
411
|
+
...group.pages.map(renderPage),
|
|
412
|
+
...group.groups.map(renderGroup),
|
|
413
|
+
];
|
|
414
|
+
if ((group.label ?? "").trim() === "") {
|
|
415
|
+
return <Fragment key={key}>{children}</Fragment>;
|
|
416
|
+
}
|
|
417
|
+
const expanded =
|
|
418
|
+
forceExpanded ||
|
|
419
|
+
(initiallyCollapsed
|
|
420
|
+
? toggledBranches.has(key)
|
|
421
|
+
: !toggledBranches.has(key));
|
|
422
|
+
return (
|
|
423
|
+
<div key={key} className="space-y-0.5">
|
|
424
|
+
<SidebarItem
|
|
425
|
+
label={group.label!}
|
|
426
|
+
icon={group.icon}
|
|
427
|
+
expanded={expanded}
|
|
428
|
+
onToggle={forceExpanded ? undefined : () => toggleGroup(key)}
|
|
429
|
+
onClick={() => {
|
|
430
|
+
if (!forceExpanded) toggleGroup(key);
|
|
431
|
+
}}
|
|
432
|
+
/>
|
|
433
|
+
{expanded && <SidebarTreeGroup>{children}</SidebarTreeGroup>}
|
|
434
|
+
</div>
|
|
435
|
+
);
|
|
436
|
+
};
|
|
437
|
+
|
|
375
438
|
return (
|
|
376
439
|
<nav
|
|
377
440
|
aria-label="Navegação da documentação"
|
|
@@ -384,38 +447,7 @@ function DocSidebarTree({
|
|
|
384
447
|
initiallyCollapsed && section.label.trim() !== "";
|
|
385
448
|
const sectionExpanded =
|
|
386
449
|
forceExpanded || toggledBranches.has(sectionBranch);
|
|
387
|
-
const groups = section.groups.map(
|
|
388
|
-
const key = group.navigationKey;
|
|
389
|
-
if (group.pages.length === 0) return null;
|
|
390
|
-
if ((group.label ?? "").trim() === "") {
|
|
391
|
-
return group.pages.map((page) => renderPage(page));
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
const expanded =
|
|
395
|
-
forceExpanded ||
|
|
396
|
-
(initiallyCollapsed
|
|
397
|
-
? toggledBranches.has(key)
|
|
398
|
-
: !toggledBranches.has(key));
|
|
399
|
-
return (
|
|
400
|
-
<div key={key} className="space-y-0.5">
|
|
401
|
-
<SidebarItem
|
|
402
|
-
label={group.label!}
|
|
403
|
-
icon={group.icon}
|
|
404
|
-
expanded={expanded}
|
|
405
|
-
onToggle={forceExpanded ? undefined : () => toggleGroup(key)}
|
|
406
|
-
// Durante a busca, o grupo é contexto dos resultados e permanece aberto.
|
|
407
|
-
onClick={() => {
|
|
408
|
-
if (!forceExpanded) toggleGroup(key);
|
|
409
|
-
}}
|
|
410
|
-
/>
|
|
411
|
-
{expanded && (
|
|
412
|
-
<SidebarTreeGroup>
|
|
413
|
-
{group.pages.map((page) => renderPage(page))}
|
|
414
|
-
</SidebarTreeGroup>
|
|
415
|
-
)}
|
|
416
|
-
</div>
|
|
417
|
-
);
|
|
418
|
-
});
|
|
450
|
+
const groups = section.groups.map(renderGroup);
|
|
419
451
|
return (
|
|
420
452
|
<div key={sectionBranch} className="space-y-0.5">
|
|
421
453
|
{collapsibleSection ? (
|
|
@@ -452,9 +484,24 @@ function DocSidebarTree({
|
|
|
452
484
|
key={link.href}
|
|
453
485
|
href={link.href}
|
|
454
486
|
active={link.active}
|
|
455
|
-
|
|
487
|
+
newTab={link.newTab}
|
|
488
|
+
badge={
|
|
489
|
+
link.newTab ? (
|
|
490
|
+
<ExternalLink
|
|
491
|
+
aria-hidden="true"
|
|
492
|
+
className="size-3.5 text-muted-foreground"
|
|
493
|
+
/>
|
|
494
|
+
) : undefined
|
|
495
|
+
}
|
|
496
|
+
onClick={
|
|
497
|
+
link.newTab || /^https?:\/\//i.test(link.href)
|
|
498
|
+
? undefined
|
|
499
|
+
: () => navigate(link.href)
|
|
500
|
+
}
|
|
456
501
|
label={link.label}
|
|
457
|
-
icon={
|
|
502
|
+
icon={
|
|
503
|
+
initiallyCollapsed ? <span aria-hidden="true" /> : undefined
|
|
504
|
+
}
|
|
458
505
|
/>
|
|
459
506
|
))}
|
|
460
507
|
</div>
|
|
@@ -485,8 +532,8 @@ export function DocBrowser({
|
|
|
485
532
|
// hooks); o que muda é quem vence no `??`.
|
|
486
533
|
const livePath = usePathname();
|
|
487
534
|
const path = controlledPath ?? livePath;
|
|
488
|
-
//
|
|
489
|
-
const defaultSlug = sections[0]?.
|
|
535
|
+
// Abre na primeira página do percurso, inclusive dentro de subgrupos.
|
|
536
|
+
const defaultSlug = documentEntries(sections)[0]?.entry.slug ?? "";
|
|
490
537
|
// Slug = TODOS os segmentos após o basePath, unidos (rota aninhada: `/docs/guias/deploy`
|
|
491
538
|
// → `guias/deploy`). Slug flat de 1 segmento continua igual (`join` de 1 elemento = ele).
|
|
492
539
|
const rel = path.startsWith(basePath) ? path.slice(basePath.length) : "";
|
|
@@ -567,14 +614,14 @@ export function DocBrowser({
|
|
|
567
614
|
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-6">
|
|
568
615
|
<p
|
|
569
616
|
className={
|
|
570
|
-
visibleSections.length === 0
|
|
617
|
+
query.trim() !== "" && visibleSections.length === 0
|
|
571
618
|
? "px-2 text-sm text-muted-foreground"
|
|
572
619
|
: "sr-only"
|
|
573
620
|
}
|
|
574
621
|
role={search === false ? undefined : "status"}
|
|
575
622
|
aria-atomic={search === false ? undefined : "true"}
|
|
576
623
|
>
|
|
577
|
-
{visibleSections.length === 0
|
|
624
|
+
{query.trim() !== "" && visibleSections.length === 0
|
|
578
625
|
? (searchOptions.emptyMessage ??
|
|
579
626
|
"Nenhum documento encontrado. Tente outro termo.")
|
|
580
627
|
: ""}
|
|
@@ -28,7 +28,7 @@ a mesma.
|
|
|
28
28
|
| ------------------- | ---------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
29
29
|
| `title` | `string` | | Título do cabeçalho. Sem ele e sem `description`, o card começa diretamente pelo corpo. |
|
|
30
30
|
| `titleLevel` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `3` | Nível do heading do título. Muda a hierarquia do documento, não a escala visual. |
|
|
31
|
-
| `description` | `string` | |
|
|
31
|
+
| `description` | `string` | | Orientação opcional sobre a finalidade. Regras e consequências ficam junto dos campos ou no corpo. |
|
|
32
32
|
| `cardClassName` | `string` | | Classes aplicadas à superfície do card. |
|
|
33
33
|
| `className` | `string` | | Classes aplicadas ao `<form>` interno. |
|
|
34
34
|
| Demais propriedades | Propriedades de `ActionForm` | | `action`, `defaultValues`, `fieldOptions`, `submitLabel`, `cancelLabel`, `cancelVariant`, `onCancel`, `onSuccess`, `disabled`, `onLoadingChange` e `children` mantêm o comportamento de `ActionForm`. `body` e `footer` não são aceitas: o card fornece o corpo e o rodapé. |
|
|
@@ -35,12 +35,20 @@ salvo escolha explícita em `cancelVariant`.
|
|
|
35
35
|
|
|
36
36
|
Para consultar uma coleção no mesmo formato de modal, use [`ActionListDialog`](/ui/action-list-dialog).
|
|
37
37
|
|
|
38
|
+
## Orientação e contexto
|
|
39
|
+
|
|
40
|
+
`description` apresenta a finalidade no cabeçalho, como “Escolha onde a pasta e suas subpastas
|
|
41
|
+
aparecem.”. É opcional; não repita o título. `intro` apresenta no corpo informações necessárias
|
|
42
|
+
à decisão que não pertencem a um campo específico. Regras, condições e consequências não ficam
|
|
43
|
+
somente no subheading. Quando ambos são fornecidos, ambos participam de `aria-describedby`.
|
|
44
|
+
|
|
38
45
|
## Propriedades de ActionFormDialog
|
|
39
46
|
|
|
40
47
|
| Propriedade | Tipo | Padrão | Descrição |
|
|
41
48
|
| --------------------- | ------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
42
49
|
| `open / onOpenChange` | `boolean / (open: boolean) => void` | | Estado controlado do modal. `onOpenChange(false)` é chamado no sucesso e, quando não há `onCancel` próprio, ao cancelar. |
|
|
43
50
|
| `title` | `string` | | Operação e recurso exibidos no cabeçalho, como `Criar workspace` ou `Editar workspace`. |
|
|
51
|
+
| `description` | `ReactNode` | | Orientação opcional sob o título; não recebe regras de negócio. |
|
|
44
52
|
| `intro` | `ReactNode` | | Contexto relevante no início do corpo, como texto ou `Alert`. |
|
|
45
53
|
| `submitLabel` | `string` | `'Salvar'` | Resultado da ação principal. Preserve o padrão em criação e edição comuns; sobrescreva apenas quando a operação tiver outro efeito, como `Renomear` ou `Criar nova versão`. |
|
|
46
54
|
| `footerDistribution` | `'content' \| 'equal'` | `'content'` | Mantém a largura das ações pelo conteúdo ou divide a faixa igualmente. |
|