@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/busca.js ADDED
@@ -0,0 +1,393 @@
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 { Animated, Easing, FlatList, Modal, PanResponder, 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 { KeyboardAvoiding, useCampo } from "./inputs.js";
76
+ import { useReduceMotion } from "./movimento.js";
77
+ import { Text } from "./text.js";
78
+ import { useAureaStrings, useAureaTokens } from "./theme.js";
79
+ // As mesmas contas do `inputs.tsx`, e elas são repetidas AQUI de propósito: exportá-las de lá
80
+ // tornaria pública uma conta interna do outro módulo, e um `import` de função privada entre
81
+ // módulos irmãos é o começo de um acoplamento que ninguém declara. São três linhas.
82
+ const alturaDoTamanho = (t, s) => s === "sm" ? t.size.controlHSm : s === "lg" ? t.size.controlHLg : t.size.controlHMd;
83
+ const fonteDoTamanho = (t, s) => s === "sm" ? t.size.textXs : s === "lg" ? t.size.textBase : t.size.textMd;
84
+ // ⚠ O `13` do tamanho `md` NÃO é número inventado: é o `padding-inline` literal do `.input`
85
+ // (`aurea.css:685`), e é o mesmo valor que o `inputs.tsx:60` usa. Não há token de 13 — a escala
86
+ // tem `space3`=12 e `space4`=16 —, e é por isso que o CSS o escreve cru dos dois lados.
87
+ const respiroDoTamanho = (t, s) => s === "sm" ? t.size.space3 : s === "lg" ? t.size.space4 : 13;
88
+ // ── AS DUAS CONSTANTES DE INTERAÇÃO, e elas NÃO são geometria ────────────────────────────────
89
+ // Ficam nomeadas e no topo porque número solto no meio do componente é o que o `CLAUDE.md`
90
+ // passou a proibir em 12/09/2026. **Nenhuma das duas tem linha de `aurea.css` atrás, e não
91
+ // deveria ter:** uma é tempo de digitação, a outra é fração de viewport do motor de lista.
92
+ //
93
+ // 250 ms é o intervalo entre teclas de quem digita corrido. Abaixo disso a espera não junta
94
+ // letra nenhuma e cada tecla vira uma ida ao servidor; muito acima, a lista demora a reagir e a
95
+ // pessoa acha que o campo travou. `searchDelay` existe para o app discordar.
96
+ const ESPERA_PADRAO = 250;
97
+ // Fração da altura visível que sobra abaixo antes de pedir a próxima página. Meia tela de
98
+ // antecedência: menos que isso e a lista chega ao fim antes da resposta; mais, e o app pagina
99
+ // páginas que ninguém vai ver. É prop do `FlatList`, não medida de desenho.
100
+ const ANTECEDENCIA_DE_PAGINA = 0.5;
101
+ const folha = criarFolha((t) => ({
102
+ // O gatilho é o `.input` da web (`aurea.css:685`), igual ao do `Select` — mesma altura, mesma
103
+ // borda, o mesmo raio de controle.
104
+ caixa: {
105
+ width: "100%", minWidth: 0,
106
+ borderWidth: t.size.borderWidth, borderColor: t.color.borderStrong,
107
+ borderRadius: t.size.radiusControl, backgroundColor: t.color.fieldBg,
108
+ flexDirection: "row", alignItems: "center",
109
+ },
110
+ invalido: { borderColor: t.color.danger400 ?? t.color.destructive },
111
+ desabilitado: { opacity: 0.5 },
112
+ // As ações do gatilho — limpar e a seta. O `.combobox-actions` da web (`aurea.css:1673`) é
113
+ // absoluto sobre o campo; aqui é um irmão na linha, porque não há campo por baixo.
114
+ // `space05` mede 2 — o mesmo valor, agora com nome. O `.combobox-actions` da web
115
+ // (`aurea.css:1673`) usa `gap:2px` literal; aqui o token existe, então é ele que entra.
116
+ acoes: { flexDirection: "row", alignItems: "center", gap: t.size.space05 },
117
+ // A folha, com a mesma anatomia da do `Select` (`inputs.tsx:98-113`) — e é de propósito que
118
+ // sejam iguais: são o mesmo gesto, e duas folhas diferentes para o mesmo gesto é como uma
119
+ // biblioteca deixa de ter linguagem.
120
+ fundoDaLista: { flex: 1, justifyContent: "flex-end", backgroundColor: "#00000080" },
121
+ fundoDeToque: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0 },
122
+ // 🔴 OS 85% E OS 45% ERAM INVENTADOS, e saíram em 12/09/2026. **A casa já tinha a medida:**
123
+ // o `folhaBaixo` do `BottomSheet` do Lote 5 (`overlays.tsx:104`) usa `maxHeight: "90%"`, e
124
+ // duas folhas que sobem do rodapé com alturas diferentes é como uma biblioteca deixa de ter
125
+ // linguagem. O `minHeight` sumiu: folha abraça o conteúdo, e uma altura mínima só servia para
126
+ // desenhar vazio.
127
+ lista: {
128
+ maxHeight: "90%",
129
+ borderTopLeftRadius: t.size.radiusCard, borderTopRightRadius: t.size.radiusCard,
130
+ backgroundColor: t.color.popover, borderWidth: t.size.borderWidth, borderColor: t.color.border,
131
+ },
132
+ // O puxador, copiado do `BottomSheet` (`overlays.tsx:111-112`) — mesma medida, mesmo token.
133
+ puxadorArea: { alignItems: "center", paddingVertical: t.size.space2 },
134
+ puxador: { width: 40, height: 4, borderRadius: t.size.radiusFull,
135
+ backgroundColor: t.color.borderStrong },
136
+ // O campo de digitar, no topo da folha. É o `.input-group` da web (`aurea.css:741`): a borda
137
+ // mora no GRUPO e o campo dentro dele é transparente e sem borda.
138
+ grupo: {
139
+ flexDirection: "row", alignItems: "center", gap: t.size.space1,
140
+ margin: t.size.space3,
141
+ paddingHorizontal: t.size.space3,
142
+ borderWidth: t.size.borderWidth, borderColor: t.color.borderStrong,
143
+ borderRadius: t.size.radiusControl, backgroundColor: t.color.fieldBg,
144
+ },
145
+ // ⚠ `paddingVertical: 0` e `textAlignVertical: "center"` — a MESMA correção que o `Input` do
146
+ // Lote 4 levou depois do vidro (`inputs.tsx:280-296`): sem elas o Android injeta o padding do
147
+ // tema dele no nó do Yoga e o texto sai cortado dentro de uma caixa de altura fixa. A regra do
148
+ // `CLAUDE.md` ("correção local é proibida sem responder quem mais tem esse problema") é o que
149
+ // traz as duas linhas para cá, e este campo é exatamente "quem mais".
150
+ campoDeTexto: { flex: 1, minWidth: 0, paddingVertical: 0, textAlignVertical: "center" },
151
+ opcao: {
152
+ minHeight: t.size.controlHLg, justifyContent: "center",
153
+ paddingHorizontal: t.size.space4, paddingVertical: t.size.space2,
154
+ },
155
+ opcaoEscolhida: { backgroundColor: t.color.surface3 ?? t.color.muted },
156
+ vazio: { paddingHorizontal: t.size.space4, paddingVertical: t.size.space5, alignItems: "center" },
157
+ carregando: { paddingVertical: t.size.space4, alignItems: "center" },
158
+ }));
159
+ /**
160
+ * O campo em que se DIGITA para achar um item.
161
+ *
162
+ * ```tsx
163
+ * // catálogo remoto — a Aurea não filtra, o app busca
164
+ * <Field label="Item">
165
+ * <Combobox
166
+ * items={resultados}
167
+ * value={escolhido}
168
+ * onValueChange={setEscolhido}
169
+ * onSearchChange={buscarNoServidor}
170
+ * loading={buscando}
171
+ * onEndReached={proximaPagina}
172
+ * />
173
+ * </Field>
174
+ *
175
+ * // lista na mão — a Aurea filtra
176
+ * <Combobox items={unidades} value={unidade} onValueChange={setUnidade} />
177
+ * ```
178
+ *
179
+ * ⚠ **O gatilho é `button`, não `combobox`** — e desta vez a razão é medida, não herdada do
180
+ * `Select`: `role="combobox"` mapeia no Android (`roleDescription`) e **não mapeia no iOS**; e o
181
+ * gatilho, de qualquer forma, não aceita digitação. Quem digita é o campo da folha, que leva
182
+ * `role="search"` — esse mapeia nos dois. A tabela com arquivo e linha está no topo deste módulo.
183
+ *
184
+ * ⚠ **Ele não guarda a escolha nem o texto buscado.** `value` e o que se digita são do app, como
185
+ * na web e pela mesma razão do achado I1 da auditoria: um componente com a sua própria cópia do
186
+ * estado é a segunda fonte de verdade que ninguém sabe que existe.
187
+ */
188
+ export function Combobox({ items, value, onValueChange, onSearchChange, searchDelay = ESPERA_PADRAO, loading, onEndReached, placeholder, searchPlaceholder, empty, clearable = true, draggable = true, disabled, size, chevron = "chevron--down", searchIcon = "search", style, testID, }) {
189
+ const t = useAureaTokens();
190
+ const s = folha(t);
191
+ const strings = useAureaStrings();
192
+ const campo = useCampo();
193
+ const tam = size ?? campo?.size ?? "md";
194
+ const inativo = disabled ?? campo?.disabled;
195
+ const [aberto, setAberto] = React.useState(false);
196
+ const [texto, setTexto] = React.useState("");
197
+ const reduzir = useReduceMotion();
198
+ // ── A ESPERA ────────────────────────────────────────────────────────────────────────────────
199
+ // O timer vive num `ref` e é limpo em três lugares: à digitação seguinte, ao fechar a folha e
200
+ // ao desmontar. O terceiro é o que importa e o que mais se esquece — sem ele, sair da tela com
201
+ // uma busca pendente dispara `onSearchChange` sobre um componente que já não existe, e o app
202
+ // leva um `setState` em árvore desmontada.
203
+ const relogio = React.useRef(null);
204
+ const cancelar = React.useCallback(() => {
205
+ if (relogio.current != null) {
206
+ clearTimeout(relogio.current);
207
+ relogio.current = null;
208
+ }
209
+ }, []);
210
+ React.useEffect(() => cancelar, [cancelar]);
211
+ // A referência mais recente da função de busca, para o timer não disparar a de um render
212
+ // anterior. É o padrão de `ref` para callback — e existe porque o app costuma passar uma
213
+ // arrow nova a cada render.
214
+ const buscaAtual = React.useRef(onSearchChange);
215
+ React.useEffect(() => { buscaAtual.current = onSearchChange; }, [onSearchChange]);
216
+ const digitar = React.useCallback((v) => {
217
+ setTexto(v);
218
+ if (!buscaAtual.current)
219
+ return;
220
+ cancelar();
221
+ if (searchDelay <= 0) {
222
+ buscaAtual.current(v);
223
+ return;
224
+ }
225
+ relogio.current = setTimeout(() => {
226
+ relogio.current = null;
227
+ buscaAtual.current?.(v);
228
+ }, searchDelay);
229
+ }, [cancelar, searchDelay]);
230
+ const fechar = React.useCallback(() => {
231
+ cancelar();
232
+ setAberto(false);
233
+ setTexto("");
234
+ }, [cancelar]);
235
+ // ── O ARRASTO, copiado do `BottomSheet` do Lote 5 (`overlays.tsx:427-451`) ──────────────────
236
+ // Os números são os DE LÁ, não novos: 6dp para reivindicar, limiar de 80dp ou um terço da
237
+ // altura, velocidade 1.2. Copiar em vez de reinventar é o que mantém as duas folhas com o
238
+ // mesmo tato — e é a regra do `BUILDING.md` sobre não criar escala nova no meio de um
239
+ // componente.
240
+ const arrasto = React.useRef(new Animated.Value(0)).current;
241
+ const altura = React.useRef(0);
242
+ React.useEffect(() => { if (!aberto)
243
+ arrasto.setValue(0); }, [aberto, arrasto]);
244
+ const gestos = React.useMemo(() => PanResponder.create({
245
+ onMoveShouldSetPanResponder: (_e, g) => draggable && g.dy > 6,
246
+ onPanResponderMove: (_e, g) => { if (g.dy > 0)
247
+ arrasto.setValue(g.dy); },
248
+ onPanResponderRelease: (_e, g) => {
249
+ const limiar = Math.max(80, altura.current / 3);
250
+ if (g.dy > limiar || g.vy > 1.2) {
251
+ fechar();
252
+ return;
253
+ }
254
+ if (reduzir !== false) {
255
+ arrasto.setValue(0);
256
+ return;
257
+ }
258
+ Animated.timing(arrasto, {
259
+ toValue: 0, duration: t.size.durationSlow, useNativeDriver: true,
260
+ easing: Easing.bezier(...t.easing.easeEmphasized),
261
+ }).start();
262
+ },
263
+ }), [draggable, fechar, arrasto, reduzir, t.size.durationSlow, t.easing.easeEmphasized]);
264
+ // ── O FILTRO, QUANDO ELE É NOSSO ────────────────────────────────────────────────────────────
265
+ const filtrados = React.useMemo(() => {
266
+ if (onSearchChange)
267
+ return items; // busca remota: a lista que chegou é a lista.
268
+ const alvo = dobrar(texto.trim());
269
+ if (!alvo)
270
+ return items;
271
+ return items.filter((i) => dobrar(i.label).includes(alvo));
272
+ }, [items, texto, onSearchChange]);
273
+ const nada = !loading && filtrados.length === 0;
274
+ 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: [
275
+ s.caixa,
276
+ { height: alturaDoTamanho(t, tam), paddingHorizontal: respiroDoTamanho(t, tam) },
277
+ campo?.invalido && s.invalido,
278
+ inativo && s.desabilitado,
279
+ style,
280
+ ], 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: reduzir !== false ? "none" : "slide", statusBarTranslucent: true, navigationBarTranslucent: true, onRequestClose: fechar, children: _jsxs(KeyboardAvoiding, { style: s.fundoDaLista, children: [_jsx(Pressable, { style: s.fundoDeToque, onPress: fechar, accessible: false, testID: testID ? `${testID}-fundo` : undefined }), _jsxs(Animated.View, { style: [s.lista, { transform: [{ translateY: arrasto }] }], onLayout: (e) => { altura.current = e.nativeEvent.layout.height; }, onStartShouldSetResponder: () => true, children: [_jsx(View, { style: s.puxadorArea, ...(draggable ? gestos.panHandlers : null), accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", children: _jsx(View, { style: s.puxador }) }), _jsxs(View, { style: [s.grupo, { height: alturaDoTamanho(t, tam) }], ...(draggable ? gestos.panHandlers : null), 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,
281
+ // ⚠ `autoFocus` é o que faz a folha valer a pena: abrir um campo de busca e
282
+ // exigir um segundo toque para o teclado subir é um toque a mais em cada
283
+ // cadastro. O foco entra com a folha; o teclado vem junto.
284
+ autoFocus: true, autoCorrect: false, autoCapitalize: "none",
285
+ // Mapeia nos dois sistemas — conferido no fonte do RN, tabela no topo do módulo.
286
+ accessibilityRole: "search", accessibilityLabel: searchPlaceholder ?? strings.comboboxSearch,
287
+ // `returnKeyType="search"` troca o "enter" do teclado pela lupa. É pista de
288
+ // plataforma, não decoração: diz à pessoa que aquele campo é de busca antes de
289
+ // ela digitar a primeira letra.
290
+ returnKeyType: "search", style: [
291
+ s.campoDeTexto,
292
+ { fontSize: fonteDoTamanho(t, tam), fontFamily: t.font.ui[400],
293
+ color: t.color.foreground },
294
+ ] }), 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
295
+ ? _jsx(Text, { size: "sm", tone: "muted", children: empty ?? strings.comboboxEmpty })
296
+ : empty })), _jsx(FlatList, { data: filtrados, keyExtractor: (i) => i.value, keyboardShouldPersistTaps: "handled", onEndReached: onEndReached, onEndReachedThreshold: ANTECEDENCIA_DE_PAGINA, renderItem: ({ item }) => (_jsx(Pressable, { disabled: item.disabled, onPress: () => { onValueChange?.(item); fechar(); }, accessibilityRole: "menuitem", accessibilityState: {
297
+ selected: item.value === value?.value, disabled: !!item.disabled,
298
+ }, style: [
299
+ s.opcao,
300
+ item.value === value?.value && s.opcaoEscolhida,
301
+ item.disabled && s.desabilitado,
302
+ ], children: _jsx(Text, { size: "md", weight: item.value === value?.value ? 600 : 400, children: item.label }) })) })] })] }) })] }));
303
+ }
304
+ // ── A DOBRA DE ACENTO, e por que ela é sondada em vez de presumida ───────────────────────────
305
+ // Buscar "acucar" tem de achar "açúcar" — num catálogo em português, exigir o acento certo é
306
+ // exigir que a pessoa saiba escrever o que está procurando. A dobra é `NFD` + remoção dos
307
+ // diacríticos combinantes, que é a forma padrão.
308
+ //
309
+ // **A sonda existe porque o motor é o motor, não um navegador.** `String.prototype.normalize` é
310
+ // ES2015 e está no motor, mas este pacote roda em qualquer motor que o consumidor escolha — e
311
+ // um `normalize` ausente derrubaria a busca inteira com `TypeError` em vez de degradá-la. A
312
+ // sonda roda UMA vez, no carregamento do módulo, e custa uma string de dois caracteres.
313
+ const TEM_NORMALIZE = (() => {
314
+ try {
315
+ return "é".normalize("NFD").length === 2;
316
+ }
317
+ catch {
318
+ return false;
319
+ }
320
+ })();
321
+ const dobrar = (v) => {
322
+ const minusculo = v.toLowerCase();
323
+ return TEM_NORMALIZE
324
+ ? minusculo.normalize("NFD").replace(/[̀-ͯ]/g, "")
325
+ : minusculo;
326
+ };
327
+ /**
328
+ * A lupa + o campo, numa peça só.
329
+ *
330
+ * ⚠ **Não é o mesmo papel do `Combobox`, e confundir os dois é o erro comum:** o `Combobox`
331
+ * ESCOLHE um item de um catálogo e devolve a escolha; este FILTRA o que já está na tela e devolve
332
+ * texto. Um cadastro pede o primeiro; uma lista com muitas linhas pede o segundo.
333
+ *
334
+ * ⚠ **`role="search"`, e ele mapeia nos dois sistemas** — conferido no fonte do RN 0.87.1, com
335
+ * arquivo e linha no topo deste módulo. É a diferença entre este componente e o `Combobox`, cujo
336
+ * gatilho não pôde levar `combobox` porque o iOS não tem o trait.
337
+ *
338
+ * ⚠ **A borda mora no GRUPO, não no campo** — é o `.input-group` da web (`aurea.css:741`), e é o
339
+ * que deixa lupa, campo e botão de limpar dentro de uma caixa só em vez de três.
340
+ */
341
+ export function SearchField({ value, onChangeText, onSearchChange, searchDelay = ESPERA_PADRAO, placeholder, disabled, size, icon = "search", clearable = true, onSubmit, style, testID, }) {
342
+ const t = useAureaTokens();
343
+ const s = folha(t);
344
+ const strings = useAureaStrings();
345
+ const campo = useCampo();
346
+ const tam = size ?? campo?.size ?? "md";
347
+ const inativo = disabled ?? campo?.disabled;
348
+ const [focado, setFocado] = React.useState(false);
349
+ // O campo pode ser controlado (`value`) ou não; o texto interno cobre o segundo caso, e é o que
350
+ // o botão de limpar precisa para saber se tem o que limpar.
351
+ const [interno, setInterno] = React.useState("");
352
+ const texto = value ?? interno;
353
+ const relogio = React.useRef(null);
354
+ const cancelar = React.useCallback(() => {
355
+ if (relogio.current != null) {
356
+ clearTimeout(relogio.current);
357
+ relogio.current = null;
358
+ }
359
+ }, []);
360
+ React.useEffect(() => cancelar, [cancelar]);
361
+ const buscaAtual = React.useRef(onSearchChange);
362
+ React.useEffect(() => { buscaAtual.current = onSearchChange; }, [onSearchChange]);
363
+ const digitar = React.useCallback((v) => {
364
+ setInterno(v);
365
+ onChangeText?.(v);
366
+ if (!buscaAtual.current)
367
+ return;
368
+ cancelar();
369
+ if (searchDelay <= 0) {
370
+ buscaAtual.current(v);
371
+ return;
372
+ }
373
+ relogio.current = setTimeout(() => {
374
+ relogio.current = null;
375
+ buscaAtual.current?.(v);
376
+ }, searchDelay);
377
+ }, [cancelar, onChangeText, searchDelay]);
378
+ return (_jsxs(View, { testID: testID, style: [
379
+ s.caixa,
380
+ { height: alturaDoTamanho(t, tam), paddingHorizontal: respiroDoTamanho(t, tam),
381
+ gap: t.size.space1 },
382
+ campo?.invalido && s.invalido,
383
+ // O foco é a única pista de campo ativo que sobra no telefone — mesma decisão do `Input`
384
+ // do Lote 4, e ela vale igual aqui.
385
+ focado && { borderColor: t.color.focusStrong },
386
+ inativo && s.desabilitado,
387
+ style,
388
+ ], 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: [
389
+ s.campoDeTexto,
390
+ { fontSize: fonteDoTamanho(t, tam), fontFamily: t.font.ui[400],
391
+ color: t.color.foreground },
392
+ ] }), clearable && texto.length > 0 && !inativo && (_jsx(IconButton, { name: "close", label: strings.searchClear, appearance: "ghost", size: "sm", onPress: () => digitar(""), testID: testID ? `${testID}-limpar` : undefined }))] }));
393
+ }
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";
package/dist/inputs.d.ts CHANGED
@@ -61,10 +61,34 @@ export interface InputProps {
61
61
  /**
62
62
  * O teclado que sobe. **O plano do consumidor pede `numeric` nos campos de medida e moeda**, e
63
63
  * é a prop que faz a diferença entre digitar 12,4 em dois toques ou em oito.
64
+ *
65
+ * ⚠ **Teclado não é formato, e esta prop resolve só a metade fácil** — foi a segunda lacuna
66
+ * que o consumidor mediu. Moeda, medida decimal e contador são o `NumberField` (Lote 7,
67
+ * `numero.tsx`), que formata no blur e devolve `number`. Este campo continua devolvendo texto.
64
68
  */
65
69
  keyboardType?: KeyboardTypeOptions;
66
70
  secureTextEntry?: boolean;
67
71
  autoCapitalize?: "none" | "sentences" | "words" | "characters";
72
+ /**
73
+ * Normaliza o texto **quando o foco sai** — placa, documento, telefone.
74
+ *
75
+ * ⚠ **A Aurea entrega o MOMENTO, não o formato**, e isso é a [ADR-0024] inteira: formatar
76
+ * enquanto se digita é o defeito que o USWDS publicou com reprovação WCAG registrada e que o
77
+ * MUI abandonou na v6. O formato é conhecimento de DOMÍNIO — a Aurea não sabe o que é um
78
+ * documento válido em lugar nenhum, e uma tabela de formatos por país dentro de uma biblioteca
79
+ * de interface envelhece sem ninguém perceber.
80
+ *
81
+ * <Input value={placa} onChangeText={setPlaca} formatOnBlur={(v) => v.toUpperCase()} />
82
+ *
83
+ * ⚠ **Exige o campo CONTROLADO** (`value` + `onChangeText`), e a diferença com a web é honesta:
84
+ * lá o componente escreve em `e.currentTarget.value` e o `<input>` não controlado obedece. Aqui
85
+ * o texto do `TextInput` não controlado vive dentro do nó nativo, e escrevê-lo de fora exigiria
86
+ * `setNativeProps` — API que a Nova Arquitetura desencoraja. Então o que sai daqui é o valor
87
+ * formatado por `onChangeText`; quem guarda o estado desenha.
88
+ *
89
+ * Para MOEDA e MEDIDA não use isto: use o `NumberField`, que já faz a conta e devolve `number`.
90
+ */
91
+ formatOnBlur?: (value: string) => string;
68
92
  onBlur?: () => void;
69
93
  onFocus?: () => void;
70
94
  /** Várias linhas. É o que o `Textarea` liga — raramente se põe à mão. */
@@ -79,7 +103,7 @@ export interface InputProps {
79
103
  * (`aurea.css:686`); **não há ponteiro no telefone**. O foco continua existindo e é o que marca o
80
104
  * campo ativo.
81
105
  */
82
- export declare function Input({ value, defaultValue, onChangeText, placeholder, disabled, size, keyboardType, secureTextEntry, autoCapitalize, onBlur, onFocus, multiline, style, testID, }: InputProps): React.JSX.Element;
106
+ export declare function Input({ value, defaultValue, onChangeText, placeholder, disabled, size, keyboardType, secureTextEntry, autoCapitalize, formatOnBlur, onBlur, onFocus, multiline, style, testID, }: InputProps): React.JSX.Element;
83
107
  export interface TextareaProps extends InputProps {
84
108
  /** Linhas visíveis. Vira altura mínima, porque no RN não há `rows`. */
85
109
  rows?: number;
@@ -169,6 +193,12 @@ export interface SelectProps {
169
193
  * ⚠ **O papel é `button` e não `combobox`.** `combobox` promete um campo em que se DIGITA para
170
194
  * filtrar; isto só abre uma lista. Prometer o que não se faz é o mesmo defeito do `tablist` no
171
195
  * Lote 3.
196
+ *
197
+ * ⚠ **Quem DIGITA para filtrar é o `Combobox`** (Lote 7, `busca.tsx`), e ele nasceu desta
198
+ * lacuna — o consumidor bateu nela no cadastro. **A escolha entre os dois é de tamanho de
199
+ * lista, e a linha é dura:** este monta todos os itens num `ScrollView` (abaixo), o que serve
200
+ * para unidades, estados e tipos; um catálogo de milhares de linhas aqui não fica lento, trava.
201
+ * O `Combobox` usa `FlatList` e aceita busca remota.
172
202
  */
173
203
  export declare function Select({ items, value, onChange, placeholder, disabled, size, chevron, style, testID, }: SelectProps): React.JSX.Element;
174
204
  export interface FormProps extends ViewProps {
package/dist/inputs.js CHANGED
@@ -147,14 +147,25 @@ export function Label({ children, trailing, style, ...rest }) {
147
147
  * (`aurea.css:686`); **não há ponteiro no telefone**. O foco continua existindo e é o que marca o
148
148
  * campo ativo.
149
149
  */
150
- export function Input({ value, defaultValue, onChangeText, placeholder, disabled, size, keyboardType, secureTextEntry, autoCapitalize, onBlur, onFocus, multiline, style, testID, }) {
150
+ export function Input({ value, defaultValue, onChangeText, placeholder, disabled, size, keyboardType, secureTextEntry, autoCapitalize, formatOnBlur, onBlur, onFocus, multiline, style, testID, }) {
151
151
  const t = useAureaTokens();
152
152
  const s = folha(t);
153
153
  const campo = useCampo();
154
154
  const tam = size ?? campo?.size ?? "md";
155
155
  const inativo = disabled ?? campo?.disabled;
156
156
  const [focado, setFocado] = React.useState(false);
157
- return (_jsx(TextInput, { testID: testID, value: value, defaultValue: defaultValue, onChangeText: onChangeText, placeholder: placeholder, placeholderTextColor: t.color.subtleForeground, editable: !inativo, keyboardType: keyboardType, secureTextEntry: secureTextEntry, autoCapitalize: autoCapitalize, multiline: multiline, onFocus: () => { setFocado(true); onFocus?.(); }, onBlur: () => { setFocado(false); onBlur?.(); },
157
+ return (_jsx(TextInput, { testID: testID, value: value, defaultValue: defaultValue, onChangeText: onChangeText, placeholder: placeholder, placeholderTextColor: t.color.subtleForeground, editable: !inativo, keyboardType: keyboardType, secureTextEntry: secureTextEntry, autoCapitalize: autoCapitalize, multiline: multiline, onFocus: () => { setFocado(true); onFocus?.(); }, onBlur: () => {
158
+ setFocado(false);
159
+ // Só avisa quando o texto MUDOU: emitir o mesmo valor a cada saída de foco faria o app
160
+ // re-renderizar sem motivo, e num formulário de dez campos isso é dez renders por
161
+ // preenchimento. Mesma guarda da web (`inputs-client.tsx:194`).
162
+ if (formatOnBlur && value != null) {
163
+ const formatado = formatOnBlur(value);
164
+ if (formatado !== value)
165
+ onChangeText?.(formatado);
166
+ }
167
+ onBlur?.();
168
+ },
158
169
  // O nome e a dica vêm do `Field` — ver o bloco acima sobre por que a ligação da web não
159
170
  // atravessa. Sem isto, o campo é um nó anônimo para o leitor de tela.
160
171
  accessibilityLabel: campo?.label, accessibilityHint: campo?.hint, accessibilityState: { disabled: !!inativo }, "aria-invalid": campo?.invalido, style: [
@@ -319,6 +330,12 @@ export function SegmentedControl({ items, value, onChange, label, disabled, styl
319
330
  * ⚠ **O papel é `button` e não `combobox`.** `combobox` promete um campo em que se DIGITA para
320
331
  * filtrar; isto só abre uma lista. Prometer o que não se faz é o mesmo defeito do `tablist` no
321
332
  * Lote 3.
333
+ *
334
+ * ⚠ **Quem DIGITA para filtrar é o `Combobox`** (Lote 7, `busca.tsx`), e ele nasceu desta
335
+ * lacuna — o consumidor bateu nela no cadastro. **A escolha entre os dois é de tamanho de
336
+ * lista, e a linha é dura:** este monta todos os itens num `ScrollView` (abaixo), o que serve
337
+ * para unidades, estados e tipos; um catálogo de milhares de linhas aqui não fica lento, trava.
338
+ * O `Combobox` usa `FlatList` e aceita busca remota.
322
339
  */
323
340
  export function Select({ items, value, onChange, placeholder, disabled, size, chevron = "chevron--down", style, testID, }) {
324
341
  const t = useAureaTokens();
@@ -0,0 +1,98 @@
1
+ import * as React from "react";
2
+ import { type ImageSourcePropType, type StyleProp, type ViewStyle } from "react-native";
3
+ import { type IconName } from "./icon.js";
4
+ /** URL ou `require()` de um asset local — as duas formas do `Image` do RN, como no `Avatar`. */
5
+ export type AureaImageSource = ImageSourcePropType | string;
6
+ export interface ImageProps {
7
+ source: AureaImageSource;
8
+ /**
9
+ * O nome acessível. **É o `alt` da web, e é obrigatório pelo mesmo motivo que o `label` do
10
+ * `IconButton`:** uma foto sem texto alternativo não diz nada a quem não a vê. Decorativa de
11
+ * verdade se escreve `alt=""` — explícito, como manda a WAI.
12
+ */
13
+ alt: string;
14
+ /** `16/9`, `"16/9"`, `"16:9"` ou `1.777…`. Sem ela, a imagem ocupa a altura que o `style` der. */
15
+ ratio?: number | string;
16
+ fit?: "cover" | "contain";
17
+ /** O que aparece no lugar quando o bitmap não vem. Sem ele, o glifo `image` sobre a caixa reservada. */
18
+ fallback?: React.ReactNode;
19
+ /** O glifo do substituto. Registre-o, ou passe `false`. */
20
+ fallbackIcon?: IconName | false;
21
+ /**
22
+ * Desenha OUTRO componente de imagem com a nossa pele — é o caso do consumidor que já usa
23
+ * `expo-image` por cache de disco.
24
+ *
25
+ * <Image render={<ExpoImage contentFit="cover" transition={150} />} source={u} alt="…" />
26
+ *
27
+ * Recebe `source`, `style`, `onError` e o nome acessível. **`fit` NÃO é traduzido para ele** —
28
+ * `resizeMode` é do `Image` do RN, e o `expo-image` chama a mesma coisa de `contentFit`; quem
29
+ * passa o elemento escreve a prop dele.
30
+ */
31
+ render?: React.ReactElement;
32
+ onError?: () => void;
33
+ style?: StyleProp<ViewStyle>;
34
+ testID?: string;
35
+ }
36
+ /**
37
+ * A foto, com a caixa reservada e um substituto quando ela não vem.
38
+ *
39
+ * ```tsx
40
+ * <Image source={foto.uri} alt="Frente do item" ratio="4/3" />
41
+ * ```
42
+ *
43
+ * ⚠ **A caixa nasce reservada e pintada** com a mesma superfície do `Skeleton` — sem `ratio`, o
44
+ * layout salta quando o bitmap chega, e num telefone esse salto acontece com o dedo já a caminho
45
+ * do botão.
46
+ *
47
+ * ⚠ **O substituto continua sendo a imagem para quem usa leitor de tela**, com o mesmo `alt`.
48
+ * Trocar o bitmap por uma caixa muda o que se vê, não o que a foto É — e o `role="image"` mapeia
49
+ * nos dois sistemas (`ReactAccessibilityDelegate.kt:461`, `RCTConversions.h:96`).
50
+ */
51
+ export declare function Image({ source, alt, ratio, fit, fallback, fallbackIcon, render, onError, style, testID, }: ImageProps): React.JSX.Element;
52
+ export interface AureaGalleryItem {
53
+ id: string;
54
+ source: AureaImageSource;
55
+ alt: string;
56
+ caption?: React.ReactNode;
57
+ }
58
+ export interface GalleryProps {
59
+ items: AureaGalleryItem[];
60
+ /** O nome da grade para o leitor de tela. Sem ele, a frase `galleryLabel` do provider. */
61
+ label?: string;
62
+ /** O `id` escolhido. **A galeria não guarda escolha** — é constante do app, como na web. */
63
+ selected?: string;
64
+ onSelect?: (id: string) => void;
65
+ /** Tocar abre a foto grande no `Dialog` que já existe. */
66
+ zoom?: boolean;
67
+ /** Proporção dos ladrilhos. Padrão **1** (quadrado), como o `ratio="1/1"` da web. */
68
+ ratio?: number | string;
69
+ /**
70
+ * Largura mínima de cada ladrilho, em dp. O `--gallery-min` da web é `8rem` — e `1rem = 16dp`,
71
+ * medido, então **128**.
72
+ */
73
+ minTileWidth?: number;
74
+ style?: StyleProp<ViewStyle>;
75
+ testID?: string;
76
+ }
77
+ /**
78
+ * A grade de fotos, e a foto grande quando se toca.
79
+ *
80
+ * ```tsx
81
+ * <Gallery items={fotos} zoom selected={atual} onSelect={setAtual} />
82
+ * ```
83
+ *
84
+ * ⚠ **Ampliar é DIÁLOGO, e diálogo já existe.** É a trava que a web escreveu no item L2 e ela
85
+ * atravessa inteira: a foto grande abre no `Dialog` do Lote 5, com o confinamento de foco que o
86
+ * `Modal` do RN dá pelo sistema e a saída pelo botão VOLTAR do Android. Uma segunda superfície
87
+ * flutuante aqui seria uma segunda linguagem.
88
+ *
89
+ * ⚠ **Legenda visível torna a miniatura decorativa**, e quem exigiu isso na web foi o axe, não a
90
+ * teoria: com `alt` e legenda dizendo a mesma coisa, o leitor de tela anuncia duas vezes seguidas.
91
+ * Aqui a tradução é outra (não há `alt=""` no RN) mas a regra é a mesma — **com legenda, quem
92
+ * carrega o nome é o LADRILHO, e a imagem sai da árvore**. O `alt` não se perde: ele continua
93
+ * nomeando a foto ampliada, que é onde não há legenda ao lado.
94
+ *
95
+ * ⚠ **Sem `onSelect` e sem `zoom` os ladrilhos não são alvos.** Um `Pressable` que não faz nada é
96
+ * um alvo que engana quem navega por leitor de tela — mesma decisão da web, mesma razão.
97
+ */
98
+ export declare function Gallery({ items, label, selected, onSelect, zoom, ratio, minTileWidth, style, testID, }: GalleryProps): React.JSX.Element;