@1doc/1ds-react 2.13.0 → 2.14.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
@@ -1,5 +1,24 @@
1
1
  # @1doc/1ds-react
2
2
 
3
+ ## 2.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - aca81ec: Ajustes pedidos na migração de produto para o 1DS, e um componente novo.
8
+
9
+ - **Counter** (novo): número que conta até o valor, para KPI de painel e total de páginas. Formato do `Intl.NumberFormat` (pt-BR por padrão), `prefix`/`suffix`/`decimals`, largura do valor final reservada, leitor de tela ouvindo só o valor final e respeito a `prefers-reduced-motion`.
10
+ - **ListItem**: slot `actions`, renderizado no `li` ao lado do elemento clicável (o ⋮ de menu deixa de ficar dentro do link). No Vue, o ListItem com `li` passa a ter raiz única, então funciona dentro de `TransitionGroup`.
11
+ - **Tooltip**: a cópia escondida do texto não escapa mais de área com rolagem (o wrapper ganhou `position: relative`), e a seta acompanha o gatilho quando o balão é empurrado para caber perto da borda.
12
+ - **Dropdown**: `href`, `target` e `rel` no `DropdownItem`. Com `href`, o item é um `<a role="menuitem">` (Ctrl+clique e nova aba funcionam), e o `onSelect` continua sendo chamado.
13
+ - **Button**: `href`, `target` e `rel`. Com `href`, o Button é um `<a>` com a mesma aparência; desabilitado, volta a ser `<button disabled>`. O hover do `variant="icon"` virou uma camada translúcida da cor do ícone: no fundo claro o cinza é o mesmo de antes, e sobre fundo colorido não vira mais um quadrado branco.
14
+ - **StatCard**: `secondaryTone` (cor da segunda medida), `loading` (os números viram Skeleton e o cartão ganha `aria-busy`) e `value` aceitando um nó, como um `Counter` (slot `value` no Vue).
15
+
16
+ ## 2.13.1
17
+
18
+ ### Patch Changes
19
+
20
+ - 1a927c5: StatCard: a segunda medida (`secondaryValue`) passa a ter o mesmo tamanho e peso do número principal, em todas as combinações (`md`, `sm` e `labelPosition="top"`). Com o rótulo no topo, o `secondaryLabel` fica na mesma linha da `description` ("Geral" ao lado de "Meu Setor"), em vez de colado embaixo do número.
21
+
3
22
  ## 2.13.0
4
23
 
5
24
  ### Minor Changes
@@ -22,6 +22,7 @@
22
22
  line-height: 1;
23
23
  white-space: nowrap;
24
24
  text-decoration: none;
25
+ box-sizing: border-box; /* com href o Button é <a>, que nasce content-box */
25
26
 
26
27
  transition:
27
28
  background-color var(--umds-semantic-motion-duration-fast) var(--umds-semantic-motion-easing-standard),
@@ -210,8 +211,12 @@
210
211
  .umds-button--icon.umds-button--md { width: var(--umds-primitive-spacing-sm); } /* 40px */
211
212
  .umds-button--icon.umds-button--lg { width: var(--umds-primitive-spacing-md); } /* 48px */
212
213
 
214
+ /* Camada de estado translúcida, da cor do próprio ícone: funciona sobre
215
+ qualquer fundo (o ⋮ num item azul selecionado não vira um quadrado branco).
216
+ 12% de neutral-dark-down (#565656) sobre o branco dá #EBEBEB, o mesmo
217
+ neutral-light-medium de antes: no fundo claro o visual não muda. */
213
218
  .umds-button--icon:hover:not(:disabled) {
214
- background-color: var(--umds-semantic-color-neutral-light-medium);
219
+ background-color: color-mix(in srgb, currentColor 12%, transparent);
215
220
  }
216
221
 
