@lotics/ui 42.0.0 → 42.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/colors.ts CHANGED
@@ -1,386 +1,17 @@
1
- const palette = {
2
- black: "rgba(0,0,0,1)",
3
- white: "rgba(255,255,255,1)",
4
-
5
- // We use RGBA so that we can use React Native Animations with colors
6
- red: {
7
- 50: "rgba(254, 242, 242, 1)",
8
- 100: "rgba(254, 226, 226, 1)",
9
- 200: "rgba(254, 202, 202, 1)",
10
- 300: "rgba(252, 165, 165, 1)",
11
- 400: "rgba(248, 113, 113, 1)",
12
- 500: "rgba(239, 68, 68, 1)",
13
- 600: "rgba(220, 38, 38, 1)",
14
- 700: "rgba(185, 28, 28, 1)",
15
- 800: "rgba(153, 27, 27, 1)",
16
- 900: "rgba(127, 29, 29, 1)",
17
- 950: "rgba(69, 10, 10, 1)",
18
- },
19
- orange: {
20
- 50: "rgba(255, 247, 237, 1)",
21
- 100: "rgba(255, 237, 213, 1)",
22
- 200: "rgba(254, 215, 170, 1)",
23
- 300: "rgba(253, 186, 116, 1)",
24
- 400: "rgba(251, 146, 60, 1)",
25
- 500: "rgba(249, 115, 22, 1)",
26
- 600: "rgba(234, 88, 12, 1)",
27
- 700: "rgba(194, 65, 12, 1)",
28
- 800: "rgba(154, 52, 18, 1)",
29
- 900: "rgba(124, 45, 18, 1)",
30
- 950: "rgba(67, 20, 7, 1)",
31
- },
32
- amber: {
33
- 50: "rgba(255, 251, 235, 1)",
34
- 100: "rgba(254, 243, 199, 1)",
35
- 200: "rgba(253, 230, 138, 1)",
36
- 300: "rgba(252, 211, 77, 1)",
37
- 400: "rgba(251, 191, 36, 1)",
38
- 500: "rgba(245, 158, 11, 1)",
39
- 600: "rgba(217, 119, 6, 1)",
40
- 700: "rgba(180, 83, 9, 1)",
41
- 800: "rgba(146, 64, 14, 1)",
42
- 900: "rgba(120, 53, 15, 1)",
43
- 950: "rgba(69, 26, 3, 1)",
44
- },
45
- yellow: {
46
- 50: "rgba(254, 252, 232, 1)",
47
- 100: "rgba(254, 249, 195, 1)",
48
- 200: "rgba(254, 240, 138, 1)",
49
- 300: "rgba(253, 224, 71, 1)",
50
- 400: "rgba(250, 204, 21, 1)",
51
- 500: "rgba(234, 179, 8, 1)",
52
- 600: "rgba(202, 138, 4, 1)",
53
- 700: "rgba(161, 98, 7, 1)",
54
- 800: "rgba(133, 77, 14, 1)",
55
- 900: "rgba(113, 63, 18, 1)",
56
- 950: "rgba(66, 32, 6, 1)",
57
- },
58
- lime: {
59
- 50: "rgba(247, 254, 231, 1)",
60
- 100: "rgba(236, 252, 203, 1)",
61
- 200: "rgba(217, 249, 157, 1)",
62
- 300: "rgba(190, 242, 100, 1)",
63
- 400: "rgba(163, 230, 53, 1)",
64
- 500: "rgba(132, 204, 22, 1)",
65
- 600: "rgba(101, 163, 13, 1)",
66
- 700: "rgba(77, 124, 15, 1)",
67
- 800: "rgba(63, 98, 18, 1)",
68
- 900: "rgba(54, 83, 20, 1)",
69
- 950: "rgba(26, 46, 5, 1)",
70
- },
71
- green: {
72
- 50: "rgba(240, 253, 244, 1)",
73
- 100: "rgba(220, 252, 231, 1)",
74
- 200: "rgba(187, 247, 208, 1)",
75
- 300: "rgba(134, 239, 172, 1)",
76
- 400: "rgba(74, 222, 128, 1)",
77
- 500: "rgba(34, 197, 94, 1)",
78
- 600: "rgba(22, 163, 74, 1)",
79
- 700: "rgba(21, 128, 61, 1)",
80
- 800: "rgba(22, 101, 52, 1)",
81
- 900: "rgba(20, 83, 45, 1)",
82
- 950: "rgba(5, 46, 22, 1)",
83
- },
84
- emerald: {
85
- 50: "rgba(236, 253, 245, 1)",
86
- 100: "rgba(209, 250, 229, 1)",
87
- 200: "rgba(167, 243, 208, 1)",
88
- 300: "rgba(110, 231, 183, 1)",
89
- 400: "rgba(52, 211, 153, 1)",
90
- 500: "rgba(16, 185, 129, 1)",
91
- 600: "rgba(5, 150, 105, 1)",
92
- 700: "rgba(4, 120, 87, 1)",
93
- 800: "rgba(6, 95, 70, 1)",
94
- 900: "rgba(6, 78, 59, 1)",
95
- 950: "rgba(2, 44, 34, 1)",
96
- },
97
- teal: {
98
- 50: "rgba(240, 253, 250, 1)",
99
- 100: "rgba(204, 251, 241, 1)",
100
- 200: "rgba(153, 246, 228, 1)",
101
- 300: "rgba(94, 234, 212, 1)",
102
- 400: "rgba(45, 212, 191, 1)",
103
- 500: "rgba(20, 184, 166, 1)",
104
- 600: "rgba(13, 148, 136, 1)",
105
- 700: "rgba(15, 118, 110, 1)",
106
- 800: "rgba(17, 94, 89, 1)",
107
- 900: "rgba(19, 78, 74, 1)",
108
- 950: "rgba(4, 47, 46, 1)",
109
- },
110
- cyan: {
111
- 50: "rgba(236, 254, 255, 1)",
112
- 100: "rgba(207, 250, 254, 1)",
113
- 200: "rgba(165, 243, 252, 1)",
114
- 300: "rgba(103, 232, 249, 1)",
115
- 400: "rgba(34, 211, 238, 1)",
116
- 500: "rgba(6, 182, 212, 1)",
117
- 600: "rgba(8, 145, 178, 1)",
118
- 700: "rgba(14, 116, 144, 1)",
119
- 800: "rgba(21, 94, 117, 1)",
120
- 900: "rgba(22, 78, 99, 1)",
121
- 950: "rgba(8, 51, 68, 1)",
122
- },
123
- sky: {
124
- 50: "rgba(240, 249, 255, 1)",
125
- 100: "rgba(224, 242, 254, 1)",
126
- 200: "rgba(186, 230, 253, 1)",
127
- 300: "rgba(125, 211, 252, 1)",
128
- 400: "rgba(56, 189, 248, 1)",
129
- 500: "rgba(14, 165, 233, 1)",
130
- 600: "rgba(2, 132, 199, 1)",
131
- 700: "rgba(3, 105, 161, 1)",
132
- 800: "rgba(7, 89, 133, 1)",
133
- 900: "rgba(12, 74, 110, 1)",
134
- 950: "rgba(8, 47, 73, 1)",
135
- },
136
- blue: {
137
- 50: "rgba(239, 246, 255, 1)",
138
- 100: "rgba(219, 234, 254, 1)",
139
- 200: "rgba(191, 219, 254, 1)",
140
- 300: "rgba(147, 197, 253, 1)",
141
- 400: "rgba(96, 165, 250, 1)",
142
- 500: "rgba(59, 130, 246, 1)",
143
- 600: "rgba(37, 99, 235, 1)",
144
- 700: "rgba(29, 78, 216, 1)",
145
- 800: "rgba(30, 64, 175, 1)",
146
- 900: "rgba(30, 58, 138, 1)",
147
- 950: "rgba(23, 37, 84, 1)",
148
- },
149
- indigo: {
150
- 50: "rgba(238, 242, 255, 1)",
151
- 100: "rgba(224, 231, 255, 1)",
152
- 200: "rgba(199, 210, 254, 1)",
153
- 300: "rgba(165, 180, 252, 1)",
154
- 400: "rgba(129, 140, 248, 1)",
155
- 500: "rgba(99, 102, 241, 1)",
156
- 600: "rgba(79, 70, 229, 1)",
157
- 700: "rgba(67, 56, 202, 1)",
158
- 800: "rgba(55, 48, 163, 1)",
159
- 900: "rgba(49, 46, 129, 1)",
160
- 950: "rgba(30, 27, 75, 1)",
161
- },
162
- violet: {
163
- 50: "rgba(245, 243, 255, 1)",
164
- 100: "rgba(237, 233, 254, 1)",
165
- 200: "rgba(221, 214, 254, 1)",
166
- 300: "rgba(196, 181, 253, 1)",
167
- 400: "rgba(167, 139, 250, 1)",
168
- 500: "rgba(139, 92, 246, 1)",
169
- 600: "rgba(124, 58, 237, 1)",
170
- 700: "rgba(109, 40, 217, 1)",
171
- 800: "rgba(91, 33, 182, 1)",
172
- 900: "rgba(76, 29, 149, 1)",
173
- 950: "rgba(46, 16, 101, 1)",
174
- },
175
- purple: {
176
- 50: "rgba(250, 245, 255, 1)",
177
- 100: "rgba(243, 232, 255, 1)",
178
- 200: "rgba(233, 213, 255, 1)",
179
- 300: "rgba(216, 180, 254, 1)",
180
- 400: "rgba(192, 132, 252, 1)",
181
- 500: "rgba(168, 85, 247, 1)",
182
- 600: "rgba(147, 51, 234, 1)",
183
- 700: "rgba(126, 34, 206, 1)",
184
- 800: "rgba(107, 33, 168, 1)",
185
- 900: "rgba(88, 28, 135, 1)",
186
- 950: "rgba(59, 7, 100, 1)",
187
- },
188
- fuchsia: {
189
- 50: "rgba(253, 244, 255, 1)",
190
- 100: "rgba(250, 232, 255, 1)",
191
- 200: "rgba(245, 208, 254, 1)",
192
- 300: "rgba(240, 171, 252, 1)",
193
- 400: "rgba(232, 121, 249, 1)",
194
- 500: "rgba(217, 70, 239, 1)",
195
- 600: "rgba(192, 38, 211, 1)",
196
- 700: "rgba(162, 28, 175, 1)",
197
- 800: "rgba(134, 25, 143, 1)",
198
- 900: "rgba(112, 26, 117, 1)",
199
- 950: "rgba(74, 4, 78, 1)",
200
- },
201
- pink: {
202
- 50: "rgba(253, 242, 248, 1)",
203
- 100: "rgba(252, 231, 243, 1)",
204
- 200: "rgba(251, 207, 232, 1)",
205
- 300: "rgba(249, 168, 212, 1)",
206
- 400: "rgba(244, 114, 182, 1)",
207
- 500: "rgba(236, 72, 153, 1)",
208
- 600: "rgba(219, 39, 119, 1)",
209
- 700: "rgba(190, 24, 93, 1)",
210
- 800: "rgba(157, 23, 77, 1)",
211
- 900: "rgba(131, 24, 67, 1)",
212
- 950: "rgba(80, 7, 36, 1)",
213
- },
214
- rose: {
215
- 50: "rgba(255, 241, 242, 1)",
216
- 100: "rgba(255, 228, 230, 1)",
217
- 200: "rgba(254, 205, 211, 1)",
218
- 300: "rgba(253, 164, 175, 1)",
219
- 400: "rgba(251, 113, 133, 1)",
220
- 500: "rgba(244, 63, 94, 1)",
221
- 600: "rgba(225, 29, 72, 1)",
222
- 700: "rgba(190, 18, 60, 1)",
223
- 800: "rgba(159, 18, 57, 1)",
224
- 900: "rgba(136, 19, 55, 1)",
225
- 950: "rgba(76, 5, 25, 1)",
226
- },
227
- slate: {
228
- 50: "rgba(248, 250, 252, 1)",
229
- 100: "rgba(241, 245, 249, 1)",
230
- 200: "rgba(226, 232, 240, 1)",
231
- 300: "rgba(203, 213, 225, 1)",
232
- 400: "rgba(148, 163, 184, 1)",
233
- 500: "rgba(100, 116, 139, 1)",
234
- 600: "rgba(71, 85, 105, 1)",
235
- 700: "rgba(51, 65, 85, 1)",
236
- 800: "rgba(30, 41, 59, 1)",
237
- 900: "rgba(15, 23, 42, 1)",
238
- 950: "rgba(2, 6, 23, 1)",
239
- },
240
- gray: {
241
- 50: "rgba(249, 250, 251, 1)",
242
- 100: "rgba(243, 244, 246, 1)",
243
- 200: "rgba(229, 231, 235, 1)",
244
- 300: "rgba(209, 213, 219, 1)",
245
- 400: "rgba(156, 163, 175, 1)",
246
- 500: "rgba(107, 114, 128, 1)",
247
- 600: "rgba(75, 85, 99, 1)",
248
- 700: "rgba(55, 65, 81, 1)",
249
- 800: "rgba(31, 41, 55, 1)",
250
- 900: "rgba(17, 24, 39, 1)",
251
- 950: "rgba(3, 7, 18, 1)",
252
- },
253
- zinc: {
254
- 50: "rgba(250, 250, 250, 1)",
255
- 100: "rgba(244, 244, 245, 1)",
256
- 200: "rgba(228, 228, 231, 1)",
257
- 300: "rgba(212, 212, 216, 1)",
258
- 400: "rgba(161, 161, 170, 1)",
259
- 500: "rgba(113, 113, 122, 1)",
260
- 600: "rgba(82, 82, 91, 1)",
261
- 700: "rgba(63, 63, 70, 1)",
262
- 800: "rgba(39, 39, 42, 1)",
263
- 900: "rgba(24, 24, 27, 1)",
264
- 950: "rgba(9, 9, 11, 1)",
265
- },
266
- neutral: {
267
- 50: "rgba(250, 250, 250, 1)",
268
- 100: "rgba(245, 245, 245, 1)",
269
- 200: "rgba(229, 229, 229, 1)",
270
- 300: "rgba(212, 212, 212, 1)",
271
- 400: "rgba(163, 163, 163, 1)",
272
- 500: "rgba(115, 115, 115, 1)",
273
- 600: "rgba(82, 82, 82, 1)",
274
- 700: "rgba(64, 64, 64, 1)",
275
- 800: "rgba(38, 38, 38, 1)",
276
- 900: "rgba(23, 23, 23, 1)",
277
- 950: "rgba(10, 10, 10, 1)",
278
- },
279
- stone: {
280
- 50: "rgba(250, 250, 249, 1)",
281
- 100: "rgba(245, 245, 244, 1)",
282
- 200: "rgba(231, 229, 228, 1)",
283
- 300: "rgba(214, 211, 209, 1)",
284
- 400: "rgba(168, 162, 158, 1)",
285
- 500: "rgba(120, 113, 108, 1)",
286
- 600: "rgba(87, 83, 78, 1)",
287
- 700: "rgba(68, 64, 60, 1)",
288
- 800: "rgba(41, 37, 36, 1)",
289
- 900: "rgba(28, 25, 23, 1)",
290
- 950: "rgba(12, 10, 9, 1)",
291
- },
292
- };
293
-
294
- export const colors = {
295
- ...palette,
296
- border: palette.zinc["200"],
297
- border_shadow: `0px 1px 2px 0px ${palette.zinc["200"]}`,
298
- background: palette.white,
299
- shadow: `0px 0px 6px 1px ${palette.zinc["300"]}`,
300
- };
301
-
302
- /**
303
- * A low-alpha wash of a palette color (hover tints, dimmed chart segments,
304
- * icon discs). Handles the palette's rgba() strings plus rgb()/#RRGGBB.
305
- */
306
- export function withAlpha(color: string, alpha: number): string {
307
- if (color.startsWith("rgba(")) return color.replace(/,\s*[\d.]+\s*\)\s*$/, `, ${alpha})`);
308
- if (color.startsWith("rgb(")) return color.replace("rgb(", "rgba(").replace(/\)\s*$/, `, ${alpha})`);
309
- if (color.startsWith("#") && color.length === 7) {
310
- return `${color}${Math.round(alpha * 255)
311
- .toString(16)
312
- .padStart(2, "0")}`;
313
- }
314
- return color;
315
- }
316
-
317
- /**
318
- * A palette FAMILY name — the single, semantic way to reference a color across
319
- * the system (Badge, status indicators, chart series, breakdown segments).
320
- * Reference colors by NAME and let the component resolve the shade it needs;
321
- * never thread a raw hex through props — it drifts (one call site picks 500,
322
- * another 600, and the same "status" renders two greens). Excludes the
323
- * non-scale role keys (border/background/shadow/black/white).
324
- */
325
- export type ColorName = Exclude<keyof typeof colors, "border" | "border_shadow" | "background" | "shadow" | "black" | "white">;
326
-
327
- /**
328
- * The canonical SOLID shade of a family (500) — status dots, status-grid
329
- * cells, breakdown segments, chart series. Defined ONCE so every indicator of
330
- * the same color agrees on the shade.
331
- */
332
- export function solid(name: ColorName): string {
333
- return colors[name][500];
334
- }
335
-
336
- /** A low-alpha wash of a family's solid shade — cell/segment tints, dimmed
337
- * states. `tint("emerald", 0.2)` === `withAlpha(solid("emerald"), 0.2)`. */
338
- export function tint(name: ColorName, alpha: number): string {
339
- return withAlpha(solid(name), alpha);
340
- }
341
-
342
- /** Dark → light stops a `ramp` spans (the usable mid range of a scale). */
343
- const RAMP_STOPS = [700, 600, 500, 400, 300, 200] as const;
344
-
345
- /**
346
- * `count` distinct shades of ONE family, strong → light — the monochrome
347
- * "one hue family per dimension" data ramp (a Breakdown's segments, an ordered
348
- * funnel, any multi-category breakdown of a single dimension). Use this for a
349
- * COHERENT dimension instead of hand-picking shades or scattering hues; the
350
- * segment LABEL carries identity, the shade only orders. (For semantic
351
- * categories whose color carries MEANING — status — give each its own
352
- * `ColorName` and `solid()` it, don't ramp.)
353
- */
354
- export function ramp(name: ColorName, count: number): string[] {
355
- const scale = colors[name];
356
- if (count <= 1) return [scale[600]];
357
- return Array.from({ length: count }, (_, i) => {
358
- const idx = Math.round((i / (count - 1)) * (RAMP_STOPS.length - 1));
359
- return scale[RAMP_STOPS[idx]];
360
- });
361
- }
362
-
363
- /**
364
- * Is `value` a usable {@link ColorName} — a palette FAMILY, not a role key
365
- * (`border`/`background`/…) and not `black`/`white`? A family resolves to a
366
- * shade object; the role/scalar keys resolve to a string, so "value is an
367
- * object" is the test (no name list to keep in sync).
368
- */
369
- export function isColorName(value: unknown): value is ColorName {
370
- return (
371
- typeof value === "string" &&
372
- value in colors &&
373
- typeof (colors as Record<string, unknown>)[value] === "object"
374
- );
375
- }
1
+ export * from "./color_tokens";
376
2
 
