@lotics/ui 2.6.0 → 3.0.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.
Files changed (47) hide show
  1. package/package.json +2 -15
  2. package/src/format_date.test.ts +64 -0
  3. package/src/format_date.ts +71 -0
  4. package/src/react_native.d.ts +2 -2
  5. package/src/cell_date.tsx +0 -30
  6. package/src/cell_date_format.test.ts +0 -32
  7. package/src/cell_date_format.ts +0 -73
  8. package/src/cell_number.test.ts +0 -42
  9. package/src/cell_number.tsx +0 -25
  10. package/src/cell_number_format.ts +0 -42
  11. package/src/cell_select.tsx +0 -68
  12. package/src/cell_text.tsx +0 -45
  13. package/src/grid/data_grid.tsx +0 -2003
  14. package/src/grid/data_grid_columns.test.ts +0 -72
  15. package/src/grid/data_grid_columns.ts +0 -30
  16. package/src/grid/data_grid_context.ts +0 -119
  17. package/src/grid/dispatch_safely.ts +0 -39
  18. package/src/grid/engine.module.css +0 -114
  19. package/src/grid/engine.tsx +0 -1042
  20. package/src/grid/helpers.ts +0 -205
  21. package/src/grid/layout.test.ts +0 -515
  22. package/src/grid/layout.ts +0 -425
  23. package/src/grid/recycling.test.ts +0 -236
  24. package/src/grid/recycling.ts +0 -172
  25. package/src/grid/row_cell.module.css +0 -105
  26. package/src/grid/row_cell.tsx +0 -313
  27. package/src/grid/search_highlight.ts +0 -71
  28. package/src/grid/select_cell.tsx +0 -58
  29. package/src/grid/select_group_summary_cell.tsx +0 -76
  30. package/src/grid/select_header_cell.tsx +0 -32
  31. package/src/grid/skeleton_row.module.css +0 -34
  32. package/src/grid/skeleton_row.tsx +0 -20
  33. package/src/grid/use_grid_groups.ts +0 -311
  34. package/src/grid/use_scroll_to_cell.ts +0 -135
  35. package/src/grid/use_virtual_grid.ts +0 -383
  36. package/src/grid/visibility.test.ts +0 -208
  37. package/src/grid/visibility.ts +0 -77
  38. package/src/kanban/constants.ts +0 -18
  39. package/src/kanban/default_renderers.tsx +0 -160
  40. package/src/kanban/drag_preview.tsx +0 -157
  41. package/src/kanban/index.ts +0 -13
  42. package/src/kanban/insert_card_zone.tsx +0 -135
  43. package/src/kanban/kanban_board.tsx +0 -635
  44. package/src/kanban/kanban_card.tsx +0 -321
  45. package/src/kanban/kanban_column.tsx +0 -499
  46. package/src/kanban/placeholders.tsx +0 -54
  47. package/src/kanban/types.ts +0 -116
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "2.6.0",
3
+ "version": "3.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -31,8 +31,8 @@
31
31
  "./kpi_card": "./src/kpi_card.tsx",
32
32
  "./kpi_strip": "./src/kpi_strip.tsx",
33
33
  "./empty_state": "./src/empty_state.tsx",
34
+ "./format_date": "./src/format_date.ts",
34
35
  "./format_money": "./src/format_money.ts",
35
- "./kanban": "./src/kanban/index.ts",
36
36
  "./calendar": "./src/calendar/index.ts",
37
37
  "./gantt": "./src/gantt/index.ts",
38
38
  "./ring_gauge": "./src/ring_gauge.tsx",
@@ -164,19 +164,6 @@
164
164
  "./landmark": "./src/landmark.tsx",
165
165
  "./skip_link": "./src/skip_link.tsx",
166
166
  "./text_utils": "./src/text_utils.ts",
167
- "./cell_text": "./src/cell_text.tsx",
168
- "./cell_number": "./src/cell_number.tsx",
169
- "./cell_number_format": "./src/cell_number_format.ts",
170
- "./cell_date": "./src/cell_date.tsx",
171
- "./cell_date_format": "./src/cell_date_format.ts",
172
- "./cell_select": "./src/cell_select.tsx",
173
- "./grid/engine": "./src/grid/engine.tsx",
174
- "./grid/layout": "./src/grid/layout.ts",
175
- "./grid/use_virtual_grid": "./src/grid/use_virtual_grid.ts",
176
- "./grid/skeleton_row": "./src/grid/skeleton_row.tsx",
177
- "./grid/data_grid": "./src/grid/data_grid.tsx",
178
- "./grid/data_grid_context": "./src/grid/data_grid_context.ts",
179
- "./grid/search_highlight": "./src/grid/search_highlight.ts",
180
167
  "./column_filter": "./src/column_filter.tsx",