217
222
  .umds-button--icon:disabled {
@@ -22,8 +22,11 @@ import './Button.css';
22
22
  * @property {boolean} autoFocus - Foca o botão automaticamente ao montar
23
23
  * @property {number} tabIndex - Ordem de tabulação nativa
24
24
  * @property {object} style - Estilos inline aplicados ao botão (objeto no React, string/objeto no Vue)
25
- * @quandoUsar AÇÃO que muda estado: salvar, enviar, abrir modal, confirmar. Use `variant="icon"` para ação só com ícone (sempre com `ariaLabel`).
26
- * @quandoNaoUsar Para NAVEGAR para outra página ou recurso — use `Link`, que gera `<a>` e permite abrir em nova aba. A regra: se o usuário pode querer "abrir em nova aba", é `Link`.
25
+ * @property {string} href - Botão que NAVEGA: com `href` o Button vira `<a>` com a mesma aparência (Ctrl+clique e "abrir em nova aba" funcionam). Desabilitado, volta a ser `<button disabled>`.
26
+ * @property {string} target - Destino do link (`_blank` ganha `rel="noopener noreferrer"`)
27
+ * @property {string} rel - Relação do link; sobrescreve o padrão do `target`
28
+ * @quandoUsar AÇÃO que muda estado: salvar, enviar, abrir modal, confirmar. Use `variant="icon"` para ação só com ícone (sempre com `ariaLabel`). Para navegação com cara de botão (o "Ver todos" de um rodapé de card), passe `href`: sai um `<a>` de verdade.
29
+ * @quandoNaoUsar Para navegar com cara de texto, no meio de uma frase, use `Link`. Nunca navegue por `onClick`: sem `href` não há Ctrl+clique nem "abrir em nova aba".
27
30
  */
28
31
  export interface ButtonProps {
29
32
  variant?: "primary" | "secondary" | "neutral" | "neutral-text" | "text" | "negative" | "negative-outlined" | "icon";
@@ -48,6 +51,12 @@ export interface ButtonProps {
48
51
  form?: string;
49
52
  autoFocus?: boolean;
50
53
  tabIndex?: number;
54
+ /** Botão que navega: vira `<a>` com a mesma aparência. Desabilitado, volta a ser `<button>`. */
55
+ href?: string;
56
+ /** Destino do link. `_blank` ganha `rel="noopener noreferrer"`. */
57
+ target?: string;
58
+ /** Relação do link. Sobrescreve o padrão do `target`. */
59
+ rel?: string;
51
60
  }
52
61
  import { IconProp } from "../Icon/Icon";
53
62
  declare function Button(props: ButtonProps): import("react/jsx-runtime").JSX.Element;
@@ -63,15 +63,27 @@ function Button(props) {
63
63
  function endIconIsIcon() {
64
64
  return isIconValue(props.endIcon);
65
65
  }
66
+ function isLink() {
67
+ return !!props.href && !props.disabled;
68
+ }
69
+ function relValue() {
70
+ if (props.rel)
71
+ return props.rel;
72
+ return props.target === "_blank" ? "noopener noreferrer" : undefined;
73
+ }
66
74
  function handleClick(event) {
67
75
  // Clique bloqueado durante o carregamento — evita dupla submissão
68
- // sem mudar o visual (o botão não fica disabled).
69
- if (props.loading)
76
+ // sem mudar o visual (o botão não fica disabled). No `<a>`, segura
77
+ // também a navegação.
78
+ if (props.loading) {
79
+ if (event && event.preventDefault)
80
+ event.preventDefault();
70
81
  return;
82
+ }
71
83
  if (props.onClick) {
72
84
  props.onClick(event);
73
85
  }
74
86
  }
75
- return (_jsxs("button", { type: props.type || "button", disabled: props.disabled, id: props.id, name: props.name, value: props.value, form: props.form, autoFocus: props.autoFocus || undefined, tabIndex: props.tabIndex, "aria-label": props.ariaLabel, "aria-busy": props.loading, onClick: (event) => handleClick(event), className: classes(), children: [showLoadingAtStart() ? (_jsx("span", { "aria-hidden": "true", className: "umds-button__loading", children: _jsx(Icon, { variant: "inherit", icon: props.loadingIcon || faRefresh, size: iconSize() }) })) : null, showStartIcon() ? (_jsx(_Fragment, { children: startIconIsIcon() ? (_jsx(Icon, { variant: "inherit", icon: props.startIcon, size: iconSize() })) : (_jsx(_Fragment, { children: _jsx(_Fragment, { children: props.startIcon }) })) })) : null, showLoadingAtCenter() ? (_jsx("span", { "aria-hidden": "true", className: "umds-button__loading", children: _jsx(Icon, { variant: "inherit", icon: props.loadingIcon || faRefresh, size: iconSize() }) })) : null, showLoadingAtCenter() ? (_jsx("span", { className: "umds-button__visually-hidden", children: props.children })) : null, showChildren() ? _jsx(_Fragment, { children: props.children }) : null, showEndIcon() ? (_jsx(_Fragment, { children: endIconIsIcon() ? (_jsx(Icon, { variant: "inherit", icon: props.endIcon, size: iconSize() })) : (_jsx(_Fragment, { children: _jsx(_Fragment, { children: props.endIcon }) })) })) : null, showLoadingAtEnd() ? (_jsx("span", { "aria-hidden": "true", className: "umds-button__loading", children: _jsx(Icon, { variant: "inherit", icon: props.loadingIcon || faRefresh, size: iconSize() }) })) : null] }));
87
+ return (_jsx(_Fragment, { children: isLink() ? (_jsx(_Fragment, { children: _jsxs("a", { href: props.href, target: props.target, rel: relValue(), id: props.id, tabIndex: props.tabIndex, "aria-label": props.ariaLabel, "aria-busy": props.loading, onClick: (event) => handleClick(event), className: classes(), children: [showLoadingAtStart() ? (_jsx("span", { "aria-hidden": "true", className: "umds-button__loading", children: _jsx(Icon, { variant: "inherit", icon: props.loadingIcon || faRefresh, size: iconSize() }) })) : null, showStartIcon() ? (_jsx(_Fragment, { children: startIconIsIcon() ? (_jsx(Icon, { variant: "inherit", icon: props.startIcon, size: iconSize() })) : (_jsx(_Fragment, { children: _jsx(_Fragment, { children: props.startIcon }) })) })) : null, showLoadingAtCenter() ? (_jsx("span", { "aria-hidden": "true", className: "umds-button__loading", children: _jsx(Icon, { variant: "inherit", icon: props.loadingIcon || faRefresh, size: iconSize() }) })) : null, showLoadingAtCenter() ? (_jsx("span", { className: "umds-button__visually-hidden", children: props.children })) : null, showChildren() ? _jsx(_Fragment, { children: props.children }) : null, showEndIcon() ? (_jsx(_Fragment, { children: endIconIsIcon() ? (_jsx(Icon, { variant: "inherit", icon: props.endIcon, size: iconSize() })) : (_jsx(_Fragment, { children: _jsx(_Fragment, { children: props.endIcon }) })) })) : null, showLoadingAtEnd() ? (_jsx("span", { "aria-hidden": "true", className: "umds-button__loading", children: _jsx(Icon, { variant: "inherit", icon: props.loadingIcon || faRefresh, size: iconSize() }) })) : null] }) })) : (_jsx(_Fragment, { children: _jsxs("button", { type: props.type || "button", disabled: props.disabled, id: props.id, name: props.name, value: props.value, form: props.form, autoFocus: props.autoFocus || undefined, tabIndex: props.tabIndex, "aria-label": props.ariaLabel, "aria-busy": props.loading, onClick: (event) => handleClick(event), className: classes(), children: [showLoadingAtStart() ? (_jsx("span", { "aria-hidden": "true", className: "umds-button__loading", children: _jsx(Icon, { variant: "inherit", icon: props.loadingIcon || faRefresh, size: iconSize() }) })) : null, showStartIcon() ? (_jsx(_Fragment, { children: startIconIsIcon() ? (_jsx(Icon, { variant: "inherit", icon: props.startIcon, size: iconSize() })) : (_jsx(_Fragment, { children: _jsx(_Fragment, { children: props.startIcon }) })) })) : null, showLoadingAtCenter() ? (_jsx("span", { "aria-hidden": "true", className: "umds-button__loading", children: _jsx(Icon, { variant: "inherit", icon: props.loadingIcon || faRefresh, size: iconSize() }) })) : null, showLoadingAtCenter() ? (_jsx("span", { className: "umds-button__visually-hidden", children: props.children })) : null, showChildren() ? _jsx(_Fragment, { children: props.children }) : null, showEndIcon() ? (_jsx(_Fragment, { children: endIconIsIcon() ? (_jsx(Icon, { variant: "inherit", icon: props.endIcon, size: iconSize() })) : (_jsx(_Fragment, { children: _jsx(_Fragment, { children: props.endIcon }) })) })) : null, showLoadingAtEnd() ? (_jsx("span", { "aria-hidden": "true", className: "umds-button__loading", children: _jsx(Icon, { variant: "inherit", icon: props.loadingIcon || faRefresh, size: iconSize() }) })) : null] }) })) }));
76
88
  }
77
89
  export default Button;
@@ -0,0 +1,43 @@
1
+ /* ─────────────────────────────────────────────
2
+ COUNTER
3
+ Número que conta até o valor. Herda fonte, tamanho e cor de onde está.
4
+ A reserva (cópia invisível do valor final) e o número que corre ocupam a
5
+ mesma célula da grade: a largura é a do valor final desde o primeiro quadro.
6
+ ───────────────────────────────────────────── */
7
+
8
+ .umds-counter {
9
+ /* Contém a cópia escondida (__leitura, absolute): sem isso ela escapa do
10
+ overflow de uma área que rola (o mesmo bug corrigido no Tooltip). */
11
+ position: relative;
12
+ display: inline-grid;
13
+ font-variant-numeric: tabular-nums;
14
+ white-space: nowrap;
15
+ }
16
+
17
+ .umds-counter__reserva,
18
+ .umds-counter__numero {
19
+ grid-area: 1 / 1;
20
+ }
21
+
22
+ .umds-counter__reserva {
23
+ visibility: hidden;
24
+ }
25
+
26
+ .umds-counter__numero {
27
+ justify-self: end;
28
+ }
29
+
30
+ /* Valor final para o leitor de tela. Receita visually-hidden; os 1px não são
31
+ medida de design. */
32
+ .umds-counter__leitura {
33
+ position: absolute;
34
+ width: 1px;
35
+ height: 1px;
36
+ margin: -1px;
37
+ padding: 0;
38
+ border: 0;
39
+ overflow: hidden;
40
+ clip: rect(0, 0, 0, 0);
41
+ clip-path: inset(50%);
42
+ white-space: nowrap;
43
+ }
@@ -0,0 +1,57 @@
1
+ import './Counter.css';
2
+ /**
3
+ * Counter — número que conta até o valor.
4
+ *
5
+ * Ao aparecer, o número sobe de `from` (0) até `value`; quando `value` muda,
6
+ * conta do valor atual até o novo. É o número do painel ("1.284 processos") e o
7
+ * total de páginas da caixa de entrada. Herda fonte, tamanho e cor de onde
8
+ * está, então serve dentro de um título, de um `StatCard` ou no meio do texto.
9
+ *
10
+ * Três decisões que valem saber:
11
+ *
12
+ * - **O leitor de tela lê só o valor final.** O número que corre é
13
+ * `aria-hidden`; uma cópia escondida com o valor final é o que se anuncia.
14
+ * Ninguém ouve "1, 37, 210, 1.284".
15
+ * - **A largura não pula.** Uma cópia invisível do valor final reserva o
16
+ * espaço, e os algarismos são tabulares: o texto em volta não dança enquanto
17
+ * o número conta.
18
+ * - **Movimento reduzido é respeitado.** Com `prefers-reduced-motion` (ou
19
+ * `disableAnimation`) o número aparece direto no valor final.
20
+ *
21
+ * O formato é o do `Intl.NumberFormat` em `locale` (pt-BR: "1.284", "22,31").
22
+ * As casas decimais seguem as do `value`, a menos que `decimals` diga outra
23
+ * coisa.
24
+ * @property {number} value - Valor final (número ou texto numérico)
25
+ * @property {number} from - De onde a contagem começa ao montar (padrão 0)
26
+ * @property {number} duration - Duração da contagem em ms (padrão 1000)
27
+ * @property {number} decimals - Casas decimais (padrão: as do `value`)
28
+ * @property {string} locale - Idioma do formato (padrão "pt-BR")
29
+ * @property {string} prefix - Texto antes do número ("R$ ")
30
+ * @property {string} suffix - Texto depois do número ("%", " páginas")
31
+ * @property {boolean} disableAnimation - Mostra o valor final direto, sem contar
32
+ * @property {string} className - Classes CSS adicionais
33
+ * @quandoUsar Número de destaque que merece atenção ao aparecer: KPI de painel, total de páginas ou de itens de uma caixa de entrada, contadores de resultado.
34
+ * @quandoNaoUsar Para contagem pequena presa a outro elemento (não lidos de uma aba), use `Badge`. Para número que muda o tempo todo (cronômetro, campo editável), mostre o texto direto: a animação a cada mudança cansa. Em tabela (`DataTable`), número não anima.
35
+ */
36
+ export interface CounterProps {
37
+ /** Valor final. Aceita número ou texto numérico ("1284", "22.31"). */
38
+ value: number | string;
39
+ /** De onde a contagem começa ao montar. @default 0 */
40
+ from?: number;
41
+ /** Duração da contagem, em milissegundos. @default 1000 */
42
+ duration?: number;
43
+ /** Casas decimais. Sem a prop, as do `value`. */
44
+ decimals?: number;
45
+ /** Idioma do formato do número. @default 'pt-BR' */
46
+ locale?: string;
47
+ /** Texto antes do número ("R$ "). */
48
+ prefix?: string;
49
+ /** Texto depois do número ("%", " páginas"). */
50
+ suffix?: string;
51
+ /** Mostra o valor final direto, sem contar. @default false */
52
+ disableAnimation?: boolean;
53
+ /** Classes CSS adicionais. */
54
+ className?: string;
55
+ }
56
+ declare function Counter(props: CounterProps): import("react/jsx-runtime").JSX.Element;
57
+ export default Counter;
@@ -0,0 +1,99 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import './Counter.css';
4
+ import { useState, useRef, useEffect } from "react";
5
+ function Counter(props) {
6
+ const quadroRef = useRef(null);
7
+ const ultimoRef = useRef(null);
8
+ const alvoRef = useRef(null);
9
+ const [exibido, setExibido] = useState(() => null);
10
+ function alvo() {
11
+ const numero = Number(props.value);
12
+ return isFinite(numero) ? numero : 0;
13
+ }
14
+ function casas() {
15
+ if (typeof props.decimals === "number")
16
+ return props.decimals;
17
+ const texto = String(props.value);
18
+ const ponto = texto.indexOf(".");
19
+ return ponto < 0 ? 0 : Math.min(texto.length - ponto - 1, 4);
20
+ }
21
+ function formatar(numero) {
22
+ const digitos = casas();
23
+ const formatado = new Intl.NumberFormat(props.locale || "pt-BR", {
24
+ minimumFractionDigits: digitos,
25
+ maximumFractionDigits: digitos,
26
+ }).format(numero);
27
+ return (props.prefix || "") + formatado + (props.suffix || "");
28
+ }
29
+ function semAnimacao() {
30
+ if (props.disableAnimation)
31
+ return true;
32
+ if (typeof window === "undefined" || !window.matchMedia)
33
+ return false;
34
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
35
+ }
36
+ function parar() {
37
+ if (quadroRef.current !== null && typeof window !== "undefined") {
38
+ window.cancelAnimationFrame(quadroRef.current);
39
+ }
40
+ quadroRef.current = null;
41
+ }
42
+ function animar(de, para) {
43
+ parar();
44
+ const duracao = typeof props.duration === "number" ? props.duration : 1000;
45
+ if (semAnimacao() ||
46
+ duracao <= 0 ||
47
+ de === para ||
48
+ typeof window === "undefined") {
49
+ ultimoRef.current = para;
50
+ setExibido(para);
51
+ return;
52
+ }
53
+ const inicio = performance.now();
54
+ const passo = (agora) => {
55
+ const progresso = Math.min(1, (agora - inicio) / duracao);
56
+ // ease-out cúbico: rápido no começo, assenta devagar no valor.
57
+ const suave = 1 - Math.pow(1 - progresso, 3);
58
+ const atual = progresso >= 1 ? para : de + (para - de) * suave;
59
+ ultimoRef.current = atual;
60
+ setExibido(atual);
61
+ quadroRef.current =
62
+ progresso < 1 ? window.requestAnimationFrame(passo) : null;
63
+ };
64
+ quadroRef.current = window.requestAnimationFrame(passo);
65
+ }
66
+ function textoNaTela() {
67
+ if (exibido !== null)
68
+ return formatar(exibido);
69
+ if (props.disableAnimation)
70
+ return formatar(alvo());
71
+ return formatar(typeof props.from === "number" ? props.from : 0);
72
+ }
73
+ function textoFinal() {
74
+ return formatar(alvo());
75
+ }
76
+ function classes() {
77
+ return ["umds-counter", props.className].filter(Boolean).join(" ");
78
+ }
79
+ useEffect(() => {
80
+ const para = alvo();
81
+ alvoRef.current = para;
82
+ animar(typeof props.from === "number" ? props.from : 0, para);
83
+ }, []);
84
+ useEffect(() => {
85
+ const para = alvo();
86
+ // O React roda este efeito também na montagem: o onMount já cuidou dela.
87
+ if (ultimoRef.current === null || alvoRef.current === para)
88
+ return;
89
+ alvoRef.current = para;
90
+ animar(ultimoRef.current, para);
91
+ }, [props.value]);
92
+ useEffect(() => {
93
+ return () => {
94
+ parar();
95
+ };
96
+ }, []);
97
+ return (_jsxs("span", { className: classes(), children: [_jsx("span", { className: "umds-counter__reserva", "aria-hidden": "true", children: textoFinal() }), _jsx("span", { className: "umds-counter__numero", "aria-hidden": "true", children: textoNaTela() }), _jsx("span", { className: "umds-counter__leitura", children: textoFinal() })] }));
98
+ }
99
+ export default Counter;
@@ -55,10 +55,12 @@
55
55
  align-items: center;
56
56
  gap: var(--umds-primitive-spacing-nano); /* 8px */
57
57
  width: 100%;
58
+ box-sizing: border-box; /* o item com href é <a>, que nasce content-box */
58
59
  padding: var(--umds-primitive-spacing-nano) var(--umds-primitive-spacing-xxxs); /* 8px 16px */
59
60
  background: none;
60
61
  border: none;
61
62
  text-align: left;
63
+ text-decoration: none;
62
64
  font-family: var(--umds-semantic-font-family-base);
63
65
  font-size: var(--umds-primitive-font-size-xxs); /* 14px */
64
66
  font-weight: 400;
@@ -7,6 +7,9 @@ import './Dropdown.css';
7
7
  * @property {boolean} disabled - Desabilita o item (não selecionável).
8
8
  * @property {boolean} danger - Aplica o estilo destrutivo (vermelho).
9
9
  * @property {boolean} dividerAfter - Adiciona um divisor abaixo do item.
10
+ * @property {string} href - Item que navega: vira `<a>` de verdade (Ctrl+clique e "abrir em nova aba" funcionam). O `onSelect` continua sendo chamado.
11
+ * @property {string} target - Destino do link (`_blank` ganha `rel="noopener noreferrer"`).
12
+ * @property {string} rel - Relação do link; sobrescreve o padrão do `target`.
10
13
  */
11
14
  export interface DropdownItem {
12
15
  key: string;
@@ -15,6 +18,12 @@ export interface DropdownItem {
15
18
  disabled?: boolean;
16
19
  danger?: boolean;
17
20
  dividerAfter?: boolean;
21
+ /** Item que navega: vira `<a>` (Ctrl+clique e nova aba funcionam). Desabilitado, volta a ser botão. */
22
+ href?: string;
23
+ /** Destino do link. `_blank` ganha `rel="noopener noreferrer"`. */
24
+ target?: string;
25
+ /** Relação do link. Sobrescreve o padrão do `target`. */
26
+ rel?: string;
18
27
  }
19
28
  /**
20
29
  * Dropdown component — menu de ações ancorado a um trigger.
@@ -302,8 +302,21 @@ function Dropdown(props) {
302
302
  activateIndex(nextEnabledIndex(total, -1));
303
303
  }
304
304
  else if (key === "Enter" || key === " ") {
305
- event.preventDefault();
306
305
  const item = itemList()[focusedIndex()];
306
+ // Item com href é <a>: o Enter fica com o navegador, que dispara o
307
+ // clique do link (e respeita Ctrl/Shift para nova aba/janela). O
308
+ // onClick do <a> chama o handleSelect. Espaço não ativa link no
309
+ // navegador, então vira um clique de verdade.
310
+ if (item && item.href && !item.disabled) {
311
+ if (key === " ") {
312
+ event.preventDefault();
313
+ const alvo = event.target;
314
+ if (alvo && alvo.click)
315
+ alvo.click();
316
+ }
317
+ return;
318
+ }
319
+ event.preventDefault();
307
320
  if (item)
308
321
  handleSelect(item);
309
322
  }
@@ -312,6 +325,11 @@ function Dropdown(props) {
312
325
  close();
313
326
  }
314
327
  }
328
+ function relDoItem(item) {
329
+ if (item.rel)
330
+ return item.rel;
331
+ return item.target === "_blank" ? "noopener noreferrer" : undefined;
332
+ }
315
333
  function itemClasses(item) {
316
334
  return [
317
335
  "umds-dropdown__item",
@@ -370,6 +388,6 @@ function Dropdown(props) {
370
388
  }, []);
371
389
  return (_jsxs("span", { ref: triggerRef, className: triggerClasses(), style: {
372
390
  display: "inline-flex",
373
- }, children: [_jsx("span", { className: "umds-dropdown__trigger", ref: triggerButtonRef, role: triggerIsInteractive ? undefined : "button", tabIndex: triggerIsInteractive ? undefined : 0, "aria-haspopup": triggerIsInteractive ? undefined : "menu", "aria-expanded": triggerIsInteractive ? undefined : ariaExpandedText(), onClick: (event) => toggle(), onKeyDown: (event) => handleTriggerKeydown(event), children: _jsx(_Fragment, { children: props.children }) }), _jsx(Portal, { children: mounted ? (_jsx("span", { className: "umds-dropdown__floating", ref: floatingRef, style: floatingStyle(), children: _jsx("ul", { className: "umds-dropdown__menu", role: "menu", ref: menuRef, "aria-label": props.ariaLabel, tabIndex: -1, style: menuStyle(), onKeyDown: (event) => handleMenuKeydown(event), children: itemList()?.map((item, index) => (_jsx("li", { role: "none", children: _jsxs("button", { type: "button", role: "menuitem", className: itemClasses(item), tabIndex: index === activeIndex ? 0 : -1, "data-active": index === activeIndex, disabled: item.disabled, onClick: (event) => handleSelect(item), children: [item.icon ? (_jsx("span", { className: "umds-dropdown__item-icon", "aria-hidden": "true", children: _jsx(Icon, { size: "sm", variant: "inherit", icon: item.icon }) })) : null, _jsx("span", { className: "umds-dropdown__item-label", children: item.label })] }) }, item.key))) }) })) : null })] }));
391
+ }, children: [_jsx("span", { className: "umds-dropdown__trigger", ref: triggerButtonRef, role: triggerIsInteractive ? undefined : "button", tabIndex: triggerIsInteractive ? undefined : 0, "aria-haspopup": triggerIsInteractive ? undefined : "menu", "aria-expanded": triggerIsInteractive ? undefined : ariaExpandedText(), onClick: (event) => toggle(), onKeyDown: (event) => handleTriggerKeydown(event), children: _jsx(_Fragment, { children: props.children }) }), _jsx(Portal, { children: mounted ? (_jsx("span", { className: "umds-dropdown__floating", ref: floatingRef, style: floatingStyle(), children: _jsx("ul", { className: "umds-dropdown__menu", role: "menu", ref: menuRef, "aria-label": props.ariaLabel, tabIndex: -1, style: menuStyle(), onKeyDown: (event) => handleMenuKeydown(event), children: itemList()?.map((item, index) => (_jsx("li", { role: "none", children: item.href && !item.disabled ? (_jsxs("a", { role: "menuitem", href: item.href, target: item.target, rel: relDoItem(item), className: itemClasses(item), tabIndex: index === activeIndex ? 0 : -1, "data-active": index === activeIndex, onClick: (event) => handleSelect(item), children: [item.icon ? (_jsx("span", { className: "umds-dropdown__item-icon", "aria-hidden": "true", children: _jsx(Icon, { size: "sm", variant: "inherit", icon: item.icon }) })) : null, _jsx("span", { className: "umds-dropdown__item-label", children: item.label })] })) : (_jsx(_Fragment, { children: _jsxs("button", { type: "button", role: "menuitem", className: itemClasses(item), tabIndex: index === activeIndex ? 0 : -1, "data-active": index === activeIndex, disabled: item.disabled, onClick: (event) => handleSelect(item), children: [item.icon ? (_jsx("span", { className: "umds-dropdown__item-icon", "aria-hidden": "true", children: _jsx(Icon, { size: "sm", variant: "inherit", icon: item.icon }) })) : null, _jsx("span", { className: "umds-dropdown__item-label", children: item.label })] }) })) }, item.key))) }) })) : null })] }));
374
392
  }
375
393
  export default Dropdown;
@@ -134,3 +134,50 @@ ol.umds-list {
134
134
  display: block;
135
135
  color: var(--umds-semantic-color-neutral-dark-down);
136
136
  }
137
+
138
+ /* ── Ações da linha (slot `actions`) ──
139
+ O controle fica no `li`, AO LADO do elemento clicável: clicar no ⋮ não
140
+ aciona a linha, e não há botão dentro de link. O `li` vira a linha visual
141
+ (flex), e o hover e a seleção passam do elemento para o `li`, para o fundo
142
+ cobrir as ações também. */
143
+ .umds-list__item:has(> .umds-list-item__actions) {
144
+ display: flex;
145
+ align-items: center;
146
+ gap: var(--umds-primitive-spacing-quarck); /* 4px */
147
+ padding-right: var(--umds-primitive-spacing-nano); /* 8px */
148
+ border-radius: var(--umds-primitive-border-radius-sm); /* 8px */
149
+ transition: background-color var(--umds-semantic-motion-duration-fast) var(--umds-semantic-motion-easing-standard);
150
+ }
151
+
152
+ .umds-list__item:has(> .umds-list-item__actions) > .umds-list-item {
153
+ flex: 1 1 auto;
154
+ width: auto;
155
+ }
156
+
157
+ .umds-list__item:has(> .umds-list-item__actions) > .umds-list-item,
158
+ .umds-list__item:has(> .umds-list-item__actions) > .umds-list-item.umds-list-item--interactive:hover {
159
+ background: transparent;
160
+ }
161
+
162
+ .umds-list__item:has(> .umds-list-item--interactive:not(.umds-list-item--disabled)):has(> .umds-list-item__actions):hover {
163
+ background: var(--umds-semantic-color-background-light);
164
+ }
165
+
166
+ .umds-list__item:has(> .umds-list-item--selected):has(> .umds-list-item__actions) {
167
+ background: var(--umds-semantic-color-primary-light);
168
+ }
169
+
170
+ .umds-list__item:has(> .umds-list-item--selected.umds-list-item--interactive):has(> .umds-list-item__actions):hover {
171
+ background: color-mix(
172
+ in srgb,
173
+ var(--umds-semantic-color-primary-light) 85%,
174
+ var(--umds-semantic-color-background-white)
175
+ );
176
+ }
177
+
178
+ .umds-list-item__actions {
179
+ display: inline-flex;
180
+ flex-shrink: 0;
181
+ align-items: center;
182
+ gap: var(--umds-primitive-spacing-quarck); /* 4px */
183
+ }
@@ -27,6 +27,12 @@ export type ListItemTag = "button" | "a" | "div";
27
27
  * HTML: é o título da linha. O atributo `title` (a dica nativa do navegador) é a
28
28
  * prop `tooltip`.
29
29
  *
30
+ * `actions` é o lugar de controles da linha (o ⋮ de um menu, um botão de
31
+ * excluir): fica no `li`, AO LADO do elemento clicável e não dentro dele, então
32
+ * o clique no controle não aciona a linha e o HTML continua válido. A linha toda
33
+ * (com as ações) ganha o fundo de hover e de seleção. Só existe com o `li`
34
+ * (sem `standalone`).
35
+ *
30
36
  * `standalone` tira o `li`: o ListItem vira só o elemento interativo, para uso
31
37
  * fora de um `List` (botão de número de página, item de carrossel, linha dentro
32
38
  * de um `div`). Fora de `ul`/`ol`, o `li` é HTML inválido.
@@ -43,6 +49,7 @@ export type ListItemTag = "button" | "a" | "div";
43
49
  * @property {any} meta - Terceira linha, menor (data, contexto): texto, ou slot `meta` (nó)
44
50
  * @property {any} start - Slot `start`: conteúdo antes do texto (avatar, ícone)
45
51
  * @property {any} end - Slot `end`: conteúdo no fim da linha (badge, seta, valor)
52
+ * @property {any} actions - Slot `actions`: controles da linha (menu ⋮, botão), fora do elemento clicável
46
53
  * @property {function} onClick - Callback do clique (ou de Enter/Espaço no as="div")
47
54
  * @property {string} className - Classes CSS adicionais, no elemento interativo
48
55
  * @slot start - Conteúdo antes do texto.
@@ -50,6 +57,7 @@ export type ListItemTag = "button" | "a" | "div";
50
57
  * @slot subtitle - Segunda linha (substitui a prop `subtitle`).
51
58
  * @slot meta - Terceira linha (substitui a prop `meta`).
52
59
  * @slot end - Conteúdo no fim da linha.
60
+ * @slot actions - Controles da linha, ao lado do elemento clicável (não dentro dele).
53
61
  * @quandoUsar Itens de uma lista em que a linha toda é a ação: documentos não lidos, notificações, resultados de busca, opções de menu de filtro, itens de navegação secundária. Hover, foco visível e seleção já vêm resolvidos.
54
62
  * @quandoNaoUsar Para dados comparados coluna a coluna, use `DataTable`. Para um cartão com título, ações e elevação própria, use `Card`. Para um botão solto fora de lista, use `Button`. Para a navegação principal do produto, use `SidePanelItem` ou `AppNavItem`.
55
63
  */
@@ -81,6 +89,8 @@ export interface ListItemProps {
81
89
  start?: any;
82
90
  /** Conteúdo no fim da linha (badge, seta, valor). No Vue, slot `end`. */
83
91
  end?: any;
92
+ /** Controles da linha (menu ⋮, botão), fora do elemento clicável. Só com o `li`. No Vue, slot `actions`. */
93
+ actions?: any;
84
94
  /** Chamado no clique da linha (ou em Enter/Espaço no `as="div"`). */
85
95
  onClick?: (event: any) => void;
86
96
  /** Classes CSS adicionais, no elemento interativo. */
@@ -77,6 +77,6 @@ function ListItem(props) {
77
77
  }
78
78
  return out;
79
79
  }
80
- return (_jsxs(_Fragment, { children: [!props.standalone ? (_jsxs("li", { className: "umds-list__item", children: [elementTag() === "button" ? (_jsxs("button", { type: "button", id: props.id, title: props.tooltip, disabled: props.disabled, "aria-current": ariaCurrent(), onClick: (event) => handleClick(event), ...htmlAttrs(), className: classes(), children: [props.start ? (_jsx("span", { className: "umds-list-item__start", children: _jsx(_Fragment, { children: props.start }) })) : null, _jsxs("span", { className: "umds-list-item__body", children: [props.title ? (_jsx("span", { className: "umds-list-item__title", children: _jsx(Text, { variant: "body2", as: "span", weight: "semibold", color: "inherit", children: _jsx(_Fragment, { children: props.title }) }) })) : null, props.subtitle ? (_jsx("span", { className: "umds-list-item__subtitle", children: _jsx(Text, { variant: "body2", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.subtitle }) }) })) : null, props.meta ? (_jsx("span", { className: "umds-list-item__meta", children: _jsx(Text, { variant: "caption", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.meta }) }) })) : null, _jsx(_Fragment, { children: props.children })] }), props.end ? (_jsx("span", { className: "umds-list-item__end", children: _jsx(_Fragment, { children: props.end }) })) : null] })) : null, elementTag() === "a" ? (_jsxs("a", { id: props.id, title: props.tooltip, href: hrefValue(), target: props.target, rel: relValue(), "aria-current": ariaCurrent(), "aria-disabled": ariaDisabled(), onClick: (event) => handleClick(event), ...htmlAttrs(), className: classes(), children: [props.start ? (_jsx("span", { className: "umds-list-item__start", children: _jsx(_Fragment, { children: props.start }) })) : null, _jsxs("span", { className: "umds-list-item__body", children: [props.title ? (_jsx("span", { className: "umds-list-item__title", children: _jsx(Text, { variant: "body2", as: "span", weight: "semibold", color: "inherit", children: _jsx(_Fragment, { children: props.title }) }) })) : null, props.subtitle ? (_jsx("span", { className: "umds-list-item__subtitle", children: _jsx(Text, { variant: "body2", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.subtitle }) }) })) : null, props.meta ? (_jsx("span", { className: "umds-list-item__meta", children: _jsx(Text, { variant: "caption", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.meta }) }) })) : null, _jsx(_Fragment, { children: props.children })] }), props.end ? (_jsx("span", { className: "umds-list-item__end", children: _jsx(_Fragment, { children: props.end }) })) : null] })) : null, elementTag() === "div" ? (_jsxs("div", { id: props.id, title: props.tooltip, role: divRole(), tabIndex: divTabIndex(), "aria-current": ariaCurrent(), "aria-disabled": ariaDisabled(), onClick: (event) => handleClick(event), onKeyDown: (event) => handleKeyDown(event), ...htmlAttrs(), className: classes(), children: [props.start ? (_jsx("span", { className: "umds-list-item__start", children: _jsx(_Fragment, { children: props.start }) })) : null, _jsxs("span", { className: "umds-list-item__body", children: [props.title ? (_jsx("span", { className: "umds-list-item__title", children: _jsx(Text, { variant: "body2", as: "span", weight: "semibold", color: "inherit", children: _jsx(_Fragment, { children: props.title }) }) })) : null, props.subtitle ? (_jsx("span", { className: "umds-list-item__subtitle", children: _jsx(Text, { variant: "body2", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.subtitle }) }) })) : null, props.meta ? (_jsx("span", { className: "umds-list-item__meta", children: _jsx(Text, { variant: "caption", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.meta }) }) })) : null, _jsx(_Fragment, { children: props.children })] }), props.end ? (_jsx("span", { className: "umds-list-item__end", children: _jsx(_Fragment, { children: props.end }) })) : null] })) : null] })) : null, !!props.standalone ? (_jsxs(_Fragment, { children: [elementTag() === "button" ? (_jsxs("button", { type: "button", id: props.id, title: props.tooltip, disabled: props.disabled, "aria-current": ariaCurrent(), onClick: (event) => handleClick(event), ...htmlAttrs(), className: classes(), children: [props.start ? (_jsx("span", { className: "umds-list-item__start", children: _jsx(_Fragment, { children: props.start }) })) : null, _jsxs("span", { className: "umds-list-item__body", children: [props.title ? (_jsx("span", { className: "umds-list-item__title", children: _jsx(Text, { variant: "body2", as: "span", weight: "semibold", color: "inherit", children: _jsx(_Fragment, { children: props.title }) }) })) : null, props.subtitle ? (_jsx("span", { className: "umds-list-item__subtitle", children: _jsx(Text, { variant: "body2", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.subtitle }) }) })) : null, props.meta ? (_jsx("span", { className: "umds-list-item__meta", children: _jsx(Text, { variant: "caption", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.meta }) }) })) : null, _jsx(_Fragment, { children: props.children })] }), props.end ? (_jsx("span", { className: "umds-list-item__end", children: _jsx(_Fragment, { children: props.end }) })) : null] })) : null, elementTag() === "a" ? (_jsxs("a", { id: props.id, title: props.tooltip, href: hrefValue(), target: props.target, rel: relValue(), "aria-current": ariaCurrent(), "aria-disabled": ariaDisabled(), onClick: (event) => handleClick(event), ...htmlAttrs(), className: classes(), children: [props.start ? (_jsx("span", { className: "umds-list-item__start", children: _jsx(_Fragment, { children: props.start }) })) : null, _jsxs("span", { className: "umds-list-item__body", children: [props.title ? (_jsx("span", { className: "umds-list-item__title", children: _jsx(Text, { variant: "body2", as: "span", weight: "semibold", color: "inherit", children: _jsx(_Fragment, { children: props.title }) }) })) : null, props.subtitle ? (_jsx("span", { className: "umds-list-item__subtitle", children: _jsx(Text, { variant: "body2", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.subtitle }) }) })) : null, props.meta ? (_jsx("span", { className: "umds-list-item__meta", children: _jsx(Text, { variant: "caption", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.meta }) }) })) : null, _jsx(_Fragment, { children: props.children })] }), props.end ? (_jsx("span", { className: "umds-list-item__end", children: _jsx(_Fragment, { children: props.end }) })) : null] })) : null, elementTag() === "div" ? (_jsxs("div", { id: props.id, title: props.tooltip, role: divRole(), tabIndex: divTabIndex(), "aria-current": ariaCurrent(), "aria-disabled": ariaDisabled(), onClick: (event) => handleClick(event), onKeyDown: (event) => handleKeyDown(event), ...htmlAttrs(), className: classes(), children: [props.start ? (_jsx("span", { className: "umds-list-item__start", children: _jsx(_Fragment, { children: props.start }) })) : null, _jsxs("span", { className: "umds-list-item__body", children: [props.title ? (_jsx("span", { className: "umds-list-item__title", children: _jsx(Text, { variant: "body2", as: "span", weight: "semibold", color: "inherit", children: _jsx(_Fragment, { children: props.title }) }) })) : null, props.subtitle ? (_jsx("span", { className: "umds-list-item__subtitle", children: _jsx(Text, { variant: "body2", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.subtitle }) }) })) : null, props.meta ? (_jsx("span", { className: "umds-list-item__meta", children: _jsx(Text, { variant: "caption", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.meta }) }) })) : null, _jsx(_Fragment, { children: props.children })] }), props.end ? (_jsx("span", { className: "umds-list-item__end", children: _jsx(_Fragment, { children: props.end }) })) : null] })) : null] })) : null] }));
80
+ return (_jsx(_Fragment, { children: !props.standalone ? (_jsx(_Fragment, { children: _jsxs("li", { className: "umds-list__item", children: [elementTag() === "button" ? (_jsxs("button", { type: "button", id: props.id, title: props.tooltip, disabled: props.disabled, "aria-current": ariaCurrent(), onClick: (event) => handleClick(event), ...htmlAttrs(), className: classes(), children: [props.start ? (_jsx("span", { className: "umds-list-item__start", children: _jsx(_Fragment, { children: props.start }) })) : null, _jsxs("span", { className: "umds-list-item__body", children: [props.title ? (_jsx("span", { className: "umds-list-item__title", children: _jsx(Text, { variant: "body2", as: "span", weight: "semibold", color: "inherit", children: _jsx(_Fragment, { children: props.title }) }) })) : null, props.subtitle ? (_jsx("span", { className: "umds-list-item__subtitle", children: _jsx(Text, { variant: "body2", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.subtitle }) }) })) : null, props.meta ? (_jsx("span", { className: "umds-list-item__meta", children: _jsx(Text, { variant: "caption", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.meta }) }) })) : null, _jsx(_Fragment, { children: props.children })] }), props.end ? (_jsx("span", { className: "umds-list-item__end", children: _jsx(_Fragment, { children: props.end }) })) : null] })) : null, elementTag() === "a" ? (_jsxs("a", { id: props.id, title: props.tooltip, href: hrefValue(), target: props.target, rel: relValue(), "aria-current": ariaCurrent(), "aria-disabled": ariaDisabled(), onClick: (event) => handleClick(event), ...htmlAttrs(), className: classes(), children: [props.start ? (_jsx("span", { className: "umds-list-item__start", children: _jsx(_Fragment, { children: props.start }) })) : null, _jsxs("span", { className: "umds-list-item__body", children: [props.title ? (_jsx("span", { className: "umds-list-item__title", children: _jsx(Text, { variant: "body2", as: "span", weight: "semibold", color: "inherit", children: _jsx(_Fragment, { children: props.title }) }) })) : null, props.subtitle ? (_jsx("span", { className: "umds-list-item__subtitle", children: _jsx(Text, { variant: "body2", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.subtitle }) }) })) : null, props.meta ? (_jsx("span", { className: "umds-list-item__meta", children: _jsx(Text, { variant: "caption", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.meta }) }) })) : null, _jsx(_Fragment, { children: props.children })] }), props.end ? (_jsx("span", { className: "umds-list-item__end", children: _jsx(_Fragment, { children: props.end }) })) : null] })) : null, elementTag() === "div" ? (_jsxs("div", { id: props.id, title: props.tooltip, role: divRole(), tabIndex: divTabIndex(), "aria-current": ariaCurrent(), "aria-disabled": ariaDisabled(), onClick: (event) => handleClick(event), onKeyDown: (event) => handleKeyDown(event), ...htmlAttrs(), className: classes(), children: [props.start ? (_jsx("span", { className: "umds-list-item__start", children: _jsx(_Fragment, { children: props.start }) })) : null, _jsxs("span", { className: "umds-list-item__body", children: [props.title ? (_jsx("span", { className: "umds-list-item__title", children: _jsx(Text, { variant: "body2", as: "span", weight: "semibold", color: "inherit", children: _jsx(_Fragment, { children: props.title }) }) })) : null, props.subtitle ? (_jsx("span", { className: "umds-list-item__subtitle", children: _jsx(Text, { variant: "body2", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.subtitle }) }) })) : null, props.meta ? (_jsx("span", { className: "umds-list-item__meta", children: _jsx(Text, { variant: "caption", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.meta }) }) })) : null, _jsx(_Fragment, { children: props.children })] }), props.end ? (_jsx("span", { className: "umds-list-item__end", children: _jsx(_Fragment, { children: props.end }) })) : null] })) : null, props.actions ? (_jsx("div", { className: "umds-list-item__actions", children: _jsx(_Fragment, { children: props.actions }) })) : null] }) })) : (_jsx(_Fragment, { children: _jsxs(_Fragment, { children: [elementTag() === "button" ? (_jsxs("button", { type: "button", id: props.id, title: props.tooltip, disabled: props.disabled, "aria-current": ariaCurrent(), onClick: (event) => handleClick(event), ...htmlAttrs(), className: classes(), children: [props.start ? (_jsx("span", { className: "umds-list-item__start", children: _jsx(_Fragment, { children: props.start }) })) : null, _jsxs("span", { className: "umds-list-item__body", children: [props.title ? (_jsx("span", { className: "umds-list-item__title", children: _jsx(Text, { variant: "body2", as: "span", weight: "semibold", color: "inherit", children: _jsx(_Fragment, { children: props.title }) }) })) : null, props.subtitle ? (_jsx("span", { className: "umds-list-item__subtitle", children: _jsx(Text, { variant: "body2", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.subtitle }) }) })) : null, props.meta ? (_jsx("span", { className: "umds-list-item__meta", children: _jsx(Text, { variant: "caption", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.meta }) }) })) : null, _jsx(_Fragment, { children: props.children })] }), props.end ? (_jsx("span", { className: "umds-list-item__end", children: _jsx(_Fragment, { children: props.end }) })) : null] })) : null, elementTag() === "a" ? (_jsxs("a", { id: props.id, title: props.tooltip, href: hrefValue(), target: props.target, rel: relValue(), "aria-current": ariaCurrent(), "aria-disabled": ariaDisabled(), onClick: (event) => handleClick(event), ...htmlAttrs(), className: classes(), children: [props.start ? (_jsx("span", { className: "umds-list-item__start", children: _jsx(_Fragment, { children: props.start }) })) : null, _jsxs("span", { className: "umds-list-item__body", children: [props.title ? (_jsx("span", { className: "umds-list-item__title", children: _jsx(Text, { variant: "body2", as: "span", weight: "semibold", color: "inherit", children: _jsx(_Fragment, { children: props.title }) }) })) : null, props.subtitle ? (_jsx("span", { className: "umds-list-item__subtitle", children: _jsx(Text, { variant: "body2", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.subtitle }) }) })) : null, props.meta ? (_jsx("span", { className: "umds-list-item__meta", children: _jsx(Text, { variant: "caption", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.meta }) }) })) : null, _jsx(_Fragment, { children: props.children })] }), props.end ? (_jsx("span", { className: "umds-list-item__end", children: _jsx(_Fragment, { children: props.end }) })) : null] })) : null, elementTag() === "div" ? (_jsxs("div", { id: props.id, title: props.tooltip, role: divRole(), tabIndex: divTabIndex(), "aria-current": ariaCurrent(), "aria-disabled": ariaDisabled(), onClick: (event) => handleClick(event), onKeyDown: (event) => handleKeyDown(event), ...htmlAttrs(), className: classes(), children: [props.start ? (_jsx("span", { className: "umds-list-item__start", children: _jsx(_Fragment, { children: props.start }) })) : null, _jsxs("span", { className: "umds-list-item__body", children: [props.title ? (_jsx("span", { className: "umds-list-item__title", children: _jsx(Text, { variant: "body2", as: "span", weight: "semibold", color: "inherit", children: _jsx(_Fragment, { children: props.title }) }) })) : null, props.subtitle ? (_jsx("span", { className: "umds-list-item__subtitle", children: _jsx(Text, { variant: "body2", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.subtitle }) }) })) : null, props.meta ? (_jsx("span", { className: "umds-list-item__meta", children: _jsx(Text, { variant: "caption", as: "span", color: "inherit", children: _jsx(_Fragment, { children: props.meta }) }) })) : null, _jsx(_Fragment, { children: props.children })] }), props.end ? (_jsx("span", { className: "umds-list-item__end", children: _jsx(_Fragment, { children: props.end }) })) : null] })) : null] }) })) }));
81
81
  }