377
3
  /**
378
- * Coerce an arbitrary color token to a {@link ColorName}, falling back to a
379
- * neutral. The single graceful-degradation point for stored option/status
380
- * colors: a select option's `color` may be a token a newer table config
381
- * introduced that this UI build predates, or absent entirely — either way a
382
- * component renders a neutral badge instead of breaking. Used by `OptionBadge`.
4
+ * The NATIVE token surface the literal table, unchanged.
5
+ *
6
+ * The values themselves live in `./color_tokens`, which both this file and
7
+ * `./colors.web.ts` re-export. That indirection exists for one concrete reason:
8
+ * a relative `./colors` resolves `.web.ts` FIRST on web, so a web variant that
9
+ * imported `./colors` would import itself, and esbuild reports it as
10
+ * "Detected cycle while resolving import". Neither variant may name the other;
11
+ * both name a third module instead.
12
+ *
13
+ * Importers never see this split — `@lotics/ui/colors` and the kit's own
14
+ * `./colors` both keep working, and which of the two files answers is decided by
15
+ * platform (the `react-native` export condition, and `.web.ts` extension order
16
+ * inside the kit).
383
17
  */
384
- export function asColorName(value: unknown, fallback: ColorName = "zinc"): ColorName {
385
- return isColorName(value) ? value : fallback;
386
- }
@@ -0,0 +1,43 @@
1
+ import { colors as contract } from "./color_tokens";
2
+
3
+ export { withAlpha, solid, tint, ramp, isColorName, asColorName } from "./color_tokens";
4
+ export type { ColorName } from "./color_tokens";
5
+
6
+ /**
7
+ * The token contract, with its FOUR themeable roles indirected through CSS
8
+ * variables so an app can carry its own visual identity.
9
+ *
10
+ * Why this file exists at all, rather than a React context: 136 of the kit's 320
11
+ * modules import `colors` directly, at module scope, into `StyleSheet.create`.
12
+ * A context would have to reach every one of them, turning static stylesheets
13
+ * into per-render inline styles — the kit's whole performance model — for a
14
+ * feature that changes four values. A variable costs nothing at runtime and
15
+ * touches no component: `colors.background` still reads `colors.background`.
16
+ *
17
+ * It is safe because react-native-web's `isWebColor` allowlists `var(` ahead of
18
+ * its own colour normalization, so a variable reaches CSS untouched on every
19
+ * colour property. That is a web-only guarantee, which is exactly why this is a
20
+ * `.web.ts` sibling: the `react-native` export condition and `.web.ts` extension
21
+ * order pick it for the browser; native keeps the literals it can parse.
22
+ *
23
+ * It reads its literals from `./color_tokens`, NEVER from `./colors` — on web a
24
+ * relative `./colors` resolves back to this very file, which esbuild reports as
25
+ * "Detected cycle while resolving import".
26
+ *
27
+ * Each variable carries its literal DEFAULT inline, so the theme is opt-in in
28
+ * the strictest sense — an app that sets nothing has no `--lotics-*` defined,
29
+ * every `var()` falls back, and the pixels are identical to before this file.
30
+ *
31
+ * ONLY these three. The palette families stay literal, and must: `withAlpha`,
32
+ * `tint` and `ramp` do string surgery on an `rgba()` to derive a wash, and a
33
+ * `var()` handed to them would produce garbage no type would catch. Keeping the
34
+ * themeable set to chrome keeps those functions total — the maintainability
35
+ * argument and the "colour must keep meaning status" argument turn out to be the
36
+ * same argument.
37
+ */
38
+ export const colors = {
39
+ ...contract,
40
+ background: `var(--lotics-background, ${contract.background})`,
41
+ border: `var(--lotics-border, ${contract.border})`,
42
+ accent: `var(--lotics-accent, ${contract.accent})`,
43
+ };
package/src/locale.tsx CHANGED
@@ -47,6 +47,11 @@ export interface LoticsLocale {
47
47
  * in-cell editors): the select-all/deselect-all links, the empty state, the
