@lotics/ui 45.4.0 → 45.5.1

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,35 @@ 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
+ ## 45.5.0
8
+
9
+ **`InlineSelect` and `InlineMemberSelect` are a `combobox`, not a `button`.** They rest as a
10
+ `<div role="combobox">` carrying `aria-expanded`, where they rested as a `<button>`
11
+ (`InlineMemberSelect` is built on `InlineSelect`, so it follows). Other inline fields are
12
+ unchanged. Nothing to change in your code — but a test
13
+ that reached one with `getByRole("button")` now finds nothing, and wants `getByRole("combobox")`.
14
+ The old role was wrong twice over: it is not the pattern APG names for a control that holds a
15
+ value and opens a list, and a `<button>` may not contain one, which is what kept a tag field's
16
+ chips from carrying their own remove ✕.
17
+
18
+ **A multi `InlineSelect`'s menu rows no longer fall back to `renderSelected`.** Pass
19
+ `renderOptionContent` for custom rows; without it the list shows plain labels. `renderSelected`
20
+ now means the removable RESTING chip and takes `(option, { remove })`, so borrowing it for the
21
+ menu put a ✕ on every option in the list.
22
+
23
+ **Every option list's rows sit 2px apart.** `OptionList` declared that spacing all along, but
24
+ put it on the ScrollView's viewport rather than its content container, where it fell between
25
+ nothing — so rows rendered flush and two adjacent SELECTED rows merged into one tinted block. Every
26
+ selector that opens a list (`Select`, `Picker`, `InlineSelect`, the member pickers, menus) is now
27
+ slightly taller for the same number of rows. A field's chips take 4px on BOTH axes — the row gap was
28
+ half the column gap, pinching a wrapped field against its own first line. A list row is read top to
29
+ bottom and needs only enough space not to merge with its neighbour; a chip sits beside other
30
+ objects and wants the wider setting.
31
+
32
+ **A multi `InlineSelect`'s chips follow the working set while its list is open** — the draft you
33
+ are building, not the stored value. Ticking a row or pressing a chip's ✕ now shows immediately
34
+ instead of at popover-close. The commit is unchanged: one write per visit, on close.
35
+
7
36
  **`FileRow`'s `leading` slot is a SIBLING of the press door, not inside it.** No API change;
8
37
  a row with `onPress` now has a slightly smaller press target, excluding the leading mark.
9
38
 
package/docs/catalog.md CHANGED
@@ -1174,8 +1174,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1174
1174
  `InlineSelect`/`InlineMemberSelect` render the resting value like its option —
1175
1175
  `renderOptionContent` by default, `renderSelected` to override — a chip/badge at rest, not
