@aurea-uds/native 0.7.0 → 0.8.1

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/midia.js ADDED
@@ -0,0 +1,181 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Aurea nativo — a MÍDIA: `Image` e `Gallery`.
3
+ //
4
+ // Lote 7 do `NATIVE.md` §8, terceira das três lacunas medidas pelo consumidor — e a que ele
5
+ // classificou como acabamento. A frase dele é exata: *"O `PhotoInput` entra foto. Nada mostra
6
+ // foto."* Conferido: o `sistema.tsx:184-225` devolve `AureaPhoto {uri, width, height}` e o único
7
+ // componente do pacote que desenha um bitmap é o `Avatar` (`display.tsx:279-297`), que é outro
8
+ // papel — um retrato redondo do tamanho de um controle, com iniciais por trás.
9
+ //
10
+ // ── A RESPOSTA "USA O `Image` DO RN E NÃO É LACUNA" FOI CONSIDERADA, E RECUSADA ──────────────
11
+ // Era uma das três saídas possíveis, e é tentadora porque o RN já tem um `Image`. Ela cai por
12
+ // medição, não por gosto: a ficha da web (`packages/contracts/registry/Image.json`) diz que o
13
+ // componente é *"uma imagem que RESERVA A CAIXA antes dos bytes chegarem, e CAI PARA UM
14
+ // SUBSTITUTO quando eles nunca chegam"*. O `Image` cru do RN não faz nenhuma das duas.
15
+ //
16
+ // E o custo de não fazê-las já está pago e registrado nesta casa: o `Avatar` foi medido em
17
+ // 31/07/2026 com `src` quebrado e **não caía no substituto**. O conserto virou o `useEffect` por
18
+ // `source` do `display.tsx:283-284`. Mandar cada tela do app reescrever isso é devolver ao
19
+ // consumidor o problema que o design system existe para resolver — e é a terceira vez que esta
20
+ // casa corrigiria a mesma coisa em três lugares (a regra do `CLAUDE.md` sobre correção local).
21
+ //
22
+ // ── O QUE FOI MEDIDO NO CSS, COM A LINHA ─────────────────────────────────────────────────────
23
+ // .image aurea.css:1434 bloco, 100% de largura, `object-fit:cover`, raio LG, fundo surface-3
24
+ // .image-contain :1435 `object-fit:contain`, fundo transparente
25
+ // .image-broken :1436 grade centrada, cor mutedForeground
26
+ // .gallery :1448 grade auto-fill de `--gallery-min` (8rem), gap space-3
27
+ // .gallery-tile :1450 grade com gap space-1, padding space-1, raio LG
28
+ // .gallery-tile:not(.is-selected) :1458 fundo transparente, cor mutedForeground
29
+ // .gallery-caption :1459 textSm
30
+ //
31
+ // ── TRÊS COISAS DA WEB NÃO ATRAVESSAM, E CADA UMA POR UM MOTIVO DIFERENTE ────────────────────
32
+ // `loading="lazy"` não existe no RN — a decodificação preguiçosa é do motor de lista, não da
33
+ // imagem. Quem quer isso usa `FlatList`, que é o que a `Gallery` faz;
34
+ // `ratio="16/9"` a web aceita a STRING do CSS; o `aspectRatio` do RN é **número**. Aqui as
35
+ // duas formas entram, e a string é convertida — quem porta uma tela da web
36
+ // não deveria descobrir isso por um layout de altura zero;
37
+ // `render` a web troca o elemento pelo `useRender` do Base UI. Aqui é `cloneElement`,
38
+ // e serve ao mesmo caso real: o consumidor que já usa `expo-image`.
39
+ import * as React from "react";
40
+ import { Image as ImageRN, Pressable, View, } from "react-native";
41
+ import { criarFolha } from "./estilos.js";
42
+ import { Icon } from "./icon.js";
43
+ import { Grid } from "./layout.js";
44
+ import { Dialog } from "./overlays.js";
45
+ import { Text } from "./text.js";
46
+ import { useAureaStrings, useAureaTokens } from "./theme.js";
47
+ const folha = criarFolha((t) => ({
48
+ // O fundo NÃO é enfeite: ele é o marcador de carregamento inteiro. A web decidiu isso em
49
+ // prosa (`media-client.tsx`, "O MARCADOR DE CARREGAMENTO NÃO TEM ESTADO") e a razão vale aqui
50
+ // com mais força — um `useState` por imagem numa galeria de trinta fotos é trinta renders a
51
+ // mais numa lista que já rola.
52
+ imagem: {
53
+ width: "100%",
54
+ borderRadius: t.size.radiusLg,
55
+ backgroundColor: t.color.surface3 ?? t.color.muted,
56
+ },
57
+ contain: { backgroundColor: "transparent" },
58
+ quebrada: { alignItems: "center", justifyContent: "center" },
59
+ ladrilho: { gap: t.size.space1, padding: t.size.space1, borderRadius: t.size.radiusLg },
60
+ ladrilhoEscolhido: { backgroundColor: t.color.secondary },
61
+ }));
62
+ /** `"16/9"`, `"4:3"` ou o número que o RN quer. Devolve `undefined` quando não dá para ler. */
63
+ function razao(v) {
64
+ if (v == null)
65
+ return undefined;
66
+ if (typeof v === "number")
67
+ return Number.isFinite(v) && v > 0 ? v : undefined;
68
+ // A web escreve `16/9`; alguém vai escrever `16:9`. As duas entram.
69
+ const partes = v.split(/[/:]/);
70
+ if (partes.length === 2) {
71
+ const a = Number(partes[0]), b = Number(partes[1]);
72
+ if (Number.isFinite(a) && Number.isFinite(b) && b !== 0)
73
+ return a / b;
74
+ }
75
+ const n = Number(v);
76
+ return Number.isFinite(n) && n > 0 ? n : undefined;
77
+ }
78
+ // Interno de propósito: não sai pelo barril. O consumidor escreve `string` ou `require()` e o
79
+ // componente converte — expor o conversor seria superfície pública sem caso de uso.
80
+ const fonteDaImagem = (s) => typeof s === "string" ? { uri: s } : s;
81
+ /**
82
+ * A foto, com a caixa reservada e um substituto quando ela não vem.
83
+ *
84
+ * ```tsx
85
+ * <Image source={foto.uri} alt="Frente do item" ratio="4/3" />
86
+ * ```
87
+ *
88
+ * ⚠ **A caixa nasce reservada e pintada** com a mesma superfície do `Skeleton` — sem `ratio`, o
89
+ * layout salta quando o bitmap chega, e num telefone esse salto acontece com o dedo já a caminho
90
+ * do botão.
91
+ *
92
+ * ⚠ **O substituto continua sendo a imagem para quem usa leitor de tela**, com o mesmo `alt`.
93
+ * Trocar o bitmap por uma caixa muda o que se vê, não o que a foto É — e o `role="image"` mapeia
94
+ * nos dois sistemas (`ReactAccessibilityDelegate.kt:461`, `RCTConversions.h:96`).
95
+ */
96
+ export function Image({ source, alt, ratio, fit = "cover", fallback, fallbackIcon = "image", render, onError, style, testID, }) {
97
+ const t = useAureaTokens();
98
+ const s = folha(t);
99
+ const [quebrou, setQuebrou] = React.useState(false);
100
+ // A `source` nova merece uma tentativa nova — é a mesma linha do `Avatar` (`display.tsx:283`),
101
+ // e sem ela uma URL quebrada deixa um buraco permanente mesmo depois de o app trocar a foto.
102
+ React.useEffect(() => { setQuebrou(false); }, [source]);
103
+ const proporcao = razao(ratio);
104
+ const caixa = [
105
+ s.imagem,
106
+ fit === "contain" && s.contain,
107
+ proporcao != null && { aspectRatio: proporcao },
108
+ style,
109
+ ];
110
+ if (quebrou) {
111
+ return (_jsx(View, { testID: testID, style: [caixa, s.quebrada], accessible: true, accessibilityRole: "image", accessibilityLabel: alt || undefined, children: fallback ?? (fallbackIcon
112
+ ? _jsx(Icon, { name: fallbackIcon, size: "lg", color: t.color.mutedForeground })
113
+ : null) }));
114
+ }
115
+ const comuns = {
116
+ source: fonteDaImagem(source),
117
+ style: caixa,
118
+ onError: () => { setQuebrou(true); onError?.(); },
119
+ // `alt=""` é decorativo explícito: some da árvore em vez de entrar com nome vazio.
120
+ accessible: alt !== "",
121
+ accessibilityRole: alt !== "" ? "image" : undefined,
122
+ accessibilityLabel: alt !== "" ? alt : undefined,
123
+ testID,
124
+ };
125
+ // `cloneElement` e não `useRender`: o idioma do Base UI não existe aqui, e o que o caso real
126
+ // precisa é de um elemento pronto recebendo as nossas props. As props do consumidor vêm
127
+ // primeiro no objeto do elemento e as nossas depois — `source` e `onError` são o contrato
128
+ // deste componente, e deixá-las sobrescrevíveis seria prometer o substituto e não entregá-lo.
129
+ if (render)
130
+ return React.cloneElement(render, comuns);
131
+ // ⚠ O molde é `ImageStyle` e não `ViewStyle`, e a conversão é CONSCIENTE: os dois tipos só
132
+ // divergem em duas coisas — o `overflow` do `ViewStyle` aceita `"scroll"`, que o `ImageStyle`
133
+ // não tem, e o `ImageStyle` soma `resizeMode`/`tintColor`/`overlayColor`. **Nada aqui escreve
134
+ // nenhuma das duas**: a folha põe largura, raio, fundo e proporção, e o `resizeMode` vai como
135
+ // prop, ao lado. A API pública fica em `ViewStyle` de propósito — quem chama pensa em caixa, e
136
+ // fazer o consumidor importar `ImageStyle` para passar um raio seria vazar o primitivo.
137
+ return _jsx(ImageRN, { ...comuns, style: caixa, resizeMode: fit });
138
+ }
139
+ /**
140
+ * A grade de fotos, e a foto grande quando se toca.
141
+ *
142
+ * ```tsx
143
+ * <Gallery items={fotos} zoom selected={atual} onSelect={setAtual} />
144
+ * ```
145
+ *
146
+ * ⚠ **Ampliar é DIÁLOGO, e diálogo já existe.** É a trava que a web escreveu no item L2 e ela
147
+ * atravessa inteira: a foto grande abre no `Dialog` do Lote 5, com o confinamento de foco que o
148
+ * `Modal` do RN dá pelo sistema e a saída pelo botão VOLTAR do Android. Uma segunda superfície
149
+ * flutuante aqui seria uma segunda linguagem.
150
+ *
151
+ * ⚠ **Legenda visível torna a miniatura decorativa**, e quem exigiu isso na web foi o axe, não a
152
+ * teoria: com `alt` e legenda dizendo a mesma coisa, o leitor de tela anuncia duas vezes seguidas.
153
+ * Aqui a tradução é outra (não há `alt=""` no RN) mas a regra é a mesma — **com legenda, quem
154
+ * carrega o nome é o LADRILHO, e a imagem sai da árvore**. O `alt` não se perde: ele continua
155
+ * nomeando a foto ampliada, que é onde não há legenda ao lado.
156
+ *
157
+ * ⚠ **Sem `onSelect` e sem `zoom` os ladrilhos não são alvos.** Um `Pressable` que não faz nada é
158
+ * um alvo que engana quem navega por leitor de tela — mesma decisão da web, mesma razão.
159
+ */
160
+ export function Gallery({ items, label, selected, onSelect, zoom, ratio = 1, minTileWidth = 128, style, testID, }) {
161
+ const s = folha(useAureaTokens());
162
+ const strings = useAureaStrings();
163
+ const [ampliado, setAmpliado] = React.useState(null);
164
+ const interativo = !!onSelect || !!zoom;
165
+ const aberto = items.find((i) => i.id === ampliado) ?? null;
166
+ return (_jsxs(_Fragment, { children: [_jsx(Grid, { testID: testID, minColumnWidth: minTileWidth, accessibilityRole: "list", accessibilityLabel: label ?? strings.galleryLabel, style: style, children: items.map((item) => {
167
+ const temLegenda = item.caption != null;
168
+ const miolo = (_jsxs(_Fragment, { children: [_jsx(Image, { source: item.source, alt: temLegenda ? "" : item.alt, ratio: ratio }), temLegenda && (typeof item.caption === "string"
169
+ ? _jsx(Text, { size: "sm", numberOfLines: 2, children: item.caption })
170
+ : item.caption)] }));
171
+ if (!interativo) {
172
+ return _jsx(View, { style: s.ladrilho, children: miolo }, item.id);
173
+ }
174
+ return (_jsx(Pressable, { testID: testID ? `${testID}-${item.id}` : undefined, onPress: () => { onSelect?.(item.id); if (zoom)
175
+ setAmpliado(item.id); }, accessibilityRole: "imagebutton",
176
+ // Com legenda, o nome do ladrilho é a legenda e a imagem já saiu da árvore (o
177
+ // `alt=""` acima). Sem legenda, o nome é o `alt`.
178
+ accessibilityLabel: temLegenda && typeof item.caption === "string"
179
+ ? item.caption : item.alt, accessibilityState: { selected: item.id === selected }, style: [s.ladrilho, item.id === selected && s.ladrilhoEscolhido], children: miolo }, item.id));
180
+ }) }), zoom && (_jsx(Dialog, { open: aberto != null, title: aberto ? (typeof aberto.caption === "string" ? aberto.caption : aberto.alt) : "", onClose: () => setAmpliado(null), testID: testID ? `${testID}-ampliada` : undefined, children: aberto && _jsx(Image, { source: aberto.source, alt: aberto.alt, fit: "contain", ratio: ratio }) }))] }));
181
+ }
@@ -0,0 +1,108 @@
1
+ import * as React from "react";
2
+ import { type StyleProp, type ViewStyle } from "react-native";
3
+ import { type AureaFieldSize } from "./inputs.js";
4
+ /** Os separadores de um locale, derivados de um número-sonda. */
5
+ export type AureaSeparadores = {
6
+ decimal: string;
7
+ grupo: string;
8
+ };
9
+ /** Descobre o separador decimal e o de milhar do locale. Memoizado por locale. */
10
+ export declare function separadoresDoLocale(locale?: string): AureaSeparadores;
11
+ /**
12
+ * Formata um número para EXIBIÇÃO. Nunca para o valor que o app guarda.
13
+ *
14
+ * ⚠ Sem `Intl`, devolve o número cru com o separador decimal do locale — **não** uma moeda
15
+ * montada à mão. Ver a nota no topo do módulo.
16
+ */
17
+ export declare function formatarNumero(n: number, locale?: string, format?: Intl.NumberFormatOptions): string;
18
+ /**
19
+ * Lê de volta o que a pessoa digitou. **Devolve `null` quando não há número** — e `null` não é
20
+ * zero: um campo vazio e um campo com `0` são coisas diferentes num lançamento.
21
+ *
22
+ * Aceita o que a pessoa realmente digita ou cola: `R$ 1.234,50`, `1 234,50`, `-12,4`, `12.4`.
23
+ * A regra é simples e por isso previsível — **tudo que não é dígito, sinal ou o separador
24
+ * decimal DO LOCALE é lixo** e sai fora, inclusive o separador de milhar.
25
+ */
26
+ export declare function lerNumero(texto: string, locale?: string): number | null;
27
+ export interface NumberFieldProps {
28
+ /** Controlado. `null` é **vazio**, e é diferente de `0`. */
29
+ value?: number | null;
30
+ defaultValue?: number;
31
+ onValueChange?: (v: number | null) => void;
32
+ min?: number;
33
+ max?: number;
34
+ /** Quanto os botões somam e tiram. Padrão **1**. */
35
+ step?: number;
36
+ /**
37
+ * As opções do `Intl.NumberFormat`. **Mesma prop, mesmo tipo e mesmo significado da web** —
38
+ * lá elas iam para o Base UI, aqui vão para o `Intl` direto.
39
+ *
40
+ * moeda {style: "currency", currency: "BRL"} com locale "pt-BR"
41
+ * medida {maximumFractionDigits: 1} 12,4
42
+ * contador nenhuma — o padrão já é o inteiro agrupado
43
+ *
44
+ * ⚠ **`notation: "compact"` não é aceito**, e a recusa é medida: está quebrado no motor nos
45
+ * dois sistemas (motor#768, motor#1035). Em `__DEV__` sai aviso; em produção a opção é
46
+ * ignorada, porque um "1,2 mi" errado numa tela de lançamento é pior que "1.234.567".
47
+ */
48
+ format?: Intl.NumberFormatOptions;
49
+ /** O locale do `Intl`. Sem ele, o do aparelho. */
50
+ locale?: string;
51
+ disabled?: boolean;
52
+ /** Mostra o valor e não deixa editar — os botões também somem. */
53
+ readOnly?: boolean;
54
+ size?: AureaFieldSize;
55
+ /**
56
+ * Ocupa a largura disponível em vez de abraçar o conteúdo. Padrão **false**.
57
+ *
58
+ * O padrão segue o `.number-field` da web, que é `inline-flex` (`aurea.css:704`) — e o mesmo
59
+ * eixo existe na HeroUI (`fullWidth`, medido no inventário). **Ligue para moeda:** o campo em
60
+ * repouso tem `--space-16` (64dp), que cabe um contador e não cabe `R$ 1.234,50`.
61
+ */
62
+ fullWidth?: boolean;
63
+ /** O nome para o leitor de tela quando não há `Field` em volta. */
64
+ label?: string;
65
+ placeholder?: string;
66
+ /**
67
+ * O teclado. Sem ele a escolha é derivada: **`decimal-pad`**, ou o teclado com sinal quando
68
+ * `min` é negativo — porque o `decimal-pad` do iOS **não tem tecla de menos**, e um campo que
69
+ * aceita −5 e não deixa digitá-lo é um campo quebrado.
70
+ */
71
+ keyboardType?: "numeric" | "decimal-pad" | "number-pad" | "numbers-and-punctuation";
72
+ /** Os glifos dos botões. Registre-os, ou passe `false` para tirar os dois. */
73
+ icons?: {
74
+ increment: IconNameLocal;
75
+ decrement: IconNameLocal;
76
+ } | false;
77
+ style?: StyleProp<ViewStyle>;
78
+ testID?: string;
79
+ }
80
+ type IconNameLocal = string;
81
+ /**
82
+ * O número que se digita OU se empurra de um em um.
83
+ *
84
+ * ```tsx
85
+ * <Field label="Valor">
86
+ * <NumberField value={valor} onValueChange={setValor}
87
+ * format={{style: "currency", currency: "BRL"}} locale="pt-BR" />
88
+ * </Field>
89
+ *
90
+ * <Field label="Litros">
91
+ * <NumberField value={litros} onValueChange={setLitros}
92
+ * format={{maximumFractionDigits: 1}} locale="pt-BR" min={0} />
93
+ * </Field>
94
+ *
95
+ * <Field label="Quantidade">
96
+ * <NumberField value={qtd} onValueChange={setQtd} min={0} step={1} />
97
+ * </Field>
98
+ * ```
99
+ *
100
+ * ⚠ **O que sai por `onValueChange` é o número CRU, sempre.** `1234.5`, nunca `"R$ 1.234,50"`.
101
+ * É a mesma trava da ADR-0024 — lá o motor renderiza um input escondido com o valor cru; aqui não
102
+ * há formulário nativo para esconder nada, então o contrato É a assinatura da função.
103
+ *
104
+ * ⚠ **Formata no blur, e só no blur.** Enquanto o campo tem foco, ele mostra exatamente o que foi
105
+ * digitado — essa é a decisão inteira da ADR-0024, e o teste que a cobra está no lote.
106
+ */
107
+ export declare function NumberField({ value, defaultValue, onValueChange, min, max, step, format, locale, disabled, readOnly, size, fullWidth, label, placeholder, keyboardType, icons, style, testID, }: NumberFieldProps): React.JSX.Element;
108
+ export {};
package/dist/numero.js ADDED
@@ -0,0 +1,327 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Aurea nativo — o NÚMERO: `NumberField`, e o formatador que a ADR-0024 delegava ao motor.
3
+ //
4
+ // Lote 7 do `NATIVE.md` §8, segunda das três lacunas medidas pelo consumidor. O `Input` do Lote 4
5
+ // tem `keyboardType` e o comentário dele já cita a demanda (`inputs.tsx:232-235`: *"o plano do
6
+ // consumidor pede `numeric` nos campos de medida e moeda"*) — mas teclado numérico é a metade
7
+ // fácil. **A que faltava é o FORMATO**: moeda, medida decimal e contador.
8
+ //
9
+ // ── A ADR-0024 ATRAVESSA, E FOI CONFERIDA ANTES DE SE AFIRMAR ISSO ───────────────────────────
10
+ // A decisão da web é *"a Aurea entrega o MOMENTO, não o formato"*: formatar no **blur**, nunca
11
+ // enquanto se digita. As três medições que a sustentam (o `Input mask` do USWDS publicado com
12
+ // reprovação WCAG registrada, o abandono da máscara pelo MUI na v6, a prática de acessibilidade)
13
+ // são sobre COMPORTAMENTO HUMANO, não sobre plataforma — então elas valem igual aqui, e não há
14
+ // evidência nova que reabra a decisão (`decisions/README.md`).
15
+ //
16
+ // **A dúvida que a demanda levantou era outra, e estava errada:** *"no nativo não há blur
17
+ // equivalente garantido"*. Há. O `TextInput` do RN tem `onBlur` e `onFocus`, o `Input` do Lote 4
18
+ // já os expõe (`inputs.tsx:239-240`) e já os liga (`inputs.tsx:278-279`). O blur dispara ao
19
+ // perder foco, ao fechar o teclado e ao sair da tela.
20
+ //
21
+ // **O que MUDA de verdade é quem formata.** Na web, `NumberFieldRoot` do `@base-ui/react` recebe
22
+ // `format`/`locale` e chama o `Intl`. **Não há Base UI aqui.** Então a chamada ao `Intl` passa a
23
+ // ser nossa — e com ela a volta, que na web ninguém escreveu: **converter de volta o que a pessoa
24
+ // digitou**. Isto é o que o arquivo faz.
25
+ //
26
+ // ── O `Intl` NO MOTOR JS DO RN: o que foi lido, e o que dele NÃO se usa ──────────────────────
27
+ // Medido no `doc/IntlAPIs.md` do próprio motor (a fonte, não um blog), em 11/09/2026:
28
+ //
29
+ // • `Intl.NumberFormat` com `format` e `resolvedOptions` existe **nos dois sistemas**, e a
30
+ // implementação delega à plataforma — ICU no Android, `NSFormatter` no iOS. Moeda e decimal,
31
+ // que é a demanda, estão cobertos;
32
+ // • `formatToParts` é **só Android**. Por isso este arquivo NÃO o usa para descobrir os
33
+ // separadores — ele os deriva formatando um número-sonda, que funciona onde `format` funciona;
34
+ // • o resultado **varia com a versão do Android**, porque varia o ICU do aparelho. É o preço de
35
+ // não embutir ICU no bundle, e o motor o declara;
36
+ // • abaixo do Android 21 o locale cai para inglês. Fora do alvo deste pacote.
37
+ //
38
+ // **Três defeitos conhecidos, e a biblioteca fica longe dos três:**
39
+ // `notation: "compact"` quebrado nos dois (motor#768, motor#1035). **Recusado com aviso em `__DEV__`**;
40
+ // `signDisplay: "always"` com moeda, some o símbolo nos positivos no Android (motor#789);
41
+ // `format()` com STRING não é lido como decimal (motor#1418). Aqui só entra `number`, nunca texto.
42
+ //
43
+ // ⚠ **E há a possibilidade de não haver `Intl` nenhum** — o consumidor pode compilar o motor sem
44
+ // ele, ou trocar de motor. A queda é declarada e visível (o número cru, com o separador do
45
+ // locale), nunca um `R$` inventado à mão: uma moeda formatada errado é pior que uma não
46
+ // formatada, porque parece certa.
47
+ import * as React from "react";
48
+ import { Platform, TextInput, View } from "react-native";
49
+ import { IconButton } from "./actions.js";
50
+ import { criarFolha } from "./estilos.js";
51
+ import { useCampo } from "./inputs.js";
52
+ import { useAureaStrings, useAureaTokens } from "./theme.js";
53
+ const alturaDoTamanho = (t, s) => s === "sm" ? t.size.controlHSm : s === "lg" ? t.size.controlHLg : t.size.controlHMd;
54
+ const fonteDoTamanho = (t, s) => s === "sm" ? t.size.textXs : s === "lg" ? t.size.textBase : t.size.textMd;
55
+ const folha = criarFolha((t) => ({
56
+ // 🔴 ELE ESTICAVA, E NÃO DEVIA — achado em 12/09/2026, olhando a primeira imagem da vitrine.
57
+ //
58
+ // O `.number-field` da web é `inline-flex` (`aurea.css:704`) e o `.number-field-group` também
59
+ // (`:705`): os dois **ABRAÇAM o conteúdo**. A primeira versão daqui punha `flex: 1` no campo,
60
+ // que PREENCHE — e o resultado é um campo enorme com o `−` e o `+` jogados nas pontas. Era
61
+ // divergência do nosso próprio CSS, entregue sem declarar, e ela só apareceu quando houve
62
+ // imagem para olhar.
63
+ //
64
+ // ⚠ **A saída é o eixo da HeroUI, e não uma invenção minha:** o `number-field` deles publica
65
+ // `fullWidth: [base, false, group, true]` (`INVENTORY-HEROUI.json`, medido em 22/08/2026).
66
+ // Esticar ou abraçar é DECISÃO DE USO, então vira prop — com o padrão em abraçar, que é o que
67
+ // o nosso CSS já dizia.
68
+ grupo: { flexDirection: "row", alignItems: "center", gap: t.size.space1,
69
+ alignSelf: "flex-start" },
70
+ grupoLargo: { alignSelf: "stretch" },
71
+ // `--space-16` = 64dp, medido. É a largura do `.number-field-input` (`aurea.css:706`), e ela
72
+ // serve ao caso que o componente foi feito para: contador e medida curta. **Moeda formatada
73
+ // não cabe em 64dp — nem aqui nem na web**, e é para isso que existe o `fullWidth`.
74
+ campo: {
75
+ width: t.size.space16, textAlign: "center",
76
+ borderWidth: t.size.borderWidth, borderColor: t.color.borderStrong,
77
+ borderRadius: t.size.radiusControl, backgroundColor: t.color.fieldBg,
78
+ // A mesma correção de Android do `Input` do Lote 4 — ver `inputs.tsx:280-296`.
79
+ paddingVertical: 0, textAlignVertical: "center",
80
+ },
81
+ campoLargo: { width: undefined, flex: 1, minWidth: t.size.space16 },
82
+ invalido: { borderColor: t.color.danger400 ?? t.color.destructive },
83
+ desabilitado: { opacity: 0.5 },
84
+ }));
85
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
86
+ // O formatador, e a volta que a web não precisou escrever
87
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
88
+ const TEM_INTL = (() => {
89
+ try {
90
+ return typeof Intl !== "undefined" && typeof Intl.NumberFormat === "function";
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ })();
96
+ // A sonda é `12345.6`: ela tem separador de grupo E casa decimal, então a saída carrega os dois
97
+ // em posições conhecidas — o PRIMEIRO não-dígito é o de grupo, o ÚLTIMO é o decimal.
98
+ //
99
+ // pt-BR -> "12.345,6" grupo "." decimal ","
100
+ // en-US -> "12,345.6" grupo "," decimal "."
101
+ // fr-FR -> "12 345,6" grupo U+202F (espaço estreito), decimal ","
102
+ //
103
+ // É por isso que a derivação é por SONDA e não por tabela: o espaço estreito do francês é o tipo
104
+ // de detalhe que uma tabela escrita à mão erra, e que o `formatToParts` resolveria — se ele
105
+ // existisse no iOS.
106
+ const SONDA = 12345.6;
107
+ const cacheDeSeparadores = new Map();
108
+ /** Descobre o separador decimal e o de milhar do locale. Memoizado por locale. */
109
+ export function separadoresDoLocale(locale) {
110
+ const chave = locale ?? "";
111
+ const guardado = cacheDeSeparadores.get(chave);
112
+ if (guardado)
113
+ return guardado;
114
+ let achado = { decimal: ".", grupo: "," };
115
+ if (TEM_INTL) {
116
+ try {
117
+ const amostra = new Intl.NumberFormat(locale).format(SONDA);
118
+ const naoDigitos = amostra.replace(/\p{Nd}/gu, "");
119
+ if (naoDigitos.length >= 1) {
120
+ achado = {
121
+ decimal: naoDigitos.slice(-1),
122
+ grupo: naoDigitos.length >= 2 ? naoDigitos.slice(0, 1) : "",
123
+ };
124
+ }
125
+ }
126
+ catch {
127
+ // Locale inválido, ou `Intl` presente e capenga. O padrão acima é o do `Number.prototype`,
128
+ // que é o que a queda inteira usa.
129
+ }
130
+ }
131
+ cacheDeSeparadores.set(chave, achado);
132
+ return achado;
133
+ }
134
+ const cacheDeFormatos = new Map();
135
+ /**
136
+ * Formata um número para EXIBIÇÃO. Nunca para o valor que o app guarda.
137
+ *
138
+ * ⚠ Sem `Intl`, devolve o número cru com o separador decimal do locale — **não** uma moeda
139
+ * montada à mão. Ver a nota no topo do módulo.
140
+ */
141
+ export function formatarNumero(n, locale, format) {
142
+ if (TEM_INTL) {
143
+ const chave = `${locale ?? ""}|${format ? JSON.stringify(format) : ""}`;
144
+ try {
145
+ let f = cacheDeFormatos.get(chave);
146
+ if (!f) {
147
+ f = new Intl.NumberFormat(locale, format);
148
+ cacheDeFormatos.set(chave, f);
149
+ }
150
+ // ⚠ Sempre `number`, nunca `String(n)`: o motor não lê string como decimal (motor#1418).
151
+ return f.format(n);
152
+ }
153
+ catch {
154
+ // Opção que este motor não conhece. Cai para o cru em vez de derrubar a tela.
155
+ }
156
+ }
157
+ const { decimal } = separadoresDoLocale(locale);
158
+ return String(n).replace(".", decimal);
159
+ }
160
+ /**
161
+ * Lê de volta o que a pessoa digitou. **Devolve `null` quando não há número** — e `null` não é
162
+ * zero: um campo vazio e um campo com `0` são coisas diferentes num lançamento.
163
+ *
164
+ * Aceita o que a pessoa realmente digita ou cola: `R$ 1.234,50`, `1 234,50`, `-12,4`, `12.4`.
165
+ * A regra é simples e por isso previsível — **tudo que não é dígito, sinal ou o separador
166
+ * decimal DO LOCALE é lixo** e sai fora, inclusive o separador de milhar.
167
+ */
168
+ export function lerNumero(texto, locale) {
169
+ const { decimal } = separadoresDoLocale(locale);
170
+ const negativo = /-/.test(texto);
171
+ // `\p{Nd}` e não `[0-9]`: o teclado de alguns locales entrega dígitos que não são ASCII, e um
172
+ // filtro ASCII os jogaria fora em silêncio — devolvendo `null` para um número que a pessoa vê
173
+ // na tela.
174
+ let cru = "";
175
+ for (const c of texto) {
176
+ if (/\p{Nd}/u.test(c))
177
+ cru += c;
178
+ else if (c === decimal)
179
+ cru += ".";
180
+ }
181
+ // Duas casas decimais digitadas por engano ("1.2.3") não viram número — melhor devolver `null`
182
+ // e deixar o campo mostrar o que era antes do que adivinhar qual ponto a pessoa quis.
183
+ if (cru === "" || cru === ".")
184
+ return null;
185
+ if (cru.indexOf(".") !== cru.lastIndexOf("."))
186
+ return null;
187
+ const n = Number(cru);
188
+ if (!Number.isFinite(n))
189
+ return null;
190
+ return negativo ? -n : n;
191
+ }
192
+ const prender = (n, min, max) => {
193
+ let v = n;
194
+ if (min != null && v < min)
195
+ v = min;
196
+ if (max != null && v > max)
197
+ v = max;
198
+ return v;
199
+ };
200
+ // O texto que o campo mostra ENQUANTO SE EDITA: o número sem grupo e sem símbolo, com o separador
201
+ // decimal do locale. É o oposto do que se mostra em repouso, e é a decisão da ADR-0024 — editar
202
+ // "R$ 1.234,50" com o cursor no meio é o defeito que o MUI filmou.
203
+ const paraEdicao = (n, locale) => {
204
+ if (n == null)
205
+ return "";
206
+ const { decimal } = separadoresDoLocale(locale);
207
+ return String(n).replace(".", decimal);
208
+ };
209
+ /**
210
+ * O número que se digita OU se empurra de um em um.
211
+ *
212
+ * ```tsx
213
+ * <Field label="Valor">
214
+ * <NumberField value={valor} onValueChange={setValor}
215
+ * format={{style: "currency", currency: "BRL"}} locale="pt-BR" />
216
+ * </Field>
217
+ *
218
+ * <Field label="Litros">
219
+ * <NumberField value={litros} onValueChange={setLitros}
220
+ * format={{maximumFractionDigits: 1}} locale="pt-BR" min={0} />
221
+ * </Field>
222
+ *
223
+ * <Field label="Quantidade">
224
+ * <NumberField value={qtd} onValueChange={setQtd} min={0} step={1} />
225
+ * </Field>
226
+ * ```
227
+ *
228
+ * ⚠ **O que sai por `onValueChange` é o número CRU, sempre.** `1234.5`, nunca `"R$ 1.234,50"`.
229
+ * É a mesma trava da ADR-0024 — lá o motor renderiza um input escondido com o valor cru; aqui não
230
+ * há formulário nativo para esconder nada, então o contrato É a assinatura da função.
231
+ *
232
+ * ⚠ **Formata no blur, e só no blur.** Enquanto o campo tem foco, ele mostra exatamente o que foi
233
+ * digitado — essa é a decisão inteira da ADR-0024, e o teste que a cobra está no lote.
234
+ */
235
+ export function NumberField({ value, defaultValue, onValueChange, min, max, step = 1, format, locale, disabled, readOnly, size, fullWidth = false, label, placeholder, keyboardType, icons = { increment: "add", decrement: "subtract" }, style, testID, }) {
236
+ const t = useAureaTokens();
237
+ const s = folha(t);
238
+ const strings = useAureaStrings();
239
+ const campo = useCampo();
240
+ const tam = size ?? campo?.size ?? "md";
241
+ const inativo = disabled ?? campo?.disabled;
242
+ const [interno, setInterno] = React.useState(defaultValue ?? null);
243
+ const numero = value !== undefined ? value : interno;
244
+ // `null` = o campo está em repouso e mostra o FORMATADO. Uma string = está sendo editado, e a
245
+ // string é literalmente o que foi digitado. Os dois estados não se misturam, e é isso que faz
246
+ // "digitar não é interrompido" ser verdade em vez de intenção.
247
+ const [emEdicao, setEmEdicao] = React.useState(null);
248
+ if (__DEV__ && format && format.notation === "compact") {
249
+ console.warn("Aurea NumberField: `notation: \"compact\"` está quebrado no motor nos dois sistemas " +
250
+ "(motor#768, motor#1035) e por isso é ignorado. Formate o número no app se precisar de " +
251
+ "\"1,2 mi\" — e teste em aparelho, não no simulador.");
252
+ }
253
+ const formatoSeguro = React.useMemo(() => {
254
+ if (!format || format.notation !== "compact")
255
+ return format;
256
+ const { notation: _fora, compactDisplay: _fora2, ...resto } = format;
257
+ return resto;
258
+ }, [format]);
259
+ const emitir = React.useCallback((n) => {
260
+ if (value === undefined)
261
+ setInterno(n);
262
+ onValueChange?.(n);
263
+ }, [value, onValueChange]);
264
+ const mostrar = emEdicao != null
265
+ ? emEdicao
266
+ : numero == null ? "" : formatarNumero(numero, locale, formatoSeguro);
267
+ const confirmar = React.useCallback(() => {
268
+ if (emEdicao == null)
269
+ return;
270
+ const lido = lerNumero(emEdicao, locale);
271
+ setEmEdicao(null);
272
+ // Texto ilegível devolve o valor de antes — o campo volta a mostrar o formatado, e nada se
273
+ // perde. Apagar tudo, que é diferente, devolve `null`.
274
+ if (lido == null) {
275
+ if (emEdicao.trim() === "")
276
+ emitir(null);
277
+ return;
278
+ }
279
+ emitir(prender(lido, min, max));
280
+ }, [emEdicao, locale, emitir, min, max]);
281
+ const empurrar = React.useCallback((direcao) => {
282
+ // Se o campo está sendo editado, o que vale é o que está escrito — empurrar por cima do valor
283
+ // antigo descartaria a digitação em silêncio.
284
+ const base = emEdicao != null ? (lerNumero(emEdicao, locale) ?? 0) : (numero ?? 0);
285
+ setEmEdicao(null);
286
+ emitir(prender(base + direcao * step, min, max));
287
+ }, [emEdicao, numero, locale, emitir, step, min, max]);
288
+ const tecladoPadrao = min != null && min < 0
289
+ ? (Platform.OS === "ios" ? "numbers-and-punctuation" : "numeric")
290
+ : "decimal-pad";
291
+ const noLimite = (direcao) => {
292
+ const base = numero ?? 0;
293
+ return direcao === 1 ? (max != null && base >= max) : (min != null && base <= min);
294
+ };
295
+ const mostrarBotoes = icons !== false && !readOnly;
296
+ // 🔴 ACHADO PELA REFERÊNCIA, e não por mim — 12/09/2026. O inventário da HeroUI que já morava
297
+ // no repositório (`audit/activity-2/INVENTORY-HEROUI.json`, medido em 22/08 sobre
298
+ // `@heroui/react@3.2.4`) lista os estados do `number-field` deles:
299
+ //
300
+ // disabled · focus-visible · FOCUS-WITHIN · hovered · invalid · pressed
301
+ //
302
+ // Este componente nasceu **sem estado de foco nenhum** — e era o ÚNICO campo de texto do pacote
303
+ // sem ele: o `Input` do Lote 4 marca (`inputs.tsx:344`), o `Textarea` herda, o `SearchField`
304
+ // marca (`busca.tsx:520`). Inconsistência dentro da própria biblioteca, e invisível em teste
305
+ // até alguém comparar com uma referência.
306
+ //
307
+ // ⚠ **A BORDA vai no CAMPO, e não no grupo — e aqui a Aurea diverge da HeroUI de propósito.**
308
+ // Lá o `Group` carrega a caixa e o `Input` fica nu dentro dela. Aqui não: o `.number-field-group`
309
+ // do nosso CSS (`aurea.css:705`) é só `inline-flex` + `gap`, e quem tem borda é o `.input`
310
+ // (`aurea.css:685`), com os dois botões FORA dela. O estado que faltava é o deles; a geometria
311
+ // continua sendo a nossa.
312
+ const [focado, setFocado] = React.useState(false);
313
+ return (_jsxs(View, { testID: testID, style: [s.grupo, fullWidth && s.grupoLargo, inativo && s.desabilitado, style], children: [mostrarBotoes && (_jsx(IconButton, { name: icons.decrement, label: strings.decrement, appearance: "ghost", size: tam, disabled: inativo || noLimite(-1), onPress: () => empurrar(-1), testID: testID ? `${testID}-menos` : undefined })), _jsx(TextInput, { testID: testID ? `${testID}-campo` : undefined, value: mostrar, onChangeText: setEmEdicao, onFocus: () => { setFocado(true); setEmEdicao(paraEdicao(numero, locale)); }, onBlur: () => { setFocado(false); confirmar(); }, editable: !inativo && !readOnly, placeholder: placeholder, placeholderTextColor: t.color.subtleForeground, keyboardType: keyboardType ?? tecladoPadrao,
314
+ // O nome vem do `Field`, como em todo controle deste pacote — e `label` cobre quem usa o
315
+ // campo solto. Sem um dos dois, é um nó anônimo para o leitor de tela.
316
+ accessibilityLabel: label ?? campo?.label, accessibilityHint: campo?.hint, accessibilityState: { disabled: !!inativo }, "aria-invalid": campo?.invalido, style: [
317
+ s.campo,
318
+ fullWidth && s.campoLargo,
319
+ { height: alturaDoTamanho(t, tam), fontSize: fonteDoTamanho(t, tam),
320
+ fontFamily: t.font.ui[400], color: t.color.foreground },
321
+ campo?.invalido && s.invalido,
322
+ // A ORDEM IMPORTA e é a mesma do `Input` do Lote 4: o inválido vem antes, o foco
323
+ // depois. Um campo inválido que está sendo corrigido tem de mostrar que está ativo —
324
+ // invertendo, a pessoa digita sem pista nenhuma de onde o teclado está batendo.
325
+ focado && { borderColor: t.color.focusStrong },
326
+ ] }), mostrarBotoes && (_jsx(IconButton, { name: icons.increment, label: strings.increment, appearance: "ghost", size: tam, disabled: inativo || noLimite(1), onPress: () => empurrar(1), testID: testID ? `${testID}-mais` : undefined }))] }));
327
+ }
package/dist/strings.d.ts CHANGED
@@ -44,6 +44,32 @@ export interface AureaStrings {
44
44
  tableLabel: string;
45
45
  /** O nome do `Chart` sem `label`. Mesmo nome da web. */
46
46
  chartLabel: string;
47
+ /** O botão que soma um passo no `NumberField`. Mesmo nome da web. */
48
+ increment: string;
49
+ /** O botão que tira um passo no `NumberField`. Mesmo nome da web. */
50
+ decrement: string;
51
+ /** O que o `Combobox` mostra quando a busca não achou nada. Mesmo nome da web. */
52
+ comboboxEmpty: string;
53
+ /** O botão que desfaz a escolha do `Combobox`. Mesmo nome da web. */
54
+ comboboxClear: string;
55
+ /** O que o `Combobox` anuncia enquanto a busca remota não voltou. Mesmo nome da web. */
56
+ comboboxLoading: string;
57
+ /**
58
+ * O nome e o texto-guia do campo de busca DENTRO da folha do `Combobox`.
59
+ *
60
+ * ⚠ **Não existe na web**, e é uma das duas chaves deste pacote sem par lá — porque a anatomia
61
+ * é outra: lá o `<input>` É o combobox e o texto-guia vem do consumidor; aqui a folha tem um
62
+ * campo próprio, que precisa de nome mesmo quando ninguém passou um.
63
+ */
64
+ comboboxSearch: string;
65
+ /**
66
+ * O botão que apaga o que foi digitado numa busca — no `SearchField` e no campo da folha do
67
+ * `Combobox`. **Não existe na web** pela mesma razão: lá o `type="search"` do navegador desenha
68
+ * o "x" sozinho, e no React Native não há nada equivalente.
69
+ */
70
+ searchClear: string;
71
+ /** O nome da grade de fotos da `Gallery`. Mesmo nome da web. */
72
+ galleryLabel: string;
47
73
  /** A frase de cada estado universal. */
48
74
  universalState: Record<AureaUniversalState, string>;
49
75
  }