@1doc/1ds-react 2.9.0 → 2.10.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.
Files changed (115) hide show
  1. package/CHANGELOG.md +434 -0
  2. package/dist/components/Accordion/Accordion.css +13 -0
  3. package/dist/components/Accordion/Accordion.d.ts +3 -0
  4. package/dist/components/Accordion/Accordion.js +1 -0
  5. package/dist/components/AppSubheader/AppSubheader.css +10 -1
  6. package/dist/components/AttachmentCard/AttachmentCard.css +10 -8
  7. package/dist/components/AttachmentCard/AttachmentCard.d.ts +3 -0
  8. package/dist/components/AttachmentCard/AttachmentCard.js +8 -1
  9. package/dist/components/Badge/Badge.css +14 -1
  10. package/dist/components/Badge/Badge.d.ts +11 -2
  11. package/dist/components/Badge/Badge.js +3 -0
  12. package/dist/components/Calendar/Calendar.d.ts +7 -6
  13. package/dist/components/Coachmark/Coachmark.css +10 -0
  14. package/dist/components/Coachmark/Coachmark.d.ts +12 -0
  15. package/dist/components/Coachmark/Coachmark.js +46 -5
  16. package/dist/components/ColorPicker/ColorPicker.css +8 -1
  17. package/dist/components/ColorPicker/ColorPicker.d.ts +2 -2
  18. package/dist/components/DataTable/DataTable.css +174 -0
  19. package/dist/components/DataTable/DataTable.d.ts +63 -2
  20. package/dist/components/DataTable/DataTable.js +278 -6
  21. package/dist/components/DescriptionList/DescriptionList.css +14 -0
  22. package/dist/components/DescriptionList/DescriptionList.d.ts +3 -0
  23. package/dist/components/DescriptionList/DescriptionList.js +1 -0
  24. package/dist/components/EmptyState/EmptyState.d.ts +4 -2
  25. package/dist/components/EmptyState/EmptyState.js +2 -2
  26. package/dist/components/Input/Input.css +21 -0
  27. package/dist/components/Input/Input.d.ts +7 -0
  28. package/dist/components/Input/Input.js +2 -1
  29. package/dist/components/List/ListItem.d.ts +14 -2
  30. package/dist/components/List/ListItem.js +3 -2
  31. package/dist/components/RichTextEditor/RichTextEditor.css +47 -0
  32. package/dist/components/RichTextEditor/RichTextEditor.d.ts +22 -8
  33. package/dist/components/RichTextEditor/RichTextEditor.js +460 -3
  34. package/dist/components/SegmentedControl/SegmentedControl.css +24 -0
  35. package/dist/components/SegmentedControl/SegmentedControl.d.ts +2 -0
  36. package/dist/components/SegmentedControl/SegmentedControl.js +4 -1
  37. package/dist/components/Select/Select.css +94 -0
  38. package/dist/components/Select/Select.d.ts +26 -4
  39. package/dist/components/Select/Select.js +210 -35
  40. package/dist/components/StatCard/StatCard.css +65 -0
  41. package/dist/components/StatCard/StatCard.d.ts +9 -0
  42. package/dist/components/StatCard/StatCard.js +10 -1
  43. package/dist/components/Toast/Toast.css +32 -0
  44. package/dist/components/Toast/Toast.d.ts +18 -0
  45. package/dist/components/Toast/Toast.js +14 -1
  46. package/dist/components/Toast/ToastStack.js +8 -1
  47. package/dist/teste/AccordionTituloNeutro.test.d.ts +1 -0
  48. package/dist/teste/AccordionTituloNeutro.test.js +23 -0
  49. package/dist/teste/AppSubheaderRegiao.test.d.ts +1 -0
  50. package/dist/teste/AppSubheaderRegiao.test.js +13 -0
  51. package/dist/teste/AttachmentCardLista.test.d.ts +1 -0
  52. package/dist/teste/AttachmentCardLista.test.js +49 -0
  53. package/dist/teste/AttachmentCardSm.test.d.ts +1 -0
  54. package/dist/teste/AttachmentCardSm.test.js +26 -0
  55. package/dist/teste/BadgeNeutroColado.test.d.ts +1 -0
  56. package/dist/teste/BadgeNeutroColado.test.js +11 -0
  57. package/dist/teste/BadgeTag.test.js +13 -0
  58. package/dist/teste/CalendarCabecalho.test.d.ts +1 -0
  59. package/dist/teste/CalendarCabecalho.test.js +11 -0
  60. package/dist/teste/CoachmarkSemVeu.test.d.ts +1 -0
  61. package/dist/teste/CoachmarkSemVeu.test.js +85 -0
  62. package/dist/teste/ColorPickerXs.test.d.ts +1 -0
  63. package/dist/teste/ColorPickerXs.test.js +21 -0
  64. package/dist/teste/DataTableArvoreEVariantes.test.d.ts +1 -0
  65. package/dist/teste/DataTableArvoreEVariantes.test.js +190 -0
  66. package/dist/teste/DescriptionListCompacta.test.d.ts +1 -0
  67. package/dist/teste/DescriptionListCompacta.test.js +22 -0
  68. package/dist/teste/EmptyStateTituloRico.test.d.ts +1 -0
  69. package/dist/teste/EmptyStateTituloRico.test.js +23 -0
  70. package/dist/teste/InputAgrupado.test.d.ts +1 -0
  71. package/dist/teste/InputAgrupado.test.js +24 -0
  72. package/dist/teste/ListItemTooltipStandalone.test.d.ts +1 -0
  73. package/dist/teste/ListItemTooltipStandalone.test.js +39 -0
  74. package/dist/teste/RichTextEditorAlinhaImagemTabela.test.d.ts +1 -0
  75. package/dist/teste/RichTextEditorAlinhaImagemTabela.test.js +212 -0
  76. package/dist/teste/RichTextEditorEstiloDeTexto.test.d.ts +1 -0
  77. package/dist/teste/RichTextEditorEstiloDeTexto.test.js +120 -0
  78. package/dist/teste/SegmentedControlContador.test.d.ts +1 -0
  79. package/dist/teste/SegmentedControlContador.test.js +39 -0
  80. package/dist/teste/SelectAbertoControladoEFlutuante.test.d.ts +1 -0
  81. package/dist/teste/SelectAbertoControladoEFlutuante.test.js +145 -0
  82. package/dist/teste/SelectFiltroCorETamanho.test.d.ts +1 -0
  83. package/dist/teste/SelectFiltroCorETamanho.test.js +77 -0
  84. package/dist/teste/StatCardSecundariaETom.test.d.ts +1 -0
  85. package/dist/teste/StatCardSecundariaETom.test.js +45 -0
  86. package/dist/teste/ToastAcaoEDetalhes.test.d.ts +1 -0
  87. package/dist/teste/ToastAcaoEDetalhes.test.js +94 -0
  88. package/dist/utils/html-seguro.d.ts +19 -1
  89. package/dist/utils/html-seguro.js +113 -2
  90. package/dist/utils/toast.d.ts +10 -1
  91. package/dist/utils/toast.js +2 -1
  92. package/package.json +2 -2
  93. package/src/teste/AccordionTituloNeutro.test.tsx +25 -0
  94. package/src/teste/AppSubheaderRegiao.test.tsx +17 -0
  95. package/src/teste/AttachmentCardLista.test.tsx +53 -0
  96. package/src/teste/AttachmentCardSm.test.tsx +29 -0
  97. package/src/teste/BadgeNeutroColado.test.tsx +15 -0
  98. package/src/teste/BadgeTag.test.tsx +21 -0
  99. package/src/teste/CalendarCabecalho.test.tsx +11 -0
  100. package/src/teste/CoachmarkSemVeu.test.tsx +101 -0
  101. package/src/teste/ColorPickerXs.test.tsx +22 -0
  102. package/src/teste/DataTableArvoreEVariantes.test.tsx +240 -0
  103. package/src/teste/DescriptionListCompacta.test.tsx +24 -0
  104. package/src/teste/EmptyStateTituloRico.test.tsx +34 -0
  105. package/src/teste/InputAgrupado.test.tsx +26 -0
  106. package/src/teste/ListItemTooltipStandalone.test.tsx +64 -0
  107. package/src/teste/RichTextEditorAlinhaImagemTabela.test.tsx +238 -0
  108. package/src/teste/RichTextEditorEstiloDeTexto.test.tsx +137 -0
  109. package/src/teste/SegmentedControlContador.test.tsx +43 -0
  110. package/src/teste/SelectAbertoControladoEFlutuante.test.tsx +180 -0
  111. package/src/teste/SelectFiltroCorETamanho.test.tsx +90 -0
  112. package/src/teste/StatCardSecundariaETom.test.tsx +51 -0
  113. package/src/teste/ToastAcaoEDetalhes.test.tsx +109 -0
  114. package/src/utils/html-seguro.ts +105 -2
  115. package/src/utils/toast.ts +12 -2