1176
1176
  just text. **`InlineSelect` is single OR multi** — pass `multi` for a tag SET (`value: T[]`,
1177
- commits the new set on popover-CLOSE; selected tags render as badges via `renderSelected`),
1178
- mirroring `Select`'s `multi` axis; there is NO separate tag component. Both modes take
1177
+ commits the new set on popover-CLOSE), mirroring `Select`'s `multi` axis; there is NO separate
1178
+ tag component. In multi, `renderSelected(option, { remove })` is the resting CHIP BOX seam, the
1179
+ same shape `Select` gives: return `<Chip onDismiss={remove}>` for a removable tag, or a plain
1180
+ badge that ignores it — a dense grid column is the case for the badge, since the ✕ costs more
1181
+ width there than the reopen it saves. `remove` detaches that one tag whether the list is open or
1182
+ shut, and never opens the list — the innermost responder takes the gesture, so a ✕ inside the
1183
+ trigger needs nothing to hold the press back. Single has no `remove`: unsetting one value is `onClear` and its
1184
+ Clear row. Both modes take
1179
1185
  `allowCustom` (a create-a-tag/option row) + `searchable` + **`customOptionPlacement`** (default
1180
1186
  `"bottom"`, right for a tag field; pass `"top"` for a find-or-create REFERENCE picker over a long
1181
1187
  registry, so the create row stays visible while the keyboard highlight stays on the first MATCH —
@@ -754,7 +754,14 @@ A tag field's resting state should be a tidy CHIP BOX — and that's just a mult
754
754
  and no `display` mode: `renderSelected(item, { remove })` composes the anchor — render a `Chip`
755
755
  with `remove` for the ✕, or a plain `OptionBadge`/`MemberChip`/custom pill that ignores it;
756
756
  `renderOptionContent` renders the menu rows. `searchable` adds the filter field, `allowCustom` the
757
- create row. Borderless for a grid cell? Pass a `style`, never a `variant`. `Combobox` is the
757
+ create row. Borderless for a grid cell? Pass a `style`, never a `variant`.
758
+
759
+ **On a record surface the same field is `InlineSelect multi`**, and it takes the identical seam —
760
+ `renderSelected(option, { remove })`. Reach for it wherever the neighbouring values are inline
761
+ editors, so the tag field wears the same frame and hover as the row it sits in; reach for `Select`
762
+ inside a form or dialog. The ✕ detaches its tag whether the list is open or shut and never opens
763
+ the list. Two densities, one seam: a record field earns the removable `Chip`, a narrow register
764
+ column keeps the plain badge, where the ✕ costs more width than the reopen it saves. `Combobox` is the
758
765
  SINGLE-value sibling — for a search that emits one pick at a time and renders the selection
759
766
  elsewhere, use `reflectSelection={false}` (see the [templates](./templates.md)).
760
767
 
@@ -5,6 +5,7 @@ import { colors, solid, asColorName } from "@lotics/ui/colors";
5
5
  import { Icon } from "@lotics/ui/icon";
6
6
  import { CheckCircle } from "@lotics/ui/check_circle";
7
7
  import { OptionBadge, type OptionValue } from "@lotics/ui/option_badge";
8
+ import { Chip } from "@lotics/ui/chip";
8
9
  import { MemberChip } from "@lotics/ui/member_chip";
9
10
  import { Avatar } from "@lotics/ui/avatar";
10
11
  import { SearchInput } from "@lotics/ui/search_input";
@@ -77,6 +78,16 @@ const TAG_OPTIONS: TagOption[] = Object.values(TAGS).map((t) => ({ value: String
77
78
  const tagOf = (k: string): TagOption => ({ value: k, label: TAGS[k]?.label ?? k });
78
79
  const tagBadge = (t: TagOption): OptionValue => TAGS[t.value] ?? { key: t.value, label: t.label, color: null };
79
80
  const renderTagBadge = (o: { value: string; label?: string }) => <OptionBadge value={tagBadge({ value: o.value, label: o.label ?? o.value })} variant="dot" />;
81
+ // The same tag, removable. A tag field's resting state is a CHIP BOX — one ✕ per
82
+ // value beats reopening the list to untick one — and `renderSelected`'s `remove`
83
+ // is what a multi `InlineSelect` hands you for it. The column below deliberately
84
+ // does NOT use this: at 168px the ✕ costs more width than the reopen it saves, so
85
+ // a dense register keeps the plain badge. Same seam, two densities.
86
+ const renderRemovableTag = (o: { value: string; label?: string }, { remove }: { remove: () => void }) => (
87
+ <Chip onDismiss={remove}>
88
+ <OptionBadge value={tagBadge({ value: o.value, label: o.label ?? o.value })} variant="dot" />
89
+ </Chip>
90
+ );
80
91
 
81
92
  // A per-row CONTEXTUAL action. Different tasks expose different actions, each
82
93
  // wired (in a real app) to its OWN app-workflow — so ONE board drives many
@@ -580,7 +591,7 @@ function TaskAddRow({ preset, columns, onAdd }: { preset: Partial<Task>; columns
580
591
  key === "assignee" ? <InlineMemberSelect variant="bare" members={MEMBERS} value={ownerId} onSave={setOwnerId} placeholder="Assignee" accessibilityLabel="Assignee" />
581
592
  : key === "due" ? <InlineDatePicker variant="bare" value={due} optionalTime onSave={setDue} placeholder="No date" accessibilityLabel="Due date" />
582
593
  : key === "status" ? <InlineSelect variant="bare" value={status} options={STATUS_OPTIONS} onSave={setStatus} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" />
583
- : key === "tags" ? <InlineSelect multi searchable allowCustom variant="bare" value={tags.map((tag) => tag.value)} onSave={(next) => setTags(next.map(tagOf))} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} placeholder="Add tags" accessibilityLabel="Tags" />
594
+ : key === "tags" ? <InlineSelect multi searchable allowCustom variant="bare" value={tags.map((tag) => tag.value)} onSave={(next) => setTags(next.map(tagOf))} options={TAG_OPTIONS} renderSelected={renderRemovableTag} renderOptionContent={renderTagBadge} placeholder="Add tags" accessibilityLabel="Tags" />
584
595
  : null;
585
596
 
586
597
  return (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "45.4.0",
3
+ "version": "45.5.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -385,8 +385,21 @@ interface InlineEditViewProps {
385
385
  * active/open edge) is unchanged. */
386
386
  actions?: ReactNode;
387
387
  /** True while the field's popover (select/date) is open: wears the 2px active
388
- * ring so a mouse-opened trigger reads like a focused input (no `:focus-visible`). */
388
+ * ring so a mouse-opened trigger reads like a focused input (no `:focus-visible`).
389
+ * Under `role="combobox"` it is also what the field ANNOUNCES as aria-expanded. */
389
390
  active?: boolean;
391
+ /** The APG pattern the resting field implements. Default "button" — a value that
392
+ * swaps to an input in place.
393
+ *
394
+ * Pass **"combobox"** when pressing the field opens a LIST that holds its value.
395
+ * Three things follow, and they are one decision rather than three:
396
+ * it is the role APG names for this control; `active` becomes aria-expanded, so
397
+ * the field can no longer claim to be a plain button while owning an open popup;
398
+ * and react-native-web renders a `<div>` instead of a `<button>`, whose content
399
+ * model forbids interactive descendants. That last one is load-bearing — a tag
400
+ * field's chips cannot carry their own remove ✕ inside a `<button>`, because a
401
+ * button may not contain a button. `Select` reached the same conclusion. */
402
+ role?: "button" | "combobox";
390
403
  /** Raw focus on the resting view, any modality. The input-swap editors use it
391
404
  * (via `InlineEditFrame`) to open edit mode on KEYBOARD focus; the popover
392
405
  * editors leave it unset — focus never auto-opens an overlay (the WAI-ARIA
@@ -436,7 +449,11 @@ function multilineBox(numberOfLines: number | undefined): ViewStyle | null {
436
449
  }
437
450
 
438
451
  export function InlineEditView(props: InlineEditViewProps) {
439
- const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, actions, active, struck, variant = "framed", tone, onFocus, ref, anchorRef, numberOfLines } = props;
452
+ const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, actions, active, struck, variant = "framed", tone, onFocus, ref, anchorRef, numberOfLines, role = "button" } = props;
453
+ // A combobox MUST expose its expanded state; a button must not. Deriving it from
454
+ // the `active` the popover callers already pass keeps the two from drifting apart
455
+ // — there is no way to take the role without the announcement.
456
+ const patternProps = role === "combobox" ? { accessibilityRole: "combobox" as const, "aria-expanded": active === true } : { accessibilityRole: "button" as const };
440
457
  // Stop the press here so an inline editor nested in a pressable row (a task
441
458
  // row that expands on press) edits the field instead of triggering the row.
442
459
  const handlePress = onPress
@@ -465,7 +482,7 @@ export function InlineEditView(props: InlineEditViewProps) {
465
482
  disabled={disabled}
466
483
  onPress={handlePress}
467
484
  onFocus={onFocus}
468
- accessibilityRole="button"
485
+ {...patternProps}
469
486
  accessibilityLabel={accessibilityLabel}
470
487
  style={[styles.viewInner, disabled === true && styles.viewInert, multilineBox(numberOfLines)]}
471
488
  >
@@ -484,7 +501,7 @@ export function InlineEditView(props: InlineEditViewProps) {
484
501
  disabled={disabled}
485
502
  onPress={handlePress}
486
503
  onFocus={onFocus}
487
- accessibilityRole="button"
504
+ {...patternProps}
488
505
  accessibilityLabel={accessibilityLabel}
489
506
  userSelect="none"
490
507
  style={(state) => [surface(state.hovered ?? false), multilineBox(numberOfLines)]}
@@ -31,10 +31,6 @@ interface InlineSelectBaseProps<T extends string, D = unknown> {
31
31
  * rendered this two-line `MenuListItem`; this only forwards it, which is why
32
32
  * callers were hand-rebuilding the anatomy through `renderOptionContent`. */
33
33
  getOptionDescription?: (option: PickerOption<T, D>) => string | undefined;
34
- /** Render ONE selected option as its resting chip — single: the value; multi:
35
- * each tag. Falls back to `renderOptionContent`, then the plain label (single) /
36
- * a zinc `Badge` (multi). The one seam for "how the selection looks". */
37
- renderSelected?: (option: PickerOption<T, D>) => ReactNode;
38
34
  /** Empty-state content: a string reads as muted placeholder text, a NODE renders
39
35
  * as-is (e.g. an avatar "add" ghost for an `avatarOnly` member cell). */
40
36
  placeholder?: string | ReactNode;
@@ -74,6 +70,11 @@ export type InlineSelectProps<T extends string, D = unknown> =
74
70
  multi?: false;
75
71
  value: T | null;
76
72
  onSave: (next: T) => void | Promise<void>;
73
+ /** Render the selected option as the resting value. Falls back to
74
+ * `renderOptionContent`, then the plain label. No `remove`: unsetting a single
75
+ * field is `onClear` and its Clear row, and a second ✕ that silently did
76
+ * nothing whenever `onClear` was absent would be worse than no ✕ at all. */
77
+ renderSelected?: (option: PickerOption<T, D>) => ReactNode;
77
78
  /** Unset the field. Provide it for a "Clear" row whenever there IS a value —
78
79
  * kept separate from `onSave` (whose next is a non-null `T`). Single only. */
79
80
  onClear?: () => void | Promise<void>;
@@ -82,6 +83,11 @@ export type InlineSelectProps<T extends string, D = unknown> =
82
83
  multi: true;
83
84
  value: T[];
84
85
  onSave: (next: T[]) => void | Promise<void>;
86
+ /** Render ONE selected tag. Falls back to `renderOptionContent`, then a zinc
87
+ * `Badge`. `remove` detaches THAT tag — return `<Chip onDismiss={remove}>` for
88
+ * the removable chip box a tag field rests as, or a plain badge that ignores
89
+ * it. Mirrors `Select`; the ✕ removes without opening the list. */
90
+ renderSelected?: (option: PickerOption<T, D>, controls: { remove: () => void }) => ReactNode;
85
91
  });
86
92
 
87
93
  /** The shared shell: the resting view (a `Popover` trigger) that floats an
@@ -109,6 +115,10 @@ function InlineSelectShell(props: {
109
115
  <PopoverTrigger>
110
116
  <InlineEditView
111
117
  anchorRef={anchorRef}
118
+ // Both modes hold a value and open a list, which is what the role names.
119
+ // It is also what lets a multi field's chips carry their own remove ✕:
120
+ // a <button> may not contain a <button>.
121
+ role="combobox"
112
122
  actions={props.actions}
113
123
  variant={props.variant}
114
124
  display={props.display}
@@ -140,7 +150,7 @@ function InlineSelectShell(props: {
140
150
  }
141
151
 
142
152
  export function InlineSelect<T extends string, D = unknown>(props: InlineSelectProps<T, D>) {
143
- const { options, renderOptionContent, getOptionDescription, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, allowCustom = false, customOptionLabel, customOptionPlacement, variant, actions, autoFocus = false } = props;
153
+ const { options, renderOptionContent, getOptionDescription, placeholder, disabled, accessibilityLabel, searchable = false, allowCustom = false, customOptionLabel, customOptionPlacement, variant, actions, autoFocus = false } = props;
144
154
  const labels = useLoticsLocale().inline;
145
155
  const [open, setOpen] = useState(autoFocus);
146
156
  const [saving, setSaving] = useState(false);
@@ -162,15 +172,47 @@ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectP
162
172
  const searchMode = searchable || allowCustom ? "internal" : "none";
163
173
 
164
174
  if (props.multi) {
165
- const { value, onSave } = props;
175
+ const { value, onSave, renderSelected } = props;
176
+ // The chips show the WORKING set: the draft while the list is open, the stored
177
+ // value once it is shut. They used to show `value` throughout, which reads as a
178
+ // dead control the moment anything edits the draft — press a chip's ✕ with the
179
+ // list open and it just sits there until the popover closes. What you are
180
+ // building is what the field should show while you build it; ticking a row in
181
+ // the list lands in the same place, for the same reason.
182
+ //
166
183
  // Resolve each value to its option IN SELECTION ORDER; an `allowCustom` value not in
167
184
  // `options` (a just-created tag) renders from a {value,label} fallback so it still shows
168
185
  // (mirrors `Select`). Without allowCustom, an unknown value is dropped (→ nothing), as before.
169
- const selected = value
186
+ const selected = (open ? draft : value)
170
187
  .map((v) => options.find((o) => o.value === v) ?? (allowCustom ? { value: v, label: v } : null))
171
188
  .filter((o): o is PickerOption<T, D> => o != null);
189
+ /**
190
+ * Detach one tag.
191
+ *
192
+ * WHICH SET it edits depends on whether the list is open, and getting that wrong
193
+ * is silent: while open, the working set is `draft` and the close handler writes
194
+ * it back wholesale — so a ✕ that wrote straight to `value` would be UNDONE the
195
+ * moment the popover closed. Open → edit the draft and let the close commit it;
196
+ * closed → there is no draft, so write the new set immediately.
197
+ *
198
+ * Nothing here stops the press. The ✕ sits INSIDE the trigger, so opening the
199
+ * list on a removal looks like the obvious hazard — but react-native-web hands
200
+ * a gesture to one responder, and the innermost one wins. Verified against the
201
+ * gallery: with the stop removed the list still never opens.
202
+ */
203
+ const removeValue = (v: T) => () => {
204
+ if (open) {
205
+ setDraft((current) => current.filter((d) => d !== v));
206
+ return;
207
+ }
208
+ void runSave(() => onSave(value.filter((d) => d !== v)));
209
+ };
172
210
  const chip = (o: PickerOption<T, D>) =>
173
- renderSelected ? renderSelected(o) : renderOptionContent ? renderOptionContent(o) : <Badge label={o.label ?? String(o.value)} color="zinc" />;
211
+ renderSelected
212
+ ? renderSelected(o, { remove: removeValue(o.value) })
213
+ : renderOptionContent
214
+ ? renderOptionContent(o)
215
+ : <Badge label={o.label ?? String(o.value)} color="zinc" />;
174
216
  const onOpenChange = (next: boolean) => {
175
217
  if (next) {
176
218
  setDraft(value);
@@ -209,13 +251,13 @@ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectP
209
251
  const created = raw.trim() as T;
210
252
  if (created && !draft.includes(created)) setDraft([...draft, created]);
211
253
  }}
212
- renderOptionContent={renderOptionContent ?? renderSelected}
254
+ renderOptionContent={renderOptionContent}
213
255
  />
214
256
  </InlineSelectShell>
215
257
  );
216
258
  }
217
259
 
218
- const { value, onSave, onClear } = props;
260
+ const { value, onSave, onClear, renderSelected } = props;
219
261
  // An `allowCustom` value not in `options` renders from a fallback (like `Select`); a plain
220
262
  // unknown value (a removed member/status) resolves to nothing → the placeholder shows.
221
263
  const selected = options.find((o) => o.value === value) ?? (allowCustom && value != null ? { value, label: value } : undefined);
@@ -264,6 +306,9 @@ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectP
264
306
  }
