@lotics/ui 7.4.0 → 7.6.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
@@ -20,8 +20,8 @@ typeahead, async search, virtualization, and a11y the primitive already ships.
20
20
  per-instance `labels`/`clearLabel`/`selectAllLabel` still overrides for one-offs, and an un-wrapped
21
21
  app stays English. Wired so far: `Pagination`, `SortHeader`/`Table.sortLabels`, `OptionList`
22
22
  (+ `Select`/`Combobox` built on it), `FilterChip`, `Drawer`, `Confidence`, `RemainderMeter`,
23
- `ChangeReview`/`ChangeReviewActions`, and `DateRangeFilterField`/`DateFilter` (presets + footer +
24
- time-segment a11y, one `dateRange` slice). Adding a string to a wired component's `*Labels` forces
23
+ `ChangeReview`/`ChangeReviewActions`, `DateRangeFilterField`/`DateFilter` (presets + footer +
24
+ time-segment a11y, one `dateRange` slice), and `FormField` (the `optional` marker). Adding a string to a wired component's `*Labels` forces
25
25
  both packs in `locale.tsx` to fill it (compile error) — that's how the kit avoids a silent English
26
26
  leak. Not yet on the provider (pass `labels` for now): calendar/gantt, the file/preview/comment labels.
27
27
 
@@ -115,9 +115,10 @@ Pick by capability, not by name. (→ the source file for the API.)
115
115
  (bulk-select bar).
116
116
  - **Filters & view controls** — `SearchInput`, `ChipGroup`, `FilterChip` (+ `RangeSlider`,
117
117
  `Counter`), `Chip`.
118
- - **Overlays** — `Dialog`, `Drawer` (+ `DrawerFooter`), `Popover`, `Tooltip`, `OptionList`
119
- (the searchable list body host it in a `Popover`/`Dialog` for a command palette),
120
- `Alert` (the blocking confirm).
118
+ - **Overlays** — `Dialog` (centered card over a scrim), `Modal` (+ `ModalHeader`/`ModalBody`/
119
+ `ModalFooter` a full-bleed, edge-to-edge takeover with NO scrim), `Drawer` (+ `DrawerFooter`),
120
+ `Popover`, `Tooltip`, `OptionList` (the searchable list body — host it in a `Popover`/`Dialog` for
121
+ a command palette), `Alert` (the blocking confirm).
121
122
  - **Status / feedback** — `Badge` / `StatusBadge`, `Callout` (inline status), `EmptyState`,
122
123
  `CompletionState`, `ActivityIndicator` / `Loading`, `Skeleton`.
