@aurea-uds/native 0.30.0 → 0.32.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/dist/menu.js ADDED
@@ -0,0 +1,452 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Aurea nativo — `Popover` e `DropdownMenu`: a camada que abre PRESA ao gatilho (0.32.0).
3
+ //
4
+ // Os nomes e a API são os da web (`overlays.tsx` do `@aurea-uds/react`): `trigger` é um elemento,
5
+ // `side` escolhe o lado, `arrow` liga a seta do `Popover`, e o menu recebe `items` na MESMA união
6
+ // `MenuEntry`. A anatomia, as medidas e o comportamento são os do pacote NATIVO da referência (o
7
+ // `Popover` e o `Menu` dela); a pele é a da Aurea — fundo `popover`, borda, canto 22 e sombra LG, a
8
+ // mesma superfície do `Dialog`.
9
+ //
10
+ // ── O QUE MUDA NA TRAVESSIA ──────────────────────────────────────────────────────────────────
11
+ // a web (Base UI) | aqui
12
+ // ------------------------------|------------------------------------------------------------
13
+ // portal + posicionador | o `Modal` do RN (a janela de cima, sem dependência nova) e a
14
+ // | conta de posição nossa, a partir do `measureInWindow` do gatilho
15
+ // `Escape` fecha | `onRequestClose` (o VOLTAR do Android) e `onAccessibilityEscape`
16
+ // | (o gesto de saída do VoiceOver, que o `Modal` não ouve)
17
+ // clique fora fecha | o fundo tocável, IRMÃO do painel — a lição do `overlays.tsx`
18
+ // submenu ao lado | abre NO LUGAR, dentro do mesmo painel, como o da referência:
19
+ // | num telefone não há "ao lado"
20
+ // `menuitemcheckbox`/`radio` | `checkbox` e `radio`: o RN não tem os papéis de menu com marca
21
+ //
22
+ // ⚠ **A posição não está medida em aparelho.** O `measureInWindow` do gatilho e o `Modal` com
23
+ // `statusBarTranslucent` precisam contar do MESMO zero (o topo da tela). Com a tela de ponta a ponta
24
+ // do Android (o padrão das versões novas) contam; num app sem ela, o painel pode sair deslocado pela
25
+ // altura da barra de status. É pergunta para o `apps/native-smoke`, não para o dublê.
26
+ import * as React from "react";
27
+ import { Animated, Easing, Linking, Modal, Pressable, ScrollView, View, useWindowDimensions, } from "react-native";
28
+ import Svg, { Path } from "react-native-svg";
29
+ import { SafeAreaInsetsContext, initialWindowMetrics } from "react-native-safe-area-context";
30
+ import { canto, criarFolha, estadoAcessivel, letraDoToken } from "./estilos.js";
31
+ import { Icon } from "./icon.js";
32
+ import { Text } from "./text.js";
33
+ import { ForaDaMarca, useTokensDoApp } from "./theme.js";
34
+ import { useReduceMotion, driverNativo } from "./movimento.js";
35
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
36
+ // A folha
37
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
38
+ // Medidas do pacote nativo da referência, escritas com o token que dá o mesmo número. Onde a escala
39
+ // da Aurea não tem o número dela, vale o token mais perto, e a linha diz qual era.
40
+ const folha = criarFolha((t) => ({
41
+ camada: { flex: 1 },
42
+ fundoDeToque: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0 },
43
+ painel: {
44
+ position: "absolute",
45
+ backgroundColor: t.color.popover,
46
+ borderWidth: t.size.borderWidth, borderColor: t.color.border,
47
+ ...canto(t.size.radiusCard),
48
+ boxShadow: [...t.shadowLayers.shadowLg],
49
+ },
50
+ // O `Popover` da referência: 12 em cima e embaixo, 16 dos lados.
51
+ painelPopover: { paddingVertical: t.size.space3, paddingHorizontal: t.size.space4 },
52
+ // O `Menu` da referência: 12 em cima e embaixo; dos lados ela usa 6, que a escala não tem — vale
53
+ // o `space1` (4), o recheio do `.menu` da web. O texto do item continua a 16 da borda (4 + 12).
54
+ painelMenu: { paddingVertical: t.size.space3, paddingHorizontal: t.size.space1 },
55
+ // O título do `Popover`: 18 em peso médio (no componente), e 4 até o corpo — o `gap-1` do exemplo
56
+ // da referência.
57
+ titulo: { marginBottom: t.size.space1 },
58
+ // A lista rola quando passa da janela; sem crescer, para o painel ter o tamanho do conteúdo.
59
+ rolagem: { flexGrow: 0, flexShrink: 1 },
60
+ // O item: 8 em cima e embaixo, como o dela. Dos lados ela usa 10 e entre as peças 10, que a escala
61
+ // não tem: aqui 12 (o texto fica a 16 da borda, o mesmo número dela) e 8. ⚠ A altura dela é 40
62
+ // (8 + a linha de 24 + 8); aqui o mínimo é o `targetMin` (44), e não `hitSlop` — o `actions.tsx`
63
+ // mediu que o TalkBack não enxerga o `hitSlop`.
64
+ item: {
65
+ minHeight: t.size.targetMin, flexDirection: "row", alignItems: "center",
66
+ gap: t.size.space2, paddingVertical: t.size.space2, paddingHorizontal: t.size.space3,
67
+ ...canto(t.size.radiusLg),
68
+ },
69
+ // O toque: o fundo de destaque do `.menu-item` da web (`surface-3`) e o 0,98 da referência; no item
70
+ // que destrói, o fundo suave do perigo, como o dela.
71
+ itemTocando: { backgroundColor: t.color.surface3, transform: [{ scale: 0.98 }] },
72
+ itemTocandoPerigo: { backgroundColor: t.color.dangerBg, transform: [{ scale: 0.98 }] },
73
+ desabilitado: { opacity: t.size.opacityDisabled },
74
+ // Cresce, mas sem `flex: 1`: com base zero, num painel que tem a largura do conteúdo, o rótulo
75
+ // não empurraria a largura e quebraria letra a letra.
76
+ rotulo: { flexGrow: 1, flexShrink: 1, minWidth: 0 },
77
+ // A casa da marca: a caixa de 20 do indicador da referência, com o `check` de 16 — a mesma do
78
+ // `Select`. Largura FIXA, como o `.menu-mark` da web: o rótulo não anda quando a marca aparece.
79
+ indicador: { width: t.size.iconMd, height: t.size.iconMd, alignItems: "center", justifyContent: "center" },
80
+ // O ponto do rádio: 8, o da referência e o `.menu-ponto` da web. Forma, não ícone.
81
+ ponto: {
82
+ width: t.size.space2, height: t.size.space2, ...canto(t.size.radiusFull),
83
+ backgroundColor: t.color.primaryEmphasis,
84
+ },
85
+ // O rótulo de seção: 14 em peso médio e esmaecido (no componente), alinhado ao texto dos itens.
86
+ rotuloDoGrupo: { paddingHorizontal: t.size.space3, paddingTop: t.size.space2, paddingBottom: t.size.space1 },
87
+ separador: {
88
+ height: t.size.borderWidth, marginVertical: t.size.space2, marginHorizontal: t.size.space2,
89
+ backgroundColor: t.color.border,
90
+ },
91
+ // O submenu aberto é uma superfície acima do menu, como o da referência: o fundo, a linha e a
92
+ // sombra numa camada própria ATRÁS das linhas. Ela nasce já pintada (montada só com o submenu
93
+ // aberto) — caixa que ganha cor depois de montada perde o raio no Android (react-native#52415).
94
+ fundoDoSubmenu: {
95
+ position: "absolute", top: 0, right: 0, bottom: 0, left: 0,
96
+ backgroundColor: t.color.popover,
97
+ borderWidth: t.size.borderWidth, borderColor: t.color.border,
98
+ ...canto(t.size.radiusLg),
99
+ boxShadow: [...t.shadowLayers.shadowMd],
100
+ },
101
+ }));
102
+ // A seta do `Popover`: a da referência tem 20 de largura e 12 de altura (`space5` × `space3`).
103
+ const larguraDaSeta = (t) => t.size.space5;
104
+ const alturaDaSeta = (t) => t.size.space3;
105
+ const preso = (v, min, max) => Math.max(min, Math.min(v, max));
106
+ /**
107
+ * Onde o painel fica. Centrado no gatilho, como o da referência (o alinhamento padrão dela e o da
108
+ * web), e preso dentro da janela com o recuo.
109
+ *
110
+ * ⚠ **Ele VIRA de lado quando não cabe e o outro lado tem mais espaço** — o que o posicionador da
111
+ * web faz. A referência não vira: só empurra o painel para dentro da tela, e um menu pedido para
112
+ * baixo num gatilho no pé da tela acaba EM CIMA do gatilho. Depois de virar, ele ainda é preso à
113
+ * janela, como o dela.
114
+ */
115
+ function posicionar(a, c, W, H, r, d, pedido) {
116
+ const vertical = pedido === "top" || pedido === "bottom";
117
+ let lado = pedido;
118
+ if (vertical) {
119
+ const baixo = H - r.bottom - (a.y + a.height + d);
120
+ const cima = a.y - d - r.top;
121
+ if (pedido === "bottom" && c.height > baixo && cima > baixo)
122
+ lado = "top";
123
+ if (pedido === "top" && c.height > cima && baixo > cima)
124
+ lado = "bottom";
125
+ }
126
+ else {
127
+ const direita = W - r.right - (a.x + a.width + d);
128
+ const esquerda = a.x - d - r.left;
129
+ if (pedido === "right" && c.width > direita && esquerda > direita)
130
+ lado = "left";
131
+ if (pedido === "left" && c.width > esquerda && direita > esquerda)
132
+ lado = "right";
133
+ }
134
+ const left = vertical ? a.x + a.width / 2 - c.width / 2
135
+ : lado === "right" ? a.x + a.width + d : a.x - d - c.width;
136
+ const top = !vertical ? a.y + a.height / 2 - c.height / 2
137
+ : lado === "bottom" ? a.y + a.height + d : a.y - d - c.height;
138
+ return {
139
+ lado,
140
+ left: preso(left, r.left, W - r.right - c.width),
141
+ top: preso(top, r.top, H - r.bottom - c.height),
142
+ };
143
+ }
144
+ /**
145
+ * O gatilho da web é um ELEMENTO (`render={trigger}` no Base UI); aqui ele é clonado com o toque que
146
+ * abre, a REF que mede e o estado `expanded` — o toque do app continua valendo, depois do nosso.
147
+ *
148
+ * ⚠ **A medida vem da ref**, e o `Button` e o `IconButton` da Aurea a levam até o `Pressable` (no
149
+ * React 19 a `ref` é prop). Um gatilho que a engula ainda abre: cai no `currentTarget` do toque e,
150
+ * sem ele, no PONTO do dedo — o painel abre onde a pessoa tocou, em vez de não abrir.
151
+ */
152
+ function useCamada(trigger, aoFecharTambem) {
153
+ const [aberto, setAberto] = React.useState(false);
154
+ const [ancora, setAncora] = React.useState(null);
155
+ const [tamanho, setTamanho] = React.useState(null);
156
+ const no = React.useRef(null);
157
+ const { width: W, height: H } = useWindowDimensions();
158
+ const props = trigger.props;
159
+ const refOriginal = props.ref;
160
+ const medir = React.useCallback((e) => {
161
+ const alvo = no.current ?? e?.currentTarget;
162
+ if (alvo && typeof alvo.measureInWindow === "function") {
163
+ alvo.measureInWindow((x, y, width, height) => {
164
+ if ([x, y, width, height].every(Number.isFinite))
165
+ setAncora({ x, y, width, height });
166
+ });
167
+ return;
168
+ }
169
+ const toque = e?.nativeEvent;
170
+ if (toque)
171
+ setAncora({ x: toque.pageX, y: toque.pageY, width: 0, height: 0 });
172
+ }, []);
173
+ // A janela girou (ou mudou de tamanho) com a camada aberta: o gatilho andou, mede de novo.
174
+ // Só a janela entra na lista: abrir já mede no toque, e medir de novo ao abrir seria dobrado.
175
+ const abertoAgora = React.useRef(aberto);
176
+ abertoAgora.current = aberto;
177
+ React.useEffect(() => { if (abertoAgora.current)
178
+ medir(); }, [W, H, medir]);
179
+ const fechar = React.useCallback(() => {
180
+ setAberto(false);
181
+ setAncora(null);
182
+ setTamanho(null);
183
+ aoFecharTambem?.();
184
+ }, [aoFecharTambem]);
185
+ const aoMedirPainel = React.useCallback((e) => {
186
+ const { width, height } = e.nativeEvent.layout;
187
+ // Mudar `left`/`top` dispara outro `onLayout` com o MESMO tamanho; sem a comparação, laço.
188
+ setTamanho((antes) => antes && antes.width === width && antes.height === height ? antes : { width, height });
189
+ }, []);
190
+ const ref = React.useCallback((n) => {
191
+ no.current = n ?? null;
192
+ if (typeof refOriginal === "function")
193
+ refOriginal(n);
194
+ else if (refOriginal && typeof refOriginal === "object")
195
+ refOriginal.current = n;
196
+ }, [refOriginal]);
197
+ const doApp = props.onPress;
198
+ const tocar = React.useCallback((e) => {
199
+ medir(e);
200
+ setAberto(true);
201
+ doApp?.(e);
202
+ }, [medir, doApp]);
203
+ // O estado do gatilho é ESCRITO por cima do que o botão calculou (o `...rest` dele vem depois),
204
+ // então o `disabled` e o `pressed` dele entram de novo aqui, para não se perderem.
205
+ const estado = {
206
+ ...props.accessibilityState,
207
+ ...(props.disabled != null ? { disabled: !!props.disabled } : null),
208
+ ...(props.pressed !== undefined ? { checked: props.pressed } : null),
209
+ expanded: aberto,
210
+ };
211
+ const gatilho = React.cloneElement(trigger, {
212
+ ref, onPress: tocar, accessibilityState: estado, "aria-expanded": aberto,
213
+ });
214
+ return { aberto, ancora, tamanho, fechar, aoMedirPainel, gatilho };
215
+ }
216
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
217
+ // A camada: o `Modal`, o fundo tocável, o painel e a seta
218
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
219
+ const SEM_RECUO = { top: 0, right: 0, bottom: 0, left: 0 };
220
+ /**
221
+ * A entrada é a da referência: o painel nasce invisível, é MEDIDO, vai para o lugar e só então
222
+ * aparece — opacidade de 0 a 1, escala de 0,97 a 1 e um passo de 8 vindo do gatilho, no `durationBase`
223
+ * (a dela é 200; a escala de duração da Aurea tem 180). A saída é imediata: o `Modal` some na hora.
224
+ */
225
+ function CamadaPresa({ c, side, seta = false, menu = false, label, testID, children }) {
226
+ // A camada sai do fluxo: o FUNDO é pintado com os tokens do APP (ADR-0063 — ver `useTokensDoApp`).
227
+ // O texto e o ícone de dentro leem o tema do app sozinhos, porque estão dentro do `ForaDaMarca`.
228
+ const tf = useTokensDoApp();
229
+ const s = folha(tf);
230
+ const { width: W, height: H } = useWindowDimensions();
231
+ const reduzir = useReduceMotion();
232
+ // O recuo da janela é o da referência (12) somado à área do sistema — do CONTEXTO, que atravessa o
233
+ // `Modal`; sem `SafeAreaProvider`, a medida da abertura (o mesmo caminho do `RecuoDaFolha`).
234
+ const sa = React.useContext(SafeAreaInsetsContext) ?? initialWindowMetrics?.insets ?? SEM_RECUO;
235
+ const recuo = {
236
+ top: tf.size.space3 + sa.top, right: tf.size.space3 + sa.right,
237
+ bottom: tf.size.space3 + sa.bottom, left: tf.size.space3 + sa.left,
238
+ };
239
+ // A distância do gatilho: a referência usa 9, que a escala não tem — vale o `space2` (8), o mesmo
240
+ // do `Popover` da web. Com a seta, a altura dela: a ponta encosta no gatilho, sem passar por cima.
241
+ const distancia = seta ? alturaDaSeta(tf) : tf.size.space2;
242
+ const pronto = c.aberto && c.ancora != null && c.tamanho != null;
243
+ const pos = pronto ? posicionar(c.ancora, c.tamanho, W, H, recuo, distancia, side) : null;
244
+ const progresso = React.useRef(new Animated.Value(0)).current;
245
+ React.useEffect(() => {
246
+ if (!pronto) {
247
+ progresso.setValue(0);
248
+ return;
249
+ }
250
+ // `!== false`, e não `=== true`: enquanto o sistema não respondeu, não anima (ver `movimento.ts`).
251
+ if (reduzir !== false) {
252
+ progresso.setValue(1);
253
+ return;
254
+ }
255
+ const laco = Animated.timing(progresso, {
256
+ toValue: 1, duration: tf.size.durationBase, useNativeDriver: driverNativo(),
257
+ easing: Easing.bezier(...tf.easing.easeStandard),
258
+ });
259
+ laco.start();
260
+ return () => laco.stop();
261
+ }, [pronto, reduzir, progresso, tf.size.durationBase, tf.easing.easeStandard]);
262
+ // O passo vem do lado do gatilho: abaixo dele, o painel desce; acima, sobe. O da referência é a
263
+ // distância, no máximo 12.
264
+ const passo = Math.min(distancia, alturaDaSeta(tf));
265
+ const de = pos && (pos.lado === "bottom" || pos.lado === "right") ? -passo : passo;
266
+ const desloca = progresso.interpolate({ inputRange: [0, 1], outputRange: [de, 0] });
267
+ const entrada = pos
268
+ ? {
269
+ left: pos.left, top: pos.top, opacity: progresso,
270
+ transform: [
271
+ pos.lado === "top" || pos.lado === "bottom" ? { translateY: desloca } : { translateX: desloca },
272
+ { scale: progresso.interpolate({ inputRange: [0, 1], outputRange: [0.97, 1] }) },
273
+ ],
274
+ }
275
+ // Antes de medido, invisível: é a medida dele que diz onde ele vai.
276
+ : { opacity: 0 };
277
+ return (_jsx(ForaDaMarca, { children: _jsx(Modal, { visible: c.aberto, transparent: true, animationType: "none", statusBarTranslucent: true, navigationBarTranslucent: true, onRequestClose: c.fechar, testID: testID, children: c.aberto ? (_jsxs(View, { style: s.camada, children: [_jsx(Pressable, { style: s.fundoDeToque, onPress: c.fechar, accessible: false, testID: testID ? `${testID}-fundo` : undefined }), _jsxs(Animated.View, { testID: testID ? `${testID}-painel` : undefined, onLayout: c.aoMedirPainel, onAccessibilityEscape: c.fechar, pointerEvents: pos ? "auto" : "none", accessibilityRole: menu ? "menu" : undefined, accessibilityLabel: label, style: [
278
+ s.painel, menu ? s.painelMenu : s.painelPopover,
279
+ { left: recuo.left, top: recuo.top,
280
+ maxWidth: W - recuo.left - recuo.right, maxHeight: H - recuo.top - recuo.bottom },
281
+ entrada,
282
+ ], children: [seta && pos && c.ancora && c.tamanho
283
+ ? _jsx(Seta, { tf: tf, lado: pos.lado, ancora: c.ancora, painel: { ...c.tamanho, x: pos.left, y: pos.top } })
284
+ : null, children] })] })) : null }) }));
285
+ }
286
+ /**
287
+ * A seta: a medida e o lugar da referência (20 × 12, apontando para o MEIO do gatilho, a 12 das
288
+ * pontas do painel); o desenho é o da seta da web, uma curva só, e não um triângulo de bico.
289
+ * O fundo cobre a borda do painel embaixo dela, e o traço continua a borda.
290
+ */
291
+ function Seta({ tf, lado, ancora, painel }) {
292
+ const w = larguraDaSeta(tf);
293
+ const h = alturaDaSeta(tf);
294
+ const b = tf.size.borderWidth;
295
+ const meio = b / 2; // o bico recua meio traço, para o traço não ser cortado pela caixa
296
+ const vertical = lado === "top" || lado === "bottom";
297
+ // Ao longo da borda, contado de dentro dela (o absoluto do RN conta da caixa de recheio).
298
+ const ao = vertical
299
+ ? preso(ancora.x + ancora.width / 2 - painel.x - b - w / 2, tf.size.space3, painel.width - 2 * b - w - tf.size.space3)
300
+ : preso(ancora.y + ancora.height / 2 - painel.y - b - w / 2, tf.size.space3, painel.height - 2 * b - w - tf.size.space3);
301
+ // A curva: o ponto de controle a DUAS profundidades da base deixa o bico exatamente na ponta.
302
+ const linha = lado === "bottom" ? `M0 ${h} Q${w / 2} ${2 * meio - h} ${w} ${h}` // painel embaixo: aponta para cima
303
+ : lado === "top" ? `M0 0 Q${w / 2} ${2 * (h - meio)} ${w} 0` // painel em cima: para baixo
304
+ : lado === "right" ? `M${h} 0 Q${2 * meio - h} ${w / 2} ${h} ${w}` // painel à direita: para a esquerda
305
+ : `M0 0 Q${2 * (h - meio)} ${w / 2} 0 ${w}`; // painel à esquerda: para a direita
306
+ const lugar = lado === "bottom" ? { top: -h, left: ao }
307
+ : lado === "top" ? { bottom: -h, left: ao }
308
+ : lado === "right" ? { left: -h, top: ao }
309
+ : { right: -h, top: ao };
310
+ return (_jsx(View, { pointerEvents: "none", accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", style: [{ position: "absolute" }, lugar], children: _jsxs(Svg, { width: vertical ? w : h, height: vertical ? h : w, children: [_jsx(Path, { d: `${linha} Z`, fill: tf.color.popover }), _jsx(Path, { d: linha, fill: "none", stroke: tf.color.border, strokeWidth: b, strokeLinecap: "round", strokeLinejoin: "round" })] }) }));
311
+ }
312
+ /**
313
+ * A janela pequena presa ao gatilho: abre no toque, fecha no toque fora e no VOLTAR.
314
+ *
315
+ * ⚠ **Sem papel de diálogo**, pela razão medida no `overlays.tsx`: `role="dialog"` não mapeia em
316
+ * plataforma nenhuma. O confinamento do leitor de tela vem do `Modal`, que é uma janela do sistema;
317
+ * o nome vem do título, que é um cabeçalho. No iOS, o gesto de saída do VoiceOver fecha.
318
+ */
319
+ export function Popover({ trigger, title, children, side = "bottom", arrow = false, testID }) {
320
+ const c = useCamada(trigger);
321
+ return (_jsxs(_Fragment, { children: [c.gatilho, _jsxs(CamadaPresa, { c: c, side: side, seta: arrow, testID: testID, children: [_jsx(TituloDoPopover, { title: title }), typeof children === "string" ? _jsx(Text, { size: "md", tone: "muted", children: children }) : children] })] }));
322
+ }
323
+ function TituloDoPopover({ title }) {
324
+ const s = folha(useTokensDoApp());
325
+ if (title == null || title === false)
326
+ return null;
327
+ return _jsx(Text, { size: "lg", weight: 500, accessibilityRole: "header", style: s.titulo, children: title });
328
+ }
329
+ // Com um submenu aberto, o resto do menu esmaece e não responde, como o da referência: o que está
330
+ // fora do caminho aberto. A chave de cada entrada é o caminho dela (`3/1/0`).
331
+ const apagado = (k, aberto) => aberto != null && k !== aberto && !k.startsWith(`${aberto}/`) && !aberto.startsWith(`${k}/`);
332
+ /**
333
+ * O menu de comandos preso ao gatilho.
334
+ *
335
+ * O estado de quem alterna sem ser controlado (`defaultChecked`, `defaultValue`) mora AQUI, e não no
336
+ * painel: o painel desmonta ao fechar, e o item marcado tem de continuar marcado na próxima vez.
337
+ */
338
+ export function DropdownMenu({ trigger, items, side = "bottom", label, testID }) {
339
+ const tf = useTokensDoApp();
340
+ const s = folha(tf);
341
+ const [subAberto, setSubAberto] = React.useState(null);
342
+ const [marcado, setMarcado] = React.useState({});
343
+ // Fechar o menu fecha os submenus — ele reabre do começo.
344
+ const fecharSub = React.useCallback(() => setSubAberto(null), []);
345
+ const c = useCamada(trigger, fecharSub);
346
+ const marcar = React.useCallback((k, v) => setMarcado((m) => ({ ...m, [k]: v })), []);
347
+ const ctx = { tf, s, subAberto, setSubAberto, marcado, marcar, fechar: c.fechar };
348
+ return (_jsxs(_Fragment, { children: [c.gatilho, _jsx(CamadaPresa, { c: c, side: side, menu: true, label: label, testID: testID, children: _jsx(ScrollView, { style: s.rolagem, children: renderizar(items, "", null, ctx) }) })] }));
349
+ }
350
+ function renderizar(items, prefixo, paiSub, ctx) {
351
+ const { s, subAberto } = ctx;
352
+ return items.map((it, i) => {
353
+ const k = `${prefixo}${i}`;
354
+ if (it === "separator") {
355
+ return _jsx(View, { style: [s.separador, apagado(k, subAberto) && s.desabilitado], accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants" }, k);
356
+ }
357
+ switch (it.kind ?? "item") {
358
+ case "group": {
359
+ const g = it;
360
+ return (_jsxs(View, { children: [_jsx(RotuloDoGrupo, { ctx: ctx, k: k, children: g.label }), renderizar(g.items, `${k}/`, paiSub, ctx)] }, k));
361
+ }
362
+ case "radiogroup": {
363
+ const g = it;
364
+ const atual = g.value ?? ctx.marcado[k] ?? g.defaultValue;
365
+ return (_jsxs(View, { accessibilityRole: "radiogroup", accessibilityLabel: typeof g.label === "string" ? g.label : undefined, children: [_jsx(RotuloDoGrupo, { ctx: ctx, k: k, children: g.label }), g.items.map((r, j) => {
366
+ const kr = `${k}/${j}`;
367
+ const escolhido = r.value === atual;
368
+ return (_jsx(Linha, { ctx: ctx, papel: "radio", rotulo: r.label, icone: r.leadingIcon, estado: { checked: escolhido }, inativo: !!r.disabled || apagado(kr, subAberto), marca: escolhido ? _jsx(View, { style: s.ponto }) : null, aoTocar: () => {
369
+ if (!escolhido) {
370
+ if (g.value === undefined)
371
+ ctx.marcar(k, r.value);
372
+ g.onValueChange?.(r.value);
373
+ }
374
+ ctx.fechar();
375
+ } }, kr));
376
+ })] }, k));
377
+ }
378
+ case "checkbox": {
379
+ const cb = it;
380
+ const ligado = cb.checked ?? ctx.marcado[k] ?? cb.defaultChecked ?? false;
381
+ return (_jsx(Linha, { ctx: ctx, papel: "checkbox", rotulo: cb.label, icone: cb.leadingIcon, estado: { checked: ligado }, inativo: !!cb.disabled || apagado(k, subAberto), marca: ligado ? _jsx(Icon, { name: "check", size: "sm", color: ctx.tf.color.primaryEmphasis }) : null, aoTocar: () => {
382
+ // Alterna e FICA aberto — a escolha múltipla da referência e o item da web.
383
+ if (cb.checked === undefined)
384
+ ctx.marcar(k, !ligado);
385
+ cb.onCheckedChange?.(!ligado);
386
+ } }, k));
387
+ }
388
+ case "link": {
389
+ const l = it;
390
+ return (_jsx(Linha, { ctx: ctx, papel: "link", rotulo: l.label, icone: l.leadingIcon, inativo: apagado(k, subAberto), aoTocar: () => {
391
+ // Endereço que nenhum app abre rejeita a promessa; não há o que dizer, e não pode virar erro solto.
392
+ void Linking.openURL(l.href).catch(() => { });
393
+ if (l.closeOnClick ?? true)
394
+ ctx.fechar();
395
+ } }, k));
396
+ }
397
+ case "submenu": {
398
+ const sm = it;
399
+ const aberto = subAberto === k || (subAberto?.startsWith(`${k}/`) ?? false);
400
+ return (_jsx(LinhaDeSubmenu, { ctx: ctx, def: sm, aberto: aberto, inativo: !!sm.disabled || apagado(k, subAberto), alternar: () => ctx.setSubAberto(aberto ? paiSub : k), children: renderizar(sm.items, `${k}/`, k, ctx) }, k));
401
+ }
402
+ default: {
403
+ const a = it;
404
+ return (_jsx(Linha, { ctx: ctx, papel: "menuitem", rotulo: a.label, icone: a.leadingIcon, destrutivo: a.destructive, inativo: !!a.disabled || apagado(k, subAberto), aoTocar: () => { a.onClick?.(); ctx.fechar(); } }, k));
405
+ }
406
+ }
407
+ });
408
+ }
409
+ function RotuloDoGrupo({ ctx, k, children }) {
410
+ if (children == null || children === false)
411
+ return null;
412
+ return (_jsx(View, { style: [ctx.s.rotuloDoGrupo, apagado(k, ctx.subAberto) && ctx.s.desabilitado], children: typeof children === "string"
413
+ ? _jsx(Text, { size: "xs", weight: 500, tone: "muted", accessibilityRole: "header", children: children })
414
+ : children }));
415
+ }
416
+ function Linha({ ctx, papel, rotulo, icone, marca, estado, inativo = false, destrutivo = false, final, aoTocar }) {
417
+ const { tf, s } = ctx;
418
+ const cor = destrutivo ? letraDoToken(tf, "destructive") ?? tf.color.destructive : tf.color.foreground;
419
+ return (_jsxs(Pressable, { onPress: inativo ? undefined : aoTocar, disabled: inativo,
420
+ // Escrito por extenso, e não `{papel}`: o check 41 lê os papéis no fonte, e só confere o que vê.
421
+ accessibilityRole: papel === "checkbox" ? "checkbox" : papel === "radio" ? "radio"
422
+ : papel === "link" ? "link" : "menuitem", ...estadoAcessivel({ ...estado, disabled: inativo }), style: ({ pressed }) => [
423
+ s.item, pressed && (destrutivo ? s.itemTocandoPerigo : s.itemTocando), inativo && s.desabilitado,
424
+ ], children: [marca !== undefined ? _jsx(View, { style: s.indicador, children: marca }) : null, icone ? _jsx(Icon, { name: icone, size: "md", color: cor }) : null, typeof rotulo === "string" || typeof rotulo === "number"
425
+ // 16 em peso médio, o título do item da referência.
426
+ ? _jsx(Text, { size: "md", weight: 500, tone: destrutivo ? "danger" : "default", style: s.rotulo, children: rotulo })
427
+ : _jsx(View, { style: s.rotulo, children: rotulo }), final] }));
428
+ }
429
+ /** O gatilho do submenu: a seta de 16 gira 90° ao abrir, como a da referência. */
430
+ function LinhaDeSubmenu({ ctx, def, aberto, inativo, alternar, children }) {
431
+ const { tf, s } = ctx;
432
+ const reduzir = useReduceMotion();
433
+ const giro = React.useRef(new Animated.Value(aberto ? 1 : 0)).current;
434
+ const antes = React.useRef(aberto);
435
+ React.useEffect(() => {
436
+ const mudou = antes.current !== aberto;
437
+ antes.current = aberto;
438
+ // Só anima o que MUDOU: a resposta do sistema chegando não pode virar um giro de 0 para 0.
439
+ if (!mudou || reduzir !== false) {
440
+ giro.setValue(aberto ? 1 : 0);
441
+ return;
442
+ }
443
+ const laco = Animated.timing(giro, {
444
+ toValue: aberto ? 1 : 0, duration: tf.size.durationFast, useNativeDriver: driverNativo(),
445
+ easing: Easing.bezier(...tf.easing.easeStandard),
446
+ });
447
+ laco.start();
448
+ return () => laco.stop();
449
+ }, [aberto, reduzir, giro, tf.size.durationFast, tf.easing.easeStandard]);
450
+ const seta = (_jsx(Animated.View, { style: { transform: [{ rotate: giro.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "90deg"] }) }] }, children: _jsx(Icon, { name: "caret-right", size: "sm", color: tf.color.mutedForeground }) }));
451
+ return (_jsxs(View, { children: [aberto ? _jsx(View, { pointerEvents: "none", style: s.fundoDoSubmenu }) : null, _jsx(Linha, { ctx: ctx, papel: "menuitem", rotulo: def.label, icone: def.leadingIcon, estado: { expanded: aberto }, inativo: inativo, final: seta, aoTocar: alternar }), aberto ? children : null] }));
452
+ }
@@ -160,7 +160,7 @@ export interface AureaNavListItem {
160
160
  disabled?: boolean;
161
161
  /**
162
162
  * A foto da linha (AN-04, 03/10/2026) — o formato do `Avatar` daqui (`source`, `fallback`), no
163
- * tamanho dele de sempre, o mesmo da web. Toma o lugar do `icon`.
163
+ * tamanho padrão dele (`md`, 48 desde a 0.32.0). Toma o lugar do `icon`.
164
164
  */
165
165
  avatar?: {
166
166
  source?: ImageSourcePropType | string;
@@ -164,37 +164,52 @@ const folha = criarFolha((t) => ({
164
164
  rotuloEscondido: { position: "absolute", opacity: 0 },
165
165
  // ── NavList ────────────────────────────────────────────────────────────────────────────────
166
166
  lista: { gap: t.size.space05 },
167
+ // 0.32.0 (ADR-0066): a linha da lista do pacote NATIVO da referência — recheio de 16 em volta e vão
168
+ // de 12; título 16 em peso médio e descrição 14 (no componente). Até a 0.31.0 era a linha da web:
169
+ // altura mínima de controle (`controlHLg`), 8 em cima e embaixo, 12 dos lados. A altura mínima saiu:
170
+ // a altura vem do recheio e da linha do texto, como lá.
167
171
  linha: {
168
172
  flexDirection: "row", alignItems: "center", gap: t.size.space3, width: "100%",
169
- minHeight: t.size.controlHLg,
170
- paddingVertical: t.size.space2, paddingHorizontal: t.size.space3,
173
+ paddingHorizontal: t.size.space4,
171
174
  ...canto(t.size.radiusCard - t.size.space1),
172
175
  },
173
176
  // O MIOLO da linha: ícone + texto (+ valor em texto) + seta. Ele é quem toca; o que vem
174
177
  // depois dele, na moldura, é IRMÃO — ver o comentário no componente.
175
- // Nenhuma medida nova: `flex`, `minWidth` e o MESMO `gap` da moldura.
176
- linhaMiolo: { flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: t.size.space3 },
178
+ // ⚠ O recheio de CIMA E DE BAIXO mora aqui, e não na moldura: é o miolo que toca, e na referência
179
+ // a linha inteira é o alvo. Na moldura ele ficaria fora do toque, e o miolo de uma linha só
180
+ // mediria a linha do título (24), abaixo dos 44 do alvo mínimo; aqui ele mede 16 + 24 + 16 = 56.
181
+ // O valor que é componente (irmão) fica no meio da altura, como antes.
182
+ linhaMiolo: {
183
+ flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: t.size.space3,
184
+ paddingVertical: t.size.space4,
185
+ },
177
186
  linhaTexto: { flex: 1, minWidth: 0 },
178
187
  // AN-04: os sinais, com o vão de dentro do texto (`space1`), como o `.nav-list-indicators` da web.
179
188
  sinais: { flexDirection: "row", alignItems: "center", gap: t.size.space1 },
180
189
  // ── Tabs ───────────────────────────────────────────────────────────────────────────────────
181
- // A cápsula é a MESMA do `SegmentedControl`, e o CSS diz isso em voz alta no comentário de
182
- // cima do `.tabs` (`aurea.css:1114`): *"Mesma regra da .segmented"*. Os três números vêm de
183
- // lá — `gap:3px; padding:3px` — e não de mim.
190
+ // ~~A cápsula é a MESMA do `SegmentedControl` [...] `gap:3px; padding:3px`~~ — 0.32.0 (ADR-0066):
191
+ // a lista com cápsula do pacote NATIVO da referência. O trilho tem 3 de recheio lá; aqui é o
192
+ // `space1` (4), o token mais perto — a escala não tem 3, e escala nova não nasce no meio de um
193
+ // componente. É o mesmo 4 do `.tabs` da web desta versão. O vão entre as abas é 4, o dela.
194
+ // A altura sai da aba (6 + a linha de 24 + 6 = 36) mais o trilho: 44. A altura mínima de controle
195
+ // (`controlHMd`) saiu, como na referência.
184
196
  abas: {
185
- flexDirection: "row", gap: 3, padding: 3,
186
- minHeight: t.size.controlHMd, ...canto(t.size.radiusControl),
197
+ flexDirection: "row", gap: t.size.space1, padding: t.size.space1,
198
+ ...canto(t.size.radiusControl),
187
199
  backgroundColor: t.color.muted,
188
200
  },
189
- // `padding:0 14px` é literal no `.tab` (`aurea.css:1127`). A aba NÃO estica (nada de `flex`):
190
- // na web ela é conteúdo dentro de um `inline-flex`, e é isso que deixa a lista rolar quando
191
- // não cabe, em vez de espremer todas.
201
+ // A aba da referência nativa: 12 dos lados, 6 em cima e embaixo, 6 de vão entre o que vai dentro
202
+ // (o rótulo e um ícone, quando o app passa os dois), e o rótulo 16 em peso médio (no componente).
203
+ // Era o `padding:0 14px` do `.tab` da web antiga. A aba NÃO estica (nada de `flex`): é isso que
204
+ // deixa a lista rolar quando não cabe, em vez de espremer todas.
192
205
  // ⚠ O nome é `abaDeTab` porque `aba` JÁ EXISTE neste arquivo — é o item do `BottomNav`
193
206
  // (linha 78), que tem `flex: 1` e cresce. Reusar o nome teria sobrescrito um dos dois em
194
207
  // silêncio, e o `tsc` só pegou porque são chaves do mesmo objeto.
195
208
  abaDeTab: {
196
- alignItems: "center", justifyContent: "center",
197
- paddingHorizontal: 14, ...canto(t.size.radiusControl),
209
+ flexDirection: "row", alignItems: "center", justifyContent: "center",
210
+ gap: t.size.space1 + t.size.space05,
211
+ paddingHorizontal: t.size.space3, paddingVertical: t.size.space1 + t.size.space05,
212
+ ...canto(t.size.radiusControl),
198
213
  },
199
214
  // A aba escolhida: a mesma cápsula do `segmentoAtivo` (`inputs.tsx`), pelo mesmo motivo — ADR-0059.
200
215
  abaDeTabAtiva: { backgroundColor: t.color.segment, boxShadow: [...t.shadowLayers.shadowSm] },
@@ -204,8 +219,10 @@ const folha = criarFolha((t) => ({
204
219
  // fila gap de 1 unidade → space1
205
220
  // aba padding-inline de 3 unidades → space3 · padding-block de 1,5
206
221
  // → space1 + space05. Rótulo `text-base` + entrelinha 1.5 dá
207
- // 36, que é o `controlHMd`: a altura da fila da cápsula, então
208
- // trocar de variante não muda a altura da fila.
222
+ // 36, que é o `controlHMd`. ~~a altura da fila da cápsula, então
223
+ // trocar de variante não muda a altura da fila~~ — 0.32.0: a
224
+ // aba da cápsula também tem 36, mas a fila dela soma o trilho
225
+ // de 4 de cada lado (44).
209
226
  // indicador (secundária) border-bottom 2px na cor de destaque → `fioDoEscolhido`
210
227
  abasSecundarias: {
211
228
  flexDirection: "row", gap: t.size.space1,
@@ -489,7 +506,7 @@ export function NavList({ items, chevron = "caret-right", style, ...rest }) {
489
506
  const miolo = (_jsxs(_Fragment, { children: [it.avatar
490
507
  ? _jsx(Avatar, { source: it.avatar.source, fallback: it.avatar.fallback })
491
508
  : it.icon && _jsx(Icon, { name: it.icon, size: "md", color: t.color.foreground }), _jsxs(View, { style: s.linhaTexto, children: [typeof it.label === "string"
492
- ? _jsx(Text, { size: "sm", numberOfLines: 1, children: it.label }) : it.label, it.description != null && (typeof it.description === "string"
509
+ ? _jsx(Text, { size: "sm", weight: 500, numberOfLines: 1, children: it.label }) : it.label, it.description != null && (typeof it.description === "string"
493
510
  ? _jsx(Text, { size: "xs", tone: "muted", numberOfLines: 1, children: it.description })
494
511
  : it.description)] }), it.indicators && it.indicators.length > 0 && (_jsx(View, { style: s.sinais, children: it.indicators.map((x) => (_jsx(View, { accessibilityLabel: x.label, children: _jsx(Icon, { name: x.icon, size: "sm", color: t.color.mutedForeground }) }, x.label))) })), valorEhTexto && _jsx(Text, { size: "sm", tone: "muted", children: it.value }), temAcao && chevron
495
512
  && _jsx(Icon, { name: chevron, size: "sm", color: t.color.mutedForeground })] }));
@@ -574,7 +591,7 @@ export function Tabs({ tabs, value, onChange, label, justify, variant = "primary
574
591
  ativa && !secundaria && s.abaDeTabAtiva,
575
592
  it.disabled && { opacity: t.size.opacityDisabled },
576
593
  ], children: [typeof it.label === "string"
577
- ? _jsx(Text, { size: "sm", weight: ativa ? 600 : 400, style: { color: ativa ? t.color.foreground : t.color.mutedForeground }, children: it.label })
594
+ ? _jsx(Text, { size: "sm", weight: secundaria ? (ativa ? 600 : 400) : 500, style: { color: ativa ? t.color.foreground : t.color.mutedForeground }, children: it.label })
578
595
  : it.label, ativa && secundaria && _jsx(View, { style: s.fioDaAba })] }, it.id));
579
596
  }) }) }), aberta && panel === "plain" ? (_jsx(View, { accessibilityLabel: typeof aberta.label === "string" ? aberta.label : undefined, style: s.painel, children: aberta.content })) : aberta ? (_jsx(Card, { variant: "inset", accessibilityLabel: typeof aberta.label === "string" ? aberta.label : undefined, style: s.painel, children: aberta.content })) : null] }));
580
597
  }
