@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "26.1.0",
3
+ "version": "26.2.0",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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" ? { href } : { type: "button" as const, disabled };
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={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
- {label}
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 { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
16
- import { ArrowLeft, ArrowRight, Search } from "lucide-react";
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
- for (const s of sections) {
38
- for (const g of s.groups) {
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 (seção → grupo → página). */
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; não participam da sequência de leitura. */
83
- sidebarLinks?: Array<{ label: string; href: string; active?: boolean }>;
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.flatMap((section) =>
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: Array<DocGroup & { navigationKey: string }>;
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.map((group, groupIndex) => ({
289
- ...group,
290
- navigationKey: `group:${sectionIndex}:${groupIndex}`,
291
- })),
321
+ groups: indexGroups(section.groups, `group:${sectionIndex}`),
292
322
  }));
293
- if (terms.length === 0) return indexedSections;
294
- return indexedSections.flatMap((section) => {
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
- return pages.length === 0 ? [] : [{ ...group, pages }];
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 activeGroup = sections
331
- .flatMap((section) =>
332
- section.groups.map((group) => ({
333
- key: group.navigationKey,
334
- group,
335
- })),
336
- )
337
- .find(({ group }) =>
338
- group.pages.some((page) => page.slug === activeSlug),
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(activeGroup.key)) return current;
376
+ if (!keys.some((key) => current.has(key))) return current;
344
377
  const next = new Set(current);
345
- next.delete(activeGroup.key);
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((group) => {
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
- onClick={() => navigate(link.href)}
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={initiallyCollapsed ? <span aria-hidden="true" /> : undefined}
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
- // Default = 1ª página do 1º grupo da 1ª seção (a doc abre na 1ª página).
489
- const defaultSlug = sections[0]?.groups[0]?.pages[0]?.slug ?? "";
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. |
@@ -18,6 +18,8 @@ export interface DocGroup {
18
18
  label?: string
19
19
  icon?: React.ReactNode
20
20
  pages: DocEntry[]
21
+ /** Subgrupos opcionais; páginas próprias vêm antes dos grupos na sequência de leitura. */
22
+ groups?: DocGroup[]
21
23
  }
22
24
 
23
25
  export interface DocSection {