@@ -5,13 +5,19 @@
5
5
  * fora (um valor guardado, um texto colado) pode trazer `<img onerror>`,
6
6
  * `<a href="javascript:...">`, `<script>`, `style` e classes de outro site. Este
7
7
  * módulo reduz o HTML ao que o editor sabe escrever: negrito, itálico,
8
- * sublinhado, tachado, listas, parágrafos, quebras, títulos, citação e link.
8
+ * sublinhado, tachado, listas, parágrafos, quebras, títulos, citação, link,
9
+ * estilo de texto por token (`color`, `font-size`, `font-family` em `<span>`),
10
+ * alinhamento de bloco (`text-align`), imagem (`http(s)`, caminho ou `data:image`
11
+ * em PNG, JPEG, GIF e WebP) e tabela simples.
9
12
  *
10
13
  * Roda no navegador (precisa de DOM). Dentro de um `<template>` o HTML é inerte:
11
14
  * nada executa e nenhuma imagem carrega enquanto se higieniza. Sem DOM
12
15
  * (servidor), devolve o texto escapado.
13
16
  */
14
- const PERMITIDAS = ["A", "B", "STRONG", "I", "EM", "U", "S", "STRIKE", "DEL", "UL", "OL", "LI", "P", "BR", "DIV", "SPAN", "H1", "H2", "H3", "H4", "BLOCKQUOTE"];
17
+ const PERMITIDAS = ["A", "B", "STRONG", "I", "EM", "U", "S", "STRIKE", "DEL", "UL", "OL", "LI", "P", "BR", "DIV", "SPAN", "H1", "H2", "H3", "H4", "BLOCKQUOTE", "IMG", "TABLE", "THEAD", "TBODY", "TR", "TH", "TD"];
18
+ /** Blocos que aceitam `text-align`. */
19
+ const ALINHAVEIS = ["P", "DIV", "H1", "H2", "H3", "H4", "LI", "BLOCKQUOTE", "TH", "TD"];
20
+ const ALINHAMENTOS = ["left", "center", "right", "justify"];
15
21
  const DESCARTADAS = ["SCRIPT", "STYLE", "IFRAME", "OBJECT", "EMBED", "LINK", "META", "TEMPLATE", "NOSCRIPT", "SVG", "MATH", "FORM", "INPUT", "BUTTON", "TEXTAREA", "SELECT"];