82
82
  export default ListItem;
@@ -243,8 +243,25 @@
243
243
  color: var(--umds-semantic-color-feedback-info-dark);
244
244
  }
245
245
 
246
+ /* ── secondaryTone: cor da segunda medida (mesmas variantes do valueTone) ── */
247
+ .umds-stat-card--secundario-primary .umds-stat-card__valor-secundario {
248
+ color: var(--umds-semantic-color-primary-pure);
249
+ }
250
+
251
+ .umds-stat-card--secundario-positive .umds-stat-card__valor-secundario {
252
+ color: var(--umds-semantic-color-feedback-positive-dark);
253
+ }
254
+
255
+ .umds-stat-card--secundario-negative .umds-stat-card__valor-secundario {
256
+ color: var(--umds-semantic-color-feedback-negative-dark);
257
+ }
258
+
259
+ .umds-stat-card--secundario-info .umds-stat-card__valor-secundario {
260
+ color: var(--umds-semantic-color-feedback-info-dark);
261
+ }
262
+
246
263
  /* ── Segunda medida (secondaryValue, secondaryLabel) ──
247
- Menor, à direita da principal, separada por um filete. Vem depois do corpo no HTML. */
264
+ À direita da principal, separada por um filete. Vem depois do corpo no HTML. */
248
265
  .umds-stat-card__secundaria {
249
266
  display: flex;
250
267
  flex-direction: column;
@@ -263,8 +280,9 @@
263
280
  text-align: center;
264
281
  }