48
48
  * internal search-field placeholder, and the `Combobox` recents header. */
49
49
  optionList: { selectAll: string; deselectAll: string; clear: string; noResults: string; recent: string; searchPlaceholder: string };
50
+ /** `Picker`'s empty option, when the caller declares empty a CHOICE
51
+ * (`includeEmptyOption`). A value, not an action — hence "None" rather than
52
+ * the option list's "Clear": in a native `<select>` the reader picks it the
53
+ * same way they pick any other row, so it has to read like one. */
54
+ picker: { emptyOption: string };
50
55
  datePicker: DatePickerLabels;
51
56
  calendar: CalendarLabels;
52
57
  /** `FilterChip` (and `ColumnFilter`): the generic clear affordance, used when
@@ -266,6 +271,7 @@ export const en: LoticsLocale = {
266
271
  },
267
272
  referenceField: { open: "Open", change: "Change", clear: "Clear", edit: "Edit", save: "Save", saving: "Saving…", cancel: "Cancel" },
268
273
  optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
274
+ picker: { emptyOption: "None" },
269
275
  datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", startDate: "Start date", endDate: "End date", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date", invalidTime: "Enter a complete time" },
270
276
  calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
271
277
  filterChip: { clear: "Clear" },
@@ -437,6 +443,7 @@ export const vi: LoticsLocale = {
437
443
  },
438
444
  referenceField: { open: "Mở", change: "Đổi", clear: "Bỏ chọn", edit: "Sửa", save: "Lưu", saving: "Đang lưu…", cancel: "Huỷ" },
439
445
  optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", clear: "Xóa", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…" },
446
+ picker: { emptyOption: "Không có" },
440
447
  datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", startDate: "Ngày bắt đầu", endDate: "Ngày kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH", invalidDate: "Nhập ngày đầy đủ", invalidTime: "Nhập giờ đầy đủ" },
441
448
  calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
442
449
  filterChip: { clear: "Xóa" },
package/src/picker.tsx CHANGED
@@ -7,6 +7,9 @@ import { useFocusRing } from "./use_focus_ring";
7
7
  import { useHover } from "./use_hover";
8
8
  import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
9
9
  import { Icon } from "./icon";
10
+ import { useLoticsLocale } from "./locale";
11
+ import { useFormField } from "./form_field";
12
+ import { pickerEmptyOptionLabel } from "./picker_empty_option";
10
13
 
11
14
  export interface PickerOption<T extends string = string, D = unknown> {
12
15
  label?: string;
@@ -41,7 +44,15 @@ export interface PickerProps<T extends string = string> {
41
44
  testID?: string;
42
45
  disabled?: boolean;
43
46
  autoFocus?: boolean;
47
+ /** Declares empty a CHOICE: the reader may pick it to clear the field, so the
48
+ * option stays in the list after something is selected. Because it is a value
49
+ * they pick, it is NAMED — see `emptyOptionLabel`. */
44
50
  includeEmptyOption?: boolean;
