@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 +7 -6
- package/package.json +2 -1
- package/src/form_field.tsx +6 -3
- package/src/locale.tsx +9 -5
- package/src/modal.tsx +163 -0
- package/src/option_list.tsx +1 -1
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`,
|
|
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
|
|
119
|
-
|
|
120
|
-
`
|
|
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.
|
|
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",
|
package/src/form_field.tsx
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
{
|
|
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,
|
|
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
|
+
});
|
package/src/option_list.tsx
CHANGED
|
@@ -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 ??
|
|
61
|
+
placeholder={search.placeholder ?? loc.searchPlaceholder}
|
|
62
62
|
autoFocus={!small}
|
|
63
63
|
autoCapitalize="none"
|
|
64
64
|
autoCorrect={false}
|