From e0769315b173479481296d0e55a48ddac6d0df5e Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:19:57 -0300 Subject: [PATCH 01/80] test(business-days): compare calendar days in the month-walk properties A local midnight that falls in a daylight saving gap (1 December 1950 in America/Sao_Paulo) is read as 01:00, and addBusinessDays keeps the time of day of its input, as date-fns does, so the properties compared a 01:00 result with a midnight expectation. They now compare the day. --- src/add-business-days/add-business-days.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/add-business-days/add-business-days.test.ts b/src/add-business-days/add-business-days.test.ts index 7f2f9db3..fb5d6300 100644 --- a/src/add-business-days/add-business-days.test.ts +++ b/src/add-business-days/add-business-days.test.ts @@ -382,7 +382,7 @@ describe("addBusinessDays", () => { const result = addBusinessDays(new Date(year, month, 0), n, options); if (n <= businessDays.length) { - expect(result).toEqual(businessDays[n - 1]); + expect(result?.toDateString()).toBe(businessDays[n - 1]?.toDateString()); } else { expect(result?.getMonth()).toBe((month + 1) % 12); } @@ -395,7 +395,9 @@ describe("addBusinessDays", () => { test("should give the last business day of the month, walking back 1 from the first day of the month after", () => { fc.assert( fc.property(businessDayMonths(), ({ year, month, businessDays }) => { - expect(addBusinessDays(new Date(year, month + 1, 1), -1)).toEqual(businessDays.at(-1)); + expect(addBusinessDays(new Date(year, month + 1, 1), -1)?.toDateString()).toBe( + businessDays.at(-1)?.toDateString(), + ); }), ); }); From e936f512faddb373f0778a26fc56b09af580d1ae Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:24:09 -0300 Subject: [PATCH 02/80] fix(capitalize): keep more prepositions and S.A in the right case, scan linearly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the contracted and remaining prepositions (ao, aos, à, às, ante, após, até, num, numa, para, pela, pelas, pelo, pelos, perante, sob, sobre) to the default lowerCaseWords, and accept S.A without the final dot as a company designation. "entre" stays out of the list: the municipality "Desterro de Entre Rios" would otherwise lose its capital. nextWordIndex no longer slices the token array, so long input is linear instead of quadratic. JSDoc structure and the IBGE claim are fixed and the prose comments are moved into the JSDoc. Output changes (wrong to correct): "casa para todos" "Casa Para Todos" to "Casa para Todos" (same for the other added prepositions when they link two words); "empresa s.a" "Empresa S.a" to "Empresa S.A". Running capitalize over every IBGE municipality name in original, upper and lower case changes no output. --- docs/pt-br/utilities.md | 6 ++++-- docs/utilities.md | 6 ++++-- src/capitalize/capitalize.test.ts | 36 +++++++++++++++++++++++++++++++ src/capitalize/capitalize.ts | 22 ++++++++++--------- src/capitalize/constants.ts | 23 ++++++++++++++++++-- 5 files changed, 77 insertions(+), 16 deletions(-) diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index 73bd5150..cd58ef7f 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -3582,10 +3582,10 @@ Fonte: [tabelas da CID-10 V2008 que o DATASUS publica em CSV](http://www2.datasu Transforma em maiúscula a primeira letra de cada palavra, do jeito que se escreve um nome, uma razão social ou um endereço brasileiro, sem precisar de opções. -- **Opções** (`CapitalizeOptions`): `lowerCaseWords`, palavras mantidas em minúsculas entre duas palavras, por padrão preposições e artigos como `de`, `da`, `do`, `e`; `upperCaseWords`, palavras sempre em maiúsculas, por padrão designações societárias e abreviações como `LTDA`, `S.A.`, `ME`, `CNPJ` e algarismos romanos. Uma lista substitui a padrão. +- **Opções** (`CapitalizeOptions`): `lowerCaseWords`, palavras mantidas em minúsculas entre duas palavras, por padrão as preposições e a conjunção `e`, como `de`, `da`, `do`, `ao`, `para`, `pelo`, `sobre`, `até` (os artigos são só `a` e `o`); `upperCaseWords`, palavras sempre em maiúsculas, por padrão designações societárias e abreviações como `LTDA`, `S.A.`, `ME`, `CNPJ` e algarismos romanos. Uma lista substitui a padrão. - Palavras se separam em espaços, `-`, `/`, apóstrofos e pontuação colada; espaços repetidos viram um só. - Palavra minúscula que é a primeira, a última ou precede pontuação é designativo e mantém a maiúscula. -- `ME` só vira maiúsculas como designação (última palavra ou antes de outra); `SA` sem pontos fica como está (o sobrenome Sá). Sigla de estado após `/` vira maiúsculas mesmo com `upperCaseWords` informado, assim como a que termina o valor depois de `-` ou `–` entre espaços ou de `, `, o "Cidade – UF" dos Correios. +- `ME` só vira maiúsculas como designação (última palavra ou antes de outra); `S.A` sem o ponto final também é designação, já `SA` sem pontos fica como está (o sobrenome Sá). Sigla de estado após `/` vira maiúsculas mesmo com `upperCaseWords` informado, assim como a que termina o valor depois de `-` ou `–` entre espaços ou de `, `, o "Cidade – UF" dos Correios. ```javascript import { capitalize } from '@brazilian-utils/brazilian-utils'; @@ -3603,6 +3603,8 @@ capitalize('fulano comércio me'); // Fulano Comércio ME ("ME" como última pal capitalize('não-me-toque'); // Não-Me-Toque (em qualquer outro lugar "me" é palavra comum) capitalize('(empresa) ltda'); // (Empresa) LTDA capitalize('luiz von schmidt'); // Luiz von Schmidt +capitalize('casa para todos'); // Casa para Todos (preposições contraídas como "ao", "às", "pelo" e "sobre" também ficam minúsculas) +capitalize('empresa s.a'); // Empresa S.A capitalize('santana/rs'); // Santana/RS ("RS" é sigla de estado logo depois de uma "/") capitalize('porto alegre/rs'); // Porto Alegre/RS capitalize('brasília - df'); // Brasília - DF (sigla de estado como última palavra depois de " - ", " – " ou ", ") diff --git a/docs/utilities.md b/docs/utilities.md index 01953027..a0d7efe6 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -3582,10 +3582,10 @@ Source: [CID-10 V2008 tables DATASUS publishes as CSV](http://www2.datasus.gov.b Capitalize the first letter of each word, the way a Brazilian name, company name or address is written, with no options needed. -- **Options** (`CapitalizeOptions`): `lowerCaseWords`, words kept in lower case between two words, by default prepositions and articles such as `de`, `da`, `do`, `e`; `upperCaseWords`, words always in upper case, by default company designations and abbreviations such as `LTDA`, `S.A.`, `ME`, `CNPJ` and roman numerals. A list replaces its default. +- **Options** (`CapitalizeOptions`): `lowerCaseWords`, words kept in lower case between two words, by default the prepositions and the conjunction `e`, such as `de`, `da`, `do`, `ao`, `para`, `pelo`, `sobre`, `até` (the articles are only `a` and `o`); `upperCaseWords`, words always in upper case, by default company designations and abbreviations such as `LTDA`, `S.A.`, `ME`, `CNPJ` and roman numerals. A list replaces its default. - Words split at whitespace, `-`, `/`, apostrophes and adjoining punctuation; whitespace runs collapse into one space. - A lower-case word that is first, last or followed by punctuation is a designator and keeps its capital. -- `ME` is upper-cased only as a designation (last word, or before another designation); `SA` without dots is left alone (the surname Sá). A state code after a `/` is upper-cased even with `upperCaseWords` given, and so is one that ends the value after a spaced `-`, a spaced `–` or a `, `, the Correios' "Cidade – UF". +- `ME` is upper-cased only as a designation (last word, or before another designation); `S.A` without the final dot is a designation too, while `SA` without dots is left alone (the surname Sá). A state code after a `/` is upper-cased even with `upperCaseWords` given, and so is one that ends the value after a spaced `-`, a spaced `–` or a `, `, the Correios' "Cidade – UF". ```javascript import { capitalize } from '@brazilian-utils/brazilian-utils'; @@ -3603,6 +3603,8 @@ capitalize('fulano comércio me'); // Fulano Comércio ME ("ME" as the last word capitalize('não-me-toque'); // Não-Me-Toque (anywhere else "me" is an ordinary word) capitalize('(empresa) ltda'); // (Empresa) LTDA capitalize('luiz von schmidt'); // Luiz von Schmidt +capitalize('casa para todos'); // Casa para Todos (contracted prepositions such as "ao", "às", "pelo" and "sobre" stay lower case too) +capitalize('empresa s.a'); // Empresa S.A capitalize('santana/rs'); // Santana/RS ("RS" is a state code right after a "/") capitalize('porto alegre/rs'); // Porto Alegre/RS capitalize('brasília - df'); // Brasília - DF (a state code as the last word after " - ", " – " or ", ") diff --git a/src/capitalize/capitalize.test.ts b/src/capitalize/capitalize.test.ts index 2e03609a..74d6a3f7 100644 --- a/src/capitalize/capitalize.test.ts +++ b/src/capitalize/capitalize.test.ts @@ -18,6 +18,42 @@ describe("capitalize", () => { expect(capitalize("pão com manteiga")).toBe("Pão com Manteiga"); }); + test("when the value does contain the contracted and the other prepositions", () => { + expect(capitalize("casa para todos")).toBe("Casa para Todos"); + expect(capitalize("ASSOCIAÇÃO AO LADO")).toBe("Associação ao Lado"); + expect(capitalize("banco às margens")).toBe("Banco às Margens"); + expect(capitalize("carta pelo correio")).toBe("Carta pelo Correio"); + expect(capitalize("vista sobre o mar")).toBe("Vista sobre o Mar"); + expect(capitalize("ponte sob a chuva")).toBe("Ponte sob a Chuva"); + expect(capitalize("dias até a festa")).toBe("Dias até a Festa"); + expect(capitalize("mora num sítio")).toBe("Mora num Sítio"); + expect(capitalize("perante o juiz")).toBe("Perante o Juiz"); + }); + + test("when a preposition is the first word, the last word or is followed by punctuation", () => { + expect(capitalize("sobre a mesa")).toBe("Sobre a Mesa"); + expect(capitalize("até")).toBe("Até"); + expect(capitalize("rua para, 10")).toBe("Rua Para, 10"); + }); + + test("when the name is a municipality that holds a word of the preposition list", () => { + expect(capitalize("desterro de entre rios")).toBe("Desterro de Entre Rios"); + expect(capitalize("ENTRE RIOS DE MINAS")).toBe("Entre Rios de Minas"); + }); + + test("when the value has a very long run of words", () => { + const words = Array.from({ length: 50_000 }, () => "maria da silva").join(" "); + const expected = Array.from({ length: 50_000 }, () => "Maria da Silva").join(" "); + + expect(capitalize(words)).toBe(expected); + }); + + test("when the company designation is written S.A with or without the final dot", () => { + expect(capitalize("empresa s.a")).toBe("Empresa S.A"); + expect(capitalize("empresa s.a.")).toBe("Empresa S.A."); + expect(capitalize("empresa sa")).toBe("Empresa Sa"); + }); + test("when the value does contain short words", () => { expect(capitalize("a")).toBe("A"); expect(capitalize("A B C")).toBe("A B C"); diff --git a/src/capitalize/capitalize.ts b/src/capitalize/capitalize.ts index 7afd0b2f..a86f2306 100644 --- a/src/capitalize/capitalize.ts +++ b/src/capitalize/capitalize.ts @@ -59,9 +59,11 @@ const isApostrophe = (token: string): boolean => APOSTROPHE_REGEX.test(token); * @returns {number} The index of the next word. */ const nextWordIndex = (tokens: string[], index: number): number => { - const offset = tokens.slice(index + 1).findIndex((token) => isWord(token)); + for (let position = index + 1; position < tokens.length; position++) { + if (isWord(tokens[position])) return position; + } - return offset === -1 ? tokens.length : index + 1 + offset; + return tokens.length; }; /** @@ -166,6 +168,7 @@ const isStateCodePosition = ( /** * A word with its first letter in upper case and the rest in lower case, letter by code point. A + * word is never empty (the empty tokens are skipped), and neither is its upper case. A * first letter whose upper case is more than one character (`ß` becomes `SS`, the `fi` ligature * becomes `FI`) keeps its case: expanding it would drop or add letters, and a second pass over * the result would not give the result back. The rest is lower cased on its own, not sliced out @@ -176,7 +179,6 @@ const isStateCodePosition = ( * @returns {string} The word, capitalized. */ const capitalizeWord = (word: string): string => { - // A word is never empty (the empty tokens are skipped), and neither is its upper case. const [first, ...rest] = word; const [upperFirst, ...expansion] = first.toLocaleUpperCase("pt-BR"); const head = expansion.length > 0 ? first : upperFirst; @@ -242,7 +244,11 @@ const capitalizeWord = (word: string): string => { * replaces its default list entirely, so `capitalize("empresa ltda", { upperCaseWords: [] })` * gives `"Empresa Ltda"`. A `lowerCaseWords`/`upperCaseWords` that is not an array falls back to * its default, and a member of either list that is not a string is ignored, so a malformed - * option never throws. + * option never throws. The default `lowerCaseWords` list is the set of prepositions and + * conjunctions the Manual de Redação da Presidência da República keeps in lower case inside a + * proper name, and the default `upperCaseWords` list is sourced in `constants.ts` from the laws + * that create each designation. The default list is assembled inside the function, not in a + * module-level constant, so a bundle that never calls `capitalize` does not keep the spread. * * @param {string} value - The input string to be capitalized. * @param {CapitalizeOptions} [options] - Optional configuration for capitalization. @@ -250,10 +256,6 @@ const capitalizeWord = (word: string): string => { * @param {string[]} [options.upperCaseWords] - Array of words to keep in upper case (default: the Brazilian company designations, document abbreviations and roman numerals). * @returns {string} The capitalized string according to the specified rules. * - * The default `lowerCaseWords` list is the set of prepositions and conjunctions the Manual de - * Redação da Presidência da República keeps in lower case inside a proper name, and the default - * `upperCaseWords` list is sourced in `constants.ts` from the laws that create each designation. - * * @see Official: https://www4.planalto.gov.br/centrodeestudos/assuntos/manual-de-redacao-da-presidencia-da-republica/manual-de-redacao.pdf * Manual de Redação da Presidência da República, 3ª edição (Portaria nº 1.369/2018), item 5.1.8 * b) and item 10.2 a). @@ -272,6 +274,8 @@ const capitalizeWord = (word: string): string => { * capitalize("não-me-toque"); // "Não-Me-Toque" * capitalize("(empresa) ltda"); // "(Empresa) LTDA" * capitalize("luiz von schmidt"); // "Luiz von Schmidt" + * capitalize("casa para todos"); // "Casa para Todos" + * capitalize("empresa s.a"); // "Empresa S.A" * capitalize("casa de carnes s/a"); // "Casa de Carnes S/A" * capitalize("MOGI-GUAÇU"); // "Mogi-Guaçu" * capitalize("santana/rs"); // "Santana/RS" @@ -291,8 +295,6 @@ export const capitalize = (value: string, options?: CapitalizeOptions): string = word.toLocaleLowerCase("pt-BR"), ); - // The default list is assembled here, not in a module-level constant, so a bundle that never - // calls `capitalize` does not keep the spread. const upperCaseSet = toWordSet( upperCaseWords, [...COMPANY_DESIGNATIONS, ...DOCUMENT_ABBREVIATIONS, ...ROMAN_NUMERALS], diff --git a/src/capitalize/constants.ts b/src/capitalize/constants.ts index 79042d52..53ff61d0 100644 --- a/src/capitalize/constants.ts +++ b/src/capitalize/constants.ts @@ -4,7 +4,7 @@ * states the convention twice: a cargo is "redigido apenas com as iniciais maiúsculas. As * preposições que liguem as palavras do cargo devem ser grafadas em minúsculas" (item 5.1.8 b), * and a title is written "com inicial maiúscula em todas as palavras, exceto nas de ligação" - * (item 10.2 a). The same convention is used by the IBGE for the names of municipalities + * (item 10.2 a). The IBGE writes most municipality names the same way * ("Mogi das Cruzes", "Santa Bárbara d'Oeste"). The elided `d` of "d'Oeste" is not a member of * this list: on its own it is a designator ("Rua D", "Quadra D"), so `capitalize` lower-cases it * structurally, only when an apostrophe and a word follow it. Applying it to personal and institutional names @@ -21,6 +21,13 @@ */ export const PREPOSITIONS = [ "a", + "ao", + "aos", + "à", + "às", + "ante", + "após", + "até", "com", "da", "das", @@ -39,9 +46,19 @@ export const PREPOSITIONS = [ "nas", "no", "nos", + "num", + "numa", "o", + "para", + "pela", + "pelas", + "pelo", + "pelos", + "perante", "por", "sem", + "sob", + "sobre", "van", "von", ]; @@ -50,7 +67,8 @@ export const PREPOSITIONS = [ * Company designations that are written in upper case in Brazilian names, and the only words a * designation of `TRAILING_DESIGNATIONS` is upper case before. "SA" without punctuation is * deliberately absent: it is indistinguishable from the surname "Sá" typed without its accent, - * which would turn "Jose de Sa" into "Jose de SA". + * which would turn "Jose de Sa" into "Jose de SA". "S.A" without the final dot is kept, since + * the inner dot rules out the surname. * * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l6404consol.htm * Lei nº 6.404/1976, art. 3º: the sociedade anônima is designated by "companhia" or "sociedade @@ -80,6 +98,7 @@ export const COMPANY_DESIGNATIONS = [ "LTDA", "ME", "MEI", + "S.A", "S.A.", "S.S.", "S/A", From 74484c1b65ffcad4657c83edb1b40ea1e0c85417 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:25:37 -0300 Subject: [PATCH 03/80] fix(to-standard-schema): report a throwing validator as an issue A validator that threw made validate throw too, although the schema is meant to report failures as issues and the JSDoc says it never throws. The call is now wrapped and a throw counts as an invalid value. The second parameter is renamed from config to options, which is invisible to callers. Output change (wrong to correct): a wrapped function that throws used to propagate the error out of validate and now returns { issues: [...] }. The isValid* utilities never throw, so their behaviour is the same. --- docs/pt-br/utilities.md | 4 +-- docs/utilities.md | 4 +-- .../to-standard-schema.test.ts | 13 +++++++ src/to-standard-schema/to-standard-schema.ts | 35 ++++++++++++------- 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index cd58ef7f..c8a5150a 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -3774,8 +3774,8 @@ Fonte: [ISO 3779:2009](https://www.iso.org/standard/52200.html), [49 CFR 565.15] Embrulha um utilitário `isValid*` em um [Standard Schema](https://standardschema.dev), o formato de validador que bibliotecas de formulário, roteadores e frameworks de API aceitam: TanStack Form, react-hook-form, tRPC, Hono e outros. -- `config.options` é repassado ao validador a cada chamada, e `config.message` é a mensagem da issue (padrão `'Invalid value'`). Os dois fazem parte de `ToStandardSchemaOptions`. -- Valida de forma síncrona e não transforma: um valor válido volta como foi passado, um inválido gera uma única issue. +- O segundo argumento (`ToStandardSchemaOptions`) recebe `options`, repassado ao validador a cada chamada, e `message`, a mensagem da issue (padrão `'Invalid value'`). +- Valida de forma síncrona e não transforma: um valor válido volta como foi passado, um inválido gera uma única issue, e um validador que lança um erro conta como inválido. - Validadores que recebem um objeto (`isValidBankAccount`, `isValidRegistroProfissional`, `isValidIe`) funcionam do mesmo jeito. Embrulhe o `isValidIe`, que tem sobrecarga, em uma arrow function: `toStandardSchema((params) => isValidIe(params))`. - Os tipos da especificação (`StandardSchemaV1`, `StandardSchemaV1Result`, `StandardSchemaV1Issue` e os demais) também são exportados, então nada mais é instalado. diff --git a/docs/utilities.md b/docs/utilities.md index a0d7efe6..7613c2db 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -3774,8 +3774,8 @@ Source: [ISO 3779:2009](https://www.iso.org/standard/52200.html), [49 CFR 565.15 Wrap an `isValid*` utility in a [Standard Schema](https://standardschema.dev), the validator format that form libraries, routers and API frameworks accept: TanStack Form, react-hook-form, tRPC, Hono and more. -- `config.options` is handed to the validator on every call, and `config.message` is the message of the issue (default `'Invalid value'`). Both are part of `ToStandardSchemaOptions`. -- It validates synchronously and does not transform: a valid value comes back as it was given, an invalid one yields a single issue. +- The second argument (`ToStandardSchemaOptions`) takes `options`, handed to the validator on every call, and `message`, the message of the issue (default `'Invalid value'`). +- It validates synchronously and does not transform: a valid value comes back as it was given, an invalid one yields a single issue, and a validator that throws counts as invalid. - Validators that take an object (`isValidBankAccount`, `isValidRegistroProfissional`, `isValidIe`) work the same way. Wrap the overloaded `isValidIe` in an arrow function: `toStandardSchema((params) => isValidIe(params))`. - The types of the specification (`StandardSchemaV1`, `StandardSchemaV1Result`, `StandardSchemaV1Issue` and the rest) are exported too, so nothing else is installed. diff --git a/src/to-standard-schema/to-standard-schema.test.ts b/src/to-standard-schema/to-standard-schema.test.ts index cdd07dce..5d163062 100644 --- a/src/to-standard-schema/to-standard-schema.test.ts +++ b/src/to-standard-schema/to-standard-schema.test.ts @@ -121,6 +121,19 @@ describe("toStandardSchema", () => { }); }); + test("should report a validator that throws as an issue", () => { + const schema = toStandardSchema( + () => { + throw new Error("boom"); + }, + { message: "Invalid value" }, + ); + + expect(schema["~standard"].validate("x")).toStrictEqual({ + issues: [{ message: "Invalid value" }], + }); + }); + describe("properties", () => { test("should agree with the validator it wraps, whatever the value", () => { const schema = toStandardSchema(isValidCpf); diff --git a/src/to-standard-schema/to-standard-schema.ts b/src/to-standard-schema/to-standard-schema.ts index 6fda55bf..2bcd4493 100644 --- a/src/to-standard-schema/to-standard-schema.ts +++ b/src/to-standard-schema/to-standard-schema.ts @@ -93,9 +93,9 @@ const DEFAULT_MESSAGE = "Invalid value"; * TanStack Form, tRPC, Hono and the rest, next to schemas made with Zod, Valibot or ArkType. * * The schema validates synchronously and does not transform: a valid value is returned as it was - * given, and an invalid one yields a single issue. It never throws, like the validators it wraps: - * a first argument that is not a function gives a schema that rejects everything, and a `message` - * that is not a string falls back to the default. + * given, and an invalid one yields a single issue. It never throws: a validator that throws + * counts as rejecting the value, a first argument that is not a function gives a schema that + * rejects everything, and a `message` that is not a string falls back to the default. * * Validators that take a single object (`isValidIe`, `isValidBankAccount`, * `isValidRegistroProfissional`) work the same way, the object being the value under validation; @@ -103,9 +103,9 @@ const DEFAULT_MESSAGE = "Invalid value"; * `toStandardSchema((params: IsValidIeParams) => isValidIe(params))`. * * @param {(value: Value, options?: Options) => boolean} validate - The validator to wrap. - * @param {ToStandardSchemaOptions} [config] - Optional configuration. - * @param {Options} [config.options] - The options handed to the validator on every call. - * @param {string} [config.message] - The message of the issue reported for an invalid value. + * @param {ToStandardSchemaOptions} [options] - Optional configuration. + * @param {Options} [options.options] - The options handed to the validator on every call. + * @param {string} [options.message] - The message of the issue reported for an invalid value. * @returns {StandardSchemaV1} A Standard Schema whose input and output are the validator's value. * * @example @@ -122,14 +122,25 @@ const DEFAULT_MESSAGE = "Invalid value"; */ export const toStandardSchema = ( validate: (value: Value, options?: Options) => boolean, - config?: ToStandardSchemaOptions, + options?: ToStandardSchemaOptions, ): StandardSchemaV1 => { - const message = typeof config?.message === "string" ? config.message : DEFAULT_MESSAGE; + const message = typeof options?.message === "string" ? options.message : DEFAULT_MESSAGE; - // A predicate, so that an accepted `unknown` is returned as the validator's value type. - const isValue = (value: unknown): value is Value => - typeof validate === "function" && - Reflect.apply(validate, undefined, [value, config?.options]) === true; + /** + * A predicate, so that an accepted `unknown` is returned as the validator's value type. + * + * @param {unknown} value - The value under validation. + * @returns {boolean} `true` when the validator accepts the value; a throw counts as `false`. + */ + const isValue = (value: unknown): value is Value => { + if (typeof validate !== "function") return false; + + try { + return Reflect.apply(validate, undefined, [value, options?.options]) === true; + } catch { + return false; + } + }; return { "~standard": { From 7fbabbf517f539fa3b7347cde507389759493eb5 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:25:37 -0300 Subject: [PATCH 04/80] fix(convert-number-to-words): export NumberToWordsGender from the subpath entry Subpath consumers could not name the type of options.gender, since only the root entry re-exported it. The Based on note of the JSDoc is rewritten as a clean sentence under the URL. No output change. --- .../convert-number-to-words.test.ts | 10 +++++++++- src/convert-number-to-words/convert-number-to-words.ts | 8 +++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/convert-number-to-words/convert-number-to-words.test.ts b/src/convert-number-to-words/convert-number-to-words.test.ts index ee24e807..87884ed1 100644 --- a/src/convert-number-to-words/convert-number-to-words.test.ts +++ b/src/convert-number-to-words/convert-number-to-words.test.ts @@ -5,7 +5,11 @@ import { type NumberToWordsGender, } from "../_internals/number-to-words/number-to-words"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { convertNumberToWords, type ConvertNumberToWordsOptions } from "./convert-number-to-words"; +import { + convertNumberToWords, + type ConvertNumberToWordsOptions, + type NumberToWordsGender as SubpathNumberToWordsGender, +} from "./convert-number-to-words"; function expectWords( cases: readonly (readonly [number, string])[], @@ -603,4 +607,8 @@ describe("convertNumberToWords types", () => { >(); expectTypeOf(convertNumberToWords).returns.toEqualTypeOf(); }); + + test("should export the gender type from the subpath entry", () => { + expectTypeOf().toEqualTypeOf(); + }); }); diff --git a/src/convert-number-to-words/convert-number-to-words.ts b/src/convert-number-to-words/convert-number-to-words.ts index 3ba3e4ae..5e94e1cd 100644 --- a/src/convert-number-to-words/convert-number-to-words.ts +++ b/src/convert-number-to-words/convert-number-to-words.ts @@ -4,6 +4,8 @@ import { numberToWords, } from "../_internals/number-to-words/number-to-words"; +export type { NumberToWordsGender } from "../_internals/number-to-words/number-to-words"; + /** Options of `convertNumberToWords`. */ export type ConvertNumberToWordsOptions = { /** Grammatical gender used to agree "um/dois" and the hundreds group ("duzentos/duzentas", etc.) with the noun the number qualifies. Defaults to `"masculine"`. */ @@ -40,9 +42,9 @@ export type ConvertNumberToWordsOptions = { * ``` * * @see Based on: https://github.com/savoirfairelinux/num2words - * `brutils` itself has no dedicated - * number-to-words module (its `currency.py` delegates the Portuguese numeral text to this - * library's `pt_BR` locale); this is the reference for the numeral-word tables reproduced here. + * The reference for the numeral-word tables reproduced here. The Python `brutils` has no + * dedicated number-to-words module: its `currency.py` delegates the Portuguese numeral text to + * the `pt_BR` locale of this library. */ export const convertNumberToWords = ( value: number, From 9c14768957e9ef16f6cd4b4258879c86e215a9e1 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:25:42 -0300 Subject: [PATCH 05/80] docs(words): describe the e, de and zero reais rules of the words functions The number and currency sections now list the rules the code already implements: where the e goes, the singular, the de before round millions, zero reais for a negative amount that truncates to nothing, and the cents cutoff above about 90 trillion. English and Portuguese. --- docs/pt-br/utilities.md | 4 ++++ docs/utilities.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index c8a5150a..8c99597a 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -1602,6 +1602,7 @@ Escreve um número inteiro por extenso em português do Brasil: `1235` vira `"mi - **Opções** (`ConvertNumberToWordsOptions`): `gender` (padrão `"masculine"`) concorda "um/dois" e a centena ("duzentos/duzentas") com o substantivo que o número qualifica. - Aceita inteiros de `-999999999999999` a `999999999999999` (999 trilhões). Um valor não inteiro é truncado em direção a zero. - Retorna `""` para um valor fora desse intervalo ou não finito. +- O último grupo leva um "e" antes dele só quando é menor que 100 ou uma centena redonda: `1200` é `"mil e duzentos"` e `1100` é `"mil e cem"`, já `1235` é `"mil duzentos e trinta e cinco"` e `1101` é `"mil cento e um"`. ```javascript import { convertNumberToWords } from '@brazilian-utils/brazilian-utils'; @@ -1621,6 +1622,9 @@ Escreve um valor em reais por extenso, como em cheques e contratos: `1523.45` vi - O `value` é truncado (não arredondado) para 2 casas decimais. - Retorna `""` para uma entrada inválida ou um valor acima de 999 trilhões de reais. +- O singular vale para exatamente um (`"um real"`, `"um centavo"`), e um milhão, bilhão ou trilhão redondo de reais leva "de": `"um milhão de reais"`. +- Um valor que trunca para nada é `"zero reais"`, mesmo se negativo (`-0.001`); qualquer outro valor negativo recebe o prefixo `"menos"`. +- Acima de cerca de 90 trilhões de reais (`Number.MAX_SAFE_INTEGER / 100`) um número não guarda centavos, então o valor é lido como reais inteiros. ```javascript import { convertCurrencyToWords } from '@brazilian-utils/brazilian-utils'; diff --git a/docs/utilities.md b/docs/utilities.md index 7613c2db..05243761 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -1602,6 +1602,7 @@ Write an integer in Brazilian Portuguese cardinal words ("por extenso"): `1235` - **Options** (`ConvertNumberToWordsOptions`): `gender` (default `"masculine"`) agrees "um/dois" and the hundreds ("duzentos/duzentas") with the noun the number qualifies. - Accepts integers from `-999999999999999` to `999999999999999` (999 trillion). A non-integer is truncated toward zero. - Returns `""` for a value outside that range or not finite. +- The last group takes an "e" before it only when it is below 100 or a round hundred: `1200` is `"mil e duzentos"` and `1100` is `"mil e cem"`, while `1235` is `"mil duzentos e trinta e cinco"` and `1101` is `"mil cento e um"`. ```javascript import { convertNumberToWords } from '@brazilian-utils/brazilian-utils'; @@ -1621,6 +1622,9 @@ Write an amount in reais in words ("por extenso"), as on cheques and contracts: - `value` is truncated (not rounded) to 2 decimal places. - Returns `""` for invalid input or an amount above 999 trillion reais. +- The singular is used for exactly one (`"um real"`, `"um centavo"`), and a round million, billion or trillion of reais takes "de": `"um milhão de reais"`. +- An amount that truncates to nothing is `"zero reais"`, even when negative (`-0.001`); any other negative amount is prefixed with `"menos"`. +- Above about 90 trillion reais (`Number.MAX_SAFE_INTEGER / 100`) a number cannot hold cents, so the amount is read as whole reais. ```javascript import { convertCurrencyToWords } from '@brazilian-utils/brazilian-utils'; From 638031199be5e1d741431103f298194de9b1bcc2 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:26:08 -0300 Subject: [PATCH 06/80] docs(schema): validate an object in the Hono route and name the vee-validate version The Hono snippet passed a string schema to the param target, which hands the validator an object, so every request was rejected. The route now validates the JSON body with an object schema. The VeeValidate line needs version 5, which the guide now says (English and Portuguese). --- docs/guides/schema.md | 2 +- docs/pt-br/guides/schema.md | 2 +- .../document-field/templates/schema/standard.ts.tmpl | 9 ++++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/guides/schema.md b/docs/guides/schema.md index 32c322ae..5b4a03b2 100644 --- a/docs/guides/schema.md +++ b/docs/guides/schema.md @@ -149,7 +149,7 @@ Each of these builds the document on its own first, so it can be reused wherever
-No schema library at all: `toStandardSchema` gives the validator the interface every form library speaks. +No schema library at all: `toStandardSchema` gives the validator the interface every form library speaks. VeeValidate takes it as `rules` from version 5 on, and Hono validates an object, so its route wraps the validator for the JSON body.
diff --git a/docs/pt-br/guides/schema.md b/docs/pt-br/guides/schema.md index b282731e..2d61efee 100644 --- a/docs/pt-br/guides/schema.md +++ b/docs/pt-br/guides/schema.md @@ -149,7 +149,7 @@ O `narrow` recebe ele, e diz o que o valor tem que ser quando recusa:
-Sem biblioteca de schema nenhuma: o `toStandardSchema` dá ao validador a interface que toda biblioteca de formulário fala. +Sem biblioteca de schema nenhuma: o `toStandardSchema` dá ao validador a interface que toda biblioteca de formulário fala. O VeeValidate aceita esse schema em `rules` a partir da versão 5, e o Hono valida um objeto, então a rota embrulha o validador para o corpo JSON.
diff --git a/docs/snippets/document-field/templates/schema/standard.ts.tmpl b/docs/snippets/document-field/templates/schema/standard.ts.tmpl index 6aec1594..7736c375 100644 --- a/docs/snippets/document-field/templates/schema/standard.ts.tmpl +++ b/docs/snippets/document-field/templates/schema/standard.ts.tmpl @@ -14,7 +14,7 @@ export const @@kind@@Schema = toStandardSchema(isValid, { // Everything that speaks the interface takes it as it is, next to a Zod, Valibot or ArkType // schema. A few of them, all with the same @@kind@@Schema: -/** VeeValidate: the rules of a field. */ +/** VeeValidate 5 (beta) or later: the rules of a field. Version 4 does not take a Standard Schema here. */ export const use@@Name@@Field = () => useField("@@kind@@", @@kind@@Schema); /** TanStack Form: a field's validator, `validators={{ onChange: @@kind@@Schema }}`. */ @@ -30,8 +30,11 @@ export const @@kind@@Procedure = initTRPC .procedure.input(@@kind@@Schema) .query(({ input }) => input); -/** Hono: what a route takes. */ -export const @@kind@@Route = sValidator("param", @@kind@@Schema); +/** Hono: what a route takes. The JSON body is an object, so the schema is one of an object. */ +export const @@kind@@Route = sValidator( + "json", + toStandardSchema<{ @@kind@@: string }>(({ @@kind@@ }) => @@validatorContext@@), +); // react-hook-form takes one through `standardSchemaResolver`, once the schema covers the whole // form: `useForm({ resolver: standardSchemaResolver(signupSchema) })`. From 808a35969c051142924d59715843300c49555208 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:29:32 -0300 Subject: [PATCH 07/80] docs(address-form): fix the guide links and clear aria-busy on an incomplete CEP The English guide linked document-field.md, which docsify resolves from the site root and 404s; it now points at guides/document-field.md like the Portuguese one. The vanilla snippet left aria-busy on the CEP field when a pending lookup was followed by an incomplete CEP. --- docs/guides/address-form.md | 8 ++++---- docs/snippets/address-form/vanilla/address-form.html | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/guides/address-form.md b/docs/guides/address-form.md index 60d42d36..9f52bc28 100644 --- a/docs/guides/address-form.md +++ b/docs/guides/address-form.md @@ -6,12 +6,12 @@ keywords: ["CEP lookup", "address by CEP", "autofill address", "getAddressInfoBy Type a CEP and the rest of the address fills itself. Pick the framework: each example runs the code below it, which you can copy as is. -`getAddressInfoByCep` asks the CEP providers and returns the street, neighborhood, city and state, or throws when no one has that CEP. It is asked only once `isValidCep` says the CEP is complete, so a request is not made on every keystroke, and what comes back stays editable: a lookup fills a form, it does not own it. The [document field guide](document-field.md) has the mask that keeps the caret where it belongs. +`getAddressInfoByCep` asks the CEP providers and returns the street, neighborhood, city and state, or throws when no one has that CEP. It is asked only once `isValidCep` says the CEP is complete, so a request is not made on every keystroke, and what comes back stays editable: a lookup fills a form, it does not own it. The [document field guide](guides/document-field.md) has the mask that keeps the caret where it belongs.
-A hook takes the CEP and gives back what is known about it; the form draws that. The CEP field is the one the [document field guide](document-field.md) builds: +A hook takes the CEP and gives back what is known about it; the form draws that. The CEP field is the one the [document field guide](guides/document-field.md) builds:
@@ -29,7 +29,7 @@ A hook takes the CEP and gives back what is known about it; the form draws that.
-A `resource` takes the CEP and gives back what is known about it, reloading when it changes. The CEP field is the one the [document field guide](document-field.md) builds: +A `resource` takes the CEP and gives back what is known about it, reloading when it changes. The CEP field is the one the [document field guide](guides/document-field.md) builds:
@@ -47,7 +47,7 @@ A `resource` takes the CEP and gives back what is known about it, reloading when
-A composable takes the CEP and gives back what is known about it; the form draws that. The CEP field is the one the [document field guide](document-field.md) builds: +A composable takes the CEP and gives back what is known about it; the form draws that. The CEP field is the one the [document field guide](guides/document-field.md) builds:
diff --git a/docs/snippets/address-form/vanilla/address-form.html b/docs/snippets/address-form/vanilla/address-form.html index d3ff5dfc..68711786 100644 --- a/docs/snippets/address-form/vanilla/address-form.html +++ b/docs/snippets/address-form/vanilla/address-form.html @@ -62,6 +62,7 @@ // Asking before the CEP is complete is asking for nothing. if (!isValidCep(cep.value)) { status.textContent = ""; + cep.removeAttribute("aria-busy"); return; } From 829700d3c88099257e98b8b5d13ec767047bf994 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:29:32 -0300 Subject: [PATCH 08/80] docs(state-city): build the guide on getMunicipalities and fix the A, B, A race The guide imported the deprecated getCities. The four snippets now use getMunicipalities, with the IBGE code as the option value and the name as the label, since names repeat across states. The size figure is 148 KB, the size of the municipality table and its helpers in dist. In React and Vue, picking A, then B, then A again while the first table was still loading left the select on the loading message forever; the abort now also clears the asked state. The em dash of the intro is gone. English and Portuguese. --- docs/guides/state-city.md | 4 +-- docs/pt-br/guides/state-city.md | 4 +-- docs/snippets/live/run.js | 2 +- .../state-city/angular/cities-of-state.ts | 10 ++++--- .../snippets/state-city/angular/state-city.ts | 4 +-- docs/snippets/state-city/react/state-city.tsx | 4 +-- .../state-city/react/use-cities-of-state.ts | 26 +++++++++++++------ .../state-city/vanilla/state-city.html | 6 ++--- docs/snippets/state-city/vue/state-city.vue | 4 ++- .../state-city/vue/use-cities-of-state.ts | 24 +++++++++++------ 10 files changed, 55 insertions(+), 33 deletions(-) diff --git a/docs/guides/state-city.md b/docs/guides/state-city.md index 2e96dd79..70e53e5a 100644 --- a/docs/guides/state-city.md +++ b/docs/guides/state-city.md @@ -1,12 +1,12 @@ --- title: "State and city" description: "Pick a state and the cities of that state load on demand, with Brazilian Utils in React, Angular, Vue and plain JavaScript." -keywords: ["state and city select", "IBGE cities", "getCities", "lazy import", "code splitting", "municipalities of a state"] +keywords: ["state and city select", "IBGE municipalities", "getMunicipalities", "lazy import", "code splitting", "municipalities of a state"] --- Pick a state and its cities fill the second select. Pick the framework: each example runs the code below it, which you can copy as is. -The point of this one is when each table is loaded, and the answer is: when someone opens the select that shows it. Not with the page, and not when a state is picked either — a form where the city is filled in by something else, or left alone, never fetches 154 KB of cities. The states are 27 rows, 2.5 KB; the cities are 5,571 of them, 154 KB. Each util is its own subpath, so `await import("@brazilian-utils/brazilian-utils/get-cities")` fetches that table and nothing else. A bundler makes it a chunk of its own; the browser fetches it once and keeps it, so only the first opening waits. +The point of this one is when each table is loaded, and the answer is: when someone opens the select that shows it. Not with the page, and not when a state is picked either. A form where the city is filled in by something else, or left alone, never fetches 148 KB of municipalities. The states are 27 rows, 2.5 KB; the municipalities are 5,571 of them, 148 KB. Each util is its own subpath, so `await import("@brazilian-utils/brazilian-utils/get-municipalities")` fetches that table and nothing else. Each option takes the IBGE `code` of the municipality as its value and the `name` as its label, since names repeat across states (there are two "Pau D'Arco"). A bundler makes it a chunk of its own; the browser fetches it once and keeps it, so only the first opening waits.
diff --git a/docs/pt-br/guides/state-city.md b/docs/pt-br/guides/state-city.md index 69354f62..c088bd92 100644 --- a/docs/pt-br/guides/state-city.md +++ b/docs/pt-br/guides/state-city.md @@ -1,12 +1,12 @@ --- title: "Estado e cidade" description: "Escolha um estado e as cidades dele carregam sob demanda, com Brazilian Utils em React, Angular, Vue e JavaScript puro." -keywords: ["select de estado e cidade", "cidades do IBGE", "getCities", "import lazy", "code splitting", "municípios de um estado"] +keywords: ["select de estado e cidade", "municípios do IBGE", "getMunicipalities", "import lazy", "code splitting", "municípios de um estado"] --- Escolha um estado e as cidades dele preenchem o segundo select. Escolha o framework: cada exemplo roda o código logo abaixo dele, que pode ser copiado do jeito que está. -O ponto aqui é quando cada tabela é carregada, e a resposta é: quando alguém abre o select que a mostra. Nem com a página, nem ao escolher um estado — num formulário em que a cidade vem preenchida de outro lugar, ou é deixada em branco, os 154 KB de cidades nunca são buscados. Os estados são 27 linhas, 2,5 KB; as cidades são 5.571, 154 KB. Cada utilitário é um subpath, então `await import("@brazilian-utils/brazilian-utils/get-cities")` busca essa tabela e nada mais. O bundler transforma isso num chunk separado, e o browser busca uma vez e guarda, então só a primeira abertura espera. +O ponto aqui é quando cada tabela é carregada, e a resposta é: quando alguém abre o select que a mostra. Nem com a página, nem ao escolher um estado. Num formulário em que a cidade vem preenchida de outro lugar, ou é deixada em branco, os 148 KB de municípios nunca são buscados. Os estados são 27 linhas, 2,5 KB; os municípios são 5.571, 148 KB. Cada utilitário é um subpath, então `await import("@brazilian-utils/brazilian-utils/get-municipalities")` busca essa tabela e nada mais. Cada opção usa o `code` IBGE do município como valor e o `name` como rótulo, já que os nomes se repetem entre estados (existem dois "Pau D'Arco"). O bundler transforma isso num chunk separado, e o browser busca uma vez e guarda, então só a primeira abertura espera.
diff --git a/docs/snippets/live/run.js b/docs/snippets/live/run.js index 3416f144..e14b017d 100644 --- a/docs/snippets/live/run.js +++ b/docs/snippets/live/run.js @@ -17,7 +17,7 @@ imports: { "@brazilian-utils/brazilian-utils": CDN + "@brazilian-utils/brazilian-utils/+esm", "@brazilian-utils/brazilian-utils/get-states": CDN + "@brazilian-utils/brazilian-utils/get-states/+esm", - "@brazilian-utils/brazilian-utils/get-cities": CDN + "@brazilian-utils/brazilian-utils/get-cities/+esm", + "@brazilian-utils/brazilian-utils/get-municipalities": CDN + "@brazilian-utils/brazilian-utils/get-municipalities/+esm", // esm.sh, not jsDelivr, for React: jsDelivr's react-dom imports its own copy of react. react: "https://esm.sh/react@19.3.0", "react/jsx-runtime": "https://esm.sh/react@19.3.0/jsx-runtime", diff --git a/docs/snippets/state-city/angular/cities-of-state.ts b/docs/snippets/state-city/angular/cities-of-state.ts index 80cacccc..9cfd86d0 100644 --- a/docs/snippets/state-city/angular/cities-of-state.ts +++ b/docs/snippets/state-city/angular/cities-of-state.ts @@ -2,8 +2,8 @@ import { resource, type Signal } from "@angular/core"; import type { StateCode } from "@brazilian-utils/brazilian-utils"; /** - * The cities of a state, fetched the first time that state's select is opened. The table is - * 154 KB, so nothing is fetched until someone means to pick a city. What it is about is the state + * The municipalities of a state, fetched the first time that state's select is opened. The table is + * 148 KB, so nothing is fetched until someone means to pick a city. What it is about is the state * whose cities were asked for, so picking another state puts the resource back to waiting. * * A resource aborts a load it no longer wants and drops its answer, which is what the `abortSignal` @@ -14,9 +14,11 @@ export function citiesOfState(asked: Signal) { return resource({ params: () => asked() || undefined, loader: async ({ params }) => { - const { getCities } = await import("@brazilian-utils/brazilian-utils/get-cities"); + const { getMunicipalities } = await import( + "@brazilian-utils/brazilian-utils/get-municipalities" + ); - return getCities(params as StateCode); + return getMunicipalities(params as StateCode); }, }); } diff --git a/docs/snippets/state-city/angular/state-city.ts b/docs/snippets/state-city/angular/state-city.ts index 62c2ee5e..e99d0a8f 100644 --- a/docs/snippets/state-city/angular/state-city.ts +++ b/docs/snippets/state-city/angular/state-city.ts @@ -29,8 +29,8 @@ import { states } from "./states"; (focus)="askedForCities.set(state())" > - @for (city of cityList(); track city) { - + @for (city of cityList(); track city.code) { + } `, diff --git a/docs/snippets/state-city/react/state-city.tsx b/docs/snippets/state-city/react/state-city.tsx index b979c5f2..2f9e1d45 100644 --- a/docs/snippets/state-city/react/state-city.tsx +++ b/docs/snippets/state-city/react/state-city.tsx @@ -31,8 +31,8 @@ export function StateCity() { diff --git a/docs/snippets/state-city/react/use-cities-of-state.ts b/docs/snippets/state-city/react/use-cities-of-state.ts index a859c605..0f8e3686 100644 --- a/docs/snippets/state-city/react/use-cities-of-state.ts +++ b/docs/snippets/state-city/react/use-cities-of-state.ts @@ -1,15 +1,17 @@ import { useEffect, useRef, useState } from "react"; -import type { StateCode } from "@brazilian-utils/brazilian-utils"; +import type { Municipality, StateCode } from "@brazilian-utils/brazilian-utils"; /** - * The cities of a state, fetched the first time that state's select is opened. The table is - * 154 KB, so nothing is fetched until someone means to pick a city, and the browser keeps the + * The municipalities of a state, fetched the first time that state's select is opened. The table is + * 148 KB, so nothing is fetched until someone means to pick a city, and the browser keeps the * module once it has it. `import()` takes no signal, so the module is not stopped, only what is * done with it: a table that arrives for a state that is no longer picked, or after the component - * is gone, is dropped. + * is gone, is dropped. The abort also forgets which state was asked for, so picking a state again + * after leaving it while its table was on its way asks again instead of waiting for an answer that + * was dropped. */ export function useCitiesOfState(state: string) { - const [loaded, setLoaded] = useState({ state: "", cities: [] as string[] }); + const [loaded, setLoaded] = useState({ state: "", cities: [] as Municipality[] }); const [asked, setAsked] = useState(""); const pending = useRef(null); @@ -19,7 +21,13 @@ export function useCitiesOfState(state: string) { const loading = state !== "" && asked === state && cities.length === 0; // What is on its way is dropped when another state is picked and when the component goes. - useEffect(() => () => pending.current?.abort(), [state]); + useEffect( + () => () => { + pending.current?.abort(); + setAsked(""); + }, + [state], + ); const load = async () => { if (!state || loading || cities.length > 0) return; @@ -29,11 +37,13 @@ export function useCitiesOfState(state: string) { pending.current = controller; setAsked(state); - const { getCities } = await import("@brazilian-utils/brazilian-utils/get-cities"); + const { getMunicipalities } = await import( + "@brazilian-utils/brazilian-utils/get-municipalities" + ); if (controller.signal.aborted) return; - setLoaded({ state, cities: getCities(state as StateCode) }); + setLoaded({ state, cities: getMunicipalities(state as StateCode) }); }; return { cities, loading, load }; diff --git a/docs/snippets/state-city/vanilla/state-city.html b/docs/snippets/state-city/vanilla/state-city.html index 3eb0f2c5..9047124e 100644 --- a/docs/snippets/state-city/vanilla/state-city.html +++ b/docs/snippets/state-city/vanilla/state-city.html @@ -15,7 +15,7 @@