265
307
 
266
308
  const styles = StyleSheet.create({
267
- tags: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 4, rowGap: 2 },
309
+ // ONE gap on both axes: a wrapped chip is the same object as the one beside it,
310
+ // so the second line should breathe like the first. The row gap used to be half
311
+ // the column gap, which pinched a wrapped field against its own first line.
312
+ tags: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 4 },
268
313
  error: { marginTop: 4 },
269
314
  });
@@ -113,6 +113,12 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
113
113
  <ScrollView
114
114
  ref={list.scrollRef}
115
115
  style={styles.optionsList}
116
+ // The GAP has to ride the content container, not the viewport. A ScrollView
117
+ // wraps its children in one content <div>, so a `gap` in `style` sits on a
118
+ // box with a single child and falls between nothing — rows rendered flush,
119
+ // and two adjacent SELECTED rows merged into one tinted block, which reads
120
+ // as a single wide row rather than two picked ones.
121
+ contentContainerStyle={styles.optionsContent}
116
122
  nativeID={list.listboxId}
117
123
  accessibilityLabel={accessibilityLabel}
118
124
  keyboardShouldPersistTaps="handled"
@@ -235,9 +241,15 @@ const styles = StyleSheet.create({
235
241
  },
236
242
  optionsList: {
237
243
  flex: 1,
238
- gap: 4,
239
244
  maxHeight: 320,
240
245
  },
246
+ optionsContent: {
247
+ // 2, where a field's chips sit at 4. A row here is a 40px LIST line read top to
248
+ // bottom, and needs only enough space to stop two tinted rows reading as one
249
+ // block; a chip is an object sitting beside other objects and wants the wider
250
+ // setting. Two numbers because they answer two questions, not one token split.
251
+ gap: 2,
252
+ },
241
253
  selectAllContainer: {
242
254
  flexDirection: "row",
243
255
  alignItems: "center",