@aurea-uds/native 0.7.0 → 0.8.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/numero.js ADDED
@@ -0,0 +1,291 @@
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
+ // `.number-field-group` da web (`aurea.css:705`): linha, centro, `--space-1` entre as peças.
57
+ grupo: { flexDirection: "row", alignItems: "center", gap: t.size.space1 },
58
+ // O `.number-field-input` (`aurea.css:706`) é `--space-16` de largura e CENTRALIZADO. A largura
59
+ // fixa atravessa como mínimo e não como fixa: num telefone de 360dp, com dois botões ao lado,
60
+ // um campo rígido estoura a linha em `spacious`.
61
+ campo: {
62
+ flex: 1, minWidth: t.size.space16 ?? 64, textAlign: "center",
63
+ borderWidth: t.size.borderWidth, borderColor: t.color.borderStrong,
64
+ borderRadius: t.size.radiusControl, backgroundColor: t.color.fieldBg,
65
+ // A mesma correção de Android do `Input` do Lote 4 — ver `inputs.tsx:280-296`.
66
+ paddingVertical: 0, textAlignVertical: "center",
67
+ },
68
+ invalido: { borderColor: t.color.danger400 ?? t.color.destructive },
69
+ desabilitado: { opacity: 0.5 },
70
+ }));
71
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
72
+ // O formatador, e a volta que a web não precisou escrever
73
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
74
+ const TEM_INTL = (() => {
75
+ try {
76
+ return typeof Intl !== "undefined" && typeof Intl.NumberFormat === "function";
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ })();
82
+ // A sonda é `12345.6`: ela tem separador de grupo E casa decimal, então a saída carrega os dois
83
+ // em posições conhecidas — o PRIMEIRO não-dígito é o de grupo, o ÚLTIMO é o decimal.
84
+ //
85
+ // pt-BR -> "12.345,6" grupo "." decimal ","
86
+ // en-US -> "12,345.6" grupo "," decimal "."
87
+ // fr-FR -> "12 345,6" grupo U+202F (espaço estreito), decimal ","
88
+ //
89
+ // É por isso que a derivação é por SONDA e não por tabela: o espaço estreito do francês é o tipo
90
+ // de detalhe que uma tabela escrita à mão erra, e que o `formatToParts` resolveria — se ele
91
+ // existisse no iOS.
92
+ const SONDA = 12345.6;
93
+ const cacheDeSeparadores = new Map();
94
+ /** Descobre o separador decimal e o de milhar do locale. Memoizado por locale. */
95
+ export function separadoresDoLocale(locale) {
96
+ const chave = locale ?? "";
97
+ const guardado = cacheDeSeparadores.get(chave);
98
+ if (guardado)
99
+ return guardado;
100
+ let achado = { decimal: ".", grupo: "," };
101
+ if (TEM_INTL) {
102
+ try {
103
+ const amostra = new Intl.NumberFormat(locale).format(SONDA);
104
+ const naoDigitos = amostra.replace(/\p{Nd}/gu, "");
105
+ if (naoDigitos.length >= 1) {
106
+ achado = {
107
+ decimal: naoDigitos.slice(-1),
108
+ grupo: naoDigitos.length >= 2 ? naoDigitos.slice(0, 1) : "",
109
+ };
110
+ }
111
+ }
112
+ catch {
113
+ // Locale inválido, ou `Intl` presente e capenga. O padrão acima é o do `Number.prototype`,
114
+ // que é o que a queda inteira usa.
115
+ }
116
+ }
117
+ cacheDeSeparadores.set(chave, achado);
118
+ return achado;
119
+ }
120
+ const cacheDeFormatos = new Map();
121
+ /**
122
+ * Formata um número para EXIBIÇÃO. Nunca para o valor que o app guarda.
123
+ *
124
+ * ⚠ Sem `Intl`, devolve o número cru com o separador decimal do locale — **não** uma moeda
125
+ * montada à mão. Ver a nota no topo do módulo.
126
+ */
127
+ export function formatarNumero(n, locale, format) {
128
+ if (TEM_INTL) {
129
+ const chave = `${locale ?? ""}|${format ? JSON.stringify(format) : ""}`;
130
+ try {
131
+ let f = cacheDeFormatos.get(chave);
132
+ if (!f) {
133
+ f = new Intl.NumberFormat(locale, format);
134
+ cacheDeFormatos.set(chave, f);
135
+ }
136
+ // ⚠ Sempre `number`, nunca `String(n)`: o motor não lê string como decimal (motor#1418).
137
+ return f.format(n);
138
+ }
139
+ catch {
140
+ // Opção que este motor não conhece. Cai para o cru em vez de derrubar a tela.
141
+ }
142
+ }
143
+ const { decimal } = separadoresDoLocale(locale);
144
+ return String(n).replace(".", decimal);
145
+ }
146
+ /**
147
+ * Lê de volta o que a pessoa digitou. **Devolve `null` quando não há número** — e `null` não é
148
+ * zero: um campo vazio e um campo com `0` são coisas diferentes num lançamento.
149
+ *
150
+ * Aceita o que a pessoa realmente digita ou cola: `R$ 1.234,50`, `1 234,50`, `-12,4`, `12.4`.
151
+ * A regra é simples e por isso previsível — **tudo que não é dígito, sinal ou o separador
152
+ * decimal DO LOCALE é lixo** e sai fora, inclusive o separador de milhar.
153
+ */
154
+ export function lerNumero(texto, locale) {
155
+ const { decimal } = separadoresDoLocale(locale);
156
+ const negativo = /-/.test(texto);
157
+ // `\p{Nd}` e não `[0-9]`: o teclado de alguns locales entrega dígitos que não são ASCII, e um
158
+ // filtro ASCII os jogaria fora em silêncio — devolvendo `null` para um número que a pessoa vê
159
+ // na tela.
160
+ let cru = "";
161
+ for (const c of texto) {
162
+ if (/\p{Nd}/u.test(c))
163
+ cru += c;
164
+ else if (c === decimal)
165
+ cru += ".";
166
+ }
167
+ // Duas casas decimais digitadas por engano ("1.2.3") não viram número — melhor devolver `null`
168
+ // e deixar o campo mostrar o que era antes do que adivinhar qual ponto a pessoa quis.
169
+ if (cru === "" || cru === ".")
170
+ return null;
171
+ if (cru.indexOf(".") !== cru.lastIndexOf("."))
172
+ return null;
173
+ const n = Number(cru);
174
+ if (!Number.isFinite(n))
175
+ return null;
176
+ return negativo ? -n : n;
177
+ }
178
+ const prender = (n, min, max) => {
179
+ let v = n;
180
+ if (min != null && v < min)
181
+ v = min;
182
+ if (max != null && v > max)
183
+ v = max;
184
+ return v;
185
+ };
186
+ // O texto que o campo mostra ENQUANTO SE EDITA: o número sem grupo e sem símbolo, com o separador
187
+ // decimal do locale. É o oposto do que se mostra em repouso, e é a decisão da ADR-0024 — editar
188
+ // "R$ 1.234,50" com o cursor no meio é o defeito que o MUI filmou.
189
+ const paraEdicao = (n, locale) => {
190
+ if (n == null)
191
+ return "";
192
+ const { decimal } = separadoresDoLocale(locale);
193
+ return String(n).replace(".", decimal);
194
+ };
195
+ /**
196
+ * O número que se digita OU se empurra de um em um.
197
+ *
198
+ * ```tsx
199
+ * <Field label="Valor">
200
+ * <NumberField value={valor} onValueChange={setValor}
201
+ * format={{style: "currency", currency: "BRL"}} locale="pt-BR" />
202
+ * </Field>
203
+ *
204
+ * <Field label="Litros">
205
+ * <NumberField value={litros} onValueChange={setLitros}
206
+ * format={{maximumFractionDigits: 1}} locale="pt-BR" min={0} />
207
+ * </Field>
208
+ *
209
+ * <Field label="Quantidade">
210
+ * <NumberField value={qtd} onValueChange={setQtd} min={0} step={1} />
211
+ * </Field>
212
+ * ```
213
+ *
214
+ * ⚠ **O que sai por `onValueChange` é o número CRU, sempre.** `1234.5`, nunca `"R$ 1.234,50"`.
215
+ * É a mesma trava da ADR-0024 — lá o motor renderiza um input escondido com o valor cru; aqui não
216
+ * há formulário nativo para esconder nada, então o contrato É a assinatura da função.
217
+ *
218
+ * ⚠ **Formata no blur, e só no blur.** Enquanto o campo tem foco, ele mostra exatamente o que foi
219
+ * digitado — essa é a decisão inteira da ADR-0024, e o teste que a cobra está no lote.
220
+ */
221
+ export function NumberField({ value, defaultValue, onValueChange, min, max, step = 1, format, locale, disabled, readOnly, size, label, placeholder, keyboardType, icons = { increment: "add", decrement: "subtract" }, style, testID, }) {
222
+ const t = useAureaTokens();
223
+ const s = folha(t);
224
+ const strings = useAureaStrings();
225
+ const campo = useCampo();
226
+ const tam = size ?? campo?.size ?? "md";
227
+ const inativo = disabled ?? campo?.disabled;
228
+ const [interno, setInterno] = React.useState(defaultValue ?? null);
229
+ const numero = value !== undefined ? value : interno;
230
+ // `null` = o campo está em repouso e mostra o FORMATADO. Uma string = está sendo editado, e a
231
+ // string é literalmente o que foi digitado. Os dois estados não se misturam, e é isso que faz
232
+ // "digitar não é interrompido" ser verdade em vez de intenção.
233
+ const [emEdicao, setEmEdicao] = React.useState(null);
234
+ if (__DEV__ && format && format.notation === "compact") {
235
+ console.warn("Aurea NumberField: `notation: \"compact\"` está quebrado no motor nos dois sistemas " +
236
+ "(motor#768, motor#1035) e por isso é ignorado. Formate o número no app se precisar de " +
237
+ "\"1,2 mi\" — e teste em aparelho, não no simulador.");
238
+ }
239
+ const formatoSeguro = React.useMemo(() => {
240
+ if (!format || format.notation !== "compact")
241
+ return format;
242
+ const { notation: _fora, compactDisplay: _fora2, ...resto } = format;
243
+ return resto;
244
+ }, [format]);
245
+ const emitir = React.useCallback((n) => {
246
+ if (value === undefined)
247
+ setInterno(n);
248
+ onValueChange?.(n);
249
+ }, [value, onValueChange]);
250
+ const mostrar = emEdicao != null
251
+ ? emEdicao
252
+ : numero == null ? "" : formatarNumero(numero, locale, formatoSeguro);
253
+ const confirmar = React.useCallback(() => {
254
+ if (emEdicao == null)
255
+ return;
256
+ const lido = lerNumero(emEdicao, locale);
257
+ setEmEdicao(null);
258
+ // Texto ilegível devolve o valor de antes — o campo volta a mostrar o formatado, e nada se
259
+ // perde. Apagar tudo, que é diferente, devolve `null`.
260
+ if (lido == null) {
261
+ if (emEdicao.trim() === "")
262
+ emitir(null);
263
+ return;
264
+ }
265
+ emitir(prender(lido, min, max));
266
+ }, [emEdicao, locale, emitir, min, max]);
267
+ const empurrar = React.useCallback((direcao) => {
268
+ // Se o campo está sendo editado, o que vale é o que está escrito — empurrar por cima do valor
269
+ // antigo descartaria a digitação em silêncio.
270
+ const base = emEdicao != null ? (lerNumero(emEdicao, locale) ?? 0) : (numero ?? 0);
271
+ setEmEdicao(null);
272
+ emitir(prender(base + direcao * step, min, max));
273
+ }, [emEdicao, numero, locale, emitir, step, min, max]);
274
+ const tecladoPadrao = min != null && min < 0
275
+ ? (Platform.OS === "ios" ? "numbers-and-punctuation" : "numeric")
276
+ : "decimal-pad";
277
+ const noLimite = (direcao) => {
278
+ const base = numero ?? 0;
279
+ return direcao === 1 ? (max != null && base >= max) : (min != null && base <= min);
280
+ };
281
+ const mostrarBotoes = icons !== false && !readOnly;
282
+ return (_jsxs(View, { testID: testID, style: [s.grupo, 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: () => setEmEdicao(paraEdicao(numero, locale)), onBlur: confirmar, editable: !inativo && !readOnly, placeholder: placeholder, placeholderTextColor: t.color.subtleForeground, keyboardType: keyboardType ?? tecladoPadrao,
283
+ // O nome vem do `Field`, como em todo controle deste pacote — e `label` cobre quem usa o
284
+ // campo solto. Sem um dos dois, é um nó anônimo para o leitor de tela.
285
+ accessibilityLabel: label ?? campo?.label, accessibilityHint: campo?.hint, accessibilityState: { disabled: !!inativo }, "aria-invalid": campo?.invalido, style: [
286
+ s.campo,
287
+ { height: alturaDoTamanho(t, tam), fontSize: fonteDoTamanho(t, tam),
288
+ fontFamily: t.font.ui[400], color: t.color.foreground },
289
+ campo?.invalido && s.invalido,
290
+ ] }), 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 }))] }));
291
+ }
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
  }
