@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 +41 -0
- package/docs/catalog.md +11 -1
- package/package.json +1 -1
- package/src/locale.tsx +7 -0
- package/src/picker.tsx +50 -5
- package/src/picker_empty_option.ts +30 -0
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
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
|
-
|
|
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
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
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
|
+
}
|