package/dist/numero.d.ts CHANGED
@@ -9,8 +9,8 @@ import { type AureaFieldSize } from "./inputs.js";
9
9
  * peça nossa —
10
10
  * e as medidas saem de escalas que JÁ existem, nenhuma nova:
11
11
  * letra `text3xl` (30) em seminegrito — o título 2 (`h2` do B-02). Escolha do Victor.
12
- * altura `controlHXl` — o degrau seguinte da escala de altura dos controles, que muda com a
13
- * densidade como as outras três.
12
+ * altura o degrau acima do `lg` na escala de toque (`touchControlHLg + space2`, 64 no
13
+ * confortável; até a 0.31.0 era o `controlHXl`, 50), como o `Button xl`.
14
14
  * largura `space24` (96): o `space16` (64) do campo comum não cabe "1.234" em 30.
15
15
  * botões os do `lg`, o maior do `IconButton`.
16
16
  */
package/dist/numero.js CHANGED
@@ -51,8 +51,12 @@ import { IconButton } from "./actions.js";
51
51
  import { canto, criarFolha, estadoAcessivel } from "./estilos.js";
52
52
  import { useCampo } from "./inputs.js";
53
53
  import { useAureaStrings, useAureaTokens, usePeleSobreAMarca } from "./theme.js";
54
- const alturaDoTamanho = (t, s) => s === "sm" ? t.size.controlHSm : s === "lg" ? t.size.controlHLg
55
- : s === "display" ? t.size.controlHXl : t.size.controlHMd;
54
+ // 0.32.0: o campo comum na altura de TOQUE, como o `Input` (40 · 48 · 56; era 32 · 36 · 40). O
55
+ // `display` (peça nossa, sem medida na referência) sobe para o degrau acima do `lg` que o `Button xl`
56
+ // também usa: `touchControlHLg + space2` = 64 (era o `controlHXl`, 50, que com a escala nova ficaria
57
+ // MAIS BAIXO que o `lg`).
58
+ const alturaDoTamanho = (t, s) => s === "sm" ? t.size.touchControlHSm : s === "lg" ? t.size.touchControlHLg
59
+ : s === "display" ? t.size.touchControlHLg + t.size.space2 : t.size.touchControlHMd;
56
60
  const fonteDoTamanho = (t, s) => s === "sm" ? t.size.textSm : s === "lg" ? t.size.textLg
57
61
  : s === "display" ? t.size.text3xl : t.size.textBase; // ADR-0050: um degrau acima da web
58
62
  const folha = criarFolha((t) => ({