@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/README.md CHANGED
@@ -6,9 +6,18 @@ Pacote **irmão** do `@aurea-uds/react`, não um wrapper dele: componentes próp
6
6
  `View`/`Text`/`Pressable`, consumindo `@aurea-uds/tokens`. Só os tokens atravessam — decisão de
7
7
  18/07/2026, registrada no `ROADMAP.md` Fase 7.
8
8
 
9
- > **Estado: O ALVO NATIVO ESTÁ COMPLETO — 46 componentes, e agora sem exceção.** O Lote 6 fechou
10
- > o plano em 09/09/2026 (`Timeline`, `DataList`, `Table`), e o **`Chart`** — a única peça que
11
- > tinha ficado de fora — entrou no mesmo dia pela
9
+ > **Estado: 51 componentes.** O **Lote 7** entrou em 11/09/2026 e é o primeiro que **não sai do
10
+ > plano** — ele saiu do APP. O consumidor mediu três lacunas na primeira tela que a pessoa toca, e
11
+ > elas viraram cinco componentes: **`Combobox`, `SearchField`, `NumberField`, `Image` e
12
+ > `Gallery`**, mais a prop `formatOnBlur` no `Input`. **Zero dependência nova.**
13
+ > Ver a seção **Buscar, número e mídia** abaixo, e o `NATIVE.md` §8.
14
+ >
15
+ > ⚠ **O `Combobox` DIVERGE da web de propósito** — ele aceita busca REMOTA em seleção única, que
16
+ > lá não existe ([ADR-0043](../../decisions/0043-o-combobox-nativo-diverge-da-web-e-a-presenca-da-prop-e-a-chave.md)).
17
+ > ⚠ **Os cinco do Lote 7 ainda não rodaram em aparelho** — só em teste.
18
+ >
19
+ > **O PLANO fechou no Lote 6**, em 09/09/2026 (`Timeline`, `DataList`, `Table`), e o **`Chart`** —
20
+ > a única peça que tinha ficado de fora — entrou no mesmo dia pela
12
21
  > [ADR-0041](../../decisions/0041-o-motor-de-grafico-do-nativo-e-nosso-sobre-react-native-svg.md).
13
22
  >
14
23
  > ⚠ **O `Chart` é o único componente deste pacote cuja GEOMETRIA é nossa.** Na web ele é 79 linhas
@@ -219,6 +228,129 @@ leitor anuncia.
219
228
  O `behavior` muda com a plataforma — `padding` no iOS, `height` no Android — e a Aurea escolhe
220
229
  por você. Um só nos dois lados deixa metade dos aparelhos com o botão de salvar sob o teclado.
221
230
 
