@lotics/ui 4.1.0 → 4.2.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 +20 -2
- package/package.json +4 -1
- package/src/chip_group.tsx +16 -13
- package/src/colors.test.ts +45 -0
- package/src/colors.ts +25 -0
- package/src/file_dropzone.tsx +5 -3
- package/src/member_chip.tsx +51 -0
- package/src/member_select.tsx +81 -0
- package/src/option_badge.tsx +58 -0
package/AGENTS.md
CHANGED
|
@@ -29,7 +29,20 @@ Pick by capability, not by name. (→ the source file for the API.)
|
|
|
29
29
|
destination signal; the opposite of `LinkButton` (a quiet in-app action, never underlined).
|
|
30
30
|
- **Pick from a list** — `Picker` (known options, native typeahead, single/multi/custom-render,
|
|
31
31
|
no search box). Search-as-you-type / async / create-new → `Combobox`. A selectable card row →
|
|
32
|
-
`CardSelectItem`.
|
|
32
|
+
`CardSelectItem`. For a SELECT-FIELD picker, render each option as its colored chip
|
|
33
|
+
(`renderOptionContent={(o) => <OptionBadge value={o} />}`).
|
|
34
|
+
- **Pick member(s)** — `MemberSelect` (a `Picker` that renders each option as a `MemberChip`,
|
|
35
|
+
single or multi) — the ready member picker; pass it the roster (`members={useMembers().members}`).
|
|
36
|
+
Don't re-wire `Picker` + `renderOptionContent` + a directory by hand.
|
|
37
|
+
- **A person / member (display)** — `MemberChip` (avatar + name + optional secondary line) — the
|
|
38
|
+
ONE way to show a member inline: a picker option, an assignee, a `select_member` value. Pure:
|
|
39
|
+
resolve the member from your directory (`useMembers`) and pass `name` / `image`; never hand-roll
|
|
40
|
+
`Avatar` + `Text`. (The product's `MemberBadge` is just `memberId → MemberChip`; `MemberSelect`
|
|
41
|
+
renders these per option.)
|
|
42
|
+
- **A select-field value** — `OptionBadge` (a stored `select` value as its CONFIGURED colored
|
|
43
|
+
badge) — never hand-map option-key → color. Feed it a resolved option (`useFieldOptions` for a
|
|
44
|
+
picker option, or `byKey(readSelect(cell)[0]?.key)` for a stored value); multi-select wraps to
|
|
45
|
+
one badge each; a missing/unknown color token degrades to neutral.
|
|
33
46
|
- **Tags / multi-select chip box** — `TagInput` (chips + an Add-popover checklist + create), NOT
|
|
34
47
|
`Combobox multi` (see §Data entry).
|
|
35
48
|
- **Text & form** — `TextInputField`, `NumberInput`, `SearchInput`; wrap with `FormField`;
|
|
@@ -75,6 +88,7 @@ This is the most common thing to get right. Match the JOB to the pattern:
|
|
|
75
88
|
| REPEATING rows you build & revise | **line items** (create→preview→edit) | add / edit / remove, live totals |
|
|
76
89
|
| CHARGES that bill onto documents | **billing** (`tpl_billing`) | the invoice document is the unit |
|
|
77
90
|
| a multi-value TAG field | **`TagInput`** | a chip box, not a search input |
|
|
91
|
+
| ONE choice from a small visible set | **`ChipGroup` pills** (or `RadioPicker`) | required single-select, one tap, every option visible |
|
|
78
92
|
| a STATUS with terminal outcomes | **disposition** (open → resolve → revise) | guides the decision |
|
|
79
93
|
| FILES | **attachment field** (dropzone + grid + gallery) | add / preview / delete |
|
|
80
94
|
| many entries FAST | **quick capture** (one row, Enter to add) | repeat-entry speed |
|
|
@@ -333,6 +347,9 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
|
|
|
333
347
|
|
|
334
348
|
text · card (Card · CardHeader · CardHeaderTitle · CardHeaderMeta · CardBody · CardFooter) ·
|
|
335
349
|
section_heading (SectionHeading · SectionHeadingTitle — compound, owns no margin) · badge ·
|
|
350
|
+
option_badge (OptionBadge — a select value as its configured colored badge) ·
|
|
351
|
+
member_chip (MemberChip — avatar + name; the universal person render) ·
|
|
352
|
+
member_select (MemberSelect — a Picker of MemberChip options; the member picker) ·
|
|
336
353
|
status_badge · button · icon_button · link · link_button · pill_button · tabs · segmented_control ·
|
|
337
354
|
picker · combobox · tag_input (TagInput — chip box + Add-popover; for tags, not Combobox multi) ·
|
|
338
355
|
text_input_field · number_input · search_input · form_field · checkbox · checkbox_input · switch ·
|
|
@@ -349,4 +366,5 @@ status_grid (StatusGrid + StatusLegend) · heatmap · legend_item · remainder_m
|
|
|
349
366
|
scan_field · file_dropzone · file_thumbnail · file_thumbnail_grid · file_preview ·
|
|
350
367
|
file_gallery_modal · image_gallery · avatar · skeleton · activity_indicator · loading · divider ·
|
|
351
368
|
spacer · stack · section_card · page_header · page_content · calendar (calendar/index.ts) · gantt ·
|
|
352
|
-
comments_thread · format_money · format_date · colors (solid · tint · ramp · ColorName
|
|
369
|
+
comments_thread · format_money · format_date · colors (solid · tint · ramp · ColorName ·
|
|
370
|
+
isColorName · asColorName — coerce a stored option/status token to a ColorName, neutral fallback).
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
7
7
|
"./colors": "./src/colors.ts",
|
|
8
|
+
"./option_badge": "./src/option_badge.tsx",
|
|
9
|
+
"./member_chip": "./src/member_chip.tsx",
|
|
10
|
+
"./member_select": "./src/member_select.tsx",
|
|
8
11
|
"./mime": "./src/mime.ts",
|
|
9
12
|
"./download": "./src/download.ts",
|
|
10
13
|
"./file_picker": "./src/file_picker.ts",
|
package/src/chip_group.tsx
CHANGED
|
@@ -3,17 +3,19 @@ import { Text } from "./text";
|
|
|
3
3
|
import { PressableHighlight } from "./pressable_highlight";
|
|
4
4
|
import { pillSurfaceStyle } from "./control_surface";
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
6
|
+
// One-of-N chips: every option visible, one tap to switch, the row wraps on
|
|
7
|
+
// narrow widths. Quiet zinc styling — bordered white at rest, dark fill when
|
|
8
|
+
// active (color stays reserved for status + primary actions). Two jobs, one
|
|
9
|
+
// control:
|
|
10
|
+
// - a SMALL, HOT FILTER the user flips between constantly ("narrow this
|
|
11
|
+
// view") — model the unfiltered state as an explicit option (e.g. "All").
|
|
12
|
+
// - a small REQUIRED single-select in a form / composer (a call outcome, a
|
|
13
|
+
// 1–N grade) — pills keep every choice in view and entry to one tap; an
|
|
14
|
+
// empty initial value (nothing chosen yet) is fine here.
|
|
15
|
+
// Reach for `Picker` when options are many or space is tight (it hides the set
|
|
16
|
+
// behind a click); for `RadioPicker` when the radio-circle "this writes"
|
|
17
|
+
// affordance suits a denser form. It's affordance + density, not
|
|
18
|
+
// filter-vs-write — the same ChipGroup serves both.
|
|
17
19
|
|
|
18
20
|
export interface ChipOption<T extends string = string> {
|
|
19
21
|
label: string;
|
|
@@ -29,8 +31,9 @@ export interface ChipGroupProps<T extends string = string> {
|
|
|
29
31
|
*/
|
|
30
32
|
accessibilityLabel: string;
|
|
31
33
|
options: ChipOption<T>[];
|
|
32
|
-
/** The active option
|
|
33
|
-
*
|
|
34
|
+
/** The active option. As a FILTER, model the unfiltered state as an explicit
|
|
35
|
+
* option (e.g. "All"); as a FORM selector, an empty value (nothing chosen
|
|
36
|
+
* yet) is valid. */
|
|
34
37
|
value: T;
|
|
35
38
|
onValueChange: (value: T) => void;
|
|
36
39
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { isColorName, asColorName } from "./colors";
|
|
3
|
+
|
|
4
|
+
describe("isColorName", () => {
|
|
5
|
+
it("accepts palette family names", () => {
|
|
6
|
+
expect(isColorName("blue")).toBe(true);
|
|
7
|
+
expect(isColorName("emerald")).toBe(true);
|
|
8
|
+
expect(isColorName("zinc")).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("rejects role keys and scalar colors (not selectable families)", () => {
|
|
12
|
+
expect(isColorName("border")).toBe(false);
|
|
13
|
+
expect(isColorName("border_shadow")).toBe(false);
|
|
14
|
+
expect(isColorName("background")).toBe(false);
|
|
15
|
+
expect(isColorName("shadow")).toBe(false);
|
|
16
|
+
expect(isColorName("black")).toBe(false);
|
|
17
|
+
expect(isColorName("white")).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("rejects unknown tokens and non-strings", () => {
|
|
21
|
+
expect(isColorName("chartreuse")).toBe(false);
|
|
22
|
+
expect(isColorName("")).toBe(false);
|
|
23
|
+
expect(isColorName(undefined)).toBe(false);
|
|
24
|
+
expect(isColorName(null)).toBe(false);
|
|
25
|
+
expect(isColorName(42)).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("asColorName", () => {
|
|
30
|
+
it("passes a valid family through", () => {
|
|
31
|
+
expect(asColorName("purple")).toBe("purple");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("degrades an unknown/absent token to the neutral default", () => {
|
|
35
|
+
// The graceful-degradation contract: a select option whose color token this
|
|
36
|
+
// build doesn't recognize, or none at all, renders neutral rather than break.
|
|
37
|
+
expect(asColorName("chartreuse")).toBe("zinc");
|
|
38
|
+
expect(asColorName(undefined)).toBe("zinc");
|
|
39
|
+
expect(asColorName(null)).toBe("zinc");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("honors a custom fallback", () => {
|
|
43
|
+
expect(asColorName(undefined, "slate")).toBe("slate");
|
|
44
|
+
});
|
|
45
|
+
});
|
package/src/colors.ts
CHANGED
|
@@ -359,3 +359,28 @@ export function ramp(name: ColorName, count: number): string[] {
|
|
|
359
359
|
return scale[RAMP_STOPS[idx]];
|
|
360
360
|
});
|
|
361
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
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
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`.
|
|
383
|
+
*/
|
|
384
|
+
export function asColorName(value: unknown, fallback: ColorName = "zinc"): ColorName {
|
|
385
|
+
return isColorName(value) ? value : fallback;
|
|
386
|
+
}
|
package/src/file_dropzone.tsx
CHANGED
|
@@ -20,8 +20,9 @@ export interface FileDropzoneProps {
|
|
|
20
20
|
hint?: string;
|
|
21
21
|
/** Main line while a drag hovers the zone ("Thả để tải lên"). */
|
|
22
22
|
dropLabel?: string;
|
|
23
|
-
/**
|
|
24
|
-
*
|
|
23
|
+
/** Minimum zone height — it grows to fit the icon + labels + padding, so a
|
|
24
|
+
* small value never crops the content. Size it to the surface (compact ~120 in
|
|
25
|
+
* a form field, 200+ as a screen's main affordance). Default 160. */
|
|
25
26
|
height?: number;
|
|
26
27
|
disabled?: boolean;
|
|
27
28
|
accessibilityLabel?: string;
|
|
@@ -127,7 +128,7 @@ export function FileDropzone(props: FileDropzoneProps) {
|
|
|
127
128
|
onHoverOut={() => setHovered(false)}
|
|
128
129
|
style={[
|
|
129
130
|
styles.zone,
|
|
130
|
-
{ height },
|
|
131
|
+
{ minHeight: height },
|
|
131
132
|
hovered && !dragging ? styles.zoneHovered : null,
|
|
132
133
|
dragging ? styles.zoneDragging : null,
|
|
133
134
|
disabled ? styles.zoneDisabled : null,
|
|
@@ -160,6 +161,7 @@ const styles = StyleSheet.create({
|
|
|
160
161
|
borderColor: colors.zinc[300],
|
|
161
162
|
backgroundColor: colors.zinc[50],
|
|
162
163
|
paddingHorizontal: 20,
|
|
164
|
+
paddingVertical: 16,
|
|
163
165
|
},
|
|
164
166
|
zoneHovered: {
|
|
165
167
|
borderColor: colors.zinc[400],
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, StyleSheet, StyleProp, ViewStyle } from "react-native";
|
|
3
|
+
import { Avatar } from "./avatar";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
|
|
6
|
+
interface MemberChipProps {
|
|
7
|
+
/** Display name. Empty/blank falls back to a neutral label — pass
|
|
8
|
+
* `name={member.name || member.email}` to prefer email when unnamed. */
|
|
9
|
+
name?: string | null;
|
|
10
|
+
/** Avatar image URL (a member's `image` from `useMembers`). Absent → initials. */
|
|
11
|
+
image?: string | null;
|
|
12
|
+
/** Optional secondary line under the name — e.g. email, role, department. */
|
|
13
|
+
secondary?: string | null;
|
|
14
|
+
/** Avatar diameter in px. Default 28. */
|
|
15
|
+
size?: number;
|
|
16
|
+
style?: StyleProp<ViewStyle>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Render a member/person as an avatar + name (+ optional secondary line) — the
|
|
21
|
+
* one canonical way to show a person inline: a member-picker option, an assignee,
|
|
22
|
+
* a `select_member` cell in a register or detail. The {@link Avatar} falls back
|
|
23
|
+
* to initials when there's no image.
|
|
24
|
+
*
|
|
25
|
+
* Pure: pass the member's fields in (from `useMembers`, or a resolved
|
|
26
|
+
* `select_member` cell joined against that roster); this component fetches
|
|
27
|
+
* nothing and carries no domain types.
|
|
28
|
+
*/
|
|
29
|
+
export function MemberChip({ name, image, secondary, size = 28, style }: MemberChipProps) {
|
|
30
|
+
const displayName = name?.trim() || "Unknown";
|
|
31
|
+
return (
|
|
32
|
+
<View style={[styles.row, style]}>
|
|
33
|
+
<Avatar size={size} name={displayName} source={image ? { uri: image } : undefined} />
|
|
34
|
+
<View style={styles.text}>
|
|
35
|
+
<Text userSelect="none" numberOfLines={1}>
|
|
36
|
+
{displayName}
|
|
37
|
+
</Text>
|
|
38
|
+
{secondary ? (
|
|
39
|
+
<Text userSelect="none" size="sm" color="zinc-500" numberOfLines={1}>
|
|
40
|
+
{secondary}
|
|
41
|
+
</Text>
|
|
42
|
+
) : null}
|
|
43
|
+
</View>
|
|
44
|
+
</View>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const styles = StyleSheet.create({
|
|
49
|
+
row: { flexDirection: "row", alignItems: "center", gap: 6 },
|
|
50
|
+
text: { flexShrink: 1 },
|
|
51
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React, { useCallback, useMemo } from "react";
|
|
2
|
+
import { StyleProp, ViewStyle } from "react-native";
|
|
3
|
+
import {
|
|
4
|
+
Picker,
|
|
5
|
+
type PickerOption,
|
|
6
|
+
type PickerValue,
|
|
7
|
+
type PickerOnValueChange,
|
|
8
|
+
type PickerOnClose,
|
|
9
|
+
} from "./picker";
|
|
10
|
+
import { MemberChip } from "./member_chip";
|
|
11
|
+
|
|
12
|
+
/** A candidate member for {@link MemberSelect}. Shaped to accept a `useMembers`
|
|
13
|
+
* row from `@lotics/app-sdk` (or any roster) directly. */
|
|
14
|
+
export interface MemberSelectMember {
|
|
15
|
+
id: string;
|
|
16
|
+
name?: string | null;
|
|
17
|
+
image?: string | null;
|
|
18
|
+
email?: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface MemberSelectProps<MULTI extends boolean = false> {
|
|
22
|
+
/**
|
|
23
|
+
* The candidate roster. An app feeds `useMembers()`; the product feeds any
|
|
24
|
+
* directory. Each member carries its own avatar (`image`), so options render
|
|
25
|
+
* with no side lookup.
|
|
26
|
+
*/
|
|
27
|
+
members: MemberSelectMember[];
|
|
28
|
+
value?: PickerValue<string, MULTI> | null;
|
|
29
|
+
onValueChange?: PickerOnValueChange<string, MULTI>;
|
|
30
|
+
onClose?: PickerOnClose<string, MULTI>;
|
|
31
|
+
multi?: MULTI;
|
|
32
|
+
placeholder?: string;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
autoFocus?: boolean;
|
|
35
|
+
includeEmptyOption?: boolean;
|
|
36
|
+
style?: StyleProp<ViewStyle>;
|
|
37
|
+
testID?: string;
|
|
38
|
+
accessibilityLabel?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Choose member(s) — a {@link Picker} whose every option renders as a
|
|
43
|
+
* {@link MemberChip} (avatar + name). The one member-picker shape, so an app
|
|
44
|
+
* never re-wires `Picker` + `renderOptionContent` + a directory by hand. PURE:
|
|
45
|
+
* pass the candidate `members` (an app feeds `useMembers`, the product any
|
|
46
|
+
* roster); this fetches nothing. Single or multi via `multi`. Options are
|
|
47
|
+
* single-line by design — a picker row identifies a person by name + avatar;
|
|
48
|
+
* for a denser identity (email/role), render {@link MemberChip} with `secondary`
|
|
49
|
+
* on a roomier surface (a register row, a detail), not in a picker.
|
|
50
|
+
*
|
|
51
|
+
* ```tsx
|
|
52
|
+
* const { members } = useMembers();
|
|
53
|
+
* <MemberSelect members={members} value={assignee} onValueChange={setAssignee} />
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export function MemberSelect<MULTI extends boolean = false>(props: MemberSelectProps<MULTI>) {
|
|
57
|
+
const { members, ...picker } = props;
|
|
58
|
+
|
|
59
|
+
const byId = useMemo(() => new Map(members.map((m) => [m.id, m])), [members]);
|
|
60
|
+
const options = useMemo<PickerOption<string>[]>(
|
|
61
|
+
() => members.map((m) => ({ value: m.id, label: m.name || m.email || m.id })),
|
|
62
|
+
[members],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const renderOptionContent = useCallback(
|
|
66
|
+
(option: PickerOption<string>) => {
|
|
67
|
+
const member = byId.get(option.value);
|
|
68
|
+
if (!member) return null;
|
|
69
|
+
return <MemberChip name={member.name || member.email} image={member.image} />;
|
|
70
|
+
},
|
|
71
|
+
[byId],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Picker<string, MULTI>
|
|
76
|
+
options={options}
|
|
77
|
+
renderOptionContent={renderOptionContent}
|
|
78
|
+
{...picker}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, StyleSheet, StyleProp, ViewStyle } from "react-native";
|
|
3
|
+
import { Badge } from "./badge";
|
|
4
|
+
import { asColorName } from "./colors";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One select option, as carried by a query CELL (`readSelect`) or the option
|
|
8
|
+
* LIST (`useFieldOptions`) in `@lotics/app-sdk`. `key` is optional — used only
|
|
9
|
+
* as a stable React key. `color` is a palette token; it may be absent (a cell
|
|
10
|
+
* carries only key + label) or a token this UI build doesn't recognize — either
|
|
11
|
+
* way the badge degrades to a neutral color.
|
|
12
|
+
*/
|
|
13
|
+
export interface OptionValue {
|
|
14
|
+
key?: string;
|
|
15
|
+
label: string;
|
|
16
|
+
color?: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface OptionBadgeProps {
|
|
20
|
+
/**
|
|
21
|
+
* A single option, an array (a multi-select cell → one badge each), or
|
|
22
|
+
* null/empty (renders nothing — pair with your own placeholder).
|
|
23
|
+
*/
|
|
24
|
+
value?: OptionValue | OptionValue[] | null;
|
|
25
|
+
/** Badge weight — see {@link Badge}. Default "tonal". */
|
|
26
|
+
variant?: "tonal" | "dot";
|
|
27
|
+
style?: StyleProp<ViewStyle>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Render a select-field value as colored {@link Badge}(s) with the option's
|
|
32
|
+
* CONFIGURED color resolved automatically — so an app never hand-maintains an
|
|
33
|
+
* option-key → color map, and a freshly added or renamed option just renders.
|
|
34
|
+
* Multi-select wraps to one badge per option; an empty value renders nothing; an
|
|
35
|
+
* option with a missing/unrecognized color token falls back to a neutral badge.
|
|
36
|
+
*
|
|
37
|
+
* Feed it straight from `@lotics/app-sdk`: a `useFieldOptions` option for a
|
|
38
|
+
* picker, or `byKey(readSelect(cell)[0]?.key)` for a stored value.
|
|
39
|
+
*/
|
|
40
|
+
export function OptionBadge({ value, variant, style }: OptionBadgeProps) {
|
|
41
|
+
const options = value == null ? [] : Array.isArray(value) ? value : [value];
|
|
42
|
+
if (options.length === 0) return null;
|
|
43
|
+
if (options.length === 1) {
|
|
44
|
+
const o = options[0];
|
|
45
|
+
return <Badge label={o.label} color={asColorName(o.color)} variant={variant} style={style} />;
|
|
46
|
+
}
|
|
47
|
+
return (
|
|
48
|
+
<View style={[styles.wrap, style]}>
|
|
49
|
+
{options.map((o, i) => (
|
|
50
|
+
<Badge key={o.key ?? String(i)} label={o.label} color={asColorName(o.color)} variant={variant} />
|
|
51
|
+
))}
|
|
52
|
+
</View>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const styles = StyleSheet.create({
|
|
57
|
+
wrap: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 4 },
|
|
58
|
+
});
|