51
+ /** Overrides the name of that empty choice (locale default: "None"). Distinct
52
+ * from `placeholder`, which is the hint shown while nothing is chosen: a hint
53
+ * and a value are different jobs, and one string cannot do both — read as a
54
+ * prompt it invites input, read as a row it looks selectable. */
55
+ emptyOptionLabel?: string;
45
56
  value?: T | null;
46
57
  onValueChange?: (value: T) => void;
47
58
  }
@@ -61,12 +72,25 @@ export function Picker<T extends string>(props: PickerProps<T>) {
61
72
  includeEmptyOption,
62
73
  onValueChange,
63
74
  placeholder,
75
+ emptyOptionLabel,
64
76
  accessibilityLabel,
65
77
  style,
66
78
  disabled = false,
67
79
  autoFocus = false,
68
80
  } = props;
69
81
 
82
+ const binding = useFormField();
83
+ const describedBy =
84
+ [binding?.descriptionId, binding?.warningId, binding?.errorId].filter(Boolean).join(" ") ||
85
+ undefined;
86
+ const locale = useLoticsLocale();
87
+ const emptyLabel = pickerEmptyOptionLabel({
88
+ includeEmptyOption,
89
+ emptyOptionLabel,
90
+ placeholder,
91
+ localeEmptyOption: locale.picker.emptyOption,
92
+ });
93
+
70
94
  const pickerRef = useRef<RNPicker<string>>(null);