231
+ ## Buscar, número e mídia
232
+
233
+ O **Lote 7** (11/09/2026). Três lacunas que o app mediu, e não o plano — `NATIVE.md` §8.
234
+
235
+ ### `Combobox` — digitar para achar um item
236
+
237
+ ⚠ **Não confunda com o `Select`.** A escolha entre os dois é de TAMANHO DE LISTA, e a linha é
238
+ dura: o `Select` monta todos os itens num `ScrollView` — serve para unidades, estados e tipos.
239
+ **Um catálogo de milhares de linhas ali trava.** O `Combobox` usa `FlatList` e aceita busca remota.
240
+
241
+ ```tsx
242
+ import {Combobox, Field} from "@aurea-uds/native";
243
+
244
+ // ① catálogo REMOTO — a Aurea não filtra, o app busca
245
+ <Field label="Item">
246
+ <Combobox
247
+ items={resultados} // a lista JÁ BUSCADA
248
+ value={escolhido} // o ITEM inteiro, não o id
249
+ onValueChange={setEscolhido}
250
+ onSearchChange={buscar} // ← a presença desta prop DESLIGA o filtro da Aurea
251
+ loading={buscando}
252
+ onEndReached={proximaPagina}
253
+ />
254
+ </Field>
255
+
256
+ // ② lista NA MÃO — a Aurea filtra, ignorando acento e caixa
257
+ <Combobox items={unidades} value={unidade} onValueChange={setUnidade} />
258
+ ```
259
+
260
+ **A regra em uma linha: `onSearchChange` presente = a Aurea não filtra.** É presença de prop, não
261
+ configuração — e ela existe porque filtrar de novo o que o servidor devolveu esconde resultado.
262
+
263
+ | | |
264
+ |---|---|
265
+ | espera antes de chamar `onSearchChange` | **250 ms**. `searchDelay={0}` desliga |
266
+ | onde se digita | **dentro da folha**, não no gatilho — o teclado ocupa metade da tela |
267
+ | `value` | o **item inteiro**: numa busca remota a lista some debaixo da escolha |
268
+ | ícones a registrar | `chevron--down`, `search`, `close` |
269
+
270
+ ### `SearchField` — filtrar o que já está na tela
271
+
272
+ Outro papel: o `Combobox` **escolhe** de um catálogo e devolve a escolha; este **filtra** e
273
+ devolve texto.
274
+
275
+ ```tsx
276
+ <SearchField placeholder="Filtrar" onSearchChange={setFiltro} />
277
+ ```
278
+
279
+ ### `NumberField` — moeda, medida e contador
280
+
281
+ ```tsx
282
+ <Field label="Valor">
283
+ <NumberField value={valor} onValueChange={setValor}
284
+ format={{style: "currency", currency: "BRL"}} locale="pt-BR" />
285
+ </Field>
286
+
287
+ <Field label="Litros">
288
+ <NumberField value={litros} onValueChange={setLitros}
289
+ format={{maximumFractionDigits: 1}} locale="pt-BR" min={0} />
290
+ </Field>
291
+
292
+ <Field label="Quantidade">
293
+ <NumberField value={qtd} onValueChange={setQtd} min={0} step={1} />
294
+ </Field>
295
+ ```
296
+
297
+ ⚠ **O que sai por `onValueChange` é `number`, sempre** — `1234.5`, nunca `"R$ 1.234,50"`.
298
+
299
+ ⚠ **Formata no BLUR, e só no blur** ([ADR-0024](../../decisions/0024-mascara-de-campo-e-o-momento-nao-o-formato.md)
300
+ e [ADR-0042](../../decisions/0042-o-intl-e-o-formatador-do-nativo-e-a-volta-e-nossa.md)). Enquanto
301
+ o campo tem foco, ele mostra exatamente o que foi digitado. Máscara ao vivo é o defeito que o
302
+ USWDS publicou com reprovação WCAG registrada e que o MUI abandonou na v6.
303
+
304
+ ⚠ **`notation: "compact"` é recusado** — quebrado no motor JS do RN nos dois sistemas. Sai aviso em
305
+ `__DEV__` e a opção é ignorada; o resto do formato sobrevive.
306
+
307
+ Os três auxiliares são públicos, porque o app tem o mesmo problema fora do campo:
308
+
309
+ ```tsx
310
+ import {formatarNumero, lerNumero, separadoresDoLocale} from "@aurea-uds/native";
311
+
312
+ formatarNumero(1234.5, "pt-BR", {style: "currency", currency: "BRL"}); // "R$ 1.234,50"
313
+ lerNumero("R$ 1.234,50", "pt-BR"); // 1234.5
314
+ lerNumero("", "pt-BR"); // null ← não é 0
315
+ ```
316
+
317
+ Para **texto** (placa, documento, telefone) o momento é o mesmo e o formato é seu:
318
+
319
+ ```tsx
320
+ <Input value={placa} onChangeText={setPlaca} formatOnBlur={(v) => v.toUpperCase()} />
321
+ ```
322
+
323
+ ⚠ `formatOnBlur` exige o campo **controlado** — o valor formatado sai por `onChangeText`.
324
+
325
+ ### `Image` e `Gallery` — mostrar foto
326
+
327
+ ⚠ **`Image` NÃO é o `Image` do React Native com outra pele.** Ele faz as duas coisas que o
328
+ primitivo cru não faz: **reserva a caixa** antes dos bytes (sem isso o layout salta) e **cai para
329
+ um substituto** quando eles não vêm (sem isso uma URL quebrada deixa buraco).
330
+
331
+ ```tsx
332
+ <Image source={foto.uri} alt="Frente do item" ratio="4/3" />
333
+
334
+ <Gallery items={fotos} zoom selected={atual} onSelect={setAtual} />
335
+ ```
336
+
337
+ | | |
338
+ |---|---|
339
+ | `ratio` | aceita `4/3`, `"4/3"`, `"16:9"` ou o número. A web usa string; o RN quer número |
340
+ | `alt` | **obrigatório**. Decorativa de verdade se escreve `alt=""`, explícito |
341
+ | ampliar | é o **`Dialog`** que já existe, não uma superfície nova |
342
+ | legenda | com legenda, o nome vai para o LADRILHO e a imagem sai da árvore de acessibilidade |
343
+ | ícone a registrar | `image` (o substituto) |
344
+
345
+ Quem já usa `expo-image` por cache de disco troca o elemento sem perder a pele:
346
+
347
+ ```tsx
348
+ <Image render={<ExpoImage contentFit="cover" transition={150} />} source={u} alt="…" />
349
+ ```
350
+
351
+ ⚠ `fit` **não** é traduzido para o elemento passado — `resizeMode` é do `Image` do RN e o
352
+ `expo-image` chama a mesma coisa de `contentFit`. Quem passa o elemento escreve a prop dele.
353
+
222
354
  ## Data e câmera
223
355
 
224
356
  Estes dois **não saem do barril** — são o único ponto do pacote com importação própria:
