@lotics/ui 42.3.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/MIGRATION.md CHANGED
@@ -4,6 +4,47 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 42.4.0 — `Picker` is named by its `FormField`, and its way back to empty has a name
8
+
9
+ Two fixes to the same control, both about a thing on screen that the control did
10
+ not actually say.
11
+
12
+ ### A `FormPicker` rendered a label the select was never associated with
13
+
14
+ `Picker` never consumed `useFormField()`, so the label above it was decoration
15
+ for sighted readers: the select announced its current value and no name.
16
+ `NumberInput` and `TextInputField` had always consumed the binding;
17
+ `FormDatePicker` and `FormSwitch` work around it by passing
18
+ `accessibilityLabel={label}` down. `FormPicker` passed nothing, so it was the one
19
+ wrapper whose control was anonymous.
20
+
21
+ `Picker` now takes `id` / `aria-labelledby` / `aria-describedby` / `aria-invalid`
22
+ from the enclosing field, exactly as the other two inputs do — so a `FormField`'s
23
+ description, warning and error are announced with it as well. An explicit
24
+ `accessibilityLabel` still applies OUTSIDE a field and is ignored inside one,
25
+ because an aria-label diverging from the visible text is what breaks
26
+ label-in-name. Nothing to change at a call site; a bare `Picker` is unaffected.
27
+
28
+ ### The way back to empty has a name
29
+
30
+ Not a type break, but it changes text you will see: every `Picker` with
31
+ `includeEmptyOption` used to render its empty row **unlabelled once a value was selected**,
32
+ and that row is the only way to clear the field. So the control offered a blank line as its
33
+ escape, at precisely the moment the reader needed it — and every select cell editor, form
34
+ select and filter condition in an app inherited that.
35
+
36
+ The row is now named. `includeEmptyOption` declares empty a **choice**, and a choice the
37
+ reader picks the way they pick any other row has to read like one: it takes the locale pack's
38
+ word (`picker.emptyOption` — "None" / "Không có") and keeps it whether or not something is
39
+ selected. Override per instance with the new **`emptyOptionLabel`** when the field has a
40
+ truer word ("No skill", "Unassigned").
41
+
42
+ `placeholder` is unchanged and stays a different job: the hint shown while nothing is chosen,
43
+ which disappears on selection. It is no longer consulted when the row is a choice, so a call
44
+ site passing `placeholder` **as** the empty option's name should move that string to
45
+ `emptyOptionLabel`. Nothing else has to change — no existing caller in this repo passed one,
46
+ which is why they were all rendering blanks.
47
+
7
48
  ## 42.0.0 — `DateFilter`'s presets are a listbox, and "Custom" is not one of them
8
49
 
9
50
  Three defects with one root: the preset row used EMPHASIS to mean "current state", inside a
package/docs/catalog.md CHANGED
@@ -765,7 +765,17 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
765
765
  ### Pickers & selection controls
766
766
 
767
767
  - **`picker`** — `Picker`: native `<select>`, plain label-only single, native typeahead;
768
- also home of the shared `PickerOption` type.
768
+ also home of the shared `PickerOption` type. Its empty row does one of two jobs and they
769
+ are not interchangeable: `includeEmptyOption` makes empty a **choice** the reader picks to
770
+ clear the field, so it is NAMED ("None", from the locale pack; override per instance with
771
+ `emptyOptionLabel`) and keeps that name after a selection — a name that disappears once
772
+ something is chosen is missing exactly when it is the only route back to empty. Without
773
+ that flag the row is a **placeholder**: it carries `placeholder` and leaves on selection.
774
+ Don't reach for `placeholder` to name a choice — a hint and a value read differently.
775
+ Inside a `FormField` (so inside `FormPicker`) it takes its NAME from the visible label
776
+ via the field's binding, along with the description/warning/error — pass `accessibilityLabel`
777
+ only for a bare `Picker` with no label beside it, since inside a field an aria-label that
778
+ differs from the visible text is what breaks label-in-name.
769
779
  - **`select`** — `Select`: rich/custom-rendered, single/multi, select-all, chips via
770
780
  `renderSelected(item, { remove })` + `searchable` + `allowCustom` — the tag field is just
771
781
  a multi Select; opens `OptionList`. Its trigger is `role="combobox"` (a `<div>`), never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "42.3.0",
3
+ "version": "42.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
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
+ }