123
124
  - **Files** — `FilesEditor` (THE all-in-one attachment field: an upload-aware grid + a toolbar below
@@ -804,7 +805,7 @@ check_circle (CheckCircle — the completion ring: an empty ring that springs to
804
805
  floating_action_bar · filter_chip · column_filter (ColumnFilter — the typed per-column filter pill +
805
806
  columnFilterToConditions; for a register filtering on several columns) · chip_group · search_input ·
806
807
  sort_header · table · data_grid (DataGrid — the inline-managed grouped table: a grouped, sortable grid of LIVE inline-editor cells (`columns[].cell` → ANY field) + optional per-row `leading` (a CheckCircle) + `renderGroupFooter` (per-group add, align with the exported `gridRowStyle`) + `labels` (localize the sort-header a11y via `SortHeaderLabels`). Owns header/sections/rows; consumer owns data + sort/group/filter/collapse state + toolbar. Renders ALL rows — MODERATE data; 10k+ → the paginated `Table` register. Examples: `tpl_task_board`, `tpl_pipeline`) · pagination · accordion · stepper (Stepper + Step — done/current/upcoming/warning/complete progress on a track (horizontal) or spine (vertical); compound `<Step status>children` OR data `steps[]`+`current`; **navigable** via `Step.onPress` (both orientations — the whole step is the tap target) + `active` to wash the selected one, so it doubles as a section/phase switcher; the guided-run / agent-feed primitive — subsumes the old StepList) ·
807
- step_progress · timeline (heterogeneous event LOG — per-row icon + expandable details, models the past; NOT progress) · drawer (+ DrawerFooter) · dialog · popover · tooltip ·
808
+ step_progress · timeline (heterogeneous event LOG — per-row icon + expandable details, models the past; NOT progress) · drawer (+ DrawerFooter) · dialog · modal (Modal + ModalHeader + ModalBody + ModalFooter — the full-bleed, edge-to-edge takeover: an OPAQUE surface that COVERS THE WHOLE SCREEN, so unlike Dialog (centered card WITH scrim) and Drawer (docked panel WITH scrim) there is nothing behind it to dim — NO scrim, NO backdrop. Lays children as a flex column: a pinned ModalHeader (eyebrow/title + an actions slot + close), a flex:1 scrolling ModalBody, a pinned ModalFooter (the commit bar, same chrome as DialogFooter/DrawerFooter). Reach for it for a focused capture / multi-step wizard / a console the user steps INTO, where surrounding chrome is a distraction; pick Dialog when the surface is a card the user can see context around) · popover · tooltip ·
808
809
  alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
809
810
  CalloutText · CalloutActions) · kpi_card · kpi_strip · metric · trend_chip · sparkline ·
810
811
  bar_chart · line_chart · pie_chart · ring_gauge · progress_bar · stacked_progress_bar · breakdown ·
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "7.4.0",
3
+ "version": "7.6.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -193,6 +193,7 @@
193
193
  "./date_calendar": "./src/date_calendar.tsx",
194
194
  "./dialog": "./src/dialog.tsx",
195
195
  "./drawer": "./src/drawer.tsx",
196
+ "./modal": "./src/modal.tsx",
196
197
  "./screen_router": "./src/screen_router.tsx",
197
198
  "./route_matching": "./src/route_matching.ts",
198
199
  "./menu_title": "./src/menu_title.tsx",
@@ -2,6 +2,7 @@ import { createContext, useContext, useId } from "react";
2
2
  import { StyleProp, View, ViewStyle } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { Spacer } from "./spacer";
5
+ import { useLoticsLocale } from "./locale";
5
6
 
6
7
  export interface FormFieldProps {
7
8
  label: string;
@@ -9,7 +10,8 @@ export interface FormFieldProps {
9
10
  description?: string;
10
11
  error?: string;
11
12
  style?: StyleProp<ViewStyle>;
12
- /** Label shown next to `label` when `optional` is true. Default: "Optional". Pass a translated string from the consumer. */
13
+ /** Label shown next to `label` when `optional` is true. Defaults to the locale
14
+ * pack's `formField.optional` ("Optional"); pass to override for one instance. */
13
15
  optionalLabel?: string;
14
16
  }
15
17
 
@@ -40,7 +42,8 @@ export function useFormField(): FormFieldBinding | null {
40
42
  }
41
43
 
42
44
  export function FormField(props: FormFieldProps & { children: React.ReactNode }) {
43
- const { label, description, error, optional, style, children, optionalLabel = "Optional" } = props;
45
+ const { label, description, error, optional, style, children, optionalLabel } = props;
46
+ const resolvedOptionalLabel = optionalLabel ?? useLoticsLocale().formField.optional;
44
47
 
45
48
  const inputId = useId();
46
49
  const labelId = `${inputId}-label`;
@@ -72,7 +75,7 @@ export function FormField(props: FormFieldProps & { children: React.ReactNode })
72
75
  )}
73
76
  {!!optional && (
74
77
  <Text numberOfLines={1} size="sm" color="zinc-500">
75
- {optionalLabel}
78
+ {resolvedOptionalLabel}
76
79
  </Text>
77
80
  )}
78
81
  </View>
package/src/locale.tsx CHANGED
@@ -24,12 +24,14 @@ export interface LoticsLocale {
24
24
  /** `SortHeader` a11y prefix + asc/desc suffixes. */
25
25
  sortHeader: Required<SortHeaderLabels>;
26
26
  /** `OptionList` (and everything built on it — `Select`, `Combobox`, the
27
- * in-cell editors): the select-all/deselect-all links, the empty state, and
28
- * the `Combobox` recents header. */
29
- optionList: { selectAll: string; deselectAll: string; noResults: string; recent: string };
27
+ * in-cell editors): the select-all/deselect-all links, the empty state, the
28
+ * internal search-field placeholder, and the `Combobox` recents header. */
29
+ optionList: { selectAll: string; deselectAll: string; noResults: string; recent: string; searchPlaceholder: string };
30
30
  /** `FilterChip` (and `ColumnFilter`): the generic clear affordance, used when
31
31
  * a call site doesn't pass a dimension-specific `clearLabel`. */
32
32
  filterChip: { clear: string };
33
+ /** `FormField`: the "Optional" marker shown next to the label when `optional`. */
34
+ formField: { optional: string };
33
35
  /** `Drawer`: the record prev/next + close controls (screen-reader names). */
34
36
  drawer: { previous: string; next: string; close: string };
35
37
  /** `Confidence`: the full level phrase ("High confidence" …). */
@@ -74,8 +76,9 @@ export const en: LoticsLocale = {
74
76
  ascending: ", ascending",
75
77
  descending: ", descending",
76
78
  },
77
- optionList: { selectAll: "Select all", deselectAll: "Deselect all", noResults: "No results", recent: "Recent" },
79
+ optionList: { selectAll: "Select all", deselectAll: "Deselect all", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
78
80
  filterChip: { clear: "Clear" },
81
+ formField: { optional: "Optional" },
79
82
  drawer: { previous: "Previous record", next: "Next record", close: "Close" },
80
83
  confidence: { high: "High confidence", medium: "Medium confidence", low: "Low confidence" },
81
84
  remainderMeter: {
@@ -123,8 +126,9 @@ export const vi: LoticsLocale = {
123
126
  ascending: " (tăng dần)",
124
127
  descending: " (giảm dần)",
125
128
  },
126
- optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", noResults: "Không có kết quả", recent: "Gần đây" },
129
+ optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…" },
127
130
  filterChip: { clear: "Xóa" },
131
+ formField: { optional: "Tùy chọn" },
128
132
  drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
129
133
  confidence: { high: "Độ tin cậy cao", medium: "Độ tin cậy trung bình", low: "Độ tin cậy thấp" },
130
134
  remainderMeter: {
package/src/modal.tsx ADDED
@@ -0,0 +1,163 @@
1
+ import { ReactNode } from "react";
2
+ import { Modal as RNModal, StyleSheet, View, ScrollView, ViewStyle } from "react-native";
3
+ import { colors } from "@lotics/ui/colors";
4
+ import { IconButton } from "@lotics/ui/icon_button";
5
+ import { Text } from "@lotics/ui/text";
6
+ import { PortalHost } from "@lotics/ui/portal";
7
+ import { useOverlayScope } from "@lotics/ui/overlay_scope";
8
+
9
+ export interface ModalProps {
10
+ open: boolean;
11
+ onClose: () => void;
12
+ /**
13
+ * The modal body. Lay it out as a flex column — a fixed `ModalHeader` and
14
+ * `ModalFooter` pin while a `ModalBody` scrolls between them.
15
+ */
16
+ children: ReactNode;
17
+ testID?: string;
18
+ }
19
+
20
+ /**
21
+ * A full-bleed, edge-to-edge takeover: an OPAQUE surface that covers the entire
22
+ * screen. Unlike `Dialog` (a centered card over a scrim) or `Drawer` (a docked
23
+ * panel over a scrim), a `Modal` has nothing behind it to dim — it owns the whole
24
+ * viewport. Reach for it for a focused capture, a multi-step wizard, or a console
25
+ * the user steps INTO and out of, where the surrounding chrome is a distraction.
26
+ *
27
+ * Lays children as a flex column so a fixed `ModalHeader` + `ModalFooter` pin
28
+ * while a `ModalBody` scrolls between them. Controlled via `open`/`onClose`; the
29
+ * header close control dismisses it (Escape on web, the back gesture on Android).
30
+ */
31
+ export function Modal(props: ModalProps) {
32
+ const { open, onClose, children, testID } = props;
33
+ useOverlayScope(open);
34
+
35
+ return (
36
+ <RNModal visible={open} onRequestClose={onClose} animationType="fade">
37
+ <View testID={testID} style={styles.surface}>
38
+ <PortalHost>{children}</PortalHost>
39
+ </View>
40
+ </RNModal>
41
+ );
42
+ }
43
+
44
+ export interface ModalHeaderProps {
45
+ /** Header title. A string renders as the standard title; a node renders as-is. */
46
+ title?: ReactNode;
47
+ /** A short caption above the title (a context label / category). */
48
+ eyebrow?: ReactNode;
49
+ /** Right-aligned controls placed before the close button (e.g. a save action). */
50
+ actions?: ReactNode;
51
+ onClose: () => void;
52
+ style?: ViewStyle;
53
+ }
54
+
55
+ /**
56
+ * The fixed top bar of a `Modal`: an optional eyebrow + title on the left, an
57
+ * optional `actions` slot, and a close control wired to `onClose` on the right.
58
+ * Render it as the FIRST child of the `Modal`; it never scrolls.
59
+ */
60
+ export function ModalHeader(props: ModalHeaderProps) {
61
+ const { title, eyebrow, actions, onClose, style } = props;
62
+
63
+ return (
64
+ <View style={[styles.header, style]}>
65
+ <View style={styles.headerText}>
66
+ {eyebrow !== undefined ? (
67
+ typeof eyebrow === "string" ? (
68
+ <Text size="xs" weight="medium" color="muted">
69
+ {eyebrow}
70
+ </Text>
71
+ ) : (
72
+ eyebrow
73
+ )
74
+ ) : null}
75
+ {title !== undefined ? (
76
+ typeof title === "string" ? (
77
+ <Text size="lg" weight="semibold">
78
+ {title}
79
+ </Text>
80
+ ) : (
81
+ title
82
+ )
83
+ ) : null}
84
+ </View>
85
+ {actions !== undefined ? <View style={styles.headerActions}>{actions}</View> : null}
86
+ <IconButton icon="x" size="lg" accessibilityLabel="Close" onPress={onClose} />
87
+ </View>
88
+ );
89
+ }
90
+
91
+ export interface ModalBodyProps {
92
+ children: ReactNode;
93
+ }
94
+
95
+ /**
96
+ * The flex:1 scrolling content region between a `Modal`'s pinned header and
97
+ * footer. Fills the remaining height; its content scrolls.
98
+ */
99
+ export function ModalBody(props: ModalBodyProps) {
100
+ const { children } = props;
101
+ return <ScrollView contentContainerStyle={styles.bodyContent}>{children}</ScrollView>;
102
+ }
103
+
104
+ export interface ModalFooterProps {
105
+ /** The action(s) — typically right-aligned `Button`s. Prepend a
106
+ * `<Text style={{ flex: 1 }}>` hint to push them right with a summary on the left. */
107
+ children: ReactNode;
108
+ align?: "start" | "end" | "space-between";
109
+ }
110
+
111
+ /**
112
+ * The pinned bottom action bar of a `Modal` — a hairline-topped band (the same
113
+ * chrome as `DialogFooter` / `DrawerFooter`) that commits the modal's work.
114
+ * Render it as the LAST child of the `Modal`, after the `ModalBody`; the body's
115
+ * `flex:1` pins it to the surface bottom.
116
+ */
117
+ export function ModalFooter(props: ModalFooterProps) {
118
+ const { children, align = "end" } = props;
119
+ const justifyContent =
120
+ align === "end" ? "flex-end" : align === "space-between" ? "space-between" : "flex-start";
121
+
122
+ return <View style={[styles.footer, { justifyContent }]}>{children}</View>;
123
+ }
124
+
125
+ const styles = StyleSheet.create({
126
+ surface: {
127
+ flex: 1,
128
+ backgroundColor: colors.white,
129
+ flexDirection: "column",
130
+ },
131
+ header: {
132
+ flexDirection: "row",
133
+ alignItems: "center",
134
+ gap: 12,
135
+ paddingLeft: 24,
136
+ paddingRight: 16,
137
+ paddingVertical: 16,
138
+ minHeight: 64,
139
+ borderBottomWidth: 1,
140
+ borderBottomColor: colors.border,
141
+ },
142
+ headerText: {
143
+ flex: 1,
144
+ gap: 2,
145
+ },
146
+ headerActions: {
147
+ flexDirection: "row",
148
+ alignItems: "center",
149
+ gap: 8,
150
+ },
151
+ bodyContent: {
152
+ padding: 24,
153
+ },
154
+ footer: {
155
+ flexDirection: "row",
156
+ alignItems: "center",
157
+ gap: 12,
158
+ paddingHorizontal: 24,
159
+ paddingVertical: 16,
160
+ borderTopWidth: 1,
161
+ borderTopColor: colors.border,
162
+ },
163
+ });
@@ -58,7 +58,7 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
58
58
  icon="search"
59
59
  value={list.query}
60
60
  onChangeText={list.setQuery}
61
- placeholder={search.placeholder ?? "Search…"}
61
+ placeholder={search.placeholder ?? loc.searchPlaceholder}
62
62
  autoFocus={!small}
63
63
  autoCapitalize="none"
64
64
  autoCorrect={false}