71
95
  // The native <select> is a text-like control — ring on any focus (like the inputs).
72
96
  const { focusVisible, focusProps } = useFocusRing({ always: true });
@@ -100,7 +124,18 @@ export function Picker<T extends string>(props: PickerProps<T>) {
100
124
  <RNPicker
101
125
  ref={pickerRef}
102
126
  testID={testID}
103
- accessibilityLabel={accessibilityLabel}
127
+ // Inside a `FormField` the VISIBLE label names the control, the same way
128
+ // it does for `NumberInput` / `TextInputField`. Without this a
129
+ // `FormPicker` rendered a label the select was never associated with, so
130
+ // it announced its current value and no name at all — the label was
131
+ // there for sighted readers only. An explicit `accessibilityLabel` is
132
+ // used only OUTSIDE a field: inside one, an aria-label that differs from
133
+ // the visible text is what breaks label-in-name.
134
+ id={binding?.inputId}
135
+ aria-labelledby={binding?.labelId}
136
+ aria-label={!binding ? accessibilityLabel : undefined}
137
+ aria-describedby={describedBy}
138
+ aria-invalid={binding?.invalid || undefined}
104
139
  onFocus={focusProps.onFocus}
105
140
  onBlur={focusProps.onBlur}
106
141
  // Empty selection maps to "" (the placeholder option's value), never
@@ -113,10 +148,20 @@ export function Picker<T extends string>(props: PickerProps<T>) {
113
148
  enabled={!disabled}
114
149
  >
115
150
  {(!value || includeEmptyOption) && (
116
- // Show the placeholder as the empty option (the standard
117
- // `<option value="" selected>Placeholder</option>` pattern) so a
118
- // native select hints what to choose.
119
- <RNPicker.Item label={!value ? (placeholder ?? "") : ""} value="" />
151
+ // Two different jobs share this one row, and which one it is doing
152
+ // depends on `includeEmptyOption`:
153
+ //
154
+ // A CHOICE (`includeEmptyOption`) the reader picks it to clear the
155
+ // field, so it is named, and named the SAME whether or not something
156
+ // is currently selected. It previously went blank the moment a value
157
+ // existed, which is exactly when the reader needs it: every select in
158
+ // the product offered an unlabelled row as its only way back to empty.
159
+ //
160
+ // A PLACEHOLDER (otherwise) — it exists only because nothing is
161
+ // chosen yet, so it carries the caller's hint and disappears on
162
+ // selection. Naming it "None" here would turn a prompt into an
163
+ // apparent value.
164
+ <RNPicker.Item label={emptyLabel} value="" />
120
165
  )}
121
166
  {options.map((option) =>
122
167
  option ? (
@@ -0,0 +1,30 @@
1
+ /**
2
+ * What `Picker`'s empty row is called — and it depends on which of two jobs that
3
+ * row is doing.
4
+ *
5
+ * A CHOICE (`includeEmptyOption`) is how the reader gets back to no value. They
6
+ * pick it the way they pick any other row, so it is NAMED, and named the same
7
+ * whether or not something is currently selected — a name that vanishes on
8
+ * selection vanishes exactly when it is needed.
9
+ *
10
+ * A PLACEHOLDER exists only because nothing is chosen yet. It carries the
11
+ * caller's hint and leaves on selection; naming it "None" would turn a prompt
12
+ * into an apparent value.
13
+ *
14
+ * RN-free so the rule is testable as the rule, rather than through a native
15
+ * `<select>` and its untranspiled dependency.
16
+ */
17
+ export function pickerEmptyOptionLabel(opts: {
18
+ includeEmptyOption?: boolean;
19
+ /** Per-instance override for the choice's name. */
20
+ emptyOptionLabel?: string;
21
+ /** The caller's hint, used only when the row is a placeholder. */
22
+ placeholder?: string;
23
+ /** The locale pack's word for "no value". */
24
+ localeEmptyOption: string;
25
+ }): string {
26
+ if (opts.includeEmptyOption) {
27
+ return opts.emptyOptionLabel ?? opts.localeEmptyOption;
28
+ }
29
+ return opts.placeholder ?? "";
30
+ }