@@ -0,0 +1,119 @@
1
+ import * as React from "react";
2
+ import { type StyleProp, type ViewStyle } from "react-native";
3
+ import { type IconName } from "./icon.js";
4
+ import { type AureaFieldSize } from "./inputs.js";
5
+ /** Uma linha do catálogo. **Mesma forma da web** (`ComboboxOption`), mais o `disabled` que o `Select` daqui já tinha. */
6
+ export interface AureaComboboxItem {
7
+ value: string;
8
+ label: string;
9
+ disabled?: boolean;
10
+ }
11
+ export interface ComboboxProps {
12
+ /**
13
+ * O que a folha mostra. **Com `onSearchChange`, é a lista JÁ BUSCADA** — a Aurea não filtra e
14
+ * não guarda; ela desenha o que chegou.
15
+ */
16
+ items: AureaComboboxItem[];
17
+ /**
18
+ * O escolhido, como ITEM inteiro e não como `value` — mesma escolha da web, e ela tem razão
19
+ * prática aqui: numa busca remota o item escolhido pode não estar mais em `items` (a pessoa
20
+ * digitou outra coisa depois de escolher), e o gatilho ainda precisa saber que rótulo mostrar.
21
+ * Guardar só o `value` deixaria o campo em branco no instante seguinte à escolha.
22
+ */
23
+ value?: AureaComboboxItem | null;
24
+ onValueChange?: (v: AureaComboboxItem | null) => void;
25
+ /**
26
+ * O que foi digitado, já com espera. **A presença desta prop DESLIGA o filtro da Aurea** — é a
27
+ * chave entre catálogo remoto e lista na mão, e é presença, não configuração.
28
+ */
29
+ onSearchChange?: (query: string) => void;
30
+ /**
31
+ * A espera antes de chamar `onSearchChange`, em ms. Padrão **250**.
32
+ *
33
+ * Não é enfeite de performance: sem ela, "cadeira" são sete chamadas ao servidor e seis
34
+ * respostas descartadas — e a última a chegar pode não ser a última pedida. `0` desliga.
35
+ */
36
+ searchDelay?: number;
37
+ /** Mostra o anel no lugar da lista. Use enquanto a busca remota não voltou. */
38
+ loading?: boolean;
39
+ /** Fim da lista alcançado — é por aqui que o catálogo remoto pagina. */
40
+ onEndReached?: () => void;
41
+ /** O que o gatilho mostra sem escolha. */
42
+ placeholder?: string;
43
+ /** O que o campo da folha mostra vazio. Sem ele, a frase `comboboxSearch` do provider. */
44
+ searchPlaceholder?: string;
45
+ /** O que a folha mostra sem resultado. Sem ele, a frase `comboboxEmpty` do provider. */
46
+ empty?: React.ReactNode;
47
+ /** Mostra o botão de limpar no gatilho quando há escolha. Padrão **true**. */
48
+ clearable?: boolean;
49
+ disabled?: boolean;
50
+ size?: AureaFieldSize;
51
+ /** O glifo da seta. Registre-o, ou passe `false`. */
52
+ chevron?: IconName | false;
53
+ /** O glifo da lupa no campo da folha. Registre-o, ou passe `false`. */
54
+ searchIcon?: IconName | false;
55
+ style?: StyleProp<ViewStyle>;
56
+ testID?: string;
57
+ }
58
+ /**
59
+ * O campo em que se DIGITA para achar um item.
60
+ *
61
+ * ```tsx
62
+ * // catálogo remoto — a Aurea não filtra, o app busca
63
+ * <Field label="Item">
64
+ * <Combobox
65
+ * items={resultados}
66
+ * value={escolhido}
67
+ * onValueChange={setEscolhido}
68
+ * onSearchChange={buscarNoServidor}
69
+ * loading={buscando}
70
+ * onEndReached={proximaPagina}
71
+ * />
72
+ * </Field>
73
+ *
74
+ * // lista na mão — a Aurea filtra
75
+ * <Combobox items={unidades} value={unidade} onValueChange={setUnidade} />
76
+ * ```
77
+ *
78
+ * ⚠ **O gatilho é `button`, não `combobox`** — e desta vez a razão é medida, não herdada do
79
+ * `Select`: `role="combobox"` mapeia no Android (`roleDescription`) e **não mapeia no iOS**; e o
80
+ * gatilho, de qualquer forma, não aceita digitação. Quem digita é o campo da folha, que leva
81
+ * `role="search"` — esse mapeia nos dois. A tabela com arquivo e linha está no topo deste módulo.
82
+ *
83
+ * ⚠ **Ele não guarda a escolha nem o texto buscado.** `value` e o que se digita são do app, como
84
+ * na web e pela mesma razão do achado I1 da auditoria: um componente com a sua própria cópia do
85
+ * estado é a segunda fonte de verdade que ninguém sabe que existe.
86
+ */
87
+ export declare function Combobox({ items, value, onValueChange, onSearchChange, searchDelay, loading, onEndReached, placeholder, searchPlaceholder, empty, clearable, disabled, size, chevron, searchIcon, style, testID, }: ComboboxProps): React.JSX.Element;
88
+ export interface SearchFieldProps {
89
+ value?: string;
90
+ onChangeText?: (v: string) => void;
91
+ /** Chamado com o texto já esperado. Mesma espera do `Combobox`, e mesmo padrão de 250 ms. */
92
+ onSearchChange?: (query: string) => void;
93
+ searchDelay?: number;
94
+ placeholder?: string;
95
+ disabled?: boolean;
96
+ size?: AureaFieldSize;
97
+ /** O glifo da lupa. Padrão `search`; `false` tira. */
98
+ icon?: IconName | false;
99
+ /** Mostra o botão de limpar quando há texto. Padrão **true**. */
100
+ clearable?: boolean;
101
+ onSubmit?: () => void;
102
+ style?: StyleProp<ViewStyle>;
103
+ testID?: string;
104
+ }
105
+ /**
106
+ * A lupa + o campo, numa peça só.
107
+ *
108
+ * ⚠ **Não é o mesmo papel do `Combobox`, e confundir os dois é o erro comum:** o `Combobox`
109
+ * ESCOLHE um item de um catálogo e devolve a escolha; este FILTRA o que já está na tela e devolve
110
+ * texto. Um cadastro pede o primeiro; uma lista com muitas linhas pede o segundo.
111
+ *
112
+ * ⚠ **`role="search"`, e ele mapeia nos dois sistemas** — conferido no fonte do RN 0.87.1, com
113
+ * arquivo e linha no topo deste módulo. É a diferença entre este componente e o `Combobox`, cujo
114
+ * gatilho não pôde levar `combobox` porque o iOS não tem o trait.
115
+ *
116
+ * ⚠ **A borda mora no GRUPO, não no campo** — é o `.input-group` da web (`aurea.css:741`), e é o
117
+ * que deixa lupa, campo e botão de limpar dentro de uma caixa só em vez de três.
118
+ */
119
+ export declare function SearchField({ value, onChangeText, onSearchChange, searchDelay, placeholder, disabled, size, icon, clearable, onSubmit, style, testID, }: SearchFieldProps): React.JSX.Element;
package/dist/busca.js ADDED
@@ -0,0 +1,338 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Aurea nativo — a BUSCA: `Combobox` e `SearchField`.
3
+ //
4
+ // Lote 7 do `NATIVE.md` §8 — o primeiro lote que nasce de DEMANDA MEDIDA e não do plano: o alvo
5
+ // nativo fechou em 46 componentes em 11/09/2026, e o consumidor bateu em três lacunas na primeira
6
+ // tela que a pessoa toca. Esta é a primeira delas, e é a que bloqueia o cadastro.
7
+ //
8
+ // ── O QUE FALTAVA, LITERALMENTE ──────────────────────────────────────────────────────────────
9
+ // O `Select` (`inputs.tsx:576`) escreve a própria lacuna: *"O papel é `button` e não `combobox`.
10
+ // `combobox` promete um campo em que se DIGITA para filtrar; isto só abre uma lista."* Era
11
+ // honesto, e continua sendo — o `Select` não mudou. O que faltava era o componente que CUMPRE a
12
+ // promessa, e é este.
13
+ //
14
+ // E havia um agravante que a demanda não citava, medido aqui: a folha do `Select` usa
15
+ // `ScrollView` (`inputs.tsx:632`), que **monta todos os itens**. Um catálogo de milhares de
16
+ // linhas ali não fica lento — ele trava. Este usa `FlatList`, como o `DataList` do Lote 6 já
17
+ // fazia (`data.tsx:191`).
18
+ //
19
+ // ── A DIVERGÊNCIA DA WEB, E ELA É DELIBERADA ─────────────────────────────────────────────────
20
+ // **A demanda é busca REMOTA de seleção única, e essa combinação não existe na web.** Medido em
21
+ // `inputs-client.tsx`:
22
+ //
23
+ // Combobox (seleção única) -> `items` e filtro do Base UI, do lado do cliente. Sem `onInputChange`.
24
+ // MultiCombobox (múltipla) -> tem `onInputChange` + `loading`, e desliga o filtro (`filter={null}`)
25
+ //
26
+ // Ou seja: quem quer buscar no servidor E escolher UM item não tem componente na web. Copiar o
27
+ // contrato de lá deixaria o consumidor com um filtro de cliente sobre milhares de linhas que ele
28
+ // nem baixou.
29
+ //
30
+ // Então este componente soma `onSearchChange` + `loading` + `onEndReached` ao contrato de seleção
31
+ // única, e **a regra é uma só e vale para os dois lados**: se `onSearchChange` existe, a Aurea
32
+ // NÃO filtra — a lista que chega é a lista que aparece, e quem busca é o app. Sem ele, a Aurea
33
+ // filtra o que recebeu. É a mesma chave do `filter={null}` do `MultiCombobox`, escrita como
34
+ // presença de prop em vez de configuração de motor.
35
+ //
36
+ // É a mesma classe de divergência que a `Table` do Lote 6 assumiu (`columns`+`rows` em vez de
37
+ // `children`): quando a plataforma muda o problema, o contrato muda com ele — declarado, não
38
+ // escondido.
39
+ //
40
+ // ── ONDE SE DIGITA, E POR QUE NÃO É NO GATILHO ───────────────────────────────────────────────
41
+ // Na web o `<input>` É o combobox: digita-se nele e a lista desce ancorada. Aqui **o gatilho
42
+ // continua sendo um botão, e o campo de digitar mora DENTRO da folha**. Três razões medidas, não
43
+ // preferidas:
44
+ //
45
+ // 1. o teclado do telefone ocupa metade da tela. Um campo ancorado no meio de um formulário
46
+ // abre o teclado por cima da própria lista — a folha sobe do rodapé e fica acima dele;
47
+ // 2. o gatilho precisa mostrar o ESCOLHIDO com a geometria do `.input` (mesma altura, mesma
48
+ // borda, a mesma seta) — é o que faz o campo parecer um campo no formulário. Um campo de
49
+ // texto ali mostraria o que se digitou, não o que se escolheu;
50
+ // 3. é o idioma dos seletores do próprio sistema, nos dois lados.
51
+ //
52
+ // ── A ACESSIBILIDADE, MEDIDA NO FONTE DO RN 0.87.1 (e não presumida) ─────────────────────────
53
+ // O Lote 5 ensinou que o RN ACEITA papel que não mapeia (`dialog` compila e vira `null`). Então
54
+ // os dois papéis daqui foram conferidos no fonte do `react-native@0.87.1` antes de usados:
55
+ //
56
+ // `role="search"` -> `accessibilityPropsConversions.h:64-65` traduz para
57
+ // `AccessibilityTraits::SearchField`, que o `RCTConversions.h:120-121`
58
+ // vira `UIAccessibilityTraitSearchField` no iOS; no Android o
59
+ // `ReactAccessibilityDelegate.kt:461` dá a `className`
60
+ // `android.widget.EditText`. **Mapeia nos dois.**
61
+ // `role="combobox"` -> no Android o `ReactAccessibilityDelegate.kt:680-682` põe
62
+ // `roleDescription` de `R.string.combobox_description` (o TalkBack diz
63
+ // "caixa de combinação"); **no iOS não há trait equivalente** — não está
64
+ // na tabela do `RCTConversions.h`. Mapeia em UM lado.
65
+ //
66
+ // Por isso o gatilho é `button` com `expanded`, como o do `Select`: ele abre uma folha e não
67
+ // aceita digitação, então `button` é o que ele É nos dois sistemas. Quem digita é o campo da
68
+ // folha, e ele leva `search` — que mapeia nos dois.
69
+ import * as React from "react";
70
+ import { FlatList, Modal, Pressable, TextInput, View, } from "react-native";
71
+ import { IconButton } from "./actions.js";
72
+ import { criarFolha } from "./estilos.js";
73
+ import { Spinner } from "./feedback.js";
74
+ import { Icon } from "./icon.js";
75
+ import { useCampo } from "./inputs.js";
76
+ import { Text } from "./text.js";
77
+ import { useAureaStrings, useAureaTokens } from "./theme.js";
78
+ // As mesmas contas do `inputs.tsx`, e elas são repetidas AQUI de propósito: exportá-las de lá
79
+ // tornaria pública uma conta interna do outro módulo, e um `import` de função privada entre
80
+ // módulos irmãos é o começo de um acoplamento que ninguém declara. São três linhas.
81
+ const alturaDoTamanho = (t, s) => s === "sm" ? t.size.controlHSm : s === "lg" ? t.size.controlHLg : t.size.controlHMd;
82
+ const fonteDoTamanho = (t, s) => s === "sm" ? t.size.textXs : s === "lg" ? t.size.textBase : t.size.textMd;
83
+ const respiroDoTamanho = (t, s) => s === "sm" ? t.size.space3 : s === "lg" ? t.size.space4 : 13;
84
+ const folha = criarFolha((t) => ({
85
+ // O gatilho é o `.input` da web (`aurea.css:685`), igual ao do `Select` — mesma altura, mesma
86
+ // borda, o mesmo raio de controle.
87
+ caixa: {
88
+ width: "100%", minWidth: 0,
89
+ borderWidth: t.size.borderWidth, borderColor: t.color.borderStrong,
90
+ borderRadius: t.size.radiusControl, backgroundColor: t.color.fieldBg,
91
+ flexDirection: "row", alignItems: "center",
92
+ },
93
+ invalido: { borderColor: t.color.danger400 ?? t.color.destructive },
94
+ desabilitado: { opacity: 0.5 },
95
+ // As ações do gatilho — limpar e a seta. O `.combobox-actions` da web (`aurea.css:1673`) é
96
+ // absoluto sobre o campo; aqui é um irmão na linha, porque não há campo por baixo.
97
+ acoes: { flexDirection: "row", alignItems: "center", gap: 2 },
98
+ // A folha, com a mesma anatomia da do `Select` (`inputs.tsx:98-113`) — e é de propósito que
99
+ // sejam iguais: são o mesmo gesto, e duas folhas diferentes para o mesmo gesto é como uma
100
+ // biblioteca deixa de ter linguagem.
101
+ fundoDaLista: { flex: 1, justifyContent: "flex-end", backgroundColor: "#00000080" },
102
+ fundoDeToque: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0 },
103
+ lista: {
104
+ // `maxHeight` MAIOR que a do `Select` (60%), e a razão é o teclado: aqui ele sobe junto, e
105
+ // uma folha de 60% com teclado aberto deixa três linhas visíveis. O `KeyboardAvoidingView`
106
+ // não entra — dentro de um `Modal` do RN ele não recebe as métricas do teclado no Android.
107
+ maxHeight: "85%", minHeight: "45%",
108
+ borderTopLeftRadius: t.size.radiusCard, borderTopRightRadius: t.size.radiusCard,
109
+ backgroundColor: t.color.popover, borderWidth: t.size.borderWidth, borderColor: t.color.border,
110
+ },
111
+ // O campo de digitar, no topo da folha. É o `.input-group` da web (`aurea.css:741`): a borda
112
+ // mora no GRUPO e o campo dentro dele é transparente e sem borda.
113
+ grupo: {
114
+ flexDirection: "row", alignItems: "center", gap: t.size.space1,
115
+ margin: t.size.space3,
116
+ paddingHorizontal: t.size.space3,
117
+ borderWidth: t.size.borderWidth, borderColor: t.color.borderStrong,
118
+ borderRadius: t.size.radiusControl, backgroundColor: t.color.fieldBg,
119
+ },
120
+ // ⚠ `paddingVertical: 0` e `textAlignVertical: "center"` — a MESMA correção que o `Input` do
121
+ // Lote 4 levou depois do vidro (`inputs.tsx:280-296`): sem elas o Android injeta o padding do
122
+ // tema dele no nó do Yoga e o texto sai cortado dentro de uma caixa de altura fixa. A regra do
123
+ // `CLAUDE.md` ("correção local é proibida sem responder quem mais tem esse problema") é o que
124
+ // traz as duas linhas para cá, e este campo é exatamente "quem mais".
125
+ campoDeTexto: { flex: 1, minWidth: 0, paddingVertical: 0, textAlignVertical: "center" },
126
+ opcao: {
127
+ minHeight: t.size.controlHLg, justifyContent: "center",
128
+ paddingHorizontal: t.size.space4, paddingVertical: t.size.space2,
129
+ },
130
+ opcaoEscolhida: { backgroundColor: t.color.surface3 ?? t.color.muted },
131
+ vazio: { paddingHorizontal: t.size.space4, paddingVertical: t.size.space5, alignItems: "center" },
132
+ carregando: { paddingVertical: t.size.space4, alignItems: "center" },
133
+ }));
134
+ /**
135
+ * O campo em que se DIGITA para achar um item.
136
+ *
137
+ * ```tsx
138
+ * // catálogo remoto — a Aurea não filtra, o app busca
139
+ * <Field label="Item">
140
+ * <Combobox
141
+ * items={resultados}
142
+ * value={escolhido}
143
+ * onValueChange={setEscolhido}
144
+ * onSearchChange={buscarNoServidor}
145
+ * loading={buscando}
146
+ * onEndReached={proximaPagina}
147
+ * />
148
+ * </Field>
149
+ *
150
+ * // lista na mão — a Aurea filtra
151
+ * <Combobox items={unidades} value={unidade} onValueChange={setUnidade} />
152
+ * ```
153
+ *
154
+ * ⚠ **O gatilho é `button`, não `combobox`** — e desta vez a razão é medida, não herdada do
155
+ * `Select`: `role="combobox"` mapeia no Android (`roleDescription`) e **não mapeia no iOS**; e o
156
+ * gatilho, de qualquer forma, não aceita digitação. Quem digita é o campo da folha, que leva
157
+ * `role="search"` — esse mapeia nos dois. A tabela com arquivo e linha está no topo deste módulo.
158
+ *
159
+ * ⚠ **Ele não guarda a escolha nem o texto buscado.** `value` e o que se digita são do app, como
160
+ * na web e pela mesma razão do achado I1 da auditoria: um componente com a sua própria cópia do
161
+ * estado é a segunda fonte de verdade que ninguém sabe que existe.
162
+ */
163
+ export function Combobox({ items, value, onValueChange, onSearchChange, searchDelay = 250, loading, onEndReached, placeholder, searchPlaceholder, empty, clearable = true, disabled, size, chevron = "chevron--down", searchIcon = "search", style, testID, }) {
164
+ const t = useAureaTokens();
165
+ const s = folha(t);
166
+ const strings = useAureaStrings();
167
+ const campo = useCampo();
168
+ const tam = size ?? campo?.size ?? "md";
169
+ const inativo = disabled ?? campo?.disabled;
170
+ const [aberto, setAberto] = React.useState(false);
171
+ const [texto, setTexto] = React.useState("");
172
+ // ── A ESPERA ────────────────────────────────────────────────────────────────────────────────
173
+ // O timer vive num `ref` e é limpo em três lugares: à digitação seguinte, ao fechar a folha e
174
+ // ao desmontar. O terceiro é o que importa e o que mais se esquece — sem ele, sair da tela com
175
+ // uma busca pendente dispara `onSearchChange` sobre um componente que já não existe, e o app
176
+ // leva um `setState` em árvore desmontada.
177
+ const relogio = React.useRef(null);
178
+ const cancelar = React.useCallback(() => {
179
+ if (relogio.current != null) {
180
+ clearTimeout(relogio.current);
181
+ relogio.current = null;
182
+ }
183
+ }, []);
184
+ React.useEffect(() => cancelar, [cancelar]);
185
+ // A referência mais recente da função de busca, para o timer não disparar a de um render
186
+ // anterior. É o padrão de `ref` para callback — e existe porque o app costuma passar uma
187
+ // arrow nova a cada render.
188
+ const buscaAtual = React.useRef(onSearchChange);
189
+ React.useEffect(() => { buscaAtual.current = onSearchChange; }, [onSearchChange]);
190
+ const digitar = React.useCallback((v) => {
191
+ setTexto(v);
192
+ if (!buscaAtual.current)
193
+ return;
194
+ cancelar();
195
+ if (searchDelay <= 0) {
196
+ buscaAtual.current(v);
197
+ return;
198
+ }
199
+ relogio.current = setTimeout(() => {
200
+ relogio.current = null;
201
+ buscaAtual.current?.(v);
202
+ }, searchDelay);
203
+ }, [cancelar, searchDelay]);
204
+ const fechar = React.useCallback(() => {
205
+ cancelar();
206
+ setAberto(false);
207
+ setTexto("");
208
+ }, [cancelar]);
209
+ // ── O FILTRO, QUANDO ELE É NOSSO ────────────────────────────────────────────────────────────
210
+ const filtrados = React.useMemo(() => {
211
+ if (onSearchChange)
212
+ return items; // busca remota: a lista que chegou é a lista.
213
+ const alvo = dobrar(texto.trim());
214
+ if (!alvo)
215
+ return items;
216
+ return items.filter((i) => dobrar(i.label).includes(alvo));
217
+ }, [items, texto, onSearchChange]);
218
+ const nada = !loading && filtrados.length === 0;
219
+ return (_jsxs(_Fragment, { children: [_jsxs(Pressable, { testID: testID, onPress: inativo ? undefined : () => setAberto(true), disabled: inativo, accessibilityRole: "button", accessibilityLabel: campo?.label, accessibilityHint: campo?.hint, accessibilityValue: { text: value?.label }, accessibilityState: { disabled: !!inativo, expanded: aberto }, style: [
220
+ s.caixa,
221
+ { height: alturaDoTamanho(t, tam), paddingHorizontal: respiroDoTamanho(t, tam) },
222
+ campo?.invalido && s.invalido,
223
+ inativo && s.desabilitado,
224
+ style,
225
+ ], children: [_jsx(Text, { size: tam === "sm" ? "xs" : tam === "lg" ? "base" : "md", tone: value ? "default" : "subtle", numberOfLines: 1, style: { flex: 1, minWidth: 0 }, children: value?.label ?? placeholder ?? "" }), _jsxs(View, { style: s.acoes, children: [clearable && value != null && !inativo && (_jsx(IconButton, { name: "close", label: strings.comboboxClear, appearance: "ghost", size: "sm", onPress: () => onValueChange?.(null), testID: testID ? `${testID}-limpar` : undefined })), chevron && _jsx(Icon, { name: chevron, size: "sm", color: t.color.subtleForeground })] })] }), _jsx(Modal, { visible: aberto, transparent: true, animationType: "slide", statusBarTranslucent: true, navigationBarTranslucent: true, onRequestClose: fechar, children: _jsxs(View, { style: s.fundoDaLista, children: [_jsx(Pressable, { style: s.fundoDeToque, onPress: fechar, accessible: false, testID: testID ? `${testID}-fundo` : undefined }), _jsxs(View, { style: s.lista, onStartShouldSetResponder: () => true, children: [_jsxs(View, { style: [s.grupo, { height: alturaDoTamanho(t, tam) }], children: [searchIcon && _jsx(Icon, { name: searchIcon, size: "sm", color: t.color.subtleForeground }), _jsx(TextInput, { testID: testID ? `${testID}-busca` : undefined, value: texto, onChangeText: digitar, placeholder: searchPlaceholder ?? strings.comboboxSearch, placeholderTextColor: t.color.subtleForeground,
226
+ // ⚠ `autoFocus` é o que faz a folha valer a pena: abrir um campo de busca e
227
+ // exigir um segundo toque para o teclado subir é um toque a mais em cada
228
+ // cadastro. O foco entra com a folha; o teclado vem junto.
229
+ autoFocus: true, autoCorrect: false, autoCapitalize: "none",
230
+ // Mapeia nos dois sistemas — conferido no fonte do RN, tabela no topo do módulo.
231
+ accessibilityRole: "search", accessibilityLabel: searchPlaceholder ?? strings.comboboxSearch,
232
+ // `returnKeyType="search"` troca o "enter" do teclado pela lupa. É pista de
233
+ // plataforma, não decoração: diz à pessoa que aquele campo é de busca antes de
234
+ // ela digitar a primeira letra.
235
+ returnKeyType: "search", style: [
236
+ s.campoDeTexto,
237
+ { fontSize: fonteDoTamanho(t, tam), fontFamily: t.font.ui[400],
238
+ color: t.color.foreground },
239
+ ] }), texto.length > 0 && (_jsx(IconButton, { name: "close", label: strings.searchClear, appearance: "ghost", size: "sm", onPress: () => digitar(""), testID: testID ? `${testID}-busca-limpar` : undefined }))] }), loading && (_jsx(View, { style: s.carregando, children: _jsx(Spinner, { label: strings.comboboxLoading }) })), nada && (_jsx(View, { style: s.vazio, children: typeof empty === "string" || empty == null
240
+ ? _jsx(Text, { size: "sm", tone: "muted", children: empty ?? strings.comboboxEmpty })
241
+ : empty })), _jsx(FlatList, { data: filtrados, keyExtractor: (i) => i.value, keyboardShouldPersistTaps: "handled", onEndReached: onEndReached, onEndReachedThreshold: 0.5, renderItem: ({ item }) => (_jsx(Pressable, { disabled: item.disabled, onPress: () => { onValueChange?.(item); fechar(); }, accessibilityRole: "menuitem", accessibilityState: {
242
+ selected: item.value === value?.value, disabled: !!item.disabled,
243
+ }, style: [
244
+ s.opcao,
245
+ item.value === value?.value && s.opcaoEscolhida,
246
+ item.disabled && s.desabilitado,
247
+ ], children: _jsx(Text, { size: "md", weight: item.value === value?.value ? 600 : 400, children: item.label }) })) })] })] }) })] }));
248
+ }
249
+ // ── A DOBRA DE ACENTO, e por que ela é sondada em vez de presumida ───────────────────────────
250
+ // Buscar "acucar" tem de achar "açúcar" — num catálogo em português, exigir o acento certo é
251
+ // exigir que a pessoa saiba escrever o que está procurando. A dobra é `NFD` + remoção dos
252
+ // diacríticos combinantes, que é a forma padrão.
253
+ //
254
+ // **A sonda existe porque o motor é o motor, não um navegador.** `String.prototype.normalize` é
255
+ // ES2015 e está no motor, mas este pacote roda em qualquer motor que o consumidor escolha — e
256
+ // um `normalize` ausente derrubaria a busca inteira com `TypeError` em vez de degradá-la. A
257
+ // sonda roda UMA vez, no carregamento do módulo, e custa uma string de dois caracteres.
258
+ const TEM_NORMALIZE = (() => {
259
+ try {
260
+ return "é".normalize("NFD").length === 2;
261
+ }
262
+ catch {
263
+ return false;
264
+ }
265
+ })();
266
+ const dobrar = (v) => {
267
+ const minusculo = v.toLowerCase();
268
+ return TEM_NORMALIZE
269
+ ? minusculo.normalize("NFD").replace(/[̀-ͯ]/g, "")
270
+ : minusculo;
271
+ };
272
+ /**
273
+ * A lupa + o campo, numa peça só.
274
+ *
275
+ * ⚠ **Não é o mesmo papel do `Combobox`, e confundir os dois é o erro comum:** o `Combobox`
276
+ * ESCOLHE um item de um catálogo e devolve a escolha; este FILTRA o que já está na tela e devolve
277
+ * texto. Um cadastro pede o primeiro; uma lista com muitas linhas pede o segundo.
278
+ *
279
+ * ⚠ **`role="search"`, e ele mapeia nos dois sistemas** — conferido no fonte do RN 0.87.1, com
280
+ * arquivo e linha no topo deste módulo. É a diferença entre este componente e o `Combobox`, cujo
281
+ * gatilho não pôde levar `combobox` porque o iOS não tem o trait.
282
+ *
283
+ * ⚠ **A borda mora no GRUPO, não no campo** — é o `.input-group` da web (`aurea.css:741`), e é o
284
+ * que deixa lupa, campo e botão de limpar dentro de uma caixa só em vez de três.
285
+ */
286
+ export function SearchField({ value, onChangeText, onSearchChange, searchDelay = 250, placeholder, disabled, size, icon = "search", clearable = true, onSubmit, style, testID, }) {
287
+ const t = useAureaTokens();
288
+ const s = folha(t);
289
+ const strings = useAureaStrings();
290
+ const campo = useCampo();
291
+ const tam = size ?? campo?.size ?? "md";
292
+ const inativo = disabled ?? campo?.disabled;
293
+ const [focado, setFocado] = React.useState(false);
294
+ // O campo pode ser controlado (`value`) ou não; o texto interno cobre o segundo caso, e é o que
295
+ // o botão de limpar precisa para saber se tem o que limpar.
296
+ const [interno, setInterno] = React.useState("");
297
+ const texto = value ?? interno;
298
+ const relogio = React.useRef(null);
299
+ const cancelar = React.useCallback(() => {
300
+ if (relogio.current != null) {
301
+ clearTimeout(relogio.current);
302
+ relogio.current = null;
303
+ }
304
+ }, []);
305
+ React.useEffect(() => cancelar, [cancelar]);
306
+ const buscaAtual = React.useRef(onSearchChange);
307
+ React.useEffect(() => { buscaAtual.current = onSearchChange; }, [onSearchChange]);
308
+ const digitar = React.useCallback((v) => {
309
+ setInterno(v);
310
+ onChangeText?.(v);
311
+ if (!buscaAtual.current)
312
+ return;
313
+ cancelar();
314
+ if (searchDelay <= 0) {
315
+ buscaAtual.current(v);
316
+ return;
317
+ }
318
+ relogio.current = setTimeout(() => {
319
+ relogio.current = null;
320
+ buscaAtual.current?.(v);
321
+ }, searchDelay);
322
+ }, [cancelar, onChangeText, searchDelay]);
323
+ return (_jsxs(View, { testID: testID, style: [
324
+ s.caixa,
325
+ { height: alturaDoTamanho(t, tam), paddingHorizontal: respiroDoTamanho(t, tam),
326
+ gap: t.size.space1 },
327
+ campo?.invalido && s.invalido,
328
+ // O foco é a única pista de campo ativo que sobra no telefone — mesma decisão do `Input`
329
+ // do Lote 4, e ela vale igual aqui.
330
+ focado && { borderColor: t.color.focusStrong },
331
+ inativo && s.desabilitado,
332
+ style,
333
+ ], children: [icon && _jsx(Icon, { name: icon, size: "sm", color: t.color.subtleForeground }), _jsx(TextInput, { testID: testID ? `${testID}-campo` : undefined, value: value, onChangeText: digitar, placeholder: placeholder, placeholderTextColor: t.color.subtleForeground, editable: !inativo, autoCorrect: false, autoCapitalize: "none", returnKeyType: "search", onSubmitEditing: onSubmit, onFocus: () => setFocado(true), onBlur: () => setFocado(false), accessibilityRole: "search", accessibilityLabel: campo?.label ?? placeholder, accessibilityHint: campo?.hint, accessibilityState: { disabled: !!inativo }, style: [
334
+ s.campoDeTexto,
335
+ { fontSize: fonteDoTamanho(t, tam), fontFamily: t.font.ui[400],
336
+ color: t.color.foreground },
337
+ ] }), clearable && texto.length > 0 && !inativo && (_jsx(IconButton, { name: "close", label: strings.searchClear, appearance: "ghost", size: "sm", onPress: () => digitar(""), testID: testID ? `${testID}-limpar` : undefined }))] }));
338
+ }
package/dist/index.d.ts CHANGED
@@ -33,3 +33,9 @@ export { Timeline, DataList, Table } from "./data.js";
33
33
  export type { TimelineProps, DataListProps, TableProps, AureaColumn, AureaTimelineItem, AureaDataListItem, } from "./data.js";
