@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 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.1.0",
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",
@@ -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
- // Exclusive filter chips the view-control sibling of the form-input
7
- // selectors. Picking between the one-of-N controls:
8
- // - ChipGroup: a SMALL, HOT filter set (≤ ~10 short options) the user flips
9
- // between constantly — every option stays visible, switching is one tap,
10
- // the row wraps on narrow widths. Reads as "narrow this view".
11
- // - Picker: many options or tight space compact, but hides the set behind
12
- // a click. Reads as "choose a value".
13
- // - RadioPicker: a form input that SETS data on a record (radio circles
14
- // signal "this writes"), not a view filter.
15
- // Chips carry quiet zinc styling: bordered white at rest, dark fill when
16
- // active color stays reserved for status semantics and primary actions.
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 exactly one; include an explicit "all" option for
33
- * the unfiltered state rather than modelling it as no selection. */
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
+ }
@@ -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
- /** Zone height — size it to the surface (compact 120 in a form field,
24
- * 200+ as a screen's main affordance). Default 160. */
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
+ });