@softize/opus 26.1.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 +18 -0
- package/docs/adr/0057-doc-browser-permite-busca-no-chrome-do-consumidor.md +27 -0
- package/package.json +1 -1
- package/src/ui/components/patterns/sidebar.tsx +17 -3
- package/src/ui/docs/DocBrowser.tsx +128 -81
- package/src/ui/docs/content/documentation-portal.md +45 -2
- package/src/ui/docs/content/sidebar.md +23 -0
- package/src/ui/docs/types.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
|
|
|
7
7
|
`opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
|
|
8
8
|
mudança cobra do seu código.
|
|
9
9
|
|
|
10
|
+
## 26.2.0 — 2026-10-09
|
|
11
|
+
|
|
12
|
+
Portais podem organizar capítulos em subgrupos e abrir ferramentas relacionadas sem substituir
|
|
13
|
+
a página em leitura.
|
|
14
|
+
|
|
15
|
+
- `DocGroup.groups` declara subgrupos opcionais. Navegação, busca, URLs diretas e sequência
|
|
16
|
+
editorial percorrem todos os níveis, com as páginas próprias antes dos subgrupos. A busca
|
|
17
|
+
preserva os ancestrais dos resultados; no modo inicialmente recolhido, restaura o estado
|
|
18
|
+
manual ao limpar. No modo expandido, os ancestrais da página ativa são reabertos. Ramos
|
|
19
|
+
vazios não aparecem, sem sugerir outro termo quando não há consulta.
|
|
20
|
+
- `DocBrowser.sidebarLinks` preserva a navegação nativa para URLs HTTP(S) absolutas.
|
|
21
|
+
- `SidebarItem` e `DocBrowser.sidebarLinks` aceitam `newTab`: o link abre em outra aba com
|
|
22
|
+
`noopener noreferrer` e informa a abertura no nome acessível. No portal, também exibe
|
|
23
|
+
um ícone de nova aba. A opção não intercepta a navegação nem chama `onClick`.
|
|
24
|
+
|
|
25
|
+
As novas propriedades são opcionais. Consumidores sem subgrupos ou `newTab` mantêm seus
|
|
26
|
+
contratos; não há migração obrigatória nesta versão.
|
|
27
|
+
|
|
10
28
|
## 26.1.0 — 2026-10-09
|
|
11
29
|
|
|
12
30
|
Subheadings descrevem a finalidade da superfície; orientações necessárias ficam visíveis no corpo
|
|
@@ -39,6 +39,19 @@ de ADRs ou versões.
|
|
|
39
39
|
|
|
40
40
|
### Hierarquia e leitura
|
|
41
41
|
|
|
42
|
+
> **Emenda — 2026-10-08, protótipo local:** `DocGroup.groups` permite subgrupos
|
|
43
|
+
> opcionais. Um assunto como Integrações pode pertencer a Domínios e ainda reunir
|
|
44
|
+
> páginas por sistema. A navegação, a busca, a seleção direta e a sequência de
|
|
45
|
+
> leitura percorrem os mesmos grupos aninhados. Páginas próprias aparecem antes
|
|
46
|
+
> dos subgrupos; ramos sem resultados somem da busca, sem perder a abertura manual.
|
|
47
|
+
> O contrato anterior continua válido quando `groups` é omitido. Uma árvore paralela
|
|
48
|
+
> no host e rótulos artificiais para simular profundidade foram descartados por
|
|
49
|
+
> duplicar comportamento ou perder a hierarquia. Testes de links diretos, busca,
|
|
50
|
+
> grupos vazios, rótulos repetidos e continuidade verificam a extensão.
|
|
51
|
+
|
|
52
|
+
> **Consolidação — 2026-10-09:** a extensão de subgrupos compõe o candidato da versão
|
|
53
|
+
> 26.2.0, sobre a base 26.1.0. Publicação e adoção permanecem etapas separadas.
|
|
54
|
+
|
|
42
55
|
`sidebarSize` configura a largura inicial usando o `PaneSize` do `Split`. A omissão preserva
|
|
43
56
|
`14rem`. `sidebarInitiallyCollapsed` permite começar com seções e grupos nomeados recolhidos,
|
|
44
57
|
inclusive quando a pessoa abre uma página por URL direta. A busca revela os resultados
|
|
@@ -52,6 +65,16 @@ Esses destinos usam `SidebarItem.href`, não uma cópia do estilo da linha. A ex
|
|
|
52
65
|
botão quando `href` é omitido, o foco nos dois modos e a abertura nativa em outra aba. Quando há
|
|
53
66
|
`onClick`, somente a navegação simples é interceptada; o host mantém a decisão de roteamento.
|
|
54
67
|
|
|
68
|
+
> **Emenda — 2026-10-09:** destinos HTTP(S) absolutos em `sidebarLinks` usam a navegação
|
|
69
|
+
> nativa, pois podem apontar para outra aplicação. `newTab` é opcional em `SidebarItem`
|
|
70
|
+
> e `sidebarLinks`: com `href`, declara `target="_blank"`, `rel="noopener noreferrer"`
|
|
71
|
+
> e a abertura no nome acessível, sem chamar `onClick`. Sem `href`, não muda o botão.
|
|
72
|
+
> O portal acrescenta o ícone pelo slot `badge` do item canônico. Interceptar esses
|
|
73
|
+
> destinos no roteador ou copiar a linha no host foi descartado: o primeiro impede
|
|
74
|
+
> a navegação entre aplicações e o segundo duplica foco, acessibilidade e composição.
|
|
75
|
+
> Testes verificam links internos e absolutos, modificadores, nome acessível, ícone
|
|
76
|
+
> nos dois modos iniciais do portal e comportamento do item aberto ou recolhido.
|
|
77
|
+
|
|
55
78
|
O modo opcional `editorial` acrescenta contexto, sumário dos headings realmente renderizados e
|
|
56
79
|
links de continuidade segundo a ordem declarada. `additionalPages` preserva URLs de páginas fora
|
|
57
80
|
da árvore e da sequência. O renderer de exemplos aceita `codeCollapsible={false}` quando ocultar
|
|
@@ -143,6 +166,10 @@ e separa a consulta documental da consulta local.
|
|
|
143
166
|
|
|
144
167
|
- Testes de `DocBrowser`: busca local e externa, vazio, largura configurável, lateral opcional,
|
|
145
168
|
persistência de grupos, expansão temporária, conteúdo do host, redirects e leitura editorial.
|
|
169
|
+
- Regressões da extensão: espaçamento dos grupos sem rótulo em diferentes níveis, acervo vazio
|
|
170
|
+
sem consulta, modificadores em links absolutos e ícone de nova aba nos dois modos iniciais.
|
|
171
|
+
- Testes de `SidebarItem`: nova aba com modificadores, botão habilitado sem `href`, estado
|
|
172
|
+
desabilitado e preservação do badge textual no nome acessível do item aberto.
|
|
146
173
|
- Testes de `DocHeader`: slots, atributos nativos e composição com ou sem busca e navegação.
|
|
147
174
|
- Testes de `DocCatalog`: busca local controlada, independência do topo, ausência de controles de
|
|
148
175
|
coleção, texto integral das versões, incompatibilidades e abertura nativa em outra aba.
|
package/package.json
CHANGED
|
@@ -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
|
|
@@ -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
|
: ""}
|
|
@@ -46,6 +46,36 @@ conceitos relacionados, não para reproduzir automaticamente a árvore de arquiv
|
|
|
46
46
|
tem `slug`, `title` e `render`; `searchText` acrescenta conteúdo à busca e `navigationTitle` permite
|
|
47
47
|
um rótulo lateral curto sem mudar o título completo.
|
|
48
48
|
|
|
49
|
+
Um grupo pode conter páginas próprias e subgrupos em `groups`. Por exemplo, uma documentação
|
|
50
|
+
por domínio pode reunir as conexões externas em Integrações e manter um grupo por sistema:
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
const sections: DocSection[] = [{
|
|
54
|
+
label: 'Domínios',
|
|
55
|
+
groups: [{
|
|
56
|
+
label: 'Integrações',
|
|
57
|
+
pages: [{ slug: 'integrations', title: 'Integrações', render: () => <p>Conexões com sistemas externos.</p> }],
|
|
58
|
+
groups: [{
|
|
59
|
+
label: 'Insider',
|
|
60
|
+
pages: [{ slug: 'integrations/insider', title: 'Insider', render: () => <p>Envio de atributos e eventos.</p> }],
|
|
61
|
+
}],
|
|
62
|
+
}],
|
|
63
|
+
}]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
As páginas próprias vêm antes dos subgrupos na sequência de leitura. Um grupo com `pages: []`
|
|
67
|
+
pode reunir somente subgrupos; ramos sem páginas em nenhum nível não aparecem na lateral.
|
|
68
|
+
O contexto editorial usa o grupo nomeado mais próximo. A busca revela os ancestrais dos
|
|
69
|
+
resultados e, no modo `sidebarInitiallyCollapsed`, restaura os ramos abertos manualmente
|
|
70
|
+
quando a consulta é limpa. No modo expandido padrão, os ancestrais da página ativa são
|
|
71
|
+
reabertos para mantê-la visível. `groups` é opcional:
|
|
72
|
+
as declarações existentes continuam válidas. Essa hierarquia é uma escolha de leitura do host,
|
|
73
|
+
não exige mover arquivos nem muda a descoberta por pastas.
|
|
74
|
+
|
|
75
|
+
Se o host também percorre as páginas para montar índices ou resolver links, esse percurso
|
|
76
|
+
precisa visitar `group.groups` recursivamente. Achatar somente `section.groups[].pages`
|
|
77
|
+
não inclui as páginas dos subgrupos.
|
|
78
|
+
|
|
49
79
|
Com `editorial`, a leitura recebe contexto, sumário dos títulos renderizados e links de página
|
|
50
80
|
anterior e seguinte. `additionalPages` preserva destinos diretos que não entram nessa sequência.
|
|
51
81
|
A busca documental do topo mantém seu rótulo, valor e função ao navegar para catálogos e detalhes;
|
|
@@ -64,6 +94,19 @@ o contrato desktop a partir de `64rem`. O aplicativo organiza a moldura do porta
|
|
|
64
94
|
|
|
65
95
|
ADRs e changelog mantêm o mapa dos guias, sem acrescentar uma árvore para cada registro. Use `sidebarLinks` para oferecer entradas
|
|
66
96
|
no mesmo nível dos grupos, após os capítulos, sem incluí-las na sequência anterior/próxima.
|
|
97
|
+
Destinos com URL HTTP(S) absoluta usam navegação nativa, inclusive para outro aplicativo;
|
|
98
|
+
destinos relativos usam o roteamento compartilhado.
|
|
99
|
+
|
|
100
|
+
Para abrir uma ferramenta sem substituir a leitura atual, declare `newTab` no destino:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
<DocBrowser sections={sections}
|
|
104
|
+
sidebarLinks={[{ label: 'Lente', href: '/lens', newTab: true }]} />
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
O link exibe um ícone de nova aba, informa a abertura no nome acessível e usa
|
|
108
|
+
`rel="noopener noreferrer"`. Sem `newTab`, mantém a navegação na aba atual.
|
|
109
|
+
|
|
67
110
|
`DocCatalog` recebe registros derivados dos Markdown e uma consulta local independente do cabeçalho.
|
|
68
111
|
Passe o catálogo em `content` para preservar a lateral:
|
|
69
112
|
|
|
@@ -148,7 +191,7 @@ não define as rotas nem a autorização do aplicativo.
|
|
|
148
191
|
|
|
149
192
|
| Propriedade | Tipo | Padrão | Uso |
|
|
150
193
|
| --- | --- | --- | --- |
|
|
151
|
-
| `sections` | `DocSection[]` | Obrigatória na entrada leve | Seções, grupos e páginas na ordem de leitura. |
|
|
194
|
+
| `sections` | `DocSection[]` | Obrigatória na entrada leve | Seções, grupos, subgrupos opcionais e páginas na ordem de leitura. |
|
|
152
195
|
| `basePath` | `string` | `/docs` | Prefixo das rotas. |
|
|
153
196
|
| `path` | `string` | Rota compartilhada | Caminho controlado por um host externo. |
|
|
154
197
|
| `redirects` | `Record<string, string>` | `{}` | Slugs anteriores e seus destinos canônicos. |
|
|
@@ -160,7 +203,7 @@ não define as rotas nem a autorização do aplicativo.
|
|
|
160
203
|
| `navigationLabel` | `string` | `Documentação` | Nome da área no contexto editorial; por exemplo, `UI` para o catálogo de componentes. |
|
|
161
204
|
| `additionalPages` | `DocEntry[]` | `[]` | Destinos diretos fora da árvore e da sequência. |
|
|
162
205
|
| `content` | `ReactNode` | — | Conteúdo do host no lugar da página documental, mantendo a lateral. O host resolve rota e título; `null` deixa a leitura vazia. |
|
|
163
|
-
| `sidebarLinks` | `{ label: string; href: string; active?: boolean }[]` | `[]` | Destinos globais fora dos capítulos; `active` marca o catálogo atual. |
|
|
206
|
+
| `sidebarLinks` | `{ label: string; href: string; active?: boolean; newTab?: boolean }[]` | `[]` | Destinos globais fora dos capítulos; `active` marca o catálogo atual e `newTab` abre em outra aba com ícone e descrição acessível. |
|
|
164
207
|
| `sidebarInitiallyCollapsed` | `boolean` | `false` | Seções e grupos nomeados começam recolhidos, inclusive em links diretos. A busca revela resultados temporariamente sem alterar os ramos abertos pela pessoa. |
|
|
165
208
|
|
|
166
209
|
A entrada histórica `@softize/opus/ui/docs` inclui o catálogo e oferece suas páginas quando
|
|
@@ -73,6 +73,10 @@ diretamente quando a composição precisar de controles, badges ou hierarquia pa
|
|
|
73
73
|
Quando o host usa um roteador, forneça também `onClick`: somente o clique simples é interceptado;
|
|
74
74
|
cliques com modificadores preservam o comportamento do link. Sem `href`, o item continua sendo um botão.
|
|
75
75
|
|
|
76
|
+
Para manter a página atual aberta ao visitar outro destino, declare `newTab` junto de `href`.
|
|
77
|
+
O link usa `target="_blank"` e `rel="noopener noreferrer"`, não chama `onClick` e informa
|
|
78
|
+
“abre em nova aba” no nome acessível, inclusive no rail recolhido. Sem `href`, `newTab` não tem efeito.
|
|
79
|
+
|
|
76
80
|
## Colapso
|
|
77
81
|
|
|
78
82
|
`collapsed` informa o estado compacto aos itens. Forneça labels acessíveis mesmo quando somente os
|
|
@@ -99,3 +103,22 @@ render(
|
|
|
99
103
|
- `Sidebar` oferece a coluna de navegação.
|
|
100
104
|
- `SidebarNav`, `SidebarItem` e elementos nativos organizam o conteúdo interno.
|
|
101
105
|
- `Surface` oferece anatomia e tratamento visual quando a região não é uma sidebar.
|
|
106
|
+
|
|
107
|
+
## Propriedades de SidebarItem
|
|
108
|
+
|
|
109
|
+
| Propriedade | Tipo | Padrão | Uso |
|
|
110
|
+
| --- | --- | --- | --- |
|
|
111
|
+
| `label` | `string` | Obrigatória | Nome do destino, também usado no modo recolhido. |
|
|
112
|
+
| `href` | `string` | — | Renderiza um link nativo quando o item está habilitado; sem ela, renderiza um botão. |
|
|
113
|
+
| `newTab` | `boolean` | `false` | Com `href`, abre em outra aba, informa a abertura no nome acessível e não chama `onClick`. |
|
|
114
|
+
| `onClick` | `() => void` | — | Ativa o botão ou intercepta o clique simples do link, exceto com `newTab`. |
|
|
115
|
+
| `icon`, `badge` | `ReactNode` | — | Ícone e complemento visual da linha. O modo recolhido mostra somente o ícone. |
|
|
116
|
+
| `active` | `boolean` | `false` | Destaca o destino atual e declara `aria-current="page"`. |
|
|
117
|
+
| `disabled` | `boolean` | `false` | Desabilita o alvo principal e impede que seja um link. |
|
|
118
|
+
| `actions` | `ReactNode` | — | Controles irmãos do alvo principal, no modo aberto. |
|
|
119
|
+
| `onToggle` | `() => void` | — | Exibe um controle separado para expandir ou recolher descendentes no modo aberto. |
|
|
120
|
+
| `expanded` | `boolean` | `false` | Estado visual e acessível do controle de expansão. |
|
|
121
|
+
| `dropPosition` | `'before' \| 'inside' \| 'after'` | — | Indicação visual de posição durante arraste, no modo aberto. |
|
|
122
|
+
| `dragProps` | `HTMLAttributes<HTMLDivElement>` | — | Eventos e atributos de arraste aplicados à linha. |
|
|
123
|
+
| `tooltipHint` | `ReactNode` | — | Complementa o tooltip do modo recolhido. |
|
|
124
|
+
| `className` | `string` | — | Classes da linha nos dois modos. |
|
package/src/ui/docs/types.ts
CHANGED