181
168
  "./chip_group": "./src/chip_group.tsx"
182
169
  },
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { formatDate, parseDate, toISODate } from "./format_date";
3
+
4
+ describe("formatDate", () => {
5
+ test("formats an ISO date in the home market (dd/MM/yyyy)", () => {
6
+ expect(formatDate("2026-05-22", { locale: "vi-VN" })).toBe("22/05/2026");
7
+ });
8
+
9
+ test("formats a Date object, not just an ISO string", () => {
10
+ expect(formatDate(new Date(2026, 4, 22), { locale: "vi-VN" })).toBe("22/05/2026");
11
+ });
12
+
13
+ test("compact drops the year (dd/MM)", () => {
14
+ expect(formatDate("2026-05-22", { locale: "vi-VN", compact: true })).toBe("22/05");
15
+ });
16
+
17
+ test("datetime includes 24h time", () => {
18
+ expect(formatDate("2026-05-22T14:30", { locale: "vi-VN", format: "datetime" })).toBe("22/05/2026 14:30");
19
+ });
20
+
21
+ test("compact datetime drops the year, keeps the time", () => {
22
+ expect(formatDate("2026-05-22T09:05", { locale: "vi-VN", format: "datetime", compact: true })).toBe("22/05 09:05");
23
+ });
24
+
25
+ test("date-only ISO does not drift across local timezone (wall-clock parse)", () => {
26
+ // `new Date('2026-01-01')` is UTC midnight; in negative-offset zones it formats as Dec 31.
27
+ expect(formatDate("2026-01-01", { locale: "vi-VN" })).toBe("01/01/2026");
28
+ });
29
+
30
+ test("empty input returns the empty label", () => {
31
+ expect(formatDate(null)).toBe("");
32
+ expect(formatDate("")).toBe("");
33
+ expect(formatDate(undefined, { emptyLabel: "—" })).toBe("—");
34
+ expect(formatDate("not-a-date", { emptyLabel: "—" })).toBe("—");
35
+ });
36
+ });
37
+
38
+ describe("parseDate", () => {
39
+ test("parses an ISO datetime to local wall-clock", () => {
40
+ const d = parseDate("2026-05-22T14:30");
41
+ expect(d).not.toBeNull();
42
+ expect(d?.getFullYear()).toBe(2026);
43
+ expect(d?.getMonth()).toBe(4);
44
+ expect(d?.getDate()).toBe(22);
45
+ expect(d?.getHours()).toBe(14);
46
+ expect(d?.getMinutes()).toBe(30);
47
+ });
48
+
49
+ test("passes a valid Date through and rejects an invalid one", () => {
50
+ const d = new Date(2026, 0, 1);
51
+ expect(parseDate(d)).toBe(d);
52
+ expect(parseDate(new Date("nonsense"))).toBeNull();
53
+ expect(parseDate(null)).toBeNull();
54
+ expect(parseDate("")).toBeNull();
55
+ });
56
+ });
57
+
58
+ describe("toISODate", () => {
59
+ test("Date or ISO value → yyyy-MM-dd", () => {
60
+ expect(toISODate(new Date(2026, 4, 22))).toBe("2026-05-22");
61
+ expect(toISODate("2026-05-22T14:30")).toBe("2026-05-22");
62
+ expect(toISODate(null)).toBe("");
63
+ });
64
+ });
@@ -0,0 +1,71 @@
1
+ export type DateFormatStyle = "date" | "datetime";
2
+
3
+ export interface FormatDateOptions {
4
+ /** "date" → 22/05/2026 · "datetime" → 22/05/2026 14:30. Default "date". */
5
+ format?: DateFormatStyle;
6
+ /** BCP-47 locale. Defaults to the product's home market, "vi-VN". */
7
+ locale?: string;
8
+ /** Drop the year — "22/05" instead of "22/05/2026" — for dense rows / timelines. */
9
+ compact?: boolean;
10
+ /** Rendered for null / empty / unparseable input. Default "". */
11
+ emptyLabel?: string;
12
+ }
13
+
14
+ const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
15
+
16
+ const pad2 = (n: number) => String(n).padStart(2, "0");
17
+
18
+ /**
19
+ * Parse a Date or a canonical timezone-naive ISO value as local wall-clock.
20
+ * `new Date("2024-03-15")` parses date-only strings as **UTC** midnight, which then
21
+ * shifts under local formatting — a phantom time and an off-by-one date in negative-offset
22
+ * zones. Building the Date from its parts keeps it naive, matching how a date picker reads the
23
+ * same value. A `Date` passes through; non-ISO strings fall back to the native parser. Returns
24
+ * `null` for null / empty / unparseable input.
25
+ */
26
+ export function parseDate(value: Date | string | null | undefined): Date | null {
27
+ if (value == null) return null;
28
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
29
+ const s = value.trim();
30
+ if (!s) return null;
31
+ const m = ISO_DATE_TIME.exec(s);
32
+ const d = m
33
+ ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0))
34
+ : new Date(s);
35
+ return Number.isNaN(d.getTime()) ? null : d;
36
+ }
37
+
38
+ /** A Date or ISO value → "yyyy-MM-dd" (the `DatePicker` / workflow date value). "" when empty. */
39
+ export function toISODate(value: Date | string | null | undefined): string {
40
+ const d = parseDate(value);
41
+ if (!d) return "";
42
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
43
+ }
44
+
45
+ /**
46
+ * THE date formatter — the date sibling of `formatMoney`. Accepts a `Date` OR an ISO string and
47
+ * returns a localized display string; defaults to the home market: `22/05/2026` (date) /
48
+ * `22/05/2026 14:30` (datetime). `compact` drops the year for dense rows (`22/05`). Never
49
+ * hand-roll dd/MM with `padStart` / `getMonth` — call this.
50
+ */
51
+ export function formatDate(value: Date | string | null | undefined, options: FormatDateOptions = {}): string {
52
+ const { format = "date", locale = "vi-VN", compact = false, emptyLabel = "" } = options;
53
+ const date = parseDate(value);
54
+ if (!date) return emptyLabel;
55
+ // Use Intl only for the locale-aware part ORDER (dd/MM for vi-VN, MM/dd for en-US), then
56
+ // reassemble with a consistent "/" — Intl's own separator is inconsistent across CLDR (vi-VN
57
+ // uses "/" with a year but "-" without). The time is appended as a stable 24h " HH:mm" (no
58
+ // locale comma, no AM/PM), matching the compact data convention.
59
+ let parts: Intl.DateTimeFormatPart[];
60
+ try {
61
+ parts = new Intl.DateTimeFormat(locale, { day: "2-digit", month: "2-digit", year: "numeric" }).formatToParts(date);
62
+ } catch {
63
+ return emptyLabel;
64
+ }
65
+ let out = parts
66
+ .filter((p) => p.type === "day" || p.type === "month" || (!compact && p.type === "year"))
67
+ .map((p) => p.value)
68
+ .join("/");
69
+ if (format === "datetime") out += ` ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
70
+ return out;
71
+ }
@@ -2,9 +2,9 @@ import "react-native";
2
2
 
3
3
  // Augments react-native's types with the web-only fields @lotics/ui consumes:
4
4
  // Pressable's `hovered` callback state, plus web-only ViewStyle / TextStyle
5
- // properties (cursor, outline, boxShadow, etc.) used by the grid + primitives.
5
+ // properties (cursor, outline, boxShadow, etc.) used by its primitives.
6
6
  //
7
- // Each consumer (frontend, iframe_runtime, container-invoices, future iframe
7
+ // Each consumer (frontend, container-invoices, future iframe
8
8
  // apps) ships its own copy of this file in its `src/`. TypeScript doesn't
9
9
  // auto-pick-up `.d.ts` files inside node_modules dependencies; the augmentation
10
10
  // has to be visible in the consumer's `include`. The CLI starter template
package/src/cell_date.tsx DELETED
@@ -1,30 +0,0 @@
1
- import { memo, useMemo } from "react";
2
- import { Text } from "./text";
3
- import { DateFormatOptions, formatDateValue } from "./cell_date_format";
4
-
5
- export interface CellDateProps extends DateFormatOptions {
6
- value: string | null | undefined;
7
- /** Label to render when the value parses as Invalid Date. Defaults to "Invalid date". */
8
- invalidLabel?: string;
9
- userSelect?: "none" | "auto";
10
- }
11
-
12
- /**
13
- * Pure-presentational date cell. Renders an empty Text for null/empty values,
14
- * a danger-coloured Text for unparseable values, and a locale-formatted Text
15
- * otherwise. Locale and format are explicit so iframe apps don't need to wire
16
- * a translation layer.
17
- */
18
- export const CellDate = memo(function CellDate(props: CellDateProps) {
19
- const { value, format, locale, dateStyle, timeStyle, invalidLabel = "Invalid date", userSelect } = props;
20
-
21
- const content = useMemo(() => {
22
- const formatted = formatDateValue(value, { format, locale, dateStyle, timeStyle });
23
- if (formatted === null) {
24
- return <Text color="danger">{invalidLabel}</Text>;
25
- }
26
- return formatted;
27
- }, [value, format, locale, dateStyle, timeStyle, invalidLabel]);
28
-
29
- return <Text userSelect={userSelect}>{content}</Text>;
30
- });
@@ -1,32 +0,0 @@
1
- // Force a positive-offset zone (UTC+7) before importing the formatter: the old
2
- // `new Date("2024-03-15")` UTC-parse would surface a phantom "07:00" here, and a
3
- // negative-offset zone would shift the date a day. The wall-clock parse must keep
4
- // the value naive so output is timezone-independent.
5
- process.env.TZ = "Asia/Ho_Chi_Minh";
6
-
7
- import { describe, it, expect } from "vitest";
8
- import { formatDateValue } from "./cell_date_format";
9
-
10
- describe("formatDateValue — wall-clock (no timezone shift)", () => {
11
- it("renders a date-only value as midnight, not the zone offset", () => {
12
- const out = formatDateValue("2024-03-15", { format: "datetime", locale: "en-US" });
13
- expect(out).toMatch(/3\/15\/2024/);
14
- expect(out).toMatch(/12:00\s?AM/i); // midnight — not 7:00 AM
15
- });
16
-
17
- it("keeps the same calendar day for a date field in any zone", () => {
18
- expect(formatDateValue("2024-03-15", { format: "date", locale: "en-US" })).toBe("3/15/2024");
19
- });
20
-
21
- it("renders a datetime value at its literal wall-clock time", () => {
22
- expect(formatDateValue("2024-03-15T09:30", { format: "datetime", locale: "en-US" })).toMatch(
23
- /9:30\s?AM/i,
24
- );
25
- });
26
-
27
- it("returns '' for empty and null (sentinel) for unparseable input", () => {
28
- expect(formatDateValue("")).toBe("");
29
- expect(formatDateValue(null)).toBe("");
30
- expect(formatDateValue("not-a-date")).toBeNull();
31
- });
32
- });
@@ -1,73 +0,0 @@
1
- export type DateCellFormat = "date" | "datetime";
2
-
3
- export interface DateFormatOptions {
4
- format?: DateCellFormat;
5
- /** BCP-47 locale tag for Intl.DateTimeFormat. Defaults to "en-US". */
6
- locale?: string;
7
- dateStyle?: "full" | "long" | "medium" | "short";
8
- timeStyle?: "full" | "long" | "medium" | "short";
9
- }
10
-
11
- const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
12
-
13
- /**
14
- * Parse a canonical, timezone-naive ISO value as wall-clock time in the local zone.
15
- * `new Date("2024-03-15")` parses date-only strings as **UTC** midnight, which then
16
- * shifts under Intl's local formatting — a phantom time on date-only values and an
17
- * off-by-one date in negative-offset zones. Building the Date from its parts keeps
18
- * it naive, matching how the picker reads the same value. Non-ISO input falls back
19
- * to the native parser.
20
- */
21
- function parseWallClock(value: string): Date | null {
22
- const m = ISO_DATE_TIME.exec(value.trim());
23
- const date = m
24
- ? new Date(
25
- Number(m[1]),
26
- Number(m[2]) - 1,
27
- Number(m[3]),
28
- Number(m[4] ?? 0),
29
- Number(m[5] ?? 0),
30
- Number(m[6] ?? 0),
31
- )
32
- : new Date(value);
33
- return isNaN(date.getTime()) ? null : date;
34
- }
35
-
36
- /**
37
- * Format an ISO date string to a localized display string.
38
- * Returns "" for null/empty input. Returns null (sentinel) for unparseable input
39
- * so callers can render an error state.
40
- */
41
- export function formatDateValue(
42
- value: string | null | undefined,
43
- options: DateFormatOptions = {},
44
- ): string | null {
45
- if (!value) return "";
46
-
47
- const date = parseWallClock(value);
48
- if (!date) return null;
49
-
50
- const locale = options.locale ?? "en-US";
51
- const { format = "date", dateStyle = "short", timeStyle = "short" } = options;
52
- const includeTime = format === "datetime";
53
-
54
- // Intl's `dateStyle: "short"` yields a 2-digit year in many locales (en-US: "5/22/26",
55
- // vi-VN: "22/05/26"); for data cells we want a compact numeric format with the full year.
56
- // When we override to explicit date parts, the time parts must also be explicit — Intl
57
- // forbids mixing `dateStyle`/`timeStyle` with `year`/`month`/`day`/`hour`/`minute`/etc.
58
- const intlOptions: Intl.DateTimeFormatOptions =
59
- dateStyle === "short"
60
- ? {
61
- year: "numeric",
62
- month: "numeric",
63
- day: "numeric",
64
- ...(includeTime ? { hour: "numeric", minute: "numeric" } : {}),
65
- }
66
- : { dateStyle, ...(includeTime ? { timeStyle } : {}) };
67
-
68
- try {
69
- return new Intl.DateTimeFormat(locale, intlOptions).format(date);
70
- } catch {
71
- return value;
72
- }
73
- }
@@ -1,42 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { formatNumberValue } from "./cell_number_format";
3
-
4
- describe("formatNumberValue", () => {
5
- it("returns empty string for null", () => {
6
- expect(formatNumberValue(null, { format: "number" })).toBe("");
7
- });
8
-
9
- it("returns empty string for undefined", () => {
10
- expect(formatNumberValue(undefined, { format: "number" })).toBe("");
11
- });
12
-
13
- it("formats plain number as toString", () => {
14
- expect(formatNumberValue(42, { format: "number" })).toBe("42");
15
- expect(formatNumberValue(3.14, {})).toBe("3.14");
16
- });
17
-
18
- it("formats percentage with % suffix", () => {
19
- expect(formatNumberValue(75, { format: "percentage" })).toBe("75%");
20
- expect(formatNumberValue(0, { format: "percentage" })).toBe("0%");
21
- });
22
-
23
- it("formats currency with explicit currency code", () => {
24
- const result = formatNumberValue(1000, { format: "currency", currency: "USD" });
25
- expect(result).toContain("US");
26
- });
27
-
28
- it("defaults to VND when currency format has no currency code", () => {
29
- const result = formatNumberValue(1000, { format: "currency" });
30
- expect(result).toBeTruthy();
31
- expect(result.length).toBeGreaterThan(0);
32
- });
33
-
34
- it("respects an explicit locale override", () => {
35
- const result = formatNumberValue(1000, {
36
- format: "currency",
37
- currency: "USD",
38
- locale: "en-US",
39
- });
40
- expect(result).toMatch(/^\$/);
41
- });
42
- });
@@ -1,25 +0,0 @@
1
- import { memo } from "react";
2
- import { Text } from "./text";
3
- import { formatNumberValue, NumberFormatOptions } from "./cell_number_format";
4
-
5
- export interface CellNumberProps extends NumberFormatOptions {
6
- value: number | null | undefined;
7
- userSelect?: "none" | "auto";
8
- align?: "left" | "right" | "center";
9
- }
10
-
11
- /**
12
- * Pure-presentational number cell. Format and locale are explicit, so iframe
13
- * apps consuming `@lotics/ui` without `@lotics/shared` can render numbers the
14
- * same way the records grid does.
15
- */
16
- export const CellNumber = memo(function CellNumber(props: CellNumberProps) {
17
- const { value, format, currency, locale, userSelect, align } = props;
18
- const text = formatNumberValue(value, { format, currency, locale });
19
-
20
- return (
21
- <Text numberOfLines={1} userSelect={userSelect} align={align}>
22
- {text}
23
- </Text>
24
- );
25
- });
@@ -1,42 +0,0 @@
1
- export type NumberCellFormat = "number" | "currency" | "percentage";
2
-
3
- export const NUMERIC_FORMATS: ReadonlySet<string> = new Set<NumberCellFormat>([
4
- "number",
5
- "currency",
6
- "percentage",
7
- ]);
8
-
9
- export function isNumericFormat(value: string | undefined): value is NumberCellFormat {
10
- return value !== undefined && NUMERIC_FORMATS.has(value);
11
- }
12
-
13
- export interface NumberFormatOptions {
14
- format?: NumberCellFormat;
15
- currency?: string;
16
- /** BCP-47 locale tag passed to Number.prototype.toLocaleString. Defaults to "vi-VN". */
17
- locale?: string;
18
- }
19
-
20
- export function formatNumberValue(
21
- value: number | null | undefined,
22
- options: NumberFormatOptions,
23
- ): string {
24
- if (value === null || value === undefined) {
25
- return "";
26
- }
27
-
28
- const locale = options.locale ?? "vi-VN";
29
-
30
- if (options.format === "currency") {
31
- return value.toLocaleString(locale, {
32
- style: "currency",
33
- currency: options.currency || "VND",
34
- });
35
- }
36
-
37
- if (options.format === "percentage") {
38
- return `${value}%`;
39
- }
40
-
41
- return String(value);
42
- }
@@ -1,68 +0,0 @@
1
- import { memo, useMemo } from "react";
2
- import { type ColorName } from "./colors";
3
- import { View } from "react-native";
4
- import { Badge } from "./badge";
5
-
6
- export interface SelectCellOption {
7
- key: string;
8
- name: string;
9
- color?: ColorName;
10
- }
11
-
12
- export interface CellSelectProps {
13
- /**
14
- * Selected key(s). Accepts a single string, an array of keys, or null/undefined.
15
- * Unknown keys are silently dropped (no matching option = nothing rendered).
16
- */
17
- value: string | string[] | null | undefined;
18
- options: ReadonlyArray<SelectCellOption>;
19
- /** Multi-select renders every selected key. Single-select renders only the first. */
20
- multi?: boolean;
21
- /** Force multi-select rendering even for single-select fields (filter previews). */
22
- showAll?: boolean;
23
- userSelect?: "none" | "auto";
24
- }
25
-
26
- /**
27
- * Pure-presentational select cell. Maps selected keys against the supplied
28
- * options[] to produce coloured Badges. Iframe apps that already have an
29
- * options table can render the same Lotics select-badge UI without depending
30
- * on `@lotics/shared` field schemas.
31
- */
32
- export const CellSelect = memo(function CellSelect(props: CellSelectProps) {
33
- const { value, options, multi = false, showAll = false, userSelect = "none" } = props;
34
-
35
- const content = useMemo(() => {
36
- const selected: string[] =
37
- value === null || value === undefined
38
- ? []
39
- : Array.isArray(value)
40
- ? value
41
- : [value];
42
-
43
- const renderBadge = (key: string) => {
44
- const option = options.find((o) => o.key === key);
45
- if (!option) return null;
46
- return <Badge key={key} color={option.color} label={option.name} userSelect={userSelect} />;
47
- };
48
-
49
- if (multi || showAll) {
50
- return selected.map(renderBadge).filter(Boolean);
51
- }
52
-
53
- const first = selected[0];
54
- return first === undefined ? null : renderBadge(first);
55
- }, [value, options, multi, showAll, userSelect]);
56
-
57
- return (
58
- <View
59
- style={{
60
- flexDirection: "row",
61
- gap: 4,
62
- alignItems: "center",
63
- }}
64
- >
65
- {content}
66
- </View>
67
- );
68
- });
package/src/cell_text.tsx DELETED
@@ -1,45 +0,0 @@
1
- import { memo } from "react";
2
- import { Platform } from "react-native";
3
- import { Text } from "./text";
4
- import { colors } from "./colors";
5
-
6
- export type TextCellFormat = "text" | "link";
7
-
8
- export interface CellTextProps {
9
- value: string | null | undefined;
10
- format?: TextCellFormat;
11
- userSelect?: "none" | "auto";
12
- }
13
-
14
- /**
15
- * Pure-presentational text cell. `format: "link"` renders as a clickable
16
- * anchor on web (preserving right-click / middle-click behavior); on native
17
- * it falls through to plain Text — link navigation is the caller's job there.
18
- */
19
- export const CellText = memo(function CellText(props: CellTextProps) {
20
- const { value, format = "text", userSelect } = props;
21
-
22
- if (format === "link" && value && Platform.OS === "web") {
23
- const href = /^https?:\/\//i.test(value) ? value : `https://${value}`;
24
- return (
25
- <a
26
- href={href}
27
- target="_blank"
28
- rel="noopener noreferrer"
29
- onClick={(e) => {
30
- e.stopPropagation();
31
- }}
32
- >
33
- <Text numberOfLines={1} style={{ color: colors.blue["600"] }} userSelect={userSelect}>
34
- {value}
35
- </Text>
36
- </a>
37
- );
38
- }
39
-
40
- return (
41
- <Text numberOfLines={1} userSelect={userSelect}>
42
- {value}
43
- </Text>
44
- );
45
- });