package/dist/strings.js CHANGED
@@ -43,6 +43,14 @@ export const defaultStrings = {
43
43
  dismissNotification: "Dismiss notification",
44
44
  tableLabel: "Table",
45
45
  chartLabel: "Chart",
46
+ increment: "Increase",
47
+ decrement: "Decrease",
48
+ comboboxEmpty: "No results",
49
+ comboboxClear: "Clear selection",
50
+ comboboxLoading: "Loading…",
51
+ comboboxSearch: "Search",
52
+ searchClear: "Clear search",
53
+ galleryLabel: "Gallery",
46
54
  universalState: {
47
55
  waiting_user: "Waiting for someone to act.",
48
56
  waiting_approval: "Waiting for approval.",
@@ -70,6 +78,14 @@ export const ptBR = {
70
78
  dismissNotification: "Dispensar notificação",
71
79
  tableLabel: "Tabela",
72
80
  chartLabel: "Gráfico",
81
+ increment: "Aumentar",
82
+ decrement: "Diminuir",
83
+ comboboxEmpty: "Nenhum resultado",
84
+ comboboxClear: "Limpar seleção",
85
+ comboboxLoading: "Carregando…",
86
+ comboboxSearch: "Buscar",
87
+ searchClear: "Limpar busca",
88
+ galleryLabel: "Galeria",
73
89
  universalState: {
74
90
  waiting_user: "Esperando alguém agir.",
75
91
  waiting_approval: "Esperando aprovação.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aurea-uds/native",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "React Native target for Aurea UDS: the theme x density provider, the first components and the Carbon icons, drawn on react-native-svg.",
5
5
  "keywords": [
6
6
  "aurea",
@@ -47,7 +47,7 @@
47
47
  "NOTICE"
48
48
  ],
49
49
  "dependencies": {
50
- "@aurea-uds/tokens": "^0.7.0"
50
+ "@aurea-uds/tokens": "^0.8.0"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "@react-native-community/datetimepicker": ">=8",