265
282
 
283
+ /* Mesmo tamanho e peso do número principal: as duas medidas se comparam lado a lado. */
266
284
  .umds-stat-card__valor-secundario {
267
- font-size: var(--umds-primitive-font-size-sm); /* 20px */
285
+ font-size: var(--umds-primitive-font-size-md);
268
286
  font-weight: var(--umds-primitive-font-weight-bold);
269
287
  line-height: 1.15;
270
288
  font-variant-numeric: tabular-nums;
@@ -285,6 +303,30 @@
285
303
  .umds-stat-card--rotulo-topo .umds-stat-card__secundaria {
286
304
  grid-column: 3;
287
305
  grid-row: 2;
306
+ /* A grade não tem vão entre colunas: o filete ganha o mesmo respiro dos dois lados. */
307
+ margin-left: var(--umds-primitive-spacing-xxxs);
308
+ }
309
+
310
+ .umds-stat-card--rotulo-topo .umds-stat-card__valor-secundario {
311
+ font-size: var(--umds-primitive-font-size-lg); /* 32px, como o principal */
312
+ font-weight: var(--umds-primitive-font-weight-extrabold);
313
+ }
314
+
315
+ /* Com rótulo na segunda medida, ela ocupa as linhas do número e da descrição
316
+ (subgrid): o rótulo dela fica na linha da descrição ("Geral" ao lado de
317
+ "Meu Setor") e o valor na linha do número principal. */
318
+ .umds-stat-card--rotulo-topo .umds-stat-card__secundaria:has(.umds-stat-card__rotulo-secundario) {
319
+ grid-row: 2 / span 2;
320
+ display: grid;
321
+ grid-template-rows: subgrid;
322
+ /* `normal` usa o vão da grade do cartão; o `micro` da regra base deslocaria a linha em 1,5px. */
323
+ row-gap: normal;
324
+ align-items: center;
325
+ }
326
+
327
+ .umds-stat-card--rotulo-topo.umds-stat-card--com-secundaria .umds-stat-card__descricao {
328
+ grid-column: 1 / 3;
329
+ grid-row: 3;
288
330
  }
289
331
 
290
332
  .umds-stat-card--rotulo-topo.umds-stat-card--com-secundaria:not(:has(.umds-stat-card__icone)) .umds-stat-card__valor {
@@ -298,3 +340,16 @@
298
340
  font-size: 1.125rem; /* 18px */
299
341
  font-weight: var(--umds-primitive-font-weight-extrabold);
300
342
  }
343
+
344
+ /* A segunda medida acompanha o número principal. */
345
+ .umds-stat-card.umds-stat-card--sm .umds-stat-card__valor-secundario {
346
+ font-size: 1.125rem; /* 18px */
347
+ font-weight: var(--umds-primitive-font-weight-extrabold);
348
+ }
349
+
350
+ /* ── loading: o Skeleton ocupa a altura do número (1em da fonte dele) ── */
351
+ .umds-stat-card[aria-busy="true"] .umds-stat-card__valor,
352
+ .umds-stat-card[aria-busy="true"] .umds-stat-card__valor-secundario {
353
+ display: inline-flex;
354
+ align-items: center;
355
+ }
@@ -14,7 +14,7 @@ import './StatCard.css';
14
14
  * pessoa a segurar o rótulo na cabeça esperando o valor.
15
15
  * - **A variação não depende só da cor.** Sobe e desce vêm com seta, porque
16
16
  * verde e vermelho sozinhos não comunicam para quem não distingue cores.
17
- * @property {string} value - O número ou medida em destaque
17
+ * @property {any} value - O número ou medida em destaque. Aceita um nó, como um `Counter` para o número contar até o valor (no Vue, slot `value`)
18
18
  * @property {string} label - O que o número significa
19
19
  * @property {IconProp} icon - Ícone Font Awesome como objeto importado
20
20
  * @property {string} variant - Cor de apoio do ícone e da variação
@@ -24,20 +24,22 @@ import './StatCard.css';
24
24
  * @property {string} description - Texto auxiliar abaixo do rótulo
25
25
  * @property {string} size - 'md' (padrão) | 'sm': o `sm` leva o número a 18px/800, o tamanho dos KPIs da home do CrisDoc (fora da escala de fonte do DS, que vai de 16 a 20). Vale também com `labelPosition="top"`.
26
26
  * @property {string} labelPosition - Onde o rótulo aparece: 'bottom' (padrão, embaixo do número) | 'top' (no topo do cartão, acima do ícone e do número, com o número em 32/800). No HTML o número continua vindo antes do rótulo, então o leitor de tela anuncia igual.
27
- * @property {string|number} secondaryValue - Segunda medida do cartão, menor, à direita da principal ("22,31%" do setor e "14,11%" do geral). Vem depois do resto no HTML. `0` aparece; vazio e ausente não.
27
+ * @property {string|number} secondaryValue - Segunda medida do cartão, no mesmo tamanho da principal, à direita dela ("22,31%" do setor e "14,11%" do geral). Com `labelPosition="top"`, o `secondaryLabel` fica na linha da `description`. Vem depois do resto no HTML. `0` aparece; vazio e ausente não.
28
28
  * @property {string} secondaryLabel - O que a segunda medida significa ("Geral")
29
29
  * @property {string} valueTone - Cor do número principal: 'default' (cor do texto) | 'primary' | 'positive' | 'negative' | 'info'. Usa as variantes escuras (contraste AA sobre o branco). Não confunda com `variant`, que pinta o ícone e a variação.
30
+ * @property {string} secondaryTone - Cor da segunda medida, com as mesmas opções de `valueTone` ("Meu Setor" em azul e "Geral" em verde, por exemplo).
31
+ * @property {boolean} loading - Carregando: os números viram Skeleton do tamanho deles (rótulos e ⓘ continuam), a variação some e o cartão ganha `aria-busy`. Dispensa montar o Skeleton por fora.
30
32
  * @property {string} infoTooltip - Texto de ajuda: mostra um ⓘ ao lado do rótulo com um Tooltip escuro. É um botão dentro do cartão; com `onClick` no cartão, evite (botão dentro de botão).
31
33
  * @property {string} infoLabel - Nome acessível do ⓘ. Troque para traduzir. Padrão: "Mais informações".
32
34
  * @property {string} align - Alinhamento do conteúdo
33
35
  * @property {string} className - Classes CSS adicionais
34
36
  * @property {function} onClick - Torna o cartão acionável (vira botão)
35
37
  * @quandoUsar Painel e visão geral: destacar UMA medida por cartão, em linha com outras. O número é o protagonista da leitura.
36
- * @quandoNaoUsar Para listar dados comparáveis — use `DataTable`. Para um bloco com título e conteúdo livre, `Card`. Para um rótulo curto de estado, `Tag` ou `Badge`. Não empilhe vários números num StatCard só: um cartão, uma medida.
38
+ * @quandoNaoUsar Para listar dados comparáveis — use `DataTable`. Para um bloco com título e conteúdo livre, `Card`. Para um rótulo curto de estado, `Tag` ou `Badge`. Não empilhe vários números num StatCard só: uma medida, ou duas comparadas (`secondaryValue`).
37
39
  */
38
40
  export interface StatCardProps {
39
- /** O número ou medida em destaque. */
40
- value?: string | number;
41
+ /** O número ou medida em destaque. Texto, número ou nó (um `Counter`); no Vue, slot `value`. */
42
+ value?: any;
41
43
  /** O que o número significa. */
42
44
  label?: string;
43
45
  /** Ícone Font Awesome como objeto importado. */
@@ -70,7 +72,7 @@ export interface StatCardProps {
70
72
  * @default 'bottom'
71
73
  */
72
74
  labelPosition?: "bottom" | "top";
73
- /** Segunda medida, menor, à direita da principal. 0 aparece; vazio e ausente não. */
75
+ /** Segunda medida, no tamanho da principal, à direita dela. 0 aparece; vazio e ausente não. */
74
76
  secondaryValue?: string | number;
75
77
  /** O que a segunda medida significa. */
76
78
  secondaryLabel?: string;
@@ -78,6 +80,10 @@ export interface StatCardProps {
78
80
  size?: "md" | "sm";
79
81
  /** Cor do número principal. @default 'default' */
80
82
  valueTone?: "default" | "primary" | "positive" | "negative" | "info";
83
+ /** Cor da segunda medida. Mesmas opções de `valueTone`. @default 'default' */
84
+ secondaryTone?: "default" | "primary" | "positive" | "negative" | "info";
85
+ /** Carregando: números viram Skeleton, a variação some, `aria-busy` no cartão. @default false */
86
+ loading?: boolean;
81
87
  /**
82
88
  * Texto de ajuda: mostra o ⓘ ao lado do rótulo, com um Tooltip escuro. É um
83
89
  * botão focável dentro do cartão; com `onClick` no cartão, evite.
@@ -1,9 +1,10 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import './StatCard.css';
4
4
  import { faCircleInfo } from "@fortawesome/free-solid-svg-icons";
5
5
  import Icon from "../Icon/Icon";
6
6
  import Tooltip from "../Tooltip/Tooltip";
7
+ import Skeleton from "../Skeleton/Skeleton";
7
8
  function StatCard(props) {
8
9
  function classes() {
9
10
  return [
@@ -16,6 +17,9 @@ function StatCard(props) {
16
17
  props.valueTone && props.valueTone !== "default"
17
18
  ? "umds-stat-card--valor-" + props.valueTone
18
19
  : "",
20
+ props.secondaryTone && props.secondaryTone !== "default"
21
+ ? "umds-stat-card--secundario-" + props.secondaryTone
22
+ : "",
19
23
  props.onClick ? "umds-stat-card--acionavel" : "",
20
24
  props.className,
21
25
  ]
@@ -58,6 +62,6 @@ function StatCard(props) {
58
62
  if (props.onClick)
59
63
  props.onClick();
60
64
  }
61
- return (_jsxs("div", { className: classes(), onClick: (event) => aoClicar(), role: ehAcionavel() ? "button" : undefined, tabIndex: ehAcionavel() ? 0 : undefined, children: [props.icon ? (_jsx("span", { className: "umds-stat-card__icone", "aria-hidden": "true", children: _jsx(Icon, { size: "md", variant: "inherit", icon: props.icon }) })) : null, _jsxs("div", { className: "umds-stat-card__corpo", children: [_jsx("span", { className: "umds-stat-card__valor", children: props.value }), _jsxs("span", { className: "umds-stat-card__rotulo", children: [props.label, props.infoTooltip ? (_jsx(Tooltip, { variant: "dark", side: "top", content: props.infoTooltip, children: _jsx("button", { type: "button", className: "umds-stat-card__info", "aria-label": props.infoLabel || "Mais informações", children: _jsx(Icon, { variant: "inherit", customSize: "14px", icon: faCircleInfo }) }) })) : null] }), props.description ? (_jsx("span", { className: "umds-stat-card__descricao", children: props.description })) : null, props.trendLabel ? (_jsxs("span", { className: classesDaVariacao(), children: [_jsx("span", { "aria-hidden": "true", children: simboloDaVariacao() }), props.trendLabel] })) : null] }), temSecundaria() ? (_jsxs("div", { className: "umds-stat-card__secundaria", children: [_jsx("span", { className: "umds-stat-card__valor-secundario", children: props.secondaryValue }), props.secondaryLabel ? (_jsx("span", { className: "umds-stat-card__rotulo-secundario", children: props.secondaryLabel })) : null] })) : null] }));
65
+ return (_jsxs("div", { className: classes(), onClick: (event) => aoClicar(), role: ehAcionavel() ? "button" : undefined, tabIndex: ehAcionavel() ? 0 : undefined, "aria-busy": props.loading ? "true" : undefined, children: [props.icon ? (_jsx("span", { className: "umds-stat-card__icone", "aria-hidden": "true", children: _jsx(Icon, { size: "md", variant: "inherit", icon: props.icon }) })) : null, _jsxs("div", { className: "umds-stat-card__corpo", children: [_jsxs("span", { className: "umds-stat-card__valor", children: [props.loading ? _jsx(Skeleton, { width: "3em", height: "1em" }) : null, !props.loading ? _jsx(_Fragment, { children: props.value }) : null] }), _jsxs("span", { className: "umds-stat-card__rotulo", children: [props.label, props.infoTooltip ? (_jsx(Tooltip, { variant: "dark", side: "top", content: props.infoTooltip, children: _jsx("button", { type: "button", className: "umds-stat-card__info", "aria-label": props.infoLabel || "Mais informações", children: _jsx(Icon, { variant: "inherit", customSize: "14px", icon: faCircleInfo }) }) })) : null] }), props.description ? (_jsx("span", { className: "umds-stat-card__descricao", children: props.description })) : null, props.trendLabel && !props.loading ? (_jsxs("span", { className: classesDaVariacao(), children: [_jsx("span", { "aria-hidden": "true", children: simboloDaVariacao() }), props.trendLabel] })) : null] }), temSecundaria() ? (_jsxs("div", { className: "umds-stat-card__secundaria", children: [_jsxs("span", { className: "umds-stat-card__valor-secundario", children: [props.loading ? _jsx(Skeleton, { width: "3em", height: "1em" }) : null, !props.loading ? _jsx(_Fragment, { children: props.secondaryValue }) : null] }), props.secondaryLabel ? (_jsx("span", { className: "umds-stat-card__rotulo-secundario", children: props.secondaryLabel })) : null] })) : null] }));
62
66
  }
63
67
  export default StatCard;
@@ -8,6 +8,9 @@
8
8
  ───────────────────────────────────────────── */
9
9
 
10
10
  .umds-tooltip {
11
+ /* Bloco de contenção da cópia escondida (__descricao, absolute): sem ele,
12
+ ela escapa do overflow de uma área que rola e estica a página. */
13
+ position: relative;
11
14
  display: inline-flex;
12
15
  height: fit-content;
13
16
  }
@@ -2,7 +2,7 @@
2
2
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import './Tooltip.css';
4
4
  import { useState, useRef, useEffect } from "react";
5
- import { computePosition, autoUpdate, offset, flip, shift, } from "@floating-ui/dom";
5
+ import { computePosition, autoUpdate, offset, flip, shift, arrow, } from "@floating-ui/dom";
6
6
  import Icon from "../Icon/Icon";
7
7
  import Portal from "../Portal/Portal";
8
8
  import { gerarId } from "../../utils/id";
@@ -28,6 +28,7 @@ function Tooltip(props) {
28
28
  const [posY, setPosY] = useState(() => 0);
29
29
  const [finalSide, setFinalSide] = useState(() => "top");
30
30
  const [finalAlign, setFinalAlign] = useState(() => "center");
31
+ const [arrowOffset, setArrowOffset] = useState(() => -1);
31
32
  const [bubbleId, setBubbleId] = useState(() => "");
32
33
  const [descId, setDescId] = useState(() => "");
33
34
  function bubbleIdText() {
@@ -92,6 +93,12 @@ function Tooltip(props) {
92
93
  padding: 8,
93
94
  }),
94
95
  ];
96
+ // A seta acompanha o gatilho quando o balão é empurrado para caber.
97
+ if (arrowRef.current)
98
+ middlewares.push(arrow({
99
+ element: arrowRef.current,
100
+ padding: 8,
101
+ }));
95
102
  computePosition(triggerRef.current, floatingRef.current, {
96
103
  placement,
97
104
  strategy: "fixed",
@@ -105,6 +112,14 @@ function Tooltip(props) {
105
112
  const parts = position.placement.split("-");
106
113
  setFinalSide(parts[0]);
107
114
  setFinalAlign(parts[1] || "center");
115
+ // Só quando o shift deslocou o balão a seta sai da posição estática
116
+ // (as 12 classes side×align): nos outros casos o visual não muda.
117
+ const deslocamento = position.middlewareData.shift;
118
+ const seta = position.middlewareData.arrow;
119
+ const empurrado = !!deslocamento && (deslocamento.x !== 0 || deslocamento.y !== 0);
120
+ const vertical = parts[0] === "left" || parts[0] === "right";
121
+ const eixo = seta ? (vertical ? seta.y : seta.x) : undefined;
122
+ setArrowOffset(empurrado && typeof eixo === "number" ? eixo : -1);
108
123
  setPositioned(true);
109
124
  });
110
125
  }
@@ -273,6 +288,22 @@ function Tooltip(props) {
273
288
  "umds-tooltip__arrow--align-" + finalAlign,
274
289
  ].join(" ");
275
290
  }
291
+ function arrowStyle() {
292
+ if (arrowOffset < 0)
293
+ return undefined;
294
+ if (finalSide === "left" || finalSide === "right") {
295
+ return {
296
+ top: arrowOffset + "px",
297
+ bottom: "auto",
298
+ transform: "rotate(45deg)",
299
+ };
300
+ }
301
+ return {
302
+ left: arrowOffset + "px",
303
+ right: "auto",
304
+ transform: "rotate(45deg)",
305
+ };
306
+ }
276
307
  useEffect(() => {
277
308
  if (!triggerRef.current)
278
309
  return;
@@ -315,6 +346,6 @@ function Tooltip(props) {
315
346
  stopKeydownWatch();
316
347
  };
317
348
  }, []);
318
- return (_jsxs("span", { ref: triggerRef, className: ["umds-tooltip", props.className].filter(Boolean).join(" "), onMouseEnter: (event) => handleOpen(), onMouseLeave: (event) => handleClose(event), children: [_jsx(_Fragment, { children: props.children }), _jsxs("span", { className: "umds-tooltip__descricao", "aria-hidden": "true", id: descIdText(), children: [props.title ? _jsx(_Fragment, { children: tituloDescrito() }) : null, props.content ? _jsx(_Fragment, { children: props.content }) : null] }), _jsx(Portal, { children: mounted ? (_jsx("span", { ref: floatingRef, className: floatingClasses(), style: floatingStyle(), children: _jsxs("span", { role: "tooltip", className: bubbleClasses(), id: bubbleIdText(), "aria-hidden": !open, children: [_jsxs("span", { className: "umds-tooltip__row", children: [props.icon ? (_jsx(Icon, { size: "sm", variant: "inherit", icon: props.icon })) : null, _jsxs("span", { className: "umds-tooltip__text", children: [props.title ? (_jsx("span", { className: "umds-tooltip__title", children: props.title })) : null, props.content ? (_jsx("span", { className: "umds-tooltip__content", children: _jsx(_Fragment, { children: props.content }) })) : null] })] }), _jsx("span", { ref: arrowRef, className: arrowClasses() })] }) })) : null })] }));
349
+ return (_jsxs("span", { ref: triggerRef, className: ["umds-tooltip", props.className].filter(Boolean).join(" "), onMouseEnter: (event) => handleOpen(), onMouseLeave: (event) => handleClose(event), children: [_jsx(_Fragment, { children: props.children }), _jsxs("span", { className: "umds-tooltip__descricao", "aria-hidden": "true", id: descIdText(), children: [props.title ? _jsx(_Fragment, { children: tituloDescrito() }) : null, props.content ? _jsx(_Fragment, { children: props.content }) : null] }), _jsx(Portal, { children: mounted ? (_jsx("span", { ref: floatingRef, className: floatingClasses(), style: floatingStyle(), children: _jsxs("span", { role: "tooltip", className: bubbleClasses(), id: bubbleIdText(), "aria-hidden": !open, children: [_jsxs("span", { className: "umds-tooltip__row", children: [props.icon ? (_jsx(Icon, { size: "sm", variant: "inherit", icon: props.icon })) : null, _jsxs("span", { className: "umds-tooltip__text", children: [props.title ? (_jsx("span", { className: "umds-tooltip__title", children: props.title })) : null, props.content ? (_jsx("span", { className: "umds-tooltip__content", children: _jsx(_Fragment, { children: props.content }) })) : null] })] }), _jsx("span", { ref: arrowRef, className: arrowClasses(), style: arrowStyle() })] }) })) : null })] }));
319
350
  }
320
351
  export default Tooltip;
package/dist/index.d.ts CHANGED
@@ -50,6 +50,8 @@ export { default as Input } from "./components/Input/Input";
50
50
  export { default as Kanban } from "./components/Kanban/Kanban";
51
51
  export { default as KanbanCard } from "./components/Kanban/KanbanCard";
52
52
  export { default as KanbanColumn } from "./components/Kanban/KanbanColumn";
53
+ export { default as Counter } from "./components/Counter/Counter";
54
+ export type * from "./components/Counter/Counter";
53
55
  export { default as Kbd } from "./components/Kbd/Kbd";
54
56
  export { default as Link } from "./components/Link/Link";
55
57
  export { default as LineChart } from "./components/Chart/LineChart";
package/dist/index.js CHANGED
@@ -56,6 +56,7 @@ export { default as Input } from "./components/Input/Input";
56
56
  export { default as Kanban } from "./components/Kanban/Kanban";
57
57
  export { default as KanbanCard } from "./components/Kanban/KanbanCard";
58
58
  export { default as KanbanColumn } from "./components/Kanban/KanbanColumn";
59
+ export { default as Counter } from "./components/Counter/Counter";
59
60
  export { default as Kbd } from "./components/Kbd/Kbd";
60
61
  export { default as Link } from "./components/Link/Link";
61
62
  export { default as LineChart } from "./components/Chart/LineChart";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,91 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { act, fireEvent, render, screen } from "@testing-library/react";
4
+ import Button from "../components/Button/Button";
5
+ import Counter from "../components/Counter/Counter";
6
+ import Dropdown from "../components/Dropdown/Dropdown";
7
+ import ListItem from "../components/List/ListItem";
8
+ import StatCard from "../components/StatCard/StatCard";
9
+ describe("Counter", () => {
10
+ it("sem animação mostra o valor final formatado em pt-BR", () => {
11
+ const { container } = render(_jsx(Counter, { value: 1284, disableAnimation: true }));
12
+ expect(container.querySelector(".umds-counter__numero")).toHaveTextContent("1.284");
13
+ expect(container.querySelector(".umds-counter__numero")).toHaveAttribute("aria-hidden", "true");
14
+ expect(container.querySelector(".umds-counter__leitura")).toHaveTextContent("1.284");
15
+ });
16
+ it("casas decimais seguem o value; prefixo e sufixo entram no texto", () => {
17
+ const { container } = render(_jsxs(_Fragment, { children: [_jsx(Counter, { value: 22.31, suffix: "%", disableAnimation: true }), _jsx(Counter, { value: 15890.5, prefix: "R$ ", decimals: 2, disableAnimation: true })] }));
18
+ const leituras = container.querySelectorAll(".umds-counter__leitura");
19
+ expect(leituras[0]).toHaveTextContent("22,31%");
20
+ expect(leituras[1].textContent).toBe("R$ 15.890,50");
21
+ });
22
+ it("conta do from até o valor e termina no valor exato", async () => {
23
+ vi.useFakeTimers();
24
+ const { container } = render(_jsx(Counter, { value: 500, duration: 100 }));
25
+ const numero = () => container.querySelector(".umds-counter__numero").textContent;
26
+ expect(numero()).toBe("0");
27
+ await act(async () => {
28
+ vi.advanceTimersByTime(500);
29
+ });
30
+ expect(numero()).toBe("500");
31
+ // A reserva e a leitura já nascem com o valor final.
32
+ expect(container.querySelector(".umds-counter__leitura")).toHaveTextContent("500");
33
+ vi.useRealTimers();
34
+ });
35
+ });
36
+ describe("ListItem · actions", () => {
37
+ it("as ações ficam no li, fora do elemento clicável", () => {
38
+ const { container } = render(_jsx("ul", { children: _jsx(ListItem, { href: "#doc", title: "Memorando", actions: _jsx("button", { type: "button", children: "Mais" }) }) }));
39
+ const acao = screen.getByRole("button", { name: "Mais" });
40
+ expect(acao.closest("a")).toBeNull();
41
+ expect(acao.parentElement).toHaveClass("umds-list-item__actions");
42
+ expect(container.querySelector("li > a")).toHaveAttribute("href", "#doc");
43
+ });
44
+ });
45
+ describe("Button · href", () => {
46
+ it("com href é um link; desabilitado volta a ser botão", () => {
47
+ render(_jsxs(_Fragment, { children: [_jsx(Button, { href: "#inbox", children: "Inbox" }), _jsx(Button, { href: "#cal", target: "_blank", children: "Calend\u00E1rio" }), _jsx(Button, { href: "#fila", disabled: true, children: "Fila" })] }));
48
+ expect(screen.getByRole("link", { name: "Inbox" })).toHaveAttribute("href", "#inbox");
49
+ expect(screen.getByRole("link", { name: "Calendário" })).toHaveAttribute("rel", "noopener noreferrer");
50
+ expect(screen.getByRole("button", { name: "Fila" })).toBeDisabled();
51
+ });
52
+ it("loading segura a navegação do link", () => {
53
+ render(_jsx(Button, { href: "#inbox", loading: true, loadingPosition: "start", children: "Inbox" }));
54
+ const link = screen.getByRole("link", { name: "Inbox" });
55
+ const evento = new MouseEvent("click", { bubbles: true, cancelable: true });
56
+ link.dispatchEvent(evento);
57
+ expect(evento.defaultPrevented).toBe(true);
58
+ });
59
+ });
60
+ describe("Dropdown · href", () => {
61
+ it("item com href vira <a role=menuitem>, e o onSelect continua", async () => {
62
+ const onSelect = vi.fn();
63
+ render(_jsx(Dropdown, { ariaLabel: "A\u00E7\u00F5es", onSelect: onSelect, items: [
64
+ { key: "assinar", label: "Assinar", href: "#assinar" },
65
+ { key: "arquivar", label: "Arquivar" },
66
+ ], children: _jsx("button", { type: "button", children: "Abrir" }) }));
67
+ fireEvent.click(screen.getByRole("button", { name: "Abrir" }));
68
+ const assinar = await screen.findByRole("menuitem", { name: "Assinar" });
69
+ expect(assinar.tagName).toBe("A");
70
+ expect(assinar).toHaveAttribute("href", "#assinar");
71
+ expect(screen.getByRole("menuitem", { name: "Arquivar" }).tagName).toBe("BUTTON");
72
+ fireEvent.click(assinar);
73
+ expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ key: "assinar" }));
74
+ });
75
+ });
76
+ describe("StatCard · loading e secondaryTone", () => {
77
+ it("loading troca os números por Skeleton e marca aria-busy", () => {
78
+ const { container } = render(_jsx(StatCard, { value: "22,31%", label: "Qualidade", secondaryValue: "14,11%", trendLabel: "+2%", trend: "up", loading: true }));
79
+ const cartao = container.querySelector(".umds-stat-card");
80
+ expect(cartao).toHaveAttribute("aria-busy", "true");
81
+ expect(cartao.textContent).not.toContain("22,31%");
82
+ expect(cartao.textContent).not.toContain("14,11%");
83
+ expect(cartao.textContent).not.toContain("+2%");
84
+ expect(cartao.textContent).toContain("Qualidade");
85
+ });
86
+ it("secondaryTone escreve a classe; default não", () => {
87
+ const { container } = render(_jsxs(_Fragment, { children: [_jsx(StatCard, { value: "1", secondaryValue: "2", secondaryTone: "positive" }), _jsx(StatCard, { value: "1", secondaryValue: "2", secondaryTone: "default" })] }));
88
+ expect(container.children[0]).toHaveClass("umds-stat-card--secundario-positive");
89
+ expect(container.children[1].className).not.toContain("secundario-");
90
+ });
91
+ });
@@ -23,6 +23,14 @@ describe("StatCard · size", () => {
23
23
  const { container } = render(_jsx(StatCard, { value: 44, label: "N\u00E3o lidos", size: "sm", labelPosition: "top" }));
24
24
  expect(container.firstElementChild).toHaveClass("umds-stat-card--sm", "umds-stat-card--rotulo-topo");
25
25
  });