16
22
  const escapar = (texto) => texto.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
17
23
  /**
@@ -44,6 +50,68 @@ export const normalizarEndereco = (bruto) => {
44
50
  return "";
45
51
  return "https://" + texto;
46
52
  };
53
+ /** Cor, tamanho e família de fonte só valem como TOKEN do DS: `var(--umds-...)`. Cor em hex, rgb ou nome sai. */
54
+ const COR_TOKEN = /^var\(--umds-(?:primitive|semantic)-[a-z0-9-]+\)$/;
55
+ const TAMANHO_TOKEN = /^var\(--umds-primitive-font-size-[a-z0-9]+\)$/;
56
+ const FAMILIA_TOKEN = /^var\(--umds-(?:primitive|semantic)-font-family-(?:base|mono)\)$/;
57
+ /**
58
+ * Declaração de estilo de texto que o editor aceita: `color`, `font-size` e `font-family`,
59
+ * cada uma só com um token do DS. Devolve a declaração limpa ("color: var(--x);") ou "".
60
+ */
61
+ export const estiloDeTexto = (propriedade, valor) => {
62
+ const prop = String(propriedade || "").trim().toLowerCase();
63
+ const val = String(valor || "").trim();
64
+ if (prop === "color" && COR_TOKEN.test(val))
65
+ return "color: " + val + ";";
66
+ if (prop === "font-size" && TAMANHO_TOKEN.test(val))
67
+ return "font-size: " + val + ";";
68
+ if (prop === "font-family" && FAMILIA_TOKEN.test(val))
69
+ return "font-family: " + val + ";";
70
+ return "";
71
+ };
72
+ /** As declarações válidas de um atributo `style` (só color, font-size e font-family por token). */
73
+ const estiloDoSpan = (estilo) => {
74
+ const partes = String(estilo || "").split(";");
75
+ let saida = "";
76
+ for (let i = 0; i < partes.length; i++) {
77
+ const corte = partes[i].indexOf(":");
78
+ if (corte === -1)
79
+ continue;
80
+ const decl = estiloDeTexto(partes[i].slice(0, corte), partes[i].slice(corte + 1));
81
+ if (decl)
82
+ saida += (saida ? " " : "") + decl;
83
+ }
84
+ return saida;
85
+ };
86
+ /**
87
+ * Endereço de imagem que o editor aceita: `http(s)`, caminho (`/x`, `./x`, `../x`) ou
88
+ * `data:image` em base64 de PNG, JPEG, GIF ou WebP (SVG fica de fora: carrega script).
89
+ * Devolve o endereço limpo, ou "" se for perigoso ou vazio.
90
+ */
91
+ export const imagemSegura = (bruto) => {
92
+ const texto = (bruto || "").trim();
93
+ if (!texto)
94
+ return "";
95
+ const semControle = texto.replace(/[\u0000-\u001f\u007f\s]+/g, "");
96
+ if (/^data:image\/(png|jpe?g|gif|webp);base64,[a-z0-9+/]+=*$/i.test(semControle))
97
+ return semControle;
98
+ // `//host/x` (sem esquema) carregaria de outro site sem o autor ter pedido: fica de fora.
99
+ if (/^(https?:\/\/|\/(?!\/)|\.\.?\/)/i.test(semControle))
100
+ return texto;
101
+ return "";
102
+ };
103
+ /** O `text-align` de um elemento, pelo `style` ou pelo atributo `align`: left, center, right, justify ou "". */
104
+ const alinhamentoDe = (elemento) => {
105
+ const estilo = String(elemento.getAttribute("style") || "");
106
+ const achado = /(?:^|;)\s*text-align\s*:\s*([a-z]+)\s*(?:;|$)/i.exec(estilo);
107
+ const valor = (achado ? achado[1] : String(elemento.getAttribute("align") || "")).toLowerCase();
108
+ return ALINHAMENTOS.indexOf(valor) !== -1 ? valor : "";
109
+ };
110
+ /** Número inteiro de 2 a 99 em `colspan`/`rowspan`; o resto some. */
111
+ const vao = (valor) => {
112
+ const n = parseInt(String(valor || ""), 10);
113
+ return n >= 2 && n <= 99 ? String(n) : "";
114
+ };
47
115
  const limparNo = (no, saida, documento) => {
48
116
  const filhos = Array.prototype.slice.call(no.childNodes);
49
117
  for (let i = 0; i < filhos.length; i++) {
@@ -62,7 +130,36 @@ const limparNo = (no, saida, documento) => {
62
130
  limparNo(filho, saida, documento);
63
131
  continue;
64
132
  }
133
+ if (tag === "IMG") {
134
+ // Imagem sem endereço seguro some inteira (não deixa um <img> quebrado nem carregando de fora).
135
+ const origem = imagemSegura(filho.getAttribute("src") || "");
136
+ if (!origem)
137
+ continue;
138
+ const imagem = documento.createElement("img");
139
+ imagem.setAttribute("src", origem);
140
+ imagem.setAttribute("alt", String(filho.getAttribute("alt") || ""));
141
+ saida.appendChild(imagem);
142
+ continue;
143
+ }
65
144
  const novo = documento.createElement(tag.toLowerCase());
145
+ if (ALINHAVEIS.indexOf(tag) !== -1) {
146
+ const alinhamento = alinhamentoDe(filho);
147
+ if (alinhamento)
148
+ novo.setAttribute("style", "text-align: " + alinhamento + ";");
149
+ }
150
+ if (tag === "SPAN") {
151
+ const estiloValido = estiloDoSpan(filho.getAttribute("style") || "");
152
+ if (estiloValido)
153
+ novo.setAttribute("style", estiloValido);
154
+ }
155
+ if (tag === "TD" || tag === "TH") {
156
+ const colunas = vao(filho.getAttribute("colspan"));
157
+ const linhas = vao(filho.getAttribute("rowspan"));
158
+ if (colunas)
159
+ novo.setAttribute("colspan", colunas);
160
+ if (linhas)
161
+ novo.setAttribute("rowspan", linhas);
162
+ }
66
163
  if (tag === "A") {
67
164
  const href = enderecoSeguro(filho.getAttribute("href") || "");
68
165
  if (href) {
@@ -85,8 +182,22 @@ export const sanitizarHtml = (html) => {
85
182
  limparNo(molde.content, saida, document);
86
183
  return saida.innerHTML;
87
184
  };
185
+ /** A tabela vazia que o editor insere: `linhas` × `colunas` células com uma quebra, para o cursor entrar. */
186
+ export const htmlDaTabela = (linhas, colunas) => {
187
+ let celulas = "";
188
+ for (let c = 0; c < colunas; c++)
189
+ celulas += "<td><br></td>";
190
+ let corpo = "";
191
+ for (let l = 0; l < linhas; l++)
192
+ corpo += "<tr>" + celulas + "</tr>";
193
+ return "<table><tbody>" + corpo + "</tbody></table><p><br></p>";
194
+ };
195
+ /** O `<img>` que o editor insere (endereço já seguro). */
196
+ export const htmlDaImagem = (endereco, alt) => '<img src="' + escapar(endereco) + '" alt="' + escapar(alt || "") + '">';
88
197
  /** O editor está "vazio": sem texto, sem lista e sem quebra solta (o `<br>` que o navegador deixa). */
89
198
  export const htmlVazio = (html) => {
199
+ if (/<(img|table)[\s>]/i.test(html || ""))
200
+ return false;
90
201
  const texto = (html || "").replace(/<br\s*\/?>/gi, "").replace(/<\/?(div|p|span)[^>]*>/gi, "").trim();
91
202
  return texto === "";
92
203
  };
@@ -21,7 +21,13 @@ export interface ToastOptions {
21
21
  description?: string;
22
22
  /** Variante semântica @default 'info' */
23
23
  variant?: ToastVariant;
24
- /** Auto-dismiss em ms; 0 = não fecha sozinho @default 5000 */
24
+ /** Linhas de detalhe abaixo da descrição */
25
+ details?: string[];
26
+ /** Rótulo do botão de ação ("Desfazer"); só aparece com `onAction` */
27
+ actionLabel?: string;
28
+ /** Chamado ao acionar o botão de ação; depois dele o toast fecha */
29
+ onAction?: (event?: any) => void;
30
+ /** Auto-dismiss em ms; 0 = não fecha sozinho @default 5000 (0 quando há ação: dá tempo de agir, WCAG 2.2.1) */
25
31
  duration?: number;
26
32
  /** Exibe o botão de fechar @default true */
27
33
  closable?: boolean;
@@ -30,6 +36,9 @@ export interface ToastData {
30
36
  id: number;
31
37
  message: string;
32
38
  description?: string;
39
+ details?: string[];
40
+ actionLabel?: string;
41
+ onAction?: (event?: any) => void;
33
42
  variant: ToastVariant;
34
43
  duration: number;
35
44
  closable: boolean;
@@ -26,7 +26,8 @@ export const toast = (options) => {
26
26
  const id = sequence;
27
27
  items = [...items, {
28
28
  variant: "info",
29
- duration: 5000,
29
+ // Toast com ação não some antes de dar tempo de agir.
30
+ duration: options.actionLabel && options.onAction ? 0 : 5000,
30
31
  closable: true,
31
32
  ...options,
32
33
  id
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1doc/1ds-react",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "main": "dist/index.js",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -45,7 +45,7 @@
45
45
  "react-dom": "^18.0.0",
46
46
  "typescript": "^5.0.0",
47
47
  "vitest": "^3.2.7",
48
- "@1doc/1ds-core": "2.9.0",
48
+ "@1doc/1ds-core": "2.10.0",
49
49
  "@1doc/1ds-tokens": "0.5.0"
50
50
  },
51
51
  "publishConfig": {
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import Accordion from "../components/Accordion/Accordion";
4
+
5
+ const ITENS = [
6
+ { id: "a", title: "Prazos", content: "Dez dias úteis." },
7
+ { id: "b", title: "Anexos", content: "Até 10 MB." },
8
+ ];
9
+
10
+ describe("Accordion · titleVariant", () => {
11
+ it("neutral liga a classe do título neutro; o padrão e default não", () => {
12
+ const { container, rerender } = render(<Accordion items={ITENS} titleVariant="neutral" />);
13
+ const raiz = () => container.firstElementChild as HTMLElement;
14
+ expect(raiz()).toHaveClass("umds-accordion--title-neutral");
15
+ rerender(<Accordion items={ITENS} titleVariant="default" />);
16
+ expect(raiz()).not.toHaveClass("umds-accordion--title-neutral");
17
+ rerender(<Accordion items={ITENS} />);
18
+ expect(raiz()).not.toHaveClass("umds-accordion--title-neutral");
19
+ });
20
+
21
+ it("combina com a variant do accordion", () => {
22
+ const { container } = render(<Accordion items={ITENS} variant="separated" titleVariant="neutral" />);
23
+ expect(container.firstElementChild).toHaveClass("umds-accordion--separated", "umds-accordion--title-neutral");
24
+ });
25
+ });
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import AppSubheader from "../components/AppSubheader/AppSubheader";
4
+ import Breadcrumb from "../components/Breadcrumb/Breadcrumb";
5
+
6
+ /** O jsdom não tem layout: a geometria real é conferida pela story "Só a página atual" (play, no navegador); a regra de CSS, pelo teste do Vue. */
7
+ describe("AppSubheader · região do contexto", () => {
8
+ it("a trilha só com a página atual não tem nada focável dentro da região", () => {
9
+ const { container } = render(
10
+ <AppSubheader>
11
+ <Breadcrumb items={[{ label: "Painel" }]} />
12
+ </AppSubheader>,
13
+ );
14
+ const contexto = container.querySelector(".umds-app-subheader__contexto") as HTMLElement;
15
+ expect(contexto.querySelector("a, button, [tabindex]")).toBeNull();
16
+ });
17
+ });
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import AttachmentCard from "../components/AttachmentCard/AttachmentCard";
5
+
6
+ const DETALHES = {
7
+ description: "Documento legível.",
8
+ fields: [
9
+ { label: "Nome completo", value: "Marcos Vinícius Tavares", copyable: true },
10
+ { label: "CPF", value: "123.456.789-09" },
11
+ ],
12
+ };
13
+
14
+ async function abrirPainel() {
15
+ const usuario = userEvent.setup();
16
+ render(<AttachmentCard fileName="cnh.pdf" documentType="CNH" status="positive" statusLabel="Pré-aprovado" statusDetails={DETALHES} />);
17
+ await usuario.click(screen.getByRole("button", { name: /Pré-aprovado/ }));
18
+ return usuario;
19
+ }
20
+
21
+ describe("AttachmentCard · o <dl> do painel é válido (axe definition-list)", () => {
22
+ it("o dl só tem div filhos, e cada div só tem dt e dd, nessa ordem", async () => {
23
+ await abrirPainel();
24
+ const dl = document.querySelector(".umds-attachment-card__campos") as HTMLElement;
25
+ expect(dl.tagName).toBe("DL");
26
+ const grupos = [...dl.children];
27
+ expect(grupos).toHaveLength(2);
28
+ for (const grupo of grupos) {
29
+ expect(grupo.tagName).toBe("DIV");
30
+ expect([...grupo.children].map((f) => f.tagName)).toEqual(["DT", "DD"]);
31
+ }
32
+ });
33
+
34
+ it("o botão de copiar mora dentro do dd, ao lado do valor, e continua com nome", async () => {
35
+ await abrirPainel();
36
+ const dd = document.querySelector(".umds-attachment-card__campo-valor") as HTMLElement;
37
+ expect(dd.tagName).toBe("DD");
38
+ expect(dd.querySelector(".umds-attachment-card__campo-valor-texto")).toHaveTextContent("Marcos Vinícius Tavares");
39
+ const botao = dd.querySelector("button") as HTMLElement;
40
+ expect(botao).toHaveAttribute("aria-label", "Copiar Nome completo");
41
+ // nenhum botão solto direto num div do dl
42
+ for (const grupo of document.querySelectorAll(".umds-attachment-card__campo")) {
43
+ expect(grupo.querySelector(":scope > button")).toBeNull();
44
+ }
45
+ });
46
+
47
+ it("o campo sem copyable não tem botão e o valor segue no dd", async () => {
48
+ await abrirPainel();
49
+ const campos = document.querySelectorAll(".umds-attachment-card__campo");
50
+ expect(campos[1].querySelector("button")).toBeNull();
51
+ expect(campos[1].querySelector("dd")).toHaveTextContent("123.456.789-09");
52
+ });
53
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import AttachmentCard from "../components/AttachmentCard/AttachmentCard";
4
+
5
+ const tamanho = (el: Element | null) => (el as HTMLElement).style.width;
6
+
7
+ describe("AttachmentCard · size", () => {
8
+ it("md (padrão): ícone do preview de 48px e o do rodapé no sm do Icon", () => {
9
+ const { container } = render(<AttachmentCard fileName="Protocolo 104-2026.pdf" />);
10
+ expect(container.firstElementChild).not.toHaveClass("umds-attachment-card--sm");
11
+ expect(tamanho(container.querySelector(".umds-attachment-card__preview .umds-icon"))).toBe("48px");
12
+ const rodape = container.querySelector(".umds-attachment-card__rodape-icone .umds-icon") as HTMLElement;
13
+ expect(rodape).toHaveClass("umds-icon--sm");
14
+ expect(rodape.style.width).toBe("");
15
+ });
16
+
17
+ it("sm: ícone do preview de 40px e o do rodapé de 12px", () => {
18
+ const { container } = render(<AttachmentCard fileName="Protocolo 104-2026.pdf" size="sm" />);
19
+ expect(container.firstElementChild).toHaveClass("umds-attachment-card--sm");
20
+ expect(tamanho(container.querySelector(".umds-attachment-card__preview .umds-icon"))).toBe("40px");
21
+ expect(tamanho(container.querySelector(".umds-attachment-card__rodape-icone .umds-icon"))).toBe("12px");
22
+ });
23
+
24
+ it("o ícone central escolhido continua valendo no sm", () => {
25
+ const { container } = render(<AttachmentCard fileName="a.pdf" size="sm" documentType="CNH" status="positive" statusLabel="Pré-aprovado" />);
26
+ expect(container.querySelector(".umds-attachment-card__tipo-texto")).toHaveTextContent("CNH");
27
+ expect(container.querySelector(".umds-attachment-card__status-texto")).toHaveTextContent("Pré-aprovado");
28
+ });
29
+ });
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import Badge from "../components/Badge/Badge";
4
+
5
+ /** A cascata de CSS de arquivo não existe no jsdom: a regra é conferida na fonte pelo teste do Vue; aqui, as classes no DOM. */
6
+ describe("Badge attached neutral", () => {
7
+ it("o neutro colado e sólido leva as três classes que a regra de CSS escolhe", () => {
8
+ const { container } = render(
9
+ <Badge attached variant="neutral" appearance="solid">
10
+ Tarefa manual
11
+ </Badge>,
12
+ );
13
+ expect(container.firstElementChild).toHaveClass("umds-badge--attached", "umds-badge--neutral", "umds-badge--solid");
14
+ });
15
+ });
@@ -62,3 +62,24 @@ describe("Tag compacta, soft, pílula e novidade", () => {
62
62
  expect(raiz(container)).toHaveClass("umds-tag--new", "umds-tag--soft");
63
63
  });
64
64
  });
65
+
66
+ describe("Badge attachedSide", () => {
67
+ it("right escreve o modificador do canto direito; left e o padrão não", () => {
68
+ const { container } = render(
69
+ <>
70
+ <Badge attached attachedSide="right">A</Badge>
71
+ <Badge attached attachedSide="left">B</Badge>
72
+ <Badge attached>C</Badge>
73
+ </>,
74
+ );
75
+ expect(container.children[0]).toHaveClass("umds-badge--attached", "umds-badge--attached-right");
76
+ expect(container.children[1]).not.toHaveClass("umds-badge--attached-right");
77
+ expect(container.children[2]).not.toHaveClass("umds-badge--attached-right");
78
+ });
79
+
80
+ it("attachedSide sem attached não faz nada", () => {
81
+ const { container } = render(<Badge attachedSide="right">A</Badge>);
82
+ expect(raiz(container)).not.toHaveClass("umds-badge--attached-right");
83
+ expect(raiz(container)).not.toHaveClass("umds-badge--attached");
84
+ });
85
+ });
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import Calendar from "../components/Calendar/Calendar";
4
+
5
+ describe("Calendar · cabeçalho", () => {
6
+ it("headerStart e headerEnd (React: props com nó) chegam ao cabeçalho", () => {
7
+ render(<Calendar events={[]} defaultDate="2026-10-08" headerStart={<span>Todos os documentos</span>} headerEnd={<span>Legenda</span>} />);
8
+ expect(screen.getByText("Todos os documentos")).toBeInTheDocument();
9
+ expect(screen.getByText("Legenda")).toBeInTheDocument();
10
+ });
11
+ });
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
3
+ import Coachmark from "../components/Coachmark/Coachmark";
4
+
5
+ const PASSOS = [{ target: "#alvo-a", title: "Novo: arrastar", content: "Arraste a linha para trocar a ordem." }];
6
+
7
+ function Pagina(props: any) {
8
+ return (
9
+ <div>
10
+ <button id="alvo-a">A</button>
11
+ <button id="fora">Fora</button>
12
+ <Coachmark steps={PASSOS} open {...props} />
13
+ </div>
14
+ );
15
+ }
16
+
17
+ describe("Coachmark · modal={false}", () => {
18
+ it("sem véu nem recorte; o cartão é um diálogo não modal e a raiz deixa o ponteiro passar", () => {
19
+ const { baseElement } = render(<Pagina modal={false} />);
20
+ expect(baseElement.querySelector(".umds-coachmark__veil")).toBeNull();
21
+ expect(baseElement.querySelector(".umds-coachmark__cutout")).toBeNull();
22
+ expect(baseElement.querySelector(".umds-coachmark")).toHaveClass("umds-coachmark--modeless");
23
+ expect(screen.getByRole("dialog", { name: "Novo: arrastar" })).toHaveAttribute("aria-modal", "false");
24
+ });
25
+
26
+ it("o padrão continua modal: véu, recorte e aria-modal verdadeiro", () => {
27
+ const { baseElement } = render(<Pagina />);
28
+ expect(baseElement.querySelector(".umds-coachmark__veil")).not.toBeNull();
29
+ expect(baseElement.querySelector(".umds-coachmark__cutout")).not.toBeNull();
30
+ expect(baseElement.querySelector(".umds-coachmark")).not.toHaveClass("umds-coachmark--modeless");
31
+ expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true");
32
+ const { unmount } = render(<Pagina modal />);
33
+ unmount();
34
+ });
35
+
36
+ it("o foco entra no cartão, mas o Tab não fica preso", async () => {
37
+ render(<Pagina modal={false} />);
38
+ const cartao = screen.getByRole("dialog");
39
+ await waitFor(() => expect(cartao).toHaveFocus());
40
+ const concluir = screen.getByRole("button", { name: "Concluir" });
41
+ concluir.focus();
42
+ const evento = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
43
+ concluir.dispatchEvent(evento);
44
+ expect(evento.defaultPrevented).toBe(false);
45
+ });
46
+
47
+ it("no modal o Tab continua preso (o último volta ao primeiro)", () => {
48
+ render(<Pagina />);
49
+ const concluir = screen.getByRole("button", { name: "Concluir" });
50
+ concluir.focus();
51
+ const evento = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
52
+ concluir.dispatchEvent(evento);
53
+ expect(evento.defaultPrevented).toBe(true);
54
+ });
55
+
56
+ it("a página segue clicável: o clique fora não é capturado e o tour não fecha sozinho", () => {
57
+ const aoFechar = vi.fn();
58
+ render(<Pagina modal={false} onClose={aoFechar} />);
59
+ fireEvent.click(screen.getByRole("button", { name: "Fora" }));
60
+ expect(aoFechar).not.toHaveBeenCalled();
61
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
62
+ });
63
+
64
+ it("o Esc fecha de qualquer lugar da página, uma vez só", () => {
65
+ const aoFechar = vi.fn();
66
+ render(<Pagina modal={false} onClose={aoFechar} />);
67
+ const fora = screen.getByRole("button", { name: "Fora" });
68
+ fora.focus();
69
+ fireEvent.keyDown(fora, { key: "Escape" });
70
+ expect(aoFechar).toHaveBeenCalledTimes(1);
71
+ expect(aoFechar).toHaveBeenCalledWith("escape");
72
+ });
73
+
74
+ it("o Esc dentro do cartão também fecha uma vez só", async () => {
75
+ const aoFechar = vi.fn();
76
+ render(<Pagina modal={false} onClose={aoFechar} />);
77
+ await waitFor(() => expect(screen.getByRole("dialog")).toHaveFocus());
78
+ fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
79
+ expect(aoFechar).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ it("depois de fechar, o ouvinte do Esc sai do documento", () => {
83
+ const aoFechar = vi.fn();
84
+ const { rerender } = render(<Pagina modal={false} onClose={aoFechar} />);
85
+ rerender(<Pagina modal={false} open={false} onClose={aoFechar} />);
86
+ fireEvent.keyDown(document, { key: "Escape" });
87
+ expect(aoFechar).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it("ao fechar, o foco volta ao elemento de antes, se a pessoa não foi a outro lugar", async () => {
91
+ const { rerender } = render(<Pagina modal={false} open={false} />);
92
+ const gatilho = screen.getByRole("button", { name: "Fora" });
93
+ gatilho.focus();
94
+ expect(gatilho).toHaveFocus();
95
+ rerender(<Pagina modal={false} open />);
96
+ // O foco entra no cartão depois da montagem (um quadro de animação).
97
+ await waitFor(() => expect(screen.getByRole("dialog")).toHaveFocus());
98
+ rerender(<Pagina modal={false} open={false} />);
99
+ await waitFor(() => expect(gatilho).toHaveFocus());
100
+ });
101
+ });
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import ColorPicker from "../components/ColorPicker/ColorPicker";
4
+
5
+ describe("ColorPicker · size xs", () => {
6
+ it("xs escreve a classe do tamanho; sm e md seguem como eram", () => {
7
+ const { container, rerender } = render(<ColorPicker ariaLabel="Cor" size="xs" />);
8
+ const raiz = () => container.firstElementChild as HTMLElement;
9
+ expect(raiz()).toHaveClass("umds-color-picker--xs");
10
+ rerender(<ColorPicker ariaLabel="Cor" size="sm" />);
11
+ expect(raiz()).toHaveClass("umds-color-picker--sm");
12
+ expect(raiz()).not.toHaveClass("umds-color-picker--xs");
13
+ rerender(<ColorPicker ariaLabel="Cor" />);
14
+ expect(raiz()).toHaveClass("umds-color-picker--md");
15
+ });
16
+
17
+ it("no xs a amostra escolhida mantém a marcação e o aria-checked", () => {
18
+ const { container } = render(<ColorPicker ariaLabel="Cor" size="xs" defaultValue="--umds-primitive-red-pure" />);
19
+ expect(screen.getByRole("radio", { name: "Vermelho" })).toHaveAttribute("aria-checked", "true");
20
+ expect(container.querySelectorAll(".umds-color-picker__swatch--selected")).toHaveLength(1);
21
+ });
22
+ });