@lotics/ui 45.3.0 → 45.5.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/AGENTS.md CHANGED
@@ -100,7 +100,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
100
100
  `error={overdue ? … }` is the tell: it makes a date due tomorrow read like one due in three
101
101
  months. → [catalog.md §Dates & times](./docs/catalog.md).
102
102
  - **A third-party channel takes `BrandMark`, our own glyphs take `Icon`.** `@lotics/ui/brand_mark`
103
- draws Facebook / Zalo / LinkedIn / Google Meet as solid single-colour silhouettes — a reader
103
+ draws Facebook / Zalo / LinkedIn / X / Google Meet as solid single-colour silhouettes — a reader
104
104
  scanning a feed recognises the logo before the word. The names are an allowlist, so a channel
105
105
  with no mark (a phone call, an event, a website) fails to typecheck and falls back to `Icon`
106
106
  instead of borrowing another company's logo. → [catalog.md §Text & formatting](./docs/catalog.md).
package/MIGRATION.md CHANGED
@@ -4,6 +4,32 @@ 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 4px 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, and a list capped at its 320px max shows one fewer.
28
+
29
+ **A multi `InlineSelect`'s chips follow the working set while its list is open** — the draft you
30
+ are building, not the stored value. Ticking a row or pressing a chip's ✕ now shows immediately
31
+ instead of at popover-close. The commit is unchanged: one write per visit, on close.
32
+
7
33
  **`FileRow`'s `leading` slot is a SIBLING of the press door, not inside it.** No API change;
8
34
  a row with `onPress` now has a slightly smaller press target, excluding the leading mark.
9
35
 
package/docs/catalog.md CHANGED
@@ -476,12 +476,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
476
476
  `SubsectionHeading` instead. Pick between this and `Eyebrow` by what is LABELLED — a block or a
477
477
  value; composition.md § Eyebrow vs group lead has the table.
478
478
  - **`brand_mark`** — `BrandMark`: an OUTSIDE brand's mark, naming the channel a touchpoint
479
- happened on — `facebook`, `zalo`, `linkedin`, `google-meet`. Solid single-colour silhouettes in
479
+ happened on — `facebook`, `zalo`, `linkedin`, `x`, `google-meet`. Solid single-colour silhouettes in
480
480
  each brand's own hex, not the full-colour logos: a byline draws these at 14–20px and a
481
481
  five-colour logo turns to mud there. `size` (default 16) and `muted` (draw in the text colour).
482
482
  The name list is an ALLOWLIST — a channel with no mark will not typecheck, so the call site
483
483
  falls back to a generic `Icon` rather than rendering the wrong company's logo. Not for the app's
484
- own glyphs; that is `icon`.
484
+ own glyphs; that is `icon`. `x` is the one mark whose brand colour is BLACK, so it is the one
485
+ that needs `muted` on a dark ground; the rest carry their own hue either way.
485
486
  - **`markdown`** — `Markdown`: the single canonical markdown renderer for chat, apps, and
486
487
  `AgentRun`; rich GFM markdown on web with copyable tables, plain-text on
487
488
  native; takes a markdown `children` string. **`variant`** decides whose type
@@ -1173,8 +1174,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1173
1174
  `InlineSelect`/`InlineMemberSelect` render the resting value like its option —
1174
1175
  `renderOptionContent` by default, `renderSelected` to override — a chip/badge at rest, not
1175
1176
  just text. **`InlineSelect` is single OR multi** — pass `multi` for a tag SET (`value: T[]`,
1176
- commits the new set on popover-CLOSE; selected tags render as badges via `renderSelected`),
1177
- 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
1178
1185
  `allowCustom` (a create-a-tag/option row) + `searchable` + **`customOptionPlacement`** (default
1179
1186
  `"bottom"`, right for a tag field; pass `"top"` for a find-or-create REFERENCE picker over a long
1180
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.3.0",
3
+ "version": "45.5.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -7,7 +7,7 @@ import { Svg, Path } from "react-native-svg";
7
7
  * email that is not tied to one provider. Inventing a mark for those is how a
8
8
  * byline ends up claiming a channel the record does not carry.
9
9
  */
10
- export type BrandName = "facebook" | "zalo" | "linkedin" | "google-meet";
10
+ export type BrandName = "facebook" | "zalo" | "linkedin" | "x" | "google-meet";
11
11
 
12
12
  interface Mark {
13
13
  /** The brand's own colour. A mark drawn in anything else is not the mark. */
@@ -40,6 +40,14 @@ const MARKS: Record<BrandName, Mark> = {
40
40
  color: "#0A66C2",
41
41
  path: "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z",
42
42
  },
43
+ x: {
44
+ // X's mark is black, and that is the mark — a brand drawn in a colour it
45
+ // does not use is not the brand. It is the one entry here that depends on
46
+ // its ground: on a dark surface pass `muted`, which draws it in the text
47
+ // colour like every other mark and keeps it visible.
48
+ color: "#000000",
49
+ path: "M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z",
50
+ },
43
51
  "google-meet": {
44
52
  color: "#00897B",
45
53
  path: "M5.53 2.13 0 7.75h5.53zm.398 0v5.62h7.608v3.65l5.47-4.45c-.014-1.22.031-2.25-.025-3.46-.148-1.09-1.287-1.47-2.236-1.36zM23.1 4.32c-.802.295-1.358.995-2.047 1.49-2.506 2.05-4.982 4.12-7.468 6.19 3.025 2.59 6.04 5.18 9.065 7.76 1.218.671 1.428-.814 1.328-1.64v-13a.828.828 0 0 0-.877-.825zM.038 8.15v7.7h5.53v-7.7zm13.577 8.1H6.008v5.62c3.864-.006 7.737.011 11.58-.009 1.02-.07 1.618-1.12 1.468-2.07v-2.51l-5.47-4.68v3.65zm-13.577 0c.02 1.44-.041 2.88.033 4.31.162.948 1.158 1.43 2.047 1.31h3.464v-5.62z",
@@ -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);
@@ -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,11 @@ 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
+ gap: 4,
248
+ },
241
249
  selectAllContainer: {
242
250
  flexDirection: "row",
243
251
  alignItems: "center",