@adatechnology/conversations-ui 0.1.0-rc.32 → 0.1.0-rc.34

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.1.0-rc.32",
3
+ "version": "0.1.0-rc.34",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -31,7 +31,7 @@
31
31
  "clsx": "^2.1.1",
32
32
  "lucide-react": "^1.21.0",
33
33
  "tailwind-merge": "^3.6.0",
34
- "@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.11"
34
+ "@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.12"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "react": "^18 || ^19",
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { DarkModeToggle } from './DarkModeToggle'
5
+
6
+ const noop = () => undefined
7
+
8
+ describe('DarkModeToggle', () => {
9
+ it('nomeia o destino do clique, não o tema atual', () => {
10
+ const markup = renderToStaticMarkup(<DarkModeToggle isDark={false} onToggle={noop} />)
11
+
12
+ expect(markup).toContain('aria-label="Tema escuro"')
13
+ })
14
+
15
+ it('inverte o rótulo quando o tema escuro está no ar', () => {
16
+ const markup = renderToStaticMarkup(<DarkModeToggle isDark onToggle={noop} />)
17
+
18
+ expect(markup).toContain('aria-label="Tema claro"')
19
+ })
20
+
21
+ it('aceita rótulo do host', () => {
22
+ const markup = renderToStaticMarkup(
23
+ <DarkModeToggle isDark={false} onToggle={noop} labels={{ toDark: 'Modo noturno' }} />,
24
+ )
25
+
26
+ expect(markup).toContain('aria-label="Modo noturno"')
27
+ })
28
+
29
+ it('só-ícone garante a área de toque mínima', () => {
30
+ const markup = renderToStaticMarkup(<DarkModeToggle isDark={false} onToggle={noop} />)
31
+
32
+ expect(markup).toContain('cv-touch')
33
+ })
34
+
35
+ it('com rótulo visível dispensa a área de toque forçada e o tooltip', () => {
36
+ const markup = renderToStaticMarkup(<DarkModeToggle isDark={false} onToggle={noop} showLabel />)
37
+
38
+ expect(markup).not.toContain('cv-touch')
39
+ expect(markup).not.toContain('data-cv-tooltip')
40
+ expect(markup).toContain('Tema escuro')
41
+ })
42
+
43
+ it('deixa o host trocar a aparência sem herdar o utilitário do pacote', () => {
44
+ const markup = renderToStaticMarkup(
45
+ <DarkModeToggle isDark={false} onToggle={noop} className="rounded-2xl px-4" />,
46
+ )
47
+
48
+ expect(markup).toContain('rounded-2xl')
49
+ expect(markup).not.toContain('rounded-lg')
50
+ expect(markup).not.toContain('px-3')
51
+ })
52
+
53
+ /** Trava o contorno da limitação documentada: token do host não é família conhecida do merge. */
54
+ it('resolve raio de token do host quando ele vem na forma arbitrária', () => {
55
+ const markup = renderToStaticMarkup(
56
+ <DarkModeToggle isDark={false} onToggle={noop} className="rounded-[var(--radius-panel)]" />,
57
+ )
58
+
59
+ expect(markup).not.toContain('rounded-lg')
60
+ })
61
+
62
+ it('cede o tamanho do ícone quando o host manda classe', () => {
63
+ const markup = renderToStaticMarkup(
64
+ <DarkModeToggle isDark={false} onToggle={noop} classNames={{ icon: 'size-4' }} />,
65
+ )
66
+
67
+ expect(markup).toContain('size-4')
68
+ expect(markup).not.toContain('width="16"')
69
+ })
70
+
71
+ it('marca o ícone como decorativo — o rótulo já é anunciado', () => {
72
+ const markup = renderToStaticMarkup(<DarkModeToggle isDark={false} onToggle={noop} />)
73
+
74
+ expect(markup).toContain('aria-hidden="true"')
75
+ })
76
+ })
@@ -0,0 +1,92 @@
1
+ import { Moon, Sun } from 'lucide-react'
2
+
3
+ import { cn } from './lib/cn'
4
+ import { ICON_SIZE_ACTION } from './icon.constant'
5
+
6
+ export interface DarkModeToggleLabels {
7
+ /** Ação de ir para o tema escuro — é o rótulo lido enquanto o tema claro está ativo. */
8
+ toDark: string
9
+ toLight: string
10
+ }
11
+
12
+ export interface DarkModeToggleClassNames {
13
+ /** O tamanho do ícone vem daqui quando o host usa a própria escala (`size-4`, `size-5`). */
14
+ icon?: string
15
+ label?: string
16
+ }
17
+
18
+ export interface DarkModeToggleProps {
19
+ isDark: boolean
20
+ onToggle: () => void
21
+ /** Rótulo ao lado do ícone. Só-ícone exige a área de toque mínima, que a classe `cv-touch` dá. */
22
+ showLabel?: boolean
23
+ labels?: Partial<DarkModeToggleLabels>
24
+ /**
25
+ * Substitui a aparência do botão. As classes passam por `tailwind-merge`, então utilitário do
26
+ * host vence o do pacote em vez de somar: `px-4` descarta o `px-3` daqui.
27
+ *
28
+ * A exceção é utilitário de token do host (`rounded-panel`), que o merge não reconhece como
29
+ * família e deixa passar junto com o `rounded-lg` do pacote — aí quem decide é a ordem no CSS
30
+ * gerado, não a do atributo. Nesse caso use a forma arbitrária (`rounded-[var(--radius-panel)]`),
31
+ * que o merge entende e substitui.
32
+ */
33
+ className?: string
34
+ classNames?: DarkModeToggleClassNames
35
+ }
36
+
37
+ export const DEFAULT_DARK_MODE_TOGGLE_LABELS: DarkModeToggleLabels = {
38
+ toDark: 'Tema escuro',
39
+ toLight: 'Tema claro',
40
+ }
41
+
42
+ /**
43
+ * Interruptor de tema, apresentacional: o estado vem do host, junto com `useDarkMode`.
44
+ *
45
+ * Ele não monta o hook de propósito. `useDarkMode` é o controlador — escreve a classe `dark` no
46
+ * `<html>` e persiste a escolha —, e uma barra lateral que também existe como gaveta fica montada
47
+ * duas vezes na mesma tela. Dois controladores guardam estados iniciais separados, e o segundo
48
+ * botão passa a mostrar o ícone contrário ao tema que está no ar. Com o estado no host, o hook é
49
+ * chamado uma vez acima dos dois e as duas cópias concordam.
50
+ *
51
+ * O rótulo nomeia o destino, não o estado atual: no claro se lê "Tema escuro", que é o que o clique
52
+ * faz. `aria-pressed` diria o oposto na mesma frase, então fica de fora.
53
+ */
54
+ export function DarkModeToggle({
55
+ isDark,
56
+ onToggle,
57
+ showLabel = false,
58
+ labels,
59
+ className,
60
+ classNames,
61
+ }: DarkModeToggleProps) {
62
+ const label = isDark
63
+ ? (labels?.toLight ?? DEFAULT_DARK_MODE_TOGGLE_LABELS.toLight)
64
+ : (labels?.toDark ?? DEFAULT_DARK_MODE_TOGGLE_LABELS.toDark)
65
+
66
+ const Icon = isDark ? Sun : Moon
67
+
68
+ return (
69
+ <button
70
+ type="button"
71
+ onClick={onToggle}
72
+ data-cv-tooltip={showLabel ? undefined : label}
73
+ aria-label={label}
74
+ className={cn(
75
+ 'inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-600 transition-colors',
76
+ 'hover:bg-gray-100 hover:text-gray-900',
77
+ 'dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100',
78
+ !showLabel && 'cv-touch',
79
+ className,
80
+ )}
81
+ >
82
+ {/* Sem `size` quando o host manda classe: a prop vira `width`/`height` no SVG e atributo
83
+ ganha da folha, deixando o ícone fora da escala de tipografia do host. */}
84
+ <Icon
85
+ aria-hidden="true"
86
+ className={cn('shrink-0', classNames?.icon)}
87
+ size={classNames?.icon ? undefined : ICON_SIZE_ACTION}
88
+ />
89
+ {showLabel ? <span className={classNames?.label}>{label}</span> : null}
90
+ </button>
91
+ )
92
+ }
package/src/index.ts CHANGED
@@ -114,6 +114,8 @@ export { buildTranscriptText, buildTranscriptFilename, downloadTextFile } from '
114
114
  export type { BuildTranscriptTextParams } from './conversationTranscript'
115
115
 
116
116
  export { useDarkMode, useIsDarkTheme } from './useDarkMode'
117
+ export { DarkModeToggle, DEFAULT_DARK_MODE_TOGGLE_LABELS } from './DarkModeToggle'
118
+ export type { DarkModeToggleProps, DarkModeToggleLabels } from './DarkModeToggle'
117
119
  export { useIsNarrow, NARROW_MAX_WIDTH_PX } from './useIsNarrow'
118
120
  export { useWaitingNotifications } from './useWaitingNotifications'
119
121
  export type {