@lotics/ui 11.8.1 → 11.8.3
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/docs/catalog.md +7 -5
- package/package.json +10 -3
- package/src/agent_run.tsx +12 -8
- package/src/avatar.tsx +3 -1
- package/src/avatar.web.tsx +3 -1
- package/src/back_button.tsx +4 -2
- package/src/bar_chart.tsx +3 -1
- package/src/composer.tsx +4 -2
- package/src/danger_zone.tsx +5 -2
- package/src/dialog.tsx +3 -1
- package/src/file_dropzone.tsx +8 -4
- package/src/file_thumbnail.tsx +4 -2
- package/src/image_gallery.tsx +9 -6
- package/src/info_popover.tsx +5 -2
- package/src/line_chart.tsx +3 -1
- package/src/locale.tsx +62 -0
- package/src/markdown.web.tsx +2 -2
- package/src/matrix.tsx +12 -7
- package/src/modal.tsx +3 -1
- package/src/pie_chart.tsx +4 -2
- package/src/popover.tsx +4 -2
- package/src/popover_nav.tsx +4 -2
- package/src/remark_gfm_safe.test.ts +33 -0
- package/src/remark_gfm_safe.ts +84 -0
- package/src/scroll_to_bottom.tsx +5 -2
- package/src/text_input_field.tsx +5 -2
package/docs/catalog.md
CHANGED
|
@@ -267,7 +267,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
267
267
|
(the heading ramp: `xl` = `##` section title, `xxl` = `#` page/record title, `xxxl` = hero
|
|
268
268
|
numbers), `weight`, `color`, alignment, tabular numerals.
|
|
269
269
|
- **`markdown`** — `Markdown`: the single canonical markdown renderer for chat, apps, and
|
|
270
|
-
`AgentRun`; rich on web
|
|
270
|
+
`AgentRun`; rich GFM markdown on web with copyable tables, plain-text on
|
|
271
271
|
native; takes a markdown `children` string.
|
|
272
272
|
- **`markdown.css`** — import once for the web markdown styling.
|
|
273
273
|
- **`format_date`** — `formatDate` / `parseDate` / `toISODate` + `DateFormatStyle`.
|
|
@@ -370,7 +370,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
370
370
|
- **`danger_zone`** — `DangerZone`: the destructive section — a soft danger-tinted frame
|
|
371
371
|
(the kit's `tint`/`solid`, never raw hex) + a danger heading + a description + a
|
|
372
372
|
destructive action slot (children, e.g. a `danger` Button); sits APART at the bottom of a
|
|
373
|
-
record/settings surface.
|
|
373
|
+
record/settings surface. The heading defaults to the locale's `dangerZone.title`
|
|
374
|
+
("Danger zone" / "Vùng nguy hiểm") — pass `title` only to override.
|
|
374
375
|
- **`landmark`** — `Landmark`: the semantic region wrapper — `kind`
|
|
375
376
|
banner|navigation|main|complementary|contentinfo|region maps to the matching HTML element
|
|
376
377
|
on web for screen-reader landmark navigation, `accessibilityRole` on native;
|
|
@@ -390,8 +391,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
390
391
|
- **`icon_button`** — `IconButton`: the icon-only circular action (see
|
|
391
392
|
[Actions](#actions)).
|
|
392
393
|
- **`back_button`** — `BackButton`: the chevron-left `IconButton` (lg, secondary) heading a
|
|
393
|
-
screen/panel: `onPress` +
|
|
394
|
-
go-back glyph — don't hand-roll it.
|
|
394
|
+
screen/panel: `onPress` + `accessibilityLabel` (defaults to the locale's `nav.back` —
|
|
395
|
+
"Back" / "Quay lại"); the one go-back glyph — don't hand-roll it.
|
|
395
396
|
- **`link`** — `Link`: the EXTERNAL hyperlink — fixed underline+blue + `role="link"`;
|
|
396
397
|
`onPress` only (the consumer wires the opener).
|
|
397
398
|
- **`text_link`** — `TextLink`: underlined text that's optionally an `onPress` action or an
|
|
@@ -804,7 +805,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
804
805
|
browser file dialog imperatively — the trigger half behind every Add-file CTA; cancel
|
|
805
806
|
resolves `[]` on modern engines.
|
|
806
807
|
- **`file_dropzone`** — `FileDropzone`: the drag-drop capture well (`onFiles`, `accept`,
|
|
807
|
-
`label`/`hint`/`dropLabel`, `height`); click falls back to a picker.
|
|
808
|
+
`label`/`hint`/`dropLabel`, `height`); click falls back to a picker. `label`/`dropLabel`
|
|
809
|
+
default from the locale's `fileDropzone` slice — pass them only to override.
|
|
808
810
|
- **`files_editor`** — `FilesEditor` — THE all-in-one attachment field: `FileGrid` + a
|
|
809
811
|
toolbar (Upload primary · Select · Download all) that swaps into a batch SELECT mode
|
|
810
812
|
(Select all · a Menu of Download/Share/Delete · Done; the per-tile ✕ is select-mode-only,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "11.8.
|
|
3
|
+
"version": "11.8.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
@@ -238,9 +238,14 @@
|
|
|
238
238
|
"dependencies": {
|
|
239
239
|
"@lotics/docx": "^0.1.0",
|
|
240
240
|
"@lotics/xlsx": "^0.1.0",
|
|
241
|
+
"mdast-util-from-markdown": "^2.0.3",
|
|
242
|
+
"mdast-util-gfm-footnote": "^2.1.0",
|
|
243
|
+
"mdast-util-gfm-strikethrough": "^2.0.0",
|
|
244
|
+
"mdast-util-gfm-table": "^2.0.0",
|
|
245
|
+
"mdast-util-gfm-task-list-item": "^2.0.0",
|
|
246
|
+
"micromark-extension-gfm": "^3.0.0",
|
|
241
247
|
"pdfjs-dist": "^6.0.227",
|
|
242
|
-
"react-markdown": "^10.0.0"
|
|
243
|
-
"remark-gfm": "^4.0.0"
|
|
248
|
+
"react-markdown": "^10.0.0"
|
|
244
249
|
},
|
|
245
250
|
"peerDependencies": {
|
|
246
251
|
"@react-native-picker/picker": ">=2.0.0",
|
|
@@ -279,6 +284,8 @@
|
|
|
279
284
|
"lucide-react": "^0.562.0",
|
|
280
285
|
"react-dom": "^19.2.0",
|
|
281
286
|
"react-native-web": "^0.21.0",
|
|
287
|
+
"remark-parse": "^11.0.0",
|
|
288
|
+
"unified": "^11.0.5",
|
|
282
289
|
"vite": "^7.2.4"
|
|
283
290
|
}
|
|
284
291
|
}
|
package/src/agent_run.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { PressableHighlight } from "./pressable_highlight";
|
|
|
10
10
|
import { Marker, type StepStatus } from "./stepper";
|
|
11
11
|
import { AnimationFadeIn } from "./animation_fade_in";
|
|
12
12
|
import { CONTROL_HEIGHT, CONTROL_RADIUS } from "./control_surface";
|
|
13
|
+
import { useLoticsLocale, type LoticsLocale } from "./locale";
|
|
13
14
|
|
|
14
15
|
export type AgentStepStatus = "running" | "done" | "error";
|
|
15
16
|
|
|
@@ -235,19 +236,20 @@ function StepBody({ label, detail, status }: { label: string; detail?: string; s
|
|
|
235
236
|
|
|
236
237
|
// The press-to-open reveal for a tool step: the caller's `peek` wins; else Input /
|
|
237
238
|
// Output / Error panels auto-built from the carried I/O; else nothing (no reveal).
|
|
238
|
-
// Raw I/O stays OUT of the row — only shown here, on demand.
|
|
239
|
-
|
|
239
|
+
// Raw I/O stays OUT of the row — only shown here, on demand. Panel titles come from
|
|
240
|
+
// the locale (the caller supplies a whole `peek` to override).
|
|
241
|
+
function stepPeek(s: AgentRunStep, labels: LoticsLocale["agentRun"]): ReactNode {
|
|
240
242
|
if (s.peek) return s.peek;
|
|
241
243
|
const hasInput = s.input !== undefined;
|
|
242
244
|
const hasOutput = s.output !== undefined;
|
|
243
245
|
if (!hasInput && !hasOutput && !s.errorText) return null;
|
|
244
246
|
return (
|
|
245
247
|
<View style={{ gap: 8 }}>
|
|
246
|
-
{hasInput ? <JsonPanel title=
|
|
248
|
+
{hasInput ? <JsonPanel title={labels.input} value={stringifyData(s.input)} /> : null}
|
|
247
249
|
{s.errorText ? (
|
|
248
|
-
<JsonPanel title=
|
|
250
|
+
<JsonPanel title={labels.error} value={s.errorText} />
|
|
249
251
|
) : hasOutput ? (
|
|
250
|
-
<JsonPanel title=
|
|
252
|
+
<JsonPanel title={labels.output} value={stringifyData(s.output)} />
|
|
251
253
|
) : null}
|
|
252
254
|
</View>
|
|
253
255
|
);
|
|
@@ -256,7 +258,8 @@ function stepPeek(s: AgentRunStep): ReactNode {
|
|
|
256
258
|
// A step's body, wrapped in a Peek when there's something to reveal (I/O or a
|
|
257
259
|
// caller peek), plain otherwise.
|
|
258
260
|
function StepContent({ s, label }: { s: AgentRunStep; label: string }) {
|
|
259
|
-
const
|
|
261
|
+
const locale = useLoticsLocale();
|
|
262
|
+
const peek = stepPeek(s, locale.agentRun);
|
|
260
263
|
const body = <StepBody label={label} detail={s.detail} status={s.status} />;
|
|
261
264
|
return peek ? (
|
|
262
265
|
<Peek accessibilityLabel={label} content={peek}>
|
|
@@ -271,15 +274,16 @@ function StepContent({ s, label }: { s: AgentRunStep; label: string }) {
|
|
|
271
274
|
// agent's reasoning never crowds the answer. Pulses while it's still streaming.
|
|
272
275
|
function ReasoningDisclosure(props: { text: string; streaming?: boolean; expanded: boolean; onToggle: () => void }) {
|
|
273
276
|
const { text, streaming, expanded, onToggle } = props;
|
|
277
|
+
const locale = useLoticsLocale();
|
|
274
278
|
return (
|
|
275
279
|
<View style={styles.group}>
|
|
276
|
-
<PressableHighlight focusRing onPress={onToggle} accessibilityRole="button" accessibilityLabel=
|
|
280
|
+
<PressableHighlight focusRing onPress={onToggle} accessibilityRole="button" accessibilityLabel={locale.agentRun.thinking} style={styles.row}>
|
|
277
281
|
<View style={styles.dotCol}>
|
|
278
282
|
<Marker status={streaming ? "current" : "done"} color={colors.zinc[400]} live={!!streaming} />
|
|
279
283
|
</View>
|
|
280
284
|
<View style={styles.rowBody}>
|
|
281
285
|
<Text size="sm" color="muted">
|
|
282
|
-
{streaming ?
|
|
286
|
+
{streaming ? locale.agentRun.thinkingStreaming : locale.agentRun.thinking}
|
|
283
287
|
</Text>
|
|
284
288
|
</View>
|
|
285
289
|
{chevron(expanded ? "up" : "down")}
|
package/src/avatar.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import React from "react";
|
|
|
3
3
|
import { View, StyleSheet, StyleProp, ViewStyle, ImageStyle } from "react-native";
|
|
4
4
|
import { Text } from "./text";
|
|
5
5
|
import { colors } from "./colors";
|
|
6
|
+
import { useLoticsLocale } from "./locale";
|
|
6
7
|
|
|
7
8
|
interface AvatarProps {
|
|
8
9
|
size?: number;
|
|
@@ -20,7 +21,8 @@ interface AvatarProps {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export function Avatar(props: AvatarProps) {
|
|
23
|
-
const
|
|
24
|
+
const locale = useLoticsLocale();
|
|
25
|
+
const { source, size = 32, name = locale.avatar.unknown, style, contentFit, announce } = props;
|
|
24
26
|
const decorative = !announce;
|
|
25
27
|
|
|
26
28
|
if (!source || !source.uri) {
|
package/src/avatar.web.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import type { ImageContentFit, ImageSource } from "expo-image";
|
|
|
2
2
|
import { Image, View, StyleSheet, StyleProp, ViewStyle, ImageStyle } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
5
6
|
|
|
6
7
|
interface AvatarProps {
|
|
7
8
|
size?: number;
|
|
@@ -28,7 +29,8 @@ interface AvatarProps {
|
|
|
28
29
|
* type-only imports so no expo-image module is ever loaded.
|
|
29
30
|
*/
|
|
30
31
|
export function Avatar(props: AvatarProps) {
|
|
31
|
-
const
|
|
32
|
+
const locale = useLoticsLocale();
|
|
33
|
+
const { source, size = 32, name = locale.avatar.unknown, style, contentFit, announce } = props;
|
|
32
34
|
const decorative = !announce;
|
|
33
35
|
|
|
34
36
|
if (!source || !source.uri) {
|
package/src/back_button.tsx
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { IconButton } from "./icon_button";
|
|
2
2
|
import { View } from "react-native";
|
|
3
|
+
import { useLoticsLocale } from "./locale";
|
|
3
4
|
|
|
4
5
|
interface BackButtonProps {
|
|
5
6
|
onPress: () => void;
|
|
6
|
-
/** Accessible name.
|
|
7
|
+
/** Accessible name. Defaults to the locale's `nav.back` ("Back" / "Quay lại"). */
|
|
7
8
|
accessibilityLabel?: string;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export function BackButton(props: BackButtonProps) {
|
|
11
|
-
const
|
|
12
|
+
const locale = useLoticsLocale();
|
|
13
|
+
const { onPress, accessibilityLabel = locale.nav.back } = props;
|
|
12
14
|
return (
|
|
13
15
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
14
16
|
<IconButton
|
package/src/bar_chart.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { View, StyleSheet } from "react-native";
|
|
|
2
2
|
import { useMemo } from "react";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
5
6
|
|
|
6
7
|
const DEFAULT_BAR_COLOR = colors.blue[500];
|
|
7
8
|
|
|
@@ -66,11 +67,12 @@ function useAxisTicks(maxValue: number) {
|
|
|
66
67
|
* one inline trend use `Sparkline`; for parts-of-a-whole, `PieChart` / `Breakdown`.
|
|
67
68
|
*/
|
|
68
69
|
export function BarChart(props: BarChartProps) {
|
|
70
|
+
const locale = useLoticsLocale();
|
|
69
71
|
const {
|
|
70
72
|
data,
|
|
71
73
|
orientation = "vertical",
|
|
72
74
|
formatNumber = defaultFormatNumber,
|
|
73
|
-
emptyLabel =
|
|
75
|
+
emptyLabel = locale.chart.noData,
|
|
74
76
|
} = props;
|
|
75
77
|
const maxValue = Math.max(...data.map((d) => d.value), 1);
|
|
76
78
|
const axisTicks = useAxisTicks(maxValue);
|
package/src/composer.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { colors } from "./colors";
|
|
|
5
5
|
import { Text } from "./text";
|
|
6
6
|
import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
|
|
7
7
|
import { useAutoGrowHeight } from "./use_auto_grow_height";
|
|
8
|
+
import { useLoticsLocale } from "./locale";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* The pure composer *chrome* — a bordered, auto-growing multiline text input
|
|
@@ -63,6 +64,7 @@ export interface ComposerProps {
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
export function Composer(props: ComposerProps) {
|
|
67
|
+
const locale = useLoticsLocale();
|
|
66
68
|
const {
|
|
67
69
|
onSend,
|
|
68
70
|
onStop,
|
|
@@ -78,8 +80,8 @@ export function Composer(props: ComposerProps) {
|
|
|
78
80
|
actionsButton,
|
|
79
81
|
footerRight,
|
|
80
82
|
maxLines = 10,
|
|
81
|
-
sendLabel =
|
|
82
|
-
stopLabel =
|
|
83
|
+
sendLabel = locale.composer.send,
|
|
84
|
+
stopLabel = locale.composer.stop,
|
|
83
85
|
} = props;
|
|
84
86
|
|
|
85
87
|
const [internalText, setInternalText] = useState("");
|
package/src/danger_zone.tsx
CHANGED
|
@@ -3,9 +3,11 @@ import { View, StyleSheet } from "react-native";
|
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { Icon } from "./icon";
|
|
5
5
|
import { solid, tint } from "./colors";
|
|
6
|
+
import { useLoticsLocale } from "./locale";
|
|
6
7
|
|
|
7
8
|
export interface DangerZoneProps {
|
|
8
|
-
/** Section heading.
|
|
9
|
+
/** Section heading. Defaults to the locale's `dangerZone.title`
|
|
10
|
+
* ("Danger zone" / "Vùng nguy hiểm"). */
|
|
9
11
|
title?: string;
|
|
10
12
|
/** A short line spelling out the consequence of the action (it's usually
|
|
11
13
|
* irreversible — say so here). */
|
|
@@ -23,7 +25,8 @@ export interface DangerZoneProps {
|
|
|
23
25
|
* action(s) go in `children`.
|
|
24
26
|
*/
|
|
25
27
|
export function DangerZone(props: DangerZoneProps) {
|
|
26
|
-
const
|
|
28
|
+
const locale = useLoticsLocale();
|
|
29
|
+
const { title = locale.dangerZone.title, description, children } = props;
|
|
27
30
|
return (
|
|
28
31
|
<View style={styles.zone}>
|
|
29
32
|
<View style={styles.heading}>
|
package/src/dialog.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { IconButton } from "@lotics/ui/icon_button";
|
|
|
7
7
|
import { Text } from "@lotics/ui/text";
|
|
8
8
|
import { BackButton } from "@lotics/ui/back_button";
|
|
9
9
|
import { useOverlayScope } from "@lotics/ui/overlay_scope";
|
|
10
|
+
import { useLoticsLocale } from "@lotics/ui/locale";
|
|
10
11
|
import {
|
|
11
12
|
ScreenRouterContext,
|
|
12
13
|
ScreenRouterInternalContext,
|
|
@@ -98,6 +99,7 @@ export function Dialog(props: DialogProps) {
|
|
|
98
99
|
|
|
99
100
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
100
101
|
const screenSize = useScreenSize();
|
|
102
|
+
const locale = useLoticsLocale();
|
|
101
103
|
|
|
102
104
|
const isControlled = controlledOpen !== undefined;
|
|
103
105
|
const open = isControlled ? controlledOpen : uncontrolledOpen;
|
|
@@ -163,7 +165,7 @@ export function Dialog(props: DialogProps) {
|
|
|
163
165
|
<PortalHost>
|
|
164
166
|
<View testID={testID} style={[styles.dialogContainer, { borderRadius }]}>
|
|
165
167
|
<View style={[styles.closeButtonContainer, { paddingHorizontal: screenSize.small ? 16 : 24 }]}>
|
|
166
|
-
<IconButton icon="x" size="lg" accessibilityLabel=
|
|
168
|
+
<IconButton icon="x" size="lg" accessibilityLabel={locale.overlay.close} onPress={handleClose} />
|
|
167
169
|
</View>
|
|
168
170
|
<View style={styles.container}>{children}</View>
|
|
169
171
|
</View>
|
package/src/file_dropzone.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { Text } from "./text";
|
|
|
6
6
|
import { pickFiles } from "./file_picker";
|
|
7
7
|
import { FOCUS_RING } from "./control_surface";
|
|
8
8
|
import { useFocusRing } from "./use_focus_ring";
|
|
9
|
+
import { useLoticsLocale } from "./locale";
|
|
9
10
|
|
|
10
11
|
export interface FileDropzoneProps {
|
|
11
12
|
/** Receives the picked/dropped files. Bytes/upload belong to the host —
|
|
@@ -18,7 +19,9 @@ export interface FileDropzoneProps {
|
|
|
18
19
|
multiple?: boolean;
|
|
19
20
|
/** Main line ("Kéo thả tệp vào đây"). */
|
|
20
21
|
label?: string;
|
|
21
|
-
/** Secondary line
|
|
22
|
+
/** Secondary line. Defaults to the locale's `fileDropzone.hint` ("or click to
|
|
23
|
+
* browse" / "hoặc bấm để chọn"); override with an accepted-types line
|
|
24
|
+
* ("… · PDF, ảnh"). */
|
|
22
25
|
hint?: string;
|
|
23
26
|
/** Main line while a drag hovers the zone ("Thả để tải lên"). */
|
|
24
27
|
dropLabel?: string;
|
|
@@ -52,13 +55,14 @@ function matchesAccept(file: File, accept: string | undefined): boolean {
|
|
|
52
55
|
* `dropLabel`. On native (no drag events) it degrades to press-to-pick.
|
|
53
56
|
*/
|
|
54
57
|
export function FileDropzone(props: FileDropzoneProps) {
|
|
58
|
+
const locale = useLoticsLocale();
|
|
55
59
|
const {
|
|
56
60
|
onFiles,
|
|
57
61
|
accept,
|
|
58
62
|
multiple = true,
|
|
59
|
-
label =
|
|
60
|
-
hint =
|
|
61
|
-
dropLabel =
|
|
63
|
+
label = locale.fileDropzone.label,
|
|
64
|
+
hint = locale.fileDropzone.hint,
|
|
65
|
+
dropLabel = locale.fileDropzone.drop,
|
|
62
66
|
height = 160,
|
|
63
67
|
disabled = false,
|
|
64
68
|
accessibilityLabel,
|
package/src/file_thumbnail.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { ActivityIndicator } from "./activity_indicator";
|
|
|
8
8
|
import { colors } from "./colors";
|
|
9
9
|
import { FOCUS_RING } from "./control_surface";
|
|
10
10
|
import { useFocusRing } from "./use_focus_ring";
|
|
11
|
+
import { useLoticsLocale } from "./locale";
|
|
11
12
|
import { useCallback, useState } from "react";
|
|
12
13
|
import {
|
|
13
14
|
GestureResponderEvent,
|
|
@@ -366,7 +367,8 @@ function TileScrim({ hovered, pressed }: { hovered?: boolean; pressed: boolean }
|
|
|
366
367
|
return <View style={[styles.tileScrim, pressed && styles.tileScrimPressed]} pointerEvents="none" />;
|
|
367
368
|
}
|
|
368
369
|
|
|
369
|
-
export function RemoveButton({ onPress, label
|
|
370
|
+
export function RemoveButton({ onPress, label }: { onPress: () => void; label?: string }) {
|
|
371
|
+
const locale = useLoticsLocale();
|
|
370
372
|
const handlePress = useCallback(
|
|
371
373
|
(e: GestureResponderEvent) => {
|
|
372
374
|
e.stopPropagation();
|
|
@@ -376,7 +378,7 @@ export function RemoveButton({ onPress, label = "Remove" }: { onPress: () => voi
|
|
|
376
378
|
);
|
|
377
379
|
return (
|
|
378
380
|
<View style={styles.removeButtonAnchor}>
|
|
379
|
-
<IconButton icon="x" size="sm" elevated accessibilityLabel={label} onPress={handlePress} />
|
|
381
|
+
<IconButton icon="x" size="sm" elevated accessibilityLabel={label ?? locale.fileThumbnail.remove} onPress={handlePress} />
|
|
380
382
|
</View>
|
|
381
383
|
);
|
|
382
384
|
}
|
package/src/image_gallery.tsx
CHANGED
|
@@ -18,6 +18,7 @@ import { FileThumbnail, type DisplayFile } from "./file_thumbnail";
|
|
|
18
18
|
import { FileGalleryModal } from "./file_gallery_modal";
|
|
19
19
|
import { RotatableImage } from "./rotatable_image";
|
|
20
20
|
import { useImageRotation } from "./use_image_rotation";
|
|
21
|
+
import { useLoticsLocale } from "./locale";
|
|
21
22
|
|
|
22
23
|
const GAP = 12;
|
|
23
24
|
|
|
@@ -33,7 +34,8 @@ const rotateButtonStyle: ViewStyle = {
|
|
|
33
34
|
export interface ImageGalleryProps {
|
|
34
35
|
images: DisplayFile[];
|
|
35
36
|
loading?: boolean;
|
|
36
|
-
/** Shown when there are no images and not loading.
|
|
37
|
+
/** Shown when there are no images and not loading. Defaults to the locale's
|
|
38
|
+
* `imageGallery.empty` ("No images." / "Chưa có ảnh."). */
|
|
37
39
|
emptyText?: string;
|
|
38
40
|
/** Optional max width. Omit to fill the container. */
|
|
39
41
|
maxWidth?: number;
|
|
@@ -52,13 +54,14 @@ export interface ImageGalleryProps {
|
|
|
52
54
|
export function ImageGallery({
|
|
53
55
|
images,
|
|
54
56
|
loading = false,
|
|
55
|
-
emptyText
|
|
57
|
+
emptyText,
|
|
56
58
|
maxWidth,
|
|
57
59
|
thumbnailPosition = "bottom",
|
|
58
60
|
mainAspectRatio = 4 / 3,
|
|
59
61
|
mainFraction = 0.6,
|
|
60
62
|
rotatable = true,
|
|
61
63
|
}: ImageGalleryProps) {
|
|
64
|
+
const locale = useLoticsLocale();
|
|
62
65
|
const [selected, setSelected] = useState(0);
|
|
63
66
|
const [zoomIdx, setZoomIdx] = useState<number | null>(null);
|
|
64
67
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
@@ -66,7 +69,7 @@ export function ImageGallery({
|
|
|
66
69
|
const rotation = useImageRotation();
|
|
67
70
|
|
|
68
71
|
if (loading) return <ActivityIndicator />;
|
|
69
|
-
if (images.length === 0) return <Text size="sm" color="muted">{emptyText}</Text>;
|
|
72
|
+
if (images.length === 0) return <Text size="sm" color="muted">{emptyText ?? locale.imageGallery.empty}</Text>;
|
|
70
73
|
|
|
71
74
|
const idx = Math.min(selected, images.length - 1);
|
|
72
75
|
const main = images[idx];
|
|
@@ -79,7 +82,7 @@ export function ImageGallery({
|
|
|
79
82
|
<View style={{ width: "100%" }}>
|
|
80
83
|
<FocusRingPressable accessibilityRole="button"
|
|
81
84
|
onPress={() => setZoomIdx(idx)}
|
|
82
|
-
accessibilityLabel=
|
|
85
|
+
accessibilityLabel={locale.imageGallery.zoom}
|
|
83
86
|
style={{
|
|
84
87
|
width: "100%",
|
|
85
88
|
aspectRatio: mainAspectRatio,
|
|
@@ -97,14 +100,14 @@ export function ImageGallery({
|
|
|
97
100
|
<View style={{ position: "absolute", top: 8, right: 8, flexDirection: "row", gap: 6 }}>
|
|
98
101
|
<FocusRingPressable accessibilityRole="button"
|
|
99
102
|
onPress={() => rotation.rotate(main.id, -1)}
|
|
100
|
-
accessibilityLabel=
|
|
103
|
+
accessibilityLabel={locale.imageGallery.rotateLeft}
|
|
101
104
|
style={rotateButtonStyle}
|
|
102
105
|
>
|
|
103
106
|
<Icon name="rotate-ccw" size={16} color={colors.white} />
|
|
104
107
|
</FocusRingPressable>
|
|
105
108
|
<FocusRingPressable accessibilityRole="button"
|
|
106
109
|
onPress={() => rotation.rotate(main.id, 1)}
|
|
107
|
-
accessibilityLabel=
|
|
110
|
+
accessibilityLabel={locale.imageGallery.rotateRight}
|
|
108
111
|
style={rotateButtonStyle}
|
|
109
112
|
>
|
|
110
113
|
<Icon name="rotate-cw" size={16} color={colors.white} />
|
package/src/info_popover.tsx
CHANGED
|
@@ -4,11 +4,13 @@ import type { PopoverSide, PopoverAlign } from "./popover";
|
|
|
4
4
|
import { IconButton } from "./icon_button";
|
|
5
5
|
import { Text } from "./text";
|
|
6
6
|
import { colors } from "./colors";
|
|
7
|
+
import { useLoticsLocale } from "./locale";
|
|
7
8
|
|
|
8
9
|
export interface InfoPopoverProps {
|
|
9
10
|
/** Explanatory text shown when the popover opens. */
|
|
10
11
|
text: string;
|
|
11
|
-
/** Accessible name for the trigger button.
|
|
12
|
+
/** Accessible name for the trigger button. Defaults to the locale's
|
|
13
|
+
* `infoPopover.more` ("More information" / "Thông tin thêm"). */
|
|
12
14
|
accessibilityLabel?: string;
|
|
13
15
|
/** Popover placement relative to the trigger. */
|
|
14
16
|
side?: PopoverSide;
|
|
@@ -25,7 +27,8 @@ export interface InfoPopoverProps {
|
|
|
25
27
|
* label) without inflating the line.
|
|
26
28
|
*/
|
|
27
29
|
export function InfoPopover(props: InfoPopoverProps) {
|
|
28
|
-
const
|
|
30
|
+
const locale = useLoticsLocale();
|
|
31
|
+
const { text, accessibilityLabel = locale.infoPopover.more, side = "bottom", align = "end" } = props;
|
|
29
32
|
return (
|
|
30
33
|
<Popover side={side} align={align}>
|
|
31
34
|
<PopoverTrigger>
|
package/src/line_chart.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { Text } from "./text";
|
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { useMemo, useState, useCallback } from "react";
|
|
5
5
|
import Svg, { Circle, Defs, Line, LinearGradient, Polygon, Polyline, Stop } from "react-native-svg";
|
|
6
|
+
import { useLoticsLocale } from "./locale";
|
|
6
7
|
|
|
7
8
|
export interface LineChartPoint {
|
|
8
9
|
x: string | number;
|
|
@@ -28,12 +29,13 @@ const defaultFormatXLabel = (x: string | number): string => String(x);
|
|
|
28
29
|
* axes, `lineColor`, `height`. (No recharts.) For a tiny inline trend use `Sparkline`.
|
|
29
30
|
*/
|
|
30
31
|
export function LineChart(props: LineChartProps) {
|
|
32
|
+
const locale = useLoticsLocale();
|
|
31
33
|
const {
|
|
32
34
|
points: data,
|
|
33
35
|
height: chartHeight = 160,
|
|
34
36
|
formatNumber = defaultFormatNumber,
|
|
35
37
|
formatXLabel = defaultFormatXLabel,
|
|
36
|
-
emptyLabel =
|
|
38
|
+
emptyLabel = locale.chart.noData,
|
|
37
39
|
lineColor = colors.blue[500],
|
|
38
40
|
} = props;
|
|
39
41
|
|
package/src/locale.tsx
CHANGED
|
@@ -41,6 +41,8 @@ export interface LoticsLocale {
|
|
|
41
41
|
floatingActionBar: { clear: string };
|
|
42
42
|
/** `FormField`: the "Optional" marker shown next to the label when `optional`. */
|
|
43
43
|
formField: { optional: string };
|
|
44
|
+
/** `DangerZone`: the section heading shown when no `title` prop is given. */
|
|
45
|
+
dangerZone: { title: string };
|
|
44
46
|
/** `Drawer`: the record prev/next + close controls (screen-reader names). */
|
|
45
47
|
drawer: { previous: string; next: string; close: string };
|
|
46
48
|
/** The `Inline*` editor family: the shared save-error line and the
|
|
@@ -88,6 +90,38 @@ export interface LoticsLocale {
|
|
|
88
90
|
* (open/download/remove + the confirm), the gallery chrome (close, prev/next,
|
|
89
91
|
* rotate), and the preview captions (not-available / load-failed / password). */
|
|
90
92
|
gallery: GalleryLabels;
|
|
93
|
+
/** `Avatar`: the fallback name (initials + a11y label) shown when no `name`. */
|
|
94
|
+
avatar: { unknown: string };
|
|
95
|
+
/** `BackButton` + `PopoverNavHeader`: the back-chevron's a11y name. */
|
|
96
|
+
nav: { back: string };
|
|
97
|
+
/** `BarChart` / `LineChart` / `PieChart`: the empty-state caption and the
|
|
98
|
+
* `PieChart` center total's caption. */
|
|
99
|
+
chart: { noData: string; total: string };
|
|
100
|
+
/** `Composer`: the send / stop button a11y + tooltip names. */
|
|
101
|
+
composer: { send: string; stop: string };
|
|
102
|
+
/** `Dialog` / `Modal` / `Popover`: the dismiss control's a11y / tooltip name. */
|
|
103
|
+
overlay: { close: string };
|
|
104
|
+
/** `FileDropzone`: the resting main line, the secondary hint, and the
|
|
105
|
+
* drag-hover line. `hint` is a generic default — apps override the prop with
|
|
106
|
+
* an accepted-types line ("… · PDF, ảnh"). */
|
|
107
|
+
fileDropzone: { label: string; hint: string; drop: string };
|
|
108
|
+
/** `FileThumbnail`'s `RemoveButton`: the ✕ a11y name. */
|
|
109
|
+
fileThumbnail: { remove: string };
|
|
110
|
+
/** `ImageGallery`: the empty state, the inline rotate controls, and the
|
|
111
|
+
* press-to-zoom a11y name. */
|
|
112
|
+
imageGallery: { empty: string; rotateLeft: string; rotateRight: string; zoom: string };
|
|
113
|
+
/** `InfoPopover`: the ⓘ trigger's a11y name. */
|
|
114
|
+
infoPopover: { more: string };
|
|
115
|
+
/** `Matrix`: the total column/row header and the legend's less→more ends. */
|
|
116
|
+
matrix: { total: string; less: string; more: string };
|
|
117
|
+
/** `ScrollToBottom`: the jump-to-latest tooltip. */
|
|
118
|
+
scrollToBottom: { tooltip: string };
|
|
119
|
+
/** `TextInputField`: the clear-button tooltip. */
|
|
120
|
+
textInputField: { clear: string };
|
|
121
|
+
/** `AgentRun`: the reasoning disclosure's label (settled / streaming) and the
|
|
122
|
+
* auto-built tool peek's Input / Error / Output panel titles. (Tool-step labels
|
|
123
|
+
* and the "{n} steps" suffix stay prop-localized — `labelForTool` / `stepsLabel`.) */
|
|
124
|
+
agentRun: { thinking: string; thinkingStreaming: string; input: string; error: string; output: string };
|
|
91
125
|
}
|
|
92
126
|
|
|
93
127
|
/** The platform default — English. Every component's hardcoded default lives
|
|
@@ -112,6 +146,7 @@ export const en: LoticsLocale = {
|
|
|
112
146
|
filterChip: { clear: "Clear" },
|
|
113
147
|
floatingActionBar: { clear: "Clear" },
|
|
114
148
|
formField: { optional: "Optional" },
|
|
149
|
+
dangerZone: { title: "Danger zone" },
|
|
115
150
|
drawer: { previous: "Previous record", next: "Next record", close: "Close" },
|
|
116
151
|
inline: { saveError: "Couldn't save. Try again.", save: "Save", cancel: "Cancel" },
|
|
117
152
|
ledger: { rowDetails: (label) => `${label} details` },
|
|
@@ -168,6 +203,19 @@ export const en: LoticsLocale = {
|
|
|
168
203
|
download: "Download file",
|
|
169
204
|
passwordProtected: "This file is password-protected and cannot be previewed",
|
|
170
205
|
},
|
|
206
|
+
avatar: { unknown: "Unknown" },
|
|
207
|
+
nav: { back: "Back" },
|
|
208
|
+
chart: { noData: "No data", total: "Total" },
|
|
209
|
+
composer: { send: "Send", stop: "Stop" },
|
|
210
|
+
overlay: { close: "Close" },
|
|
211
|
+
fileDropzone: { label: "Drag files here", hint: "or click to browse", drop: "Drop to upload" },
|
|
212
|
+
fileThumbnail: { remove: "Remove" },
|
|
213
|
+
imageGallery: { empty: "No images.", rotateLeft: "Rotate left", rotateRight: "Rotate right", zoom: "Zoom image" },
|
|
214
|
+
infoPopover: { more: "More information" },
|
|
215
|
+
matrix: { total: "Total", less: "Less", more: "More" },
|
|
216
|
+
scrollToBottom: { tooltip: "Scroll to bottom" },
|
|
217
|
+
textInputField: { clear: "Clear" },
|
|
218
|
+
agentRun: { thinking: "Thinking", thinkingStreaming: "Thinking…", input: "Input", error: "Error", output: "Output" },
|
|
171
219
|
};
|
|
172
220
|
|
|
173
221
|
/** Vietnamese. Maintained once here so every app (and the frontend) shares one
|
|
@@ -192,6 +240,7 @@ export const vi: LoticsLocale = {
|
|
|
192
240
|
filterChip: { clear: "Xóa" },
|
|
193
241
|
floatingActionBar: { clear: "Bỏ chọn" },
|
|
194
242
|
formField: { optional: "Tùy chọn" },
|
|
243
|
+
dangerZone: { title: "Vùng nguy hiểm" },
|
|
195
244
|
drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
|
|
196
245
|
inline: { saveError: "Không lưu được. Thử lại.", save: "Lưu", cancel: "Hủy" },
|
|
197
246
|
ledger: { rowDetails: (label) => `Chi tiết ${label}` },
|
|
@@ -248,6 +297,19 @@ export const vi: LoticsLocale = {
|
|
|
248
297
|
download: "Tải xuống",
|
|
249
298
|
passwordProtected: "Tệp có mật khẩu — không xem trước được",
|
|
250
299
|
},
|
|
300
|
+
avatar: { unknown: "Không rõ" },
|
|
301
|
+
nav: { back: "Quay lại" },
|
|
302
|
+
chart: { noData: "Không có dữ liệu", total: "Tổng" },
|
|
303
|
+
composer: { send: "Gửi", stop: "Dừng" },
|
|
304
|
+
overlay: { close: "Đóng" },
|
|
305
|
+
fileDropzone: { label: "Kéo tệp vào đây", hint: "hoặc bấm để chọn", drop: "Thả để tải lên" },
|
|
306
|
+
fileThumbnail: { remove: "Xóa" },
|
|
307
|
+
imageGallery: { empty: "Chưa có ảnh.", rotateLeft: "Xoay trái", rotateRight: "Xoay phải", zoom: "Phóng to ảnh" },
|
|
308
|
+
infoPopover: { more: "Thông tin thêm" },
|
|
309
|
+
matrix: { total: "Tổng", less: "Ít", more: "Nhiều" },
|
|
310
|
+
scrollToBottom: { tooltip: "Cuộn xuống cuối" },
|
|
311
|
+
textInputField: { clear: "Xóa" },
|
|
312
|
+
agentRun: { thinking: "Suy nghĩ", thinkingStreaming: "Đang suy nghĩ…", input: "Đầu vào", error: "Lỗi", output: "Kết quả" },
|
|
251
313
|
};
|
|
252
314
|
|
|
253
315
|
const LoticsLocaleContext = createContext<LoticsLocale>(en);
|
package/src/markdown.web.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import "./markdown.css";
|
|
|
2
2
|
import React, { useCallback, useRef, useState } from "react";
|
|
3
3
|
import { View } from "react-native";
|
|
4
4
|
import ReactMarkdown from "react-markdown";
|
|
5
|
-
import
|
|
5
|
+
import { remarkGfmSafe } from "./remark_gfm_safe";
|
|
6
6
|
import { Icon } from "./icon";
|
|
7
7
|
import { colors } from "./colors";
|
|
8
8
|
|
|
@@ -10,7 +10,7 @@ export function Markdown({ children }: { children: string }) {
|
|
|
10
10
|
return (
|
|
11
11
|
<View>
|
|
12
12
|
<div className="ui-markdown">
|
|
13
|
-
<ReactMarkdown remarkPlugins={[
|
|
13
|
+
<ReactMarkdown remarkPlugins={[remarkGfmSafe]} components={markdownComponents}>
|
|
14
14
|
{children}
|
|
15
15
|
</ReactMarkdown>
|
|
16
16
|
</div>
|
package/src/matrix.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { colors, withAlpha, type ColorName } from "./colors";
|
|
|
4
4
|
import { Text } from "./text";
|
|
5
5
|
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
6
6
|
import { matrixTotals, type MatrixAxisItem, type MatrixCellRef } from "./matrix_totals";
|
|
7
|
+
import { useLoticsLocale } from "./locale";
|
|
7
8
|
|
|
8
9
|
type Display = "number" | "heat" | "both";
|
|
9
10
|
|
|
@@ -121,8 +122,10 @@ export interface MatrixHeaderProps {
|
|
|
121
122
|
totalLabel?: string;
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
function MatrixHeader({ corner, totalLabel
|
|
125
|
+
function MatrixHeader({ corner, totalLabel }: MatrixHeaderProps) {
|
|
125
126
|
const { cols, rowLabelWidth, totalColWidth, hasTotals } = useMatrix();
|
|
127
|
+
const locale = useLoticsLocale();
|
|
128
|
+
const resolvedTotalLabel = totalLabel ?? locale.matrix.total;
|
|
126
129
|
return (
|
|
127
130
|
<View style={styles.headRow}>
|
|
128
131
|
<View style={[styles.rowLabel, { width: rowLabelWidth }]}>
|
|
@@ -141,7 +144,7 @@ function MatrixHeader({ corner, totalLabel = "Total" }: MatrixHeaderProps) {
|
|
|
141
144
|
))}
|
|
142
145
|
{hasTotals ? (
|
|
143
146
|
<Text size="xs" color="muted" weight="medium" align="center" numberOfLines={1} style={[styles.totalCol, { width: totalColWidth }]}>
|
|
144
|
-
{
|
|
147
|
+
{resolvedTotalLabel}
|
|
145
148
|
</Text>
|
|
146
149
|
) : null}
|
|
147
150
|
</View>
|
|
@@ -240,12 +243,13 @@ export interface MatrixTotalsProps {
|
|
|
240
243
|
label?: string;
|
|
241
244
|
}
|
|
242
245
|
|
|
243
|
-
function MatrixTotals({ label
|
|
246
|
+
function MatrixTotals({ label }: MatrixTotalsProps) {
|
|
244
247
|
const { cols, colTotals, grandTotal, formatValue, rowLabelWidth, totalColWidth } = useMatrix();
|
|
248
|
+
const locale = useLoticsLocale();
|
|
245
249
|
return (
|
|
246
250
|
<View style={styles.totalsRow}>
|
|
247
251
|
<Text size="sm" weight="semibold" color="muted" numberOfLines={1} style={[styles.rowLabel, { width: rowLabelWidth }]}>
|
|
248
|
-
{label}
|
|
252
|
+
{label ?? locale.matrix.total}
|
|
249
253
|
</Text>
|
|
250
254
|
{cols.map((c) => (
|
|
251
255
|
<Text key={c.key} size="sm" weight="semibold" tabular align="center" numberOfLines={1} style={styles.colCell}>
|
|
@@ -264,18 +268,19 @@ export interface MatrixLegendProps {
|
|
|
264
268
|
moreLabel?: string;
|
|
265
269
|
}
|
|
266
270
|
|
|
267
|
-
function MatrixLegend({ lessLabel
|
|
271
|
+
function MatrixLegend({ lessLabel, moreLabel }: MatrixLegendProps) {
|
|
268
272
|
const { heatBase } = useMatrix();
|
|
273
|
+
const locale = useLoticsLocale();
|
|
269
274
|
return (
|
|
270
275
|
<View style={styles.scale}>
|
|
271
276
|
<Text size="xs" color="muted">
|
|
272
|
-
{lessLabel}
|
|
277
|
+
{lessLabel ?? locale.matrix.less}
|
|
273
278
|
</Text>
|
|
274
279
|
{[0.15, 0.36, 0.57, 0.78, 1].map((alpha) => (
|
|
275
280
|
<View key={alpha} style={[styles.swatch, { backgroundColor: withAlpha(heatBase, alpha) }]} />
|
|
276
281
|
))}
|
|
277
282
|
<Text size="xs" color="muted">
|
|
278
|
-
{moreLabel}
|
|
283
|
+
{moreLabel ?? locale.matrix.more}
|
|
279
284
|
</Text>
|
|
280
285
|
</View>
|
|
281
286
|
);
|
package/src/modal.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { IconButton } from "@lotics/ui/icon_button";
|
|
|
5
5
|
import { Text } from "@lotics/ui/text";
|
|
6
6
|
import { PortalHost } from "@lotics/ui/portal";
|
|
7
7
|
import { useOverlayScope } from "@lotics/ui/overlay_scope";
|
|
8
|
+
import { useLoticsLocale } from "@lotics/ui/locale";
|
|
8
9
|
|
|
9
10
|
export interface ModalProps {
|
|
10
11
|
open: boolean;
|
|
@@ -59,6 +60,7 @@ export interface ModalHeaderProps {
|
|
|
59
60
|
*/
|
|
60
61
|
export function ModalHeader(props: ModalHeaderProps) {
|
|
61
62
|
const { title, eyebrow, actions, onClose, style } = props;
|
|
63
|
+
const locale = useLoticsLocale();
|
|
62
64
|
|
|
63
65
|
return (
|
|
64
66
|
<View style={[styles.header, style]}>
|
|
@@ -83,7 +85,7 @@ export function ModalHeader(props: ModalHeaderProps) {
|
|
|
83
85
|
) : null}
|
|
84
86
|
</View>
|
|
85
87
|
{actions !== undefined ? <View style={styles.headerActions}>{actions}</View> : null}
|
|
86
|
-
<IconButton icon="x" size="lg" accessibilityLabel=
|
|
88
|
+
<IconButton icon="x" size="lg" accessibilityLabel={locale.overlay.close} onPress={onClose} />
|
|
87
89
|
</View>
|
|
88
90
|
);
|
|
89
91
|
}
|
package/src/pie_chart.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { Text } from "./text";
|
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { useMemo, useState, useCallback } from "react";
|
|
5
5
|
import Svg, { Path, G } from "react-native-svg";
|
|
6
|
+
import { useLoticsLocale } from "./locale";
|
|
6
7
|
|
|
7
8
|
const FALLBACK_PIE_COLORS = [
|
|
8
9
|
colors.blue[300],
|
|
@@ -40,13 +41,14 @@ export interface PieChartProps {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export function PieChart(props: PieChartProps) {
|
|
44
|
+
const locale = useLoticsLocale();
|
|
43
45
|
const {
|
|
44
46
|
slices: chartData,
|
|
45
47
|
size = PIE_SIZE,
|
|
46
48
|
showLegend = true,
|
|
47
49
|
formatNumber = defaultFormatNumber,
|
|
48
|
-
emptyLabel =
|
|
49
|
-
centerLabel =
|
|
50
|
+
emptyLabel = locale.chart.noData,
|
|
51
|
+
centerLabel = locale.chart.total,
|
|
50
52
|
} = props;
|
|
51
53
|
|
|
52
54
|
const [isVerticalLayout, setIsVerticalLayout] = useState(false);
|
package/src/popover.tsx
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
snapshotOpenModalLayers,
|
|
20
20
|
} from "./popover_layers";
|
|
21
21
|
import { PopoverNavContext, type PopoverNavContextValue } from "./popover_nav";
|
|
22
|
+
import { useLoticsLocale } from "./locale";
|
|
22
23
|
|
|
23
24
|
export type PopoverSide = "top" | "right" | "bottom" | "left";
|
|
24
25
|
export type PopoverAlign = "start" | "center" | "end";
|
|
@@ -187,7 +188,7 @@ export interface PopoverContentProps {
|
|
|
187
188
|
disableBodyScroll?: boolean;
|
|
188
189
|
/** When true, renders as bottom sheet on small screens (close button, slide-up animation) */
|
|
189
190
|
small?: boolean;
|
|
190
|
-
/** Accessible name for the bottom-sheet close button.
|
|
191
|
+
/** Accessible name for the bottom-sheet close button. Defaults to the locale's `overlay.close` ("Close" / "Đóng"). */
|
|
191
192
|
closeLabel?: string;
|
|
192
193
|
/** When true (default), the popover moves focus into its content on open and
|
|
193
194
|
* restores it on close. Set false for an anchored panel whose trigger must
|
|
@@ -198,6 +199,7 @@ export interface PopoverContentProps {
|
|
|
198
199
|
}
|
|
199
200
|
|
|
200
201
|
export function PopoverContent(props: PopoverContentProps) {
|
|
202
|
+
const locale = useLoticsLocale();
|
|
201
203
|
const {
|
|
202
204
|
testID,
|
|
203
205
|
children,
|
|
@@ -205,7 +207,7 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
205
207
|
contentContainerStyle,
|
|
206
208
|
disableBodyScroll,
|
|
207
209
|
small = false,
|
|
208
|
-
closeLabel =
|
|
210
|
+
closeLabel = locale.overlay.close,
|
|
209
211
|
manageFocus = true,
|
|
210
212
|
} = props;
|
|
211
213
|
const { open, onOpenChange, triggerRef, side, align, offset, inheritTriggerWidth } =
|
package/src/popover_nav.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { createContext, ReactNode, useContext } from "react";
|
|
|
2
2
|
import { IconButton } from "./icon_button";
|
|
3
3
|
import { View, StyleSheet } from "react-native";
|
|
4
4
|
import { Text } from "./text";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
5
6
|
|
|
6
7
|
export interface PopoverNavContextValue {
|
|
7
8
|
currentRoute: string;
|
|
@@ -37,7 +38,7 @@ export function PopoverScreen(props: PopoverScreenProps) {
|
|
|
37
38
|
export interface PopoverNavHeaderProps {
|
|
38
39
|
title: string;
|
|
39
40
|
right?: ReactNode;
|
|
40
|
-
/** Accessible name for the back button.
|
|
41
|
+
/** Accessible name for the back button. Defaults to the locale's `nav.back` ("Back" / "Quay lại"). */
|
|
41
42
|
backLabel?: string;
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -46,7 +47,8 @@ export interface PopoverNavHeaderProps {
|
|
|
46
47
|
* Pairs with `PopoverScreen`. Distinct from `Popover`'s own plain
|
|
47
48
|
* `PopoverHeader` children container. */
|
|
48
49
|
export function PopoverNavHeader(props: PopoverNavHeaderProps) {
|
|
49
|
-
const
|
|
50
|
+
const locale = useLoticsLocale();
|
|
51
|
+
const { title, right, backLabel = locale.nav.back } = props;
|
|
50
52
|
const { goBack, canGoBack } = usePopoverNav();
|
|
51
53
|
|
|
52
54
|
return (
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { unified } from "unified";
|
|
3
|
+
import remarkParse from "remark-parse";
|
|
4
|
+
import { remarkGfmSafe } from "./remark_gfm_safe";
|
|
5
|
+
|
|
6
|
+
/** Parse markdown to an mdast tree and serialize it, so tests assert on the
|
|
7
|
+
* resulting document structure without depending on mdast node typings. */
|
|
8
|
+
function parse(markdown: string): string {
|
|
9
|
+
return JSON.stringify(unified().use(remarkParse).use(remarkGfmSafe).parse(markdown));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("remarkGfmSafe", () => {
|
|
13
|
+
it("autolinks a bare URL in normal text (micromark tokenizer, no lookbehind)", () => {
|
|
14
|
+
expect(parse("see https://example.com now")).toContain('"url":"https://example.com"');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("autolinks a bare email in normal text (the case the removed sweep also handled)", () => {
|
|
18
|
+
expect(parse("contact me at a@b.com please")).toContain('"url":"mailto:a@b.com"');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("autolinks a bare www link", () => {
|
|
22
|
+
expect(parse("visit www.example.com today")).toContain('"url":"http://www.example.com"');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("still parses GFM tables", () => {
|
|
26
|
+
expect(parse("| a | b |\n| --- | --- |\n| 1 | 2 |")).toContain('"type":"table"');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("still parses strikethrough and task lists", () => {
|
|
30
|
+
expect(parse("~~gone~~")).toContain('"type":"delete"');
|
|
31
|
+
expect(parse("- [x] done")).toContain('"checked":true');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { CompileContext, Extension, Token } from "mdast-util-from-markdown";
|
|
2
|
+
import { gfmFootnoteFromMarkdown } from "mdast-util-gfm-footnote";
|
|
3
|
+
import { gfmStrikethroughFromMarkdown } from "mdast-util-gfm-strikethrough";
|
|
4
|
+
import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
|
|
5
|
+
import { gfmTaskListItemFromMarkdown } from "mdast-util-gfm-task-list-item";
|
|
6
|
+
import { gfm } from "micromark-extension-gfm";
|
|
7
|
+
import type { Plugin } from "unified";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `remark-gfm`, rebuilt so no regex lookbehind reaches the bundle.
|
|
11
|
+
*
|
|
12
|
+
* `remark-gfm` pulls `mdast-util-gfm-autolink-literal`, whose module contains an
|
|
13
|
+
* email autolink regex *literal* with a lookbehind `(?<=…)`. Lookbehind is a
|
|
14
|
+
* **parse-time** `SyntaxError` on WebKit older than Safari 16.4 — iOS Safari and
|
|
15
|
+
* every in-app WebView built on it (Zalo, Facebook). Because a regex literal is
|
|
16
|
+
* validated when the script is parsed, that one literal aborts parsing of the
|
|
17
|
+
* whole JS bundle, so the entire app fails to load, not just markdown. Merely
|
|
18
|
+
* disabling the code at runtime is not enough — the literal must never be
|
|
19
|
+
* bundled, i.e. the module must never be imported.
|
|
20
|
+
*
|
|
21
|
+
* So we compose GFM from its parts and provide the autolink-literal fromMarkdown
|
|
22
|
+
* handlers ourselves (below). These are exactly the upstream token→link
|
|
23
|
+
* handlers, minus the post-parse regex *sweep* that carried the lookbehind — the
|
|
24
|
+
* micromark tokenizer already autolinks URLs and emails in normal text without
|
|
25
|
+
* any lookbehind, so the common case is unchanged; only the rare residual cases
|
|
26
|
+
* that sweep additionally caught are lost. Tables, strikethrough, task lists,
|
|
27
|
+
* and footnotes are untouched. No toMarkdown extensions: this feeds
|
|
28
|
+
* `react-markdown`, which parses only and never serializes.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
function enterLiteralAutolink(this: CompileContext, token: Token): void {
|
|
32
|
+
this.enter({ type: "link", title: null, url: "", children: [] }, token);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function enterLiteralAutolinkValue(this: CompileContext, token: Token): void {
|
|
36
|
+
this.config.enter.autolinkProtocol.call(this, token);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function exitLiteralAutolinkHttp(this: CompileContext, token: Token): void {
|
|
40
|
+
this.config.exit.autolinkProtocol.call(this, token);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function exitLiteralAutolinkWww(this: CompileContext, token: Token): void {
|
|
44
|
+
this.config.exit.data.call(this, token);
|
|
45
|
+
const node = this.stack[this.stack.length - 1];
|
|
46
|
+
if (node?.type === "link") node.url = "http://" + this.sliceSerialize(token);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function exitLiteralAutolinkEmail(this: CompileContext, token: Token): void {
|
|
50
|
+
this.config.exit.autolinkEmail.call(this, token);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function exitLiteralAutolink(this: CompileContext, token: Token): void {
|
|
54
|
+
this.exit(token);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const autolinkLiteralFromMarkdown: Extension = {
|
|
58
|
+
enter: {
|
|
59
|
+
literalAutolink: enterLiteralAutolink,
|
|
60
|
+
literalAutolinkEmail: enterLiteralAutolinkValue,
|
|
61
|
+
literalAutolinkHttp: enterLiteralAutolinkValue,
|
|
62
|
+
literalAutolinkWww: enterLiteralAutolinkValue,
|
|
63
|
+
},
|
|
64
|
+
exit: {
|
|
65
|
+
literalAutolink: exitLiteralAutolink,
|
|
66
|
+
literalAutolinkEmail: exitLiteralAutolinkEmail,
|
|
67
|
+
literalAutolinkHttp: exitLiteralAutolinkHttp,
|
|
68
|
+
literalAutolinkWww: exitLiteralAutolinkWww,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const remarkGfmSafe: Plugin<[]> = function () {
|
|
73
|
+
const data = this.data();
|
|
74
|
+
const micromarkExtensions = (data.micromarkExtensions ??= []);
|
|
75
|
+
const fromMarkdownExtensions = (data.fromMarkdownExtensions ??= []);
|
|
76
|
+
micromarkExtensions.push(gfm());
|
|
77
|
+
fromMarkdownExtensions.push([
|
|
78
|
+
autolinkLiteralFromMarkdown,
|
|
79
|
+
gfmFootnoteFromMarkdown(),
|
|
80
|
+
gfmStrikethroughFromMarkdown(),
|
|
81
|
+
gfmTableFromMarkdown(),
|
|
82
|
+
gfmTaskListItemFromMarkdown(),
|
|
83
|
+
]);
|
|
84
|
+
};
|
package/src/scroll_to_bottom.tsx
CHANGED
|
@@ -2,15 +2,18 @@ import { colors } from "./colors";
|
|
|
2
2
|
import { StyleSheet } from "react-native";
|
|
3
3
|
import { Icon } from "./icon";
|
|
4
4
|
import { PressableHighlight } from "./pressable_highlight";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
5
6
|
|
|
6
7
|
interface ScrollToBottomProps {
|
|
7
8
|
onPress: () => void;
|
|
8
|
-
/** Tooltip text.
|
|
9
|
+
/** Tooltip text. Defaults to the locale's `scrollToBottom.tooltip`
|
|
10
|
+
* ("Scroll to bottom" / "Cuộn xuống cuối"). */
|
|
9
11
|
tooltip?: string;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export function ScrollToBottom(props: ScrollToBottomProps) {
|
|
13
|
-
const
|
|
15
|
+
const locale = useLoticsLocale();
|
|
16
|
+
const { onPress, tooltip = locale.scrollToBottom.tooltip } = props;
|
|
14
17
|
|
|
15
18
|
return (
|
|
16
19
|
<PressableHighlight
|
package/src/text_input_field.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import { fontFamilyRegular, getInputLineHeight, getInputTextStyle } from "./text
|
|
|
16
16
|
import { useScreenSize } from "./use_screen_size";
|
|
17
17
|
import { useAutoGrowHeight } from "./use_auto_grow_height";
|
|
18
18
|
import { useFormField } from "./form_field";
|
|
19
|
+
import { useLoticsLocale } from "./locale";
|
|
19
20
|
import type { ShortcutDescriptor } from "./keyboard";
|
|
20
21
|
|
|
21
22
|
interface TextInputFieldProps extends RNTextInputProps {
|
|
@@ -29,7 +30,8 @@ interface TextInputFieldProps extends RNTextInputProps {
|
|
|
29
30
|
autoGrow?: boolean;
|
|
30
31
|
/** Keyboard shortcut badge shown in the right slot when the input is empty. */
|
|
31
32
|
shortcut?: string | ShortcutDescriptor;
|
|
32
|
-
/** Accessible name for the clear button.
|
|
33
|
+
/** Accessible name for the clear button. Defaults to the locale's
|
|
34
|
+
* `textInputField.clear` ("Clear" / "Xóa"). */
|
|
33
35
|
clearLabel?: string;
|
|
34
36
|
// DOM-only ARIA attrs not declared on React Native's TextInputProps. They
|
|
35
37
|
// are forwarded verbatim to the underlying web input.
|
|
@@ -39,6 +41,7 @@ interface TextInputFieldProps extends RNTextInputProps {
|
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export function TextInputField(props: TextInputFieldProps) {
|
|
44
|
+
const locale = useLoticsLocale();
|
|
42
45
|
const {
|
|
43
46
|
style,
|
|
44
47
|
icon,
|
|
@@ -51,7 +54,7 @@ export function TextInputField(props: TextInputFieldProps) {
|
|
|
51
54
|
disabled,
|
|
52
55
|
autoGrow,
|
|
53
56
|
shortcut,
|
|
54
|
-
clearLabel =
|
|
57
|
+
clearLabel = locale.textInputField.clear,
|
|
55
58
|
ref,
|
|
56
59
|
"aria-controls": ariaControls,
|
|
57
60
|
"aria-activedescendant": ariaActivedescendant,
|