@allxsmith/bestax-bulma 5.57.5 → 5.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/esm/components/Avatars.js +2 -2
  2. package/dist/esm/components/Avatars.js.map +1 -1
  3. package/dist/esm/components/Badge.js +2 -2
  4. package/dist/esm/components/Badge.js.map +1 -1
  5. package/dist/esm/components/Pagination.js +2 -2
  6. package/dist/esm/components/Pagination.js.map +1 -1
  7. package/dist/esm/components/Steps.js +2 -2
  8. package/dist/esm/components/Steps.js.map +1 -1
  9. package/dist/esm/components/TreeView.js +7 -5
  10. package/dist/esm/components/TreeView.js.map +1 -1
  11. package/dist/esm/components/useDataTable.js +2 -2
  12. package/dist/esm/components/useDataTable.js.map +1 -1
  13. package/dist/esm/elements/NumberFormatter.js +42 -0
  14. package/dist/esm/elements/NumberFormatter.js.map +1 -0
  15. package/dist/esm/elements/RingProgress.js +2 -2
  16. package/dist/esm/elements/RingProgress.js.map +1 -1
  17. package/dist/esm/form/Dropzone.js +2 -2
  18. package/dist/esm/form/Dropzone.js.map +1 -1
  19. package/dist/esm/form/File.js +2 -2
  20. package/dist/esm/form/File.js.map +1 -1
  21. package/dist/esm/form/Rate.js +2 -2
  22. package/dist/esm/form/Rate.js.map +1 -1
  23. package/dist/esm/form/Slider.js +2 -2
  24. package/dist/esm/form/Slider.js.map +1 -1
  25. package/dist/esm/form/Taginput.js +2 -2
  26. package/dist/esm/form/Taginput.js.map +1 -1
  27. package/dist/esm/form/_colorInternals/pickerState.js +2 -2
  28. package/dist/esm/form/_colorInternals/pickerState.js.map +1 -1
  29. package/dist/esm/helpers/LocaleProvider.js +13 -6
  30. package/dist/esm/helpers/LocaleProvider.js.map +1 -1
  31. package/dist/esm/helpers/localeFormat.js +196 -36
  32. package/dist/esm/helpers/localeFormat.js.map +1 -1
  33. package/dist/esm/helpers/useTreeView.js +54 -20
  34. package/dist/esm/helpers/useTreeView.js.map +1 -1
  35. package/dist/index.cjs +312 -76
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.esm.js +2 -0
  38. package/dist/index.esm.js.map +1 -1
  39. package/dist/types/components/TreeView.d.ts +20 -11
  40. package/dist/types/elements/NumberFormatter.d.ts +62 -0
  41. package/dist/types/helpers/LocaleProvider.d.ts +7 -0
  42. package/dist/types/helpers/localeFormat.d.ts +30 -8
  43. package/dist/types/helpers/useTreeView.d.ts +12 -6
  44. package/dist/types/index.d.ts +3 -0
  45. package/dist/types-cjs/components/TreeView.d.ts +20 -11
  46. package/dist/types-cjs/elements/NumberFormatter.d.ts +62 -0
  47. package/dist/types-cjs/helpers/LocaleProvider.d.ts +7 -0
  48. package/dist/types-cjs/helpers/localeFormat.d.ts +30 -8
  49. package/dist/types-cjs/helpers/useTreeView.d.ts +12 -6
  50. package/dist/types-cjs/index.d.ts +3 -0
  51. package/package.json +1 -1
@@ -1,50 +1,210 @@
1
1
  /* @allxsmith/bestax-bulma — AI agents: see AGENTS.md in the package root, or https://bestax.io/llms.txt */
2
2
  'use client';
3
3
  import { useMemo } from 'react';
4
- import { useLocaleScope } from './LocaleProvider.js';
4
+ import { useLocaleScope, validLocale } from './LocaleProvider.js';
5
+ import { warnOnce } from './devWarnings.js';
5
6
 
6
- // INTERNAL: deliberately not exported from src/index.ts. Number formatting
7
- // and text matching in a LocaleProvider's locale. Both return `null` outside
8
- // a provider that gives a locale, so each caller keeps the formatting and the
9
- // matching it has always had there.
7
+ // Number formatting and text matching in a locale.
8
+ //
9
+ // `useNumberFormat` and its `NumberFormatFunction` type are public, exported
10
+ // from src/index.ts. The rest is INTERNAL, deliberately not exported.
11
+ //
12
+ // Which a component calls depends on its job. A component whose job is to
13
+ // write a number, such as `NumberFormatter`, uses `useNumberFormat`, which
14
+ // writes in the runtime's locale outside a `LocaleProvider`, as the pickers
15
+ // do. Any other component that shows a number in passing, such as a badge
16
+ // count, a page number or a step's number, uses `useScopedNumberFormat`,
17
+ // and filters a list through `useLocaleMatcher`. Those two return `null`
18
+ // outside a provider, and inside one that gives no locale, as a provider
19
+ // with only a `dir` doesn't, so the component keeps the formatting and the
20
+ // matching it has always had there, as `LocaleProvider` promises.
10
21
  /** A combining mark: an accent, a vowel sign or a point written on a letter. */