34
34
  export { Chart, ChartLegend } from "./chart.js";
35
35
  export type { ChartProps, ChartLegendProps, AureaSeries, AureaChartMark } from "./chart.js";
36
+ export { Combobox, SearchField } from "./busca.js";
37
+ export type { ComboboxProps, SearchFieldProps, AureaComboboxItem } from "./busca.js";
38
+ export { NumberField, formatarNumero, lerNumero, separadoresDoLocale } from "./numero.js";
39
+ export type { NumberFieldProps, AureaSeparadores } from "./numero.js";
40
+ export { Image, Gallery } from "./midia.js";
41
+ export type { ImageProps, GalleryProps, AureaGalleryItem, AureaImageSource, } from "./midia.js";
package/dist/index.js CHANGED
@@ -86,3 +86,24 @@ export { Timeline, DataList, Table } from "./data.js";
86
86
  // ⚠ **Com duas ou mais séries a legenda NÃO desliga**: a paleta `--chart-*` é uma rampa de um
87
87
  // azul só, e duas séries vizinhas nela são indistinguíveis (ΔE 5.9, medido).
88
88
  export { Chart, ChartLegend } from "./chart.js";
89
+ // ── Lote 7 — as três lacunas que o CONSUMIDOR mediu, e não o plano ─────────────────────────
90
+ // ⚠ Este é o primeiro lote do alvo nativo que **não sai do `NATIVE.md` §5**: o plano fechou em
91
+ // 11/09/2026 com 46 componentes e "não sobrou nada". Sobrou — só que a fila passou a ser a do
92
+ // app, não a do documento. As três estão no §8, com a demanda de cada uma.
93
+ //
94
+ // ⚠ **O `Combobox` DIVERGE da web, e é deliberado:** ele soma `onSearchChange`, `loading` e
95
+ // `onEndReached` ao contrato de seleção única, porque busca remota + escolha de UM item não
96
+ // existe lá (o `Combobox` da web filtra no cliente; quem tem `onInputChange` é o
97
+ // `MultiCombobox`, que é de seleção múltipla). A regra: **`onSearchChange` presente = a Aurea
98
+ // não filtra.**
99
+ export { Combobox, SearchField } from "./busca.js";
100
+ // ⚠ O `NumberField` formata no BLUR, nunca enquanto se digita — a [ADR-0024] atravessa inteira.
101
+ // O que muda no nativo é quem chama o `Intl` (não há Base UI) e a VOLTA, que a web não precisou
102
+ // escrever: `lerNumero` converte de novo o que a pessoa digitou. Os dois auxiliares são públicos
103
+ // porque o app tem o mesmo problema em toda tela de lançamento.
104
+ export { NumberField, formatarNumero, lerNumero, separadoresDoLocale } from "./numero.js";
105
+ // ⚠ O `Image` NÃO é o `Image` do React Native com outra pele: ele **reserva a caixa** antes dos
106
+ // bytes e **cai para um substituto** quando eles não vêm — as duas coisas que a ficha da web
107
+ // promete e que o primitivo cru não faz. Foi por não fazê-las que o `Avatar` precisou de
108
+ // conserto em 31/07/2026.
109
+ export { Image, Gallery } from "./midia.js";