26
+ it("a segunda medida tem o mesmo tamanho e peso do número, no sm e no rótulo no topo", () => {
27
+ const sm = bloco(".umds-stat-card.umds-stat-card--sm .umds-stat-card__valor-secundario");
28
+ expect(sm).toMatch(/font-size:\s*1\.125rem/);
29
+ expect(sm).toMatch(/font-weight:\s*var\(--umds-primitive-font-weight-extrabold\)/);
30
+ const topo = bloco(".umds-stat-card--rotulo-topo .umds-stat-card__valor-secundario");
31
+ expect(topo).toMatch(/font-size:\s*var\(--umds-primitive-font-size-lg\)/);
32
+ expect(topo).toMatch(/font-weight:\s*var\(--umds-primitive-font-weight-extrabold\)/);
33
+ });
26
34
  it("o número continua antes do rótulo no HTML", () => {
27
35
  const { container } = render(_jsx(StatCard, { value: 44, label: "N\u00E3o lidos", size: "sm" }));
28
36
  const valor = container.querySelector(".umds-stat-card__valor");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1doc/1ds-react",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "main": "dist/index.js",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -45,8 +45,8 @@
45
45
  "react-dom": "^18.0.0",
46
46
  "typescript": "^5.0.0",
47
47
  "vitest": "^3.2.7",
48
- "@1doc/1ds-tokens": "0.7.0",
49
- "@1doc/1ds-core": "2.13.0"
48
+ "@1doc/1ds-core": "2.14.0",
49
+ "@1doc/1ds-tokens": "0.7.0"
50
50
  },
51
51
  "publishConfig": {
52
52
  "access": "public"
package/src/index.ts CHANGED
@@ -58,6 +58,8 @@ export { default as Input } from "./components/Input/Input";
58
58
  export { default as Kanban } from "./components/Kanban/Kanban";
59
59
  export { default as KanbanCard } from "./components/Kanban/KanbanCard";
60
60
  export { default as KanbanColumn } from "./components/Kanban/KanbanColumn";
61
+ export { default as Counter } from "./components/Counter/Counter";
62
+ export type * from "./components/Counter/Counter";
61
63
  export { default as Kbd } from "./components/Kbd/Kbd";
62
64
  export { default as Link } from "./components/Link/Link";
63
65
  export { default as LineChart } from "./components/Chart/LineChart";
@@ -0,0 +1,129 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { act, fireEvent, render, screen } from "@testing-library/react";
3
+ import Button from "../components/Button/Button";
4
+ import Counter from "../components/Counter/Counter";
5
+ import Dropdown from "../components/Dropdown/Dropdown";
6
+ import ListItem from "../components/List/ListItem";
7
+ import StatCard from "../components/StatCard/StatCard";
8
+
9
+ describe("Counter", () => {
10
+ it("sem animação mostra o valor final formatado em pt-BR", () => {
11
+ const { container } = render(<Counter value={1284} disableAnimation />);
12
+ expect(container.querySelector(".umds-counter__numero")).toHaveTextContent("1.284");
13
+ expect(container.querySelector(".umds-counter__numero")).toHaveAttribute("aria-hidden", "true");
14
+ expect(container.querySelector(".umds-counter__leitura")).toHaveTextContent("1.284");
15
+ });
16
+
17
+ it("casas decimais seguem o value; prefixo e sufixo entram no texto", () => {
18
+ const { container } = render(
19
+ <>
20
+ <Counter value={22.31} suffix="%" disableAnimation />
21
+ <Counter value={15890.5} prefix="R$ " decimals={2} disableAnimation />
22
+ </>,
23
+ );
24
+ const leituras = container.querySelectorAll(".umds-counter__leitura");
25
+ expect(leituras[0]).toHaveTextContent("22,31%");
26
+ expect(leituras[1].textContent).toBe("R$ 15.890,50");
27
+ });
28
+
29
+ it("conta do from até o valor e termina no valor exato", async () => {
30
+ vi.useFakeTimers();
31
+ const { container } = render(<Counter value={500} duration={100} />);
32
+ const numero = () => container.querySelector(".umds-counter__numero")!.textContent;
33
+ expect(numero()).toBe("0");
34
+ await act(async () => {
35
+ vi.advanceTimersByTime(500);
36
+ });
37
+ expect(numero()).toBe("500");
38
+ // A reserva e a leitura já nascem com o valor final.
39
+ expect(container.querySelector(".umds-counter__leitura")).toHaveTextContent("500");
40
+ vi.useRealTimers();
41
+ });
42
+ });
43
+
44
+ describe("ListItem · actions", () => {
45
+ it("as ações ficam no li, fora do elemento clicável", () => {
46
+ const { container } = render(
47
+ <ul>
48
+ <ListItem href="#doc" title="Memorando" actions={<button type="button">Mais</button>} />
49
+ </ul>,
50
+ );
51
+ const acao = screen.getByRole("button", { name: "Mais" });
52
+ expect(acao.closest("a")).toBeNull();
53
+ expect(acao.parentElement).toHaveClass("umds-list-item__actions");
54
+ expect(container.querySelector("li > a")).toHaveAttribute("href", "#doc");
55
+ });
56
+ });
57
+
58
+ describe("Button · href", () => {
59
+ it("com href é um link; desabilitado volta a ser botão", () => {
60
+ render(
61
+ <>
62
+ <Button href="#inbox">Inbox</Button>
63
+ <Button href="#cal" target="_blank">Calendário</Button>
64
+ <Button href="#fila" disabled>Fila</Button>
65
+ </>,
66
+ );
67
+ expect(screen.getByRole("link", { name: "Inbox" })).toHaveAttribute("href", "#inbox");
68
+ expect(screen.getByRole("link", { name: "Calendário" })).toHaveAttribute("rel", "noopener noreferrer");
69
+ expect(screen.getByRole("button", { name: "Fila" })).toBeDisabled();
70
+ });
71
+
72
+ it("loading segura a navegação do link", () => {
73
+ render(<Button href="#inbox" loading loadingPosition="start">Inbox</Button>);
74
+ const link = screen.getByRole("link", { name: "Inbox" });
75
+ const evento = new MouseEvent("click", { bubbles: true, cancelable: true });
76
+ link.dispatchEvent(evento);
77
+ expect(evento.defaultPrevented).toBe(true);
78
+ });
79
+ });
80
+
81
+ describe("Dropdown · href", () => {
82
+ it("item com href vira <a role=menuitem>, e o onSelect continua", async () => {
83
+ const onSelect = vi.fn();
84
+ render(
85
+ <Dropdown
86
+ ariaLabel="Ações"
87
+ onSelect={onSelect}
88
+ items={[
89
+ { key: "assinar", label: "Assinar", href: "#assinar" },
90
+ { key: "arquivar", label: "Arquivar" },
91
+ ]}
92
+ >
93
+ <button type="button">Abrir</button>
94
+ </Dropdown>,
95
+ );
96
+ fireEvent.click(screen.getByRole("button", { name: "Abrir" }));
97
+ const assinar = await screen.findByRole("menuitem", { name: "Assinar" });
98
+ expect(assinar.tagName).toBe("A");
99
+ expect(assinar).toHaveAttribute("href", "#assinar");
100
+ expect(screen.getByRole("menuitem", { name: "Arquivar" }).tagName).toBe("BUTTON");
101
+ fireEvent.click(assinar);
102
+ expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ key: "assinar" }));
103
+ });
104
+ });
105
+
106
+ describe("StatCard · loading e secondaryTone", () => {
107
+ it("loading troca os números por Skeleton e marca aria-busy", () => {
108
+ const { container } = render(
109
+ <StatCard value="22,31%" label="Qualidade" secondaryValue="14,11%" trendLabel="+2%" trend="up" loading />,
110
+ );
111
+ const cartao = container.querySelector(".umds-stat-card")!;
112
+ expect(cartao).toHaveAttribute("aria-busy", "true");
113
+ expect(cartao.textContent).not.toContain("22,31%");
114
+ expect(cartao.textContent).not.toContain("14,11%");
115
+ expect(cartao.textContent).not.toContain("+2%");
116
+ expect(cartao.textContent).toContain("Qualidade");
117
+ });
118
+
119
+ it("secondaryTone escreve a classe; default não", () => {
120
+ const { container } = render(
121
+ <>
122
+ <StatCard value="1" secondaryValue="2" secondaryTone="positive" />
123
+ <StatCard value="1" secondaryValue="2" secondaryTone="default" />
124
+ </>,
125
+ );
126
+ expect(container.children[0]).toHaveClass("umds-stat-card--secundario-positive");
127
+ expect(container.children[1].className).not.toContain("secundario-");
128
+ });
129
+ });
@@ -33,6 +33,15 @@ describe("StatCard · size", () => {
33
33
  expect(container.firstElementChild).toHaveClass("umds-stat-card--sm", "umds-stat-card--rotulo-topo");
34
34
  });
35
35
 
36
+ it("a segunda medida tem o mesmo tamanho e peso do número, no sm e no rótulo no topo", () => {
37
+ const sm = bloco(".umds-stat-card.umds-stat-card--sm .umds-stat-card__valor-secundario");
38
+ expect(sm).toMatch(/font-size:\s*1\.125rem/);
39
+ expect(sm).toMatch(/font-weight:\s*var\(--umds-primitive-font-weight-extrabold\)/);
40
+ const topo = bloco(".umds-stat-card--rotulo-topo .umds-stat-card__valor-secundario");
41
+ expect(topo).toMatch(/font-size:\s*var\(--umds-primitive-font-size-lg\)/);
42
+ expect(topo).toMatch(/font-weight:\s*var\(--umds-primitive-font-weight-extrabold\)/);
43
+ });
44
+
36
45
  it("o número continua antes do rótulo no HTML", () => {
37
46
  const { container } = render(<StatCard value={44} label="Não lidos" size="sm" />);
38
47
  const valor = container.querySelector(".umds-stat-card__valor") as HTMLElement;