11
22
  const MARK = /\p{M}/u;
12
23
  /**
13
- * The locale a component formats and matches in: inside a `LocaleProvider`,
14
- * the component's own locale when it has one (a picker's `locale`), else the
15
- * provider's; outside a provider, none.
24
+ * The warning about a locale `Intl` refuses, given to `useNumberFormat`,
25
+ * and so to `NumberFormatter`, the only callers that pass one.
16
26
  */
17
- function useScopedLocale(own) {
27
+ const warnOwnLocale = (tag) => warnOnce(`number-format-invalid-locale:${tag}`, `[bestax-bulma] NumberFormatter or useNumberFormat was given the locale ` +
28
+ `"${tag}", which is not a language tag Intl accepts, so it is ` +
29
+ "ignored and numbers are written in the LocaleProvider's locale, " +
30
+ 'else the runtime\'s. Use a BCP 47 tag such as "en-US", with a ' +
31
+ 'hyphen rather than an underscore.');
32
+ /**
33
+ * The locale a component formats and matches in: the locale given, else the
34
+ * `LocaleProvider`'s, else none. A tag `Intl` refuses counts as none, with a
35
+ * development warning, as `LocaleProvider` treats one.
36
+ */
37
+ function useResolvedLocale(own) {
18
38
  const scope = useLocaleScope();
19
- if (!scope)
20
- return undefined;
21
- return scope.locale;
39
+ const valid = useMemo(() => validLocale(own, warnOwnLocale), [own]);
40
+ return valid || scope?.locale;
22
41
  }
23
42
  /**
24
- * A formatter for numbers shown on screen, in the locale of the
25
- * `LocaleProvider` around the component, or `null` outside one that gives a
26
- * locale. Formatters are kept per set of options, since building one is the
27
- * expensive part.
43
+ * The options `Intl.NumberFormat` reads, as ECMA-402 lists them. A key reads
44
+ * each by name, so an inherited option counts as `Intl` counts it.
45
+ */
46
+ const NUMBER_FORMAT_OPTIONS = [
47
+ 'localeMatcher',
48
+ 'numberingSystem',
49
+ 'style',
50
+ 'currency',
51
+ 'currencyDisplay',
52
+ 'currencySign',
53
+ 'unit',
54
+ 'unitDisplay',
55
+ 'notation',
56
+ 'minimumIntegerDigits',
57
+ 'minimumFractionDigits',
58
+ 'maximumFractionDigits',
59
+ 'minimumSignificantDigits',
60
+ 'maximumSignificantDigits',
61
+ 'roundingIncrement',
62
+ 'roundingMode',
63
+ 'roundingPriority',
64
+ 'trailingZeroDisplay',
65
+ 'compactDisplay',
66
+ 'useGrouping',
67
+ 'signDisplay',
68
+ ];
69
+ const KNOWN_OPTIONS = new Set(NUMBER_FORMAT_OPTIONS);
70
+ /** One option's value with its type, or `null` for one a key can't hold. */
71
+ function writeOption(value) {
72
+ switch (typeof value) {
73
+ case 'string':
74
+ return JSON.stringify(value);
75
+ case 'number':
76
+ // `NaN` and `Infinity` as themselves, which `JSON.stringify` makes
77
+ // `null`, and `-0` with its sign.
78
+ return Object.is(value, -0) ? '-0' : String(value);
79
+ case 'boolean':
80
+ return String(value);
81
+ case 'bigint':
82
+ return `${value}n`;
83
+ default:
84
+ return value === null ? 'null' : null;
85
+ }
86
+ }
87
+ /**
88
+ * The options as `Intl` will read them, and a key that tells apart any two
89
+ * sets it would read differently; `null` when an option `Intl` reads holds
90
+ * an object, a function or a symbol, which `Intl` converts in ways a key
91
+ * can't follow. It reads each option `Intl` reads by name, inherited ones
92
+ * included, then every other enumerable key, own or inherited, so an option
93
+ * `Intl` gains later still keeps two sets apart. Since `Intl`'s options are
94
+ * all primitives, one of those other keys holding anything else is left
95
+ * out. An option set to `undefined` is unset, as it is for `Intl`. The
96
+ * formatter is built from the copy, so `Intl` sees what the key says, even
97
+ * of a getter.
98
+ */
99
+ function readOptions(options) {
100
+ const others = [];
101
+ for (const name in options) {
102
+ if (!KNOWN_OPTIONS.has(name))
103
+ others.push(name);
104
+ }
105
+ const names = NUMBER_FORMAT_OPTIONS.concat(others.sort());
106
+ const read = {};
107
+ const parts = [];
108
+ for (const name of names) {
109
+ const value = options[name];
110
+ if (value === undefined)
111
+ continue;
112
+ const written = writeOption(value);
113
+ if (written === null) {
114
+ // An option `Intl` reads converts an object; a key it doesn't read,
115
+ // such as a config's own `meta`, means nothing to it.
116
+ if (KNOWN_OPTIONS.has(name))
117
+ return null;
118
+ continue;
119
+ }
120
+ read[name] = value;
121
+ parts.push(`${JSON.stringify(name)}:${written}`);
122
+ }
123
+ return { key: parts.join(','), read };
124
+ }
125
+ /**
126
+ * Every formatter's `Intl.NumberFormat`s, by locale and options, so a page of
127
+ * numbers in one locale and format builds one. Building one is the expensive
128
+ * part of formatting. Past `MOST_NUMBER_FORMATS`, the one used longest ago
129
+ * goes, so a server that writes numbers in many locales doesn't keep them
130
+ * all, and the ones in use stay. A `Map` keeps its keys in the order they
131
+ * were set, so a formatter is set again each time it's used.
132
+ */
133
+ const numberFormats = new Map();
134
+ const MOST_NUMBER_FORMATS = 200;
135
+ /** The `Intl.NumberFormat` for `locale` and `options`, built once. */
136
+ function numberFormatFor(locale, options) {
137
+ let read;
138
+ let key = '';
139
+ if (options !== undefined) {
140
+ const copy = typeof options === 'object' && options !== null
141
+ ? readOptions(options)
142
+ : null;
143
+ // Options a key can't hold get a formatter of their own, which also
144
+ // throws what `Intl` throws for them.
145
+ if (!copy)
146
+ return new Intl.NumberFormat(locale, options);
147
+ read = copy.read;
148
+ key = copy.key;
149
+ }
150
+ // A locale tag has no spaces, so the key can't be read two ways.
151
+ const cacheKey = `${locale ?? ''} ${key}`;
152
+ let format = numberFormats.get(cacheKey);
153
+ if (format) {
154
+ numberFormats.delete(cacheKey);
155
+ }
156
+ else {
157
+ // Built before anything is stored, so options `Intl` refuses throw its
158
+ // error and leave nothing behind.
159
+ format = new Intl.NumberFormat(locale, read);
160
+ if (numberFormats.size >= MOST_NUMBER_FORMATS) {
161
+ numberFormats.delete(numberFormats.keys().next().value);
162
+ }
163
+ }
164
+ numberFormats.set(cacheKey, format);
165
+ return format;
166
+ }
167
+ /** A function that writes numbers in `locale`, the same one for each locale. */
168
+ function useFormatIn(locale) {
169
+ return useMemo(() => (value, options) => numberFormatFor(locale, options).format(value), [locale]);
170
+ }
171
+ /**
172
+ * A function that writes numbers for display in a locale: `locale` when given, else the `LocaleProvider`'s around the component, else the runtime's.
173
+ *
174
+ * Formatters are kept per locale and set of options, since building one is
175
+ * the expensive part, and shared, so many numbers written the same way build
176
+ * one. The function is the same from one render to the next for the same
177
+ * locale, so it's safe in a dependency array. Use it for text outside JSX,
178
+ * such as an `aria-label`, a `placeholder` or a chart's axis; in JSX,
179
+ * `NumberFormatter` also marks the number up. On a server render the
180
+ * runtime's locale is the server's, so give it a locale, here or through a
181
+ * `LocaleProvider`, where the server's and the browser's can differ. A tag
182
+ * `Intl` refuses is ignored, with a warning in development, as
183
+ * `LocaleProvider` ignores one. Options `Intl` refuses throw its error when
184
+ * the function is called.
185
+ *
186
+ * @function
187
+ * @param locale - A BCP 47 locale tag, which wins over the provider's.
188
+ * @returns {NumberFormatFunction} Writes `value` with `Intl.NumberFormat` options.
28
189
  *
29
- * @param own - The component's own locale, such as a picker's `locale`,
30
- * which wins inside a provider.
190
+ * @example
191
+ * const format = useNumberFormat();
192
+ * const placeholder = format(1500, { style: 'currency', currency: 'EUR' });
31
193
  */
32
- function useNumberFormat(own) {
33
- const locale = useScopedLocale();
34
- return useMemo(() => {
35
- if (!locale)
36
- return null;
37
- const formats = new Map();
38
- return (value, options) => {
39
- const key = options ? JSON.stringify(options) : '';
40
- let format = formats.get(key);
41
- if (!format) {
42
- format = new Intl.NumberFormat(locale, options);
43
- formats.set(key, format);
44
- }
45
- return format.format(value);
46
- };
47
- }, [locale]);
194
+ function useNumberFormat(locale) {
195
+ return useFormatIn(useResolvedLocale(locale));
196
+ }
197
+ /**
198
+ * INTERNAL, deliberately not exported from `src/index.ts`. Number formatting
199
+ * for a component that shows a number in passing, as `useNumberFormat`
200
+ * writes it in the `LocaleProvider`'s locale, or `null` outside a provider
201
+ * and inside one that gives no locale, as a provider with only a `dir`
202
+ * doesn't, where the component keeps the formatting it has always had.
203
+ */
204
+ function useScopedNumberFormat() {
205
+ const scoped = useResolvedLocale(undefined);
206
+ const format = useFormatIn(scoped);
207
+ return scoped ? format : null;
48
208
  }
49
209
  /**
50
210
  * A matcher for filtering a list by what was typed, in the locale of the
@@ -58,7 +218,7 @@ function useNumberFormat(own) {
58
218
  * matches everything.
59
219
  */
60
220
  function useLocaleMatcher() {
61
- const locale = useScopedLocale();
221
+ const locale = useResolvedLocale(undefined);
62
222
  return useMemo(() => {
63
223
  if (!locale)
64
224
  return null;
@@ -105,5 +265,5 @@ function useLocaleMatcher() {
105
265
  }, [locale]);
106
266
  }
107
267
 
108
- export { useLocaleMatcher, useNumberFormat };
268
+ export { useLocaleMatcher, useNumberFormat, useScopedNumberFormat };
109
269
  //# sourceMappingURL=localeFormat.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"localeFormat.js","sources":["../../../src/helpers/localeFormat.ts"],"sourcesContent":["// INTERNAL: deliberately not exported from src/index.ts. Number formatting\n// and text matching in a LocaleProvider's locale. Both return `null` outside\n// a provider that gives a locale, so each caller keeps the formatting and the\n// matching it has always had there.\n\nimport { useMemo } from 'react';\nimport { useLocaleScope } from './LocaleProvider';\n\n/** A combining mark: an accent, a vowel sign or a point written on a letter. */\nconst MARK = /\\p{M}/u;\n\n/** Formats a number for display, with `Intl.NumberFormat` options. */\nexport type NumberFormatter = (\n value: number,\n options?: Intl.NumberFormatOptions\n) => string;\n\n/** Whether `text` contains `query`, as the locale compares letters. */\nexport type TextMatcher = (text: string, query: string) => boolean;\n\n/**\n * The locale a component formats and matches in: inside a `LocaleProvider`,\n * the component's own locale when it has one (a picker's `locale`), else the\n * provider's; outside a provider, none.\n */\nfunction useScopedLocale(own: string | undefined): string | undefined {\n const scope = useLocaleScope();\n if (!scope) return undefined;\n return own || scope.locale;\n}\n\n/**\n * A formatter for numbers shown on screen, in the locale of the\n * `LocaleProvider` around the component, or `null` outside one that gives a\n * locale. Formatters are kept per set of options, since building one is the\n * expensive part.\n *\n * @param own - The component's own locale, such as a picker's `locale`,\n * which wins inside a provider.\n */\nexport function useNumberFormat(own?: string): NumberFormatter | null {\n const locale = useScopedLocale(own);\n return useMemo(() => {\n if (!locale) return null;\n const formats = new Map<string, Intl.NumberFormat>();\n return (value, options) => {\n const key = options ? JSON.stringify(options) : '';\n let format = formats.get(key);\n if (!format) {\n format = new Intl.NumberFormat(locale, options);\n formats.set(key, format);\n }\n return format.format(value);\n };\n }, [locale]);\n}\n\n/**\n * A matcher for filtering a list by what was typed, in the locale of the\n * `LocaleProvider` around the component, or `null` outside one that gives a\n * locale. It compares with `Intl.Collator` at `usage: 'search'` and\n * `sensitivity: 'base'`, so it ignores case and accents where the language\n * treats the letters as the same: \"cafe\" finds \"Café\", while in Swedish \"a\"\n * doesn't find \"å\", a letter of its own there. Marks the language ignores\n * count for nothing on either side, so in Arabic محمد finds مُحَمَّد and the\n * other way round. An empty query, or one of nothing but such marks,\n * matches everything.\n */\nexport function useLocaleMatcher(): TextMatcher | null {\n const locale = useScopedLocale(undefined);\n return useMemo(() => {\n if (!locale) return null;\n const collator = new Intl.Collator(locale, {\n usage: 'search',\n sensitivity: 'base',\n });\n return (text, query) => {\n const needle = query.normalize('NFC');\n if (collator.compare(needle, '') === 0) return true;\n // Compared by code point, so a slice never splits a surrogate pair.\n const chars = Array.from(text.normalize('NFC'));\n const marks = chars.map(char => MARK.test(char));\n // A slice equal to the needle can be longer or shorter than it: the\n // marks the collator ignores, Arabic harakat or Hebrew niqqud, take\n // code points on one side and not the other, and the expansions the\n // collator makes, \"ß\" against \"ss\" or \"æ\" against \"ae\", turn one letter\n // into two. So each slice runs from a letter to just before the next\n // letter, never cutting a letter from its marks, and the slices from\n // one start grow until they hold twice the needle's letters, which\n // those expansions can't get past. That keeps the work proportional to\n // the text's length, however long a label is, rather than to its\n // square.\n const most =\n 2 * Array.from(needle).filter(char => !MARK.test(char)).length;\n for (let start = 0; start < chars.length; start++) {\n if (marks[start]) continue;\n let letters = 0;\n for (let end = start + 1; end <= chars.length; end++) {\n if (!marks[end - 1]) letters++;\n if (letters > most) break;\n if (marks[end]) continue;\n if (\n collator.compare(chars.slice(start, end).join(''), needle) === 0\n ) {\n return true;\n }\n }\n }\n return false;\n };\n }, [locale]);\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"localeFormat.js","sources":["../../../src/helpers/localeFormat.ts"],"sourcesContent":["// Number formatting and text matching in a locale.\n//\n// `useNumberFormat` and its `NumberFormatFunction` type are public, exported\n// from src/index.ts. The rest is INTERNAL, deliberately not exported.\n//\n// Which a component calls depends on its job. A component whose job is to\n// write a number, such as `NumberFormatter`, uses `useNumberFormat`, which\n// writes in the runtime's locale outside a `LocaleProvider`, as the pickers\n// do. Any other component that shows a number in passing, such as a badge\n// count, a page number or a step's number, uses `useScopedNumberFormat`,\n// and filters a list through `useLocaleMatcher`. Those two return `null`\n// outside a provider, and inside one that gives no locale, as a provider\n// with only a `dir` doesn't, so the component keeps the formatting and the\n// matching it has always had there, as `LocaleProvider` promises.\n\nimport { useMemo } from 'react';\nimport { useLocaleScope, validLocale } from './LocaleProvider';\nimport { warnOnce } from './devWarnings';\n\n/** A combining mark: an accent, a vowel sign or a point written on a letter. */\nconst MARK = /\\p{M}/u;\n\n/** Formats a number for display, with `Intl.NumberFormat` options. */\nexport type NumberFormatFunction = (\n value: number,\n options?: Intl.NumberFormatOptions\n) => string;\n\n/** Whether `text` contains `query`, as the locale compares letters. */\nexport type TextMatcher = (text: string, query: string) => boolean;\n\n/**\n * The warning about a locale `Intl` refuses, given to `useNumberFormat`,\n * and so to `NumberFormatter`, the only callers that pass one.\n */\nconst warnOwnLocale = (tag: string) =>\n warnOnce(\n `number-format-invalid-locale:${tag}`,\n `[bestax-bulma] NumberFormatter or useNumberFormat was given the locale ` +\n `\"${tag}\", which is not a language tag Intl accepts, so it is ` +\n \"ignored and numbers are written in the LocaleProvider's locale, \" +\n 'else the runtime\\'s. Use a BCP 47 tag such as \"en-US\", with a ' +\n 'hyphen rather than an underscore.'\n );\n\n/**\n * The locale a component formats and matches in: the locale given, else the\n * `LocaleProvider`'s, else none. A tag `Intl` refuses counts as none, with a\n * development warning, as `LocaleProvider` treats one.\n */\nfunction useResolvedLocale(own: string | undefined): string | undefined {\n const scope = useLocaleScope();\n const valid = useMemo(() => validLocale(own, warnOwnLocale), [own]);\n return valid || scope?.locale;\n}\n\n/**\n * The options `Intl.NumberFormat` reads, as ECMA-402 lists them. A key reads\n * each by name, so an inherited option counts as `Intl` counts it.\n */\nconst NUMBER_FORMAT_OPTIONS = [\n 'localeMatcher',\n 'numberingSystem',\n 'style',\n 'currency',\n 'currencyDisplay',\n 'currencySign',\n 'unit',\n 'unitDisplay',\n 'notation',\n 'minimumIntegerDigits',\n 'minimumFractionDigits',\n 'maximumFractionDigits',\n 'minimumSignificantDigits',\n 'maximumSignificantDigits',\n 'roundingIncrement',\n 'roundingMode',\n 'roundingPriority',\n 'trailingZeroDisplay',\n 'compactDisplay',\n 'useGrouping',\n 'signDisplay',\n];\nconst KNOWN_OPTIONS = new Set(NUMBER_FORMAT_OPTIONS);\n\n/** One option's value with its type, or `null` for one a key can't hold. */\nfunction writeOption(value: unknown): string | null {\n switch (typeof value) {\n case 'string':\n return JSON.stringify(value);\n case 'number':\n // `NaN` and `Infinity` as themselves, which `JSON.stringify` makes\n // `null`, and `-0` with its sign.\n return Object.is(value, -0) ? '-0' : String(value);\n case 'boolean':\n return String(value);\n case 'bigint':\n return `${value}n`;\n default:\n return value === null ? 'null' : null;\n }\n}\n\n/**\n * The options as `Intl` will read them, and a key that tells apart any two\n * sets it would read differently; `null` when an option `Intl` reads holds\n * an object, a function or a symbol, which `Intl` converts in ways a key\n * can't follow. It reads each option `Intl` reads by name, inherited ones\n * included, then every other enumerable key, own or inherited, so an option\n * `Intl` gains later still keeps two sets apart. Since `Intl`'s options are\n * all primitives, one of those other keys holding anything else is left\n * out. An option set to `undefined` is unset, as it is for `Intl`. The\n * formatter is built from the copy, so `Intl` sees what the key says, even\n * of a getter.\n */\nfunction readOptions(\n options: object\n): { key: string; read: Record<string, unknown> } | null {\n const others: string[] = [];\n for (const name in options) {\n if (!KNOWN_OPTIONS.has(name)) others.push(name);\n }\n const names = NUMBER_FORMAT_OPTIONS.concat(others.sort());\n const read: Record<string, unknown> = {};\n const parts: string[] = [];\n for (const name of names) {\n const value = (options as Record<string, unknown>)[name];\n if (value === undefined) continue;\n const written = writeOption(value);\n if (written === null) {\n // An option `Intl` reads converts an object; a key it doesn't read,\n // such as a config's own `meta`, means nothing to it.\n if (KNOWN_OPTIONS.has(name)) return null;\n continue;\n }\n read[name] = value;\n parts.push(`${JSON.stringify(name)}:${written}`);\n }\n return { key: parts.join(','), read };\n}\n\n/**\n * Every formatter's `Intl.NumberFormat`s, by locale and options, so a page of\n * numbers in one locale and format builds one. Building one is the expensive\n * part of formatting. Past `MOST_NUMBER_FORMATS`, the one used longest ago\n * goes, so a server that writes numbers in many locales doesn't keep them\n * all, and the ones in use stay. A `Map` keeps its keys in the order they\n * were set, so a formatter is set again each time it's used.\n */\nconst numberFormats = new Map<string, Intl.NumberFormat>();\nconst MOST_NUMBER_FORMATS = 200;\n\n/** The `Intl.NumberFormat` for `locale` and `options`, built once. */\nfunction numberFormatFor(\n locale: string | undefined,\n options: Intl.NumberFormatOptions | undefined\n): Intl.NumberFormat {\n let read: Intl.NumberFormatOptions | undefined;\n let key = '';\n if (options !== undefined) {\n const copy =\n typeof options === 'object' && options !== null\n ? readOptions(options)\n : null;\n // Options a key can't hold get a formatter of their own, which also\n // throws what `Intl` throws for them.\n if (!copy) return new Intl.NumberFormat(locale, options);\n read = copy.read;\n key = copy.key;\n }\n // A locale tag has no spaces, so the key can't be read two ways.\n const cacheKey = `${locale ?? ''} ${key}`;\n let format = numberFormats.get(cacheKey);\n if (format) {\n numberFormats.delete(cacheKey);\n } else {\n // Built before anything is stored, so options `Intl` refuses throw its\n // error and leave nothing behind.\n format = new Intl.NumberFormat(locale, read);\n if (numberFormats.size >= MOST_NUMBER_FORMATS) {\n numberFormats.delete(numberFormats.keys().next().value as string);\n }\n }\n numberFormats.set(cacheKey, format);\n return format;\n}\n\n/** A function that writes numbers in `locale`, the same one for each locale. */\nfunction useFormatIn(locale: string | undefined): NumberFormatFunction {\n return useMemo<NumberFormatFunction>(\n () => (value, options) => numberFormatFor(locale, options).format(value),\n [locale]\n );\n}\n\n/**\n * A function that writes numbers for display in a locale: `locale` when given, else the `LocaleProvider`'s around the component, else the runtime's.\n *\n * Formatters are kept per locale and set of options, since building one is\n * the expensive part, and shared, so many numbers written the same way build\n * one. The function is the same from one render to the next for the same\n * locale, so it's safe in a dependency array. Use it for text outside JSX,\n * such as an `aria-label`, a `placeholder` or a chart's axis; in JSX,\n * `NumberFormatter` also marks the number up. On a server render the\n * runtime's locale is the server's, so give it a locale, here or through a\n * `LocaleProvider`, where the server's and the browser's can differ. A tag\n * `Intl` refuses is ignored, with a warning in development, as\n * `LocaleProvider` ignores one. Options `Intl` refuses throw its error when\n * the function is called.\n *\n * @function\n * @param locale - A BCP 47 locale tag, which wins over the provider's.\n * @returns {NumberFormatFunction} Writes `value` with `Intl.NumberFormat` options.\n *\n * @example\n * const format = useNumberFormat();\n * const placeholder = format(1500, { style: 'currency', currency: 'EUR' });\n */\nexport function useNumberFormat(locale?: string): NumberFormatFunction {\n return useFormatIn(useResolvedLocale(locale));\n}\n\n/**\n * INTERNAL, deliberately not exported from `src/index.ts`. Number formatting\n * for a component that shows a number in passing, as `useNumberFormat`\n * writes it in the `LocaleProvider`'s locale, or `null` outside a provider\n * and inside one that gives no locale, as a provider with only a `dir`\n * doesn't, where the component keeps the formatting it has always had.\n */\nexport function useScopedNumberFormat(): NumberFormatFunction | null {\n const scoped = useResolvedLocale(undefined);\n const format = useFormatIn(scoped);\n return scoped ? format : null;\n}\n\n/**\n * A matcher for filtering a list by what was typed, in the locale of the\n * `LocaleProvider` around the component, or `null` outside one that gives a\n * locale. It compares with `Intl.Collator` at `usage: 'search'` and\n * `sensitivity: 'base'`, so it ignores case and accents where the language\n * treats the letters as the same: \"cafe\" finds \"Café\", while in Swedish \"a\"\n * doesn't find \"å\", a letter of its own there. Marks the language ignores\n * count for nothing on either side, so in Arabic محمد finds مُحَمَّد and the\n * other way round. An empty query, or one of nothing but such marks,\n * matches everything.\n */\nexport function useLocaleMatcher(): TextMatcher | null {\n const locale = useResolvedLocale(undefined);\n return useMemo(() => {\n if (!locale) return null;\n const collator = new Intl.Collator(locale, {\n usage: 'search',\n sensitivity: 'base',\n });\n return (text, query) => {\n const needle = query.normalize('NFC');\n if (collator.compare(needle, '') === 0) return true;\n // Compared by code point, so a slice never splits a surrogate pair.\n const chars = Array.from(text.normalize('NFC'));\n const marks = chars.map(char => MARK.test(char));\n // A slice equal to the needle can be longer or shorter than it: the\n // marks the collator ignores, Arabic harakat or Hebrew niqqud, take\n // code points on one side and not the other, and the expansions the\n // collator makes, \"ß\" against \"ss\" or \"æ\" against \"ae\", turn one letter\n // into two. So each slice runs from a letter to just before the next\n // letter, never cutting a letter from its marks, and the slices from\n // one start grow until they hold twice the needle's letters, which\n // those expansions can't get past. That keeps the work proportional to\n // the text's length, however long a label is, rather than to its\n // square.\n const most =\n 2 * Array.from(needle).filter(char => !MARK.test(char)).length;\n for (let start = 0; start < chars.length; start++) {\n if (marks[start]) continue;\n let letters = 0;\n for (let end = start + 1; end <= chars.length; end++) {\n if (!marks[end - 1]) letters++;\n if (letters > most) break;\n if (marks[end]) continue;\n if (\n collator.compare(chars.slice(start, end).join(''), needle) === 0\n ) {\n return true;\n }\n }\n }\n return false;\n };\n }, [locale]);\n}\n"],"names":[],"mappings":"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"}
@@ -6,15 +6,23 @@ import { getActiveElementInTree } from './shadowDom.js';
6
6
  import { useTypeahead, typeaheadMatch, typeaheadText } from './typeahead.js';
7
7
 
8
8
  // INTERNAL: deliberately not exported from src/index.ts.
9
+ /** The keys and clicks of both checkbox modes, which differ only in what a check reaches. */
10
+ const CHECKBOX_ACTIONS = {
11
+ enter: ['open'],
12
+ space: ['check'],
13
+ click: ['check'],
14
+ };
9
15
  /**
10
16
  * What Enter, Space and a click on the row do in each mode, from the tables
11
- * in #1096. `open` opens or closes a parent and does nothing on a leaf. A
12
- * click on the chevron only ever opens or closes.
17
+ * in #1096, with `checkbox-independent` from #1430. `open` opens or closes a
18
+ * parent and does nothing on a leaf. A click on the chevron only ever opens
19
+ * or closes.
13
20
  */
14
21
  const MODES = {
15
22
  none: { enter: ['open'], space: ['open'], click: ['open'] },
16
23
  single: { enter: ['select'], space: ['select'], click: ['select', 'open'] },
17
- checkbox: { enter: ['open'], space: ['check'], click: ['check'] },
24
+ checkbox: CHECKBOX_ACTIONS,
25
+ 'checkbox-independent': CHECKBOX_ACTIONS,
18
26
  };
19
27
  /** Left and Right, swapped for a right-to-left page. */
20
28
  const MIRRORED = {
@@ -155,6 +163,13 @@ function useControlled(controlled, initial) {
155
163
  };
156
164
  return [isControlled ? controlled : own, set];
157
165
  }
166
+ /**
167
+ * The list with `value` added at the end, or, when it's `present`, with
168
+ * every copy of it taken out. Everything else stays as it was, in its order.
169
+ */
170
+ function addedOrRemoved(list, value, present) {
171
+ return present ? list.filter(v => v !== value) : [...list, value];
172
+ }
158
173
  /**
159
174
  * The open state, the selection, the keys, the tab stop, the positions and
160
175
  * the registry of a TreeView (#1096). Focus moves for real, with a roving
@@ -163,10 +178,12 @@ function useControlled(controlled, initial) {
163
178
  * browser (#1171), read off the page once the tree has mounted.
164
179
  *
165
180
  * Every item registers its value, its parent and whether it's disabled from
166
- * a layout effect. In a checkbox tree a closed item's children still render
167
- * and register while drawing nothing, so the registry knows every item and a
168
- * check reaches all of them. The keys read the items on the page, in
169
- * document order, each time they need them.
181
+ * a layout effect. In a `checkbox` tree a closed item's children still
182
+ * render and register while drawing nothing, so the registry knows every item
183
+ * and a check reaches all of them. A `checkbox-independent` tree reads each
184
+ * box off the checked list alone, so a check adds or takes out its own value
185
+ * and nothing else. The keys read the items on the page, in document order,
186
+ * each time they need them.
170
187
  *
171
188
  * @function useTreeView
172
189
  * @param options - The TreeView's props.
@@ -182,9 +199,15 @@ function useTreeView(options) {
182
199
  const [expanded, setExpanded] = useControlled(options.expanded, options.defaultExpanded);
183
200
  const [selection, setSelection] = useControlled(options.selected, options.defaultSelected);
184
201
  const [checkedList, setChecked] = useControlled(options.checked, options.defaultChecked);
202
+ // In a `checkbox` tree a check reaches the items under it, by the rules. In
203
+ // a `checkbox-independent` one each box is its own item's, read off the list.
204
+ const cascade = mode === 'checkbox';
205
+ const independent = mode === 'checkbox-independent';
206
+ // A box on every item, in either checkbox tree.
207
+ const boxed = cascade || independent;
185
208
  // Each is read in its own mode only.
186
209
  const selected = mode === 'single' ? selection : null;
187
- const listed = new Set(mode === 'checkbox' ? checkedList : []);
210
+ const listed = new Set(boxed ? checkedList : []);
188
211
  const treeRef = useRef(null);
189
212
  const registryRef = useRef({
190
213
  records: new Map(),
@@ -205,21 +228,28 @@ function useTreeView(options) {
205
228
  // Each item's place among the items beside it, by its key.
206
229
  const [positions, setPositions] = useState(NO_POSITIONS);
207
230
  const { records } = registryRef.current;
208
- const checks = mode === 'checkbox'
231
+ const checks = cascade
209
232
  ? // eslint-disable-next-line react-hooks/refs -- read in render on purpose: items write the registry from their layout effects and then ask for this render with `refresh`
210
233
  readChecks(records, listed).states
211
234
  : undefined;
212
235
  const tabStop = focused ?? (resting === undefined ? selected : resting);
236
+ // An item's box, decided here alone, so the box drawn, the tab stop and a
237
+ // toggle agree: off the list in a `checkbox-independent` tree, and by the
238
+ // rules in a `checkbox` one, where it's `undefined` until the item
239
+ // registers. `undefined` in a tree with no boxes.
240
+ const checkOf = (value) => independent ? listed.has(value) : checks?.get(value);
213
241
  const isOpen = (value) => expanded.includes(value);
214
242
  const stateOf = (value, path, disabled) => {
215
243
  if (mode === 'single') {
216
244
  return disabled ? {} : { selected: value === selected };
217
245
  }
218
- if (checks) {
219
- // Until the items register, a value stands for its branch, and a
220
- // parent can't know it's mixed.
221
- const fromList = listed.has(value) || (!disabled && path.some(v => listed.has(v)));
222
- return { checked: checks.get(value) ?? fromList };
246
+ if (boxed) {
247
+ // Until the items register in a `checkbox` tree, a value stands for
248
+ // its branch, and a parent can't know it's mixed.
249
+ return {
250
+ checked: checkOf(value) ??
251
+ (listed.has(value) || (!disabled && path.some(v => listed.has(v)))),
252
+ };
223
253
  }
224
254
  return {};
225
255
  };
@@ -229,9 +259,8 @@ function useTreeView(options) {
229
259
  };
230
260
  const run = {
231
261
  open: ({ value, isParent, open }) => {
232
- if (isParent) {
233
- changeOpen(open ? expanded.filter(v => v !== value) : [...expanded, value]);
234
- }
262
+ if (isParent)
263
+ changeOpen(addedOrRemoved(expanded, value, open));
235
264
  },
236
265
  select: ({ value }) => {
237
266
  if (value === selected)
@@ -240,7 +269,11 @@ function useTreeView(options) {
240
269
  options.onSelectedChange?.(value);
241
270
  },
242
271
  check: ({ value }) => {
243
- const next = toggledList(records, checkedList, value);
272
+ // A `checkbox-independent` box is its own item's alone, so its value
273
+ // comes or goes and the rest of the list stays as it was.
274
+ const next = independent
275
+ ? addedOrRemoved(checkedList, value, checkOf(value) === true)
276
+ : toggledList(records, checkedList, value);
244
277
  setChecked(next);
245
278
  options.onCheckedChange?.(next);
246
279
  },
@@ -289,7 +322,7 @@ function useTreeView(options) {
289
322
  }
290
323
  if (focused !== null && !isShown(focused))
291
324
  setFocused(null);
292
- const preferred = onPage.find(value => value === selected || checks?.get(value) === true);
325
+ const preferred = onPage.find(value => value === selected || checkOf(value) === true);
293
326
  const next = preferred ?? (isShown(resting) ? resting : heir(restingPathRef.current));
294
327
  if (next !== resting) {
295
328
  setResting(next);
@@ -428,7 +461,8 @@ function useTreeView(options) {
428
461
  };
429
462
  return {
430
463
  treeRef,
431
- rendersClosed: mode === 'checkbox',
464
+ rendersClosed: cascade,
465
+ multiselectable: boxed,
432
466
  tabStop,
433
467
  isOpen,
434
468
  stateOf,