@lotics/ui 9.0.0 → 11.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 (41) hide show
  1. package/AGENTS.md +41 -10
  2. package/examples/tpl_allocate.tsx +2 -2
  3. package/examples/tpl_attendance.tsx +1 -1
  4. package/examples/tpl_calendar.tsx +1 -1
  5. package/examples/tpl_dashboard.tsx +1 -1
  6. package/examples/tpl_documents.tsx +1 -1
  7. package/examples/tpl_item_list.tsx +1 -1
  8. package/examples/tpl_lookup.tsx +1 -1
  9. package/examples/tpl_pick.tsx +3 -3
  10. package/examples/tpl_pivot.tsx +1 -1
  11. package/examples/tpl_record.tsx +24 -30
  12. package/examples/tpl_report.tsx +1 -1
  13. package/examples/tpl_rollup.tsx +1 -1
  14. package/examples/tpl_shifts.tsx +1 -1
  15. package/examples/tpl_statements.tsx +1 -1
  16. package/examples/tpl_stock.tsx +1 -1
  17. package/examples/tpl_task_board.tsx +1 -1
  18. package/examples/tpl_tasks.tsx +1 -1
  19. package/examples/tpl_tower.tsx +1 -1
  20. package/package.json +2 -7
  21. package/src/button.tsx +5 -1
  22. package/src/button_colors.ts +4 -1
  23. package/src/locale.tsx +4 -0
  24. package/src/page_content.tsx +1 -1
  25. package/src/page_header.tsx +2 -4
  26. package/src/popover_nav.tsx +40 -0
  27. package/src/record_summary.tsx +3 -1
  28. package/src/ring_gauge.tsx +1 -1
  29. package/src/section_heading.tsx +70 -9
  30. package/src/section_stack.tsx +67 -0
  31. package/src/text.css +13 -2
  32. package/src/text.tsx +8 -2
  33. package/src/text_utils.ts +6 -1
  34. package/src/trend_footer.tsx +3 -1
  35. package/src/use_screen_size.ts +1 -1
  36. package/src/animation_horizontal_slide.tsx +0 -75
  37. package/src/form_time_picker.tsx +0 -22
  38. package/src/highlighted_text.tsx +0 -92
  39. package/src/menu_title.tsx +0 -15
  40. package/src/pager_view.tsx +0 -167
  41. package/src/popover_header.tsx +0 -38
package/src/locale.tsx CHANGED
@@ -45,6 +45,8 @@ export interface LoticsLocale {
45
45
  sectionHeading: { info: string };
46
46
  /** `Chip`: the ✕ default name when no `dismissTooltip` is given. */
47
47
  chip: { remove: string };
48
+ /** `TrendFooter`: the direction words of the trend sentence. */
49
+ trendFooter: { up: string; down: string };
48
50
  /** `SuggestionChip`: the press target's default name and the ✕ tooltip. */
49
51
  suggestionChip: { add: (label: string) => string; dismiss: string };
50
52
  /** `Confidence`: the full level phrase ("High confidence" …). */
@@ -105,6 +107,7 @@ export const en: LoticsLocale = {
105
107
  ledger: { rowDetails: (label) => `${label} details` },
106
108
  sectionHeading: { info: "About this data" },
107
109
  chip: { remove: "Remove" },
110
+ trendFooter: { up: "Up", down: "Down" },
108
111
  suggestionChip: { add: (label) => `Add: ${label}`, dismiss: "Dismiss suggestion" },
109
112
  confidence: { high: "High confidence", medium: "Medium confidence", low: "Low confidence" },
110
113
  remainderMeter: {
@@ -181,6 +184,7 @@ export const vi: LoticsLocale = {
181
184
  ledger: { rowDetails: (label) => `Chi tiết ${label}` },
182
185
  sectionHeading: { info: "Giải thích dữ liệu" },
183
186
  chip: { remove: "Xóa" },
187
+ trendFooter: { up: "Tăng", down: "Giảm" },
184
188
  suggestionChip: { add: (label) => `Thêm: ${label}`, dismiss: "Bỏ gợi ý" },
185
189
  confidence: { high: "Độ tin cậy cao", medium: "Độ tin cậy trung bình", low: "Độ tin cậy thấp" },
186
190
  remainderMeter: {
@@ -84,7 +84,7 @@ export function PageContent(props: PageContentProps) {
84
84
  }}
85
85
  >
86
86
  {!!title && (
87
- <Text size="xl" weight="semibold">
87
+ <Text size="xxl" weight="semibold">
88
88
  {title}
89
89
  </Text>
90
90
  )}
@@ -1,7 +1,6 @@
1
1
  import { View } from "react-native";
2
2
  import { Text } from "@lotics/ui/text";
3
3
  import { ReactNode } from "react";
4
- import { useScreenSize } from "@lotics/ui/use_screen_size";
5
4
 
6
5
  interface PageHeaderProps {
7
6
  title: string;
@@ -12,7 +11,6 @@ interface PageHeaderProps {
12
11
 
13
12
  export function PageHeader(props: PageHeaderProps) {
14
13
  const { title, description, left, right } = props;
15
- const screenSize = useScreenSize();
16
14
 
17
15
  const hasNav = !!left || !!right;
18
16
 
@@ -24,12 +22,12 @@ export function PageHeader(props: PageHeaderProps) {
24
22
  {left}
25
23
  {right}
26
24
  </View>
27
- <Text size="xl" weight="semibold">
25
+ <Text size="xxl" weight="semibold">
28
26
  {title}
29
27
  </Text>
30
28
  </>
31
29
  ) : (
32
- <Text size="xl" weight="semibold">
30
+ <Text size="xxl" weight="semibold">
33
31
  {title}
34
32
  </Text>
35
33
  )}
@@ -1,4 +1,7 @@
1
1
  import { createContext, ReactNode, useContext } from "react";
2
+ import { IconButton } from "./icon_button";
3
+ import { View, StyleSheet } from "react-native";
4
+ import { Text } from "./text";
2
5
 
3
6
  export interface PopoverNavContextValue {
4
7
  currentRoute: string;
@@ -30,3 +33,40 @@ export function PopoverScreen(props: PopoverScreenProps) {
30
33
 
31
34
  return <>{children}</>;
32
35
  }
36
+
37
+ export interface PopoverNavHeaderProps {
38
+ title: string;
39
+ right?: ReactNode;
40
+ /** Accessible name for the back button. Default: "Back". Pass a translated string from the consumer. */
41
+ backLabel?: string;
42
+ }
43
+
44
+ /** The routed-popover title row: a back chevron auto-appears while `canGoBack`
45
+ * (reads `usePopoverNav`), sm-medium title fills, `right` a trailing action.
46
+ * Pairs with `PopoverScreen`. Distinct from `Popover`'s own plain
47
+ * `PopoverHeader` children container. */
48
+ export function PopoverNavHeader(props: PopoverNavHeaderProps) {
49
+ const { title, right, backLabel = "Back" } = props;
50
+ const { goBack, canGoBack } = usePopoverNav();
51
+
52
+ return (
53
+ <View style={navHeaderStyles.container}>
54
+ {canGoBack && (
55
+ <IconButton icon="chevron-left" size="lg" color="secondary" accessibilityLabel={backLabel} onPress={goBack} />
56
+ )}
57
+ <Text size="sm" weight="medium" style={{ flex: 1 }}>
58
+ {title}
59
+ </Text>
60
+ {right}
61
+ </View>
62
+ );
63
+ }
64
+
65
+ const navHeaderStyles = StyleSheet.create({
66
+ container: {
67
+ paddingLeft: 8,
68
+ flexDirection: "row",
69
+ alignItems: "center",
70
+ gap: 8,
71
+ },
72
+ });
@@ -47,7 +47,9 @@ export function RecordSummary(props: RecordSummaryProps) {
47
47
  <View style={styles.identityRow}>
48
48
  <View style={styles.identity}>
49
49
  <View style={styles.titleRow}>
50
- <Text size="xl" weight="semibold" tabular level={level}>
50
+ {/* xxl — the `#` of the heading ramp (# xxl → ## xl section
51
+ ### lg subsection); the identity always tops the outline */}
52
+ <Text size="xxl" weight="semibold" tabular level={level}>
51
53
  {title}
52
54
  </Text>
53
55
  {status ?? null}
@@ -53,7 +53,7 @@ export function RingGauge(props: RingGaugeProps) {
53
53
  transform={`rotate(-90 ${center} ${center})`}
54
54
  />
55
55
  </svg>
56
- <Text size="xxl" weight="semibold">
56
+ <Text size="xxxl" weight="semibold">
57
57
  {Math.round(clamped)}%
58
58
  </Text>
59
59
  </View>
@@ -38,13 +38,72 @@ export interface SectionProps {
38
38
  */
39
39
  export function Section(props: SectionProps) {
40
40
  // gap 12 spaces the heading from its body. Space BETWEEN sections belongs to
41
- // the page column: flat record/form pages use `gap: 32` on the content column
42
- // with a bare `Divider` BETWEEN sections (never under the heading the
43
- // heading belongs to its content; the hairline separates it from the LAST
44
- // section).
41
+ // the page column (`SectionStack`, 56 + hairline); a body of `Subsection`
42
+ // groups divides via a nested `SubsectionStack` (24 + hairline)
43
+ // never a hairline directly under the heading (it belongs to its content).
45
44
  return <View style={[{ gap: 12 }, props.style]}>{props.children}</View>;
46
45
  }
47
46
 
47
+ export interface SubsectionProps {
48
+ children: React.ReactNode;
49
+ style?: StyleProp<ViewStyle>;
50
+ }
51
+
52
+ /**
53
+ * One named group INSIDE a `Section` — the level below `Section` on a long
54
+ * record surface (`###` in the heading ramp). Compose a `SubsectionHeading`
55
+ * then the group's rows; sibling subsections stack in a `SubsectionStack`
56
+ * (24 + hairline between groups — no margins, no hand-rolled dividers). A
57
+ * headingless `Subsection` is fine for the section's lead group.
58
+ *
59
+ * <Section>
60
+ * <SectionHeading><SectionHeadingTitle>Delivery</SectionHeadingTitle></SectionHeading>
61
+ * <SubsectionStack>
62
+ * <Subsection>{lead rows}</Subsection>
63
+ * <Subsection>
64
+ * <SubsectionHeading><SubsectionHeadingTitle>Carrier</SubsectionHeadingTitle></SubsectionHeading>
65
+ * {rows}
66
+ * </Subsection>
67
+ * </SubsectionStack>
68
+ * </Section>
69
+ */
70
+ export function Subsection(props: SubsectionProps) {
71
+ return <View style={[{ gap: 12 }, props.style]}>{props.children}</View>;
72
+ }
73
+
74
+ export interface SubsectionHeadingProps {
75
+ children: React.ReactNode;
76
+ style?: StyleProp<ViewStyle>;
77
+ }
78
+
79
+ export function SubsectionHeading(props: SubsectionHeadingProps) {
80
+ return (
81
+ <View style={[{ flexDirection: "row", alignItems: "center", gap: 10 }, props.style]}>
82
+ {props.children}
83
+ </View>
84
+ );
85
+ }
86
+
87
+ export interface SubsectionHeadingTitleProps {
88
+ children: React.ReactNode;
89
+ /** Heading rank. Defaults to 3 — one level under `SectionHeadingTitle`'s 2. */
90
+ level?: HeadingLevel;
91
+ }
92
+
93
+ /** The subsection title — ALWAYS `###` (lg semibold), the fixed step under the
94
+ * xl section title and above the md/sm body. Grows to push siblings (a
95
+ * `SectionHeadingMeta`, a `Badge`, an action) to the right edge. */
96
+ export function SubsectionHeadingTitle(props: SubsectionHeadingTitleProps) {
97
+ const { children, level = 3 } = props;
98
+ return (
99
+ <View style={{ flex: 1 }}>
100
+ <Text level={level} size="lg" weight="semibold">
101
+ {children}
102
+ </Text>
103
+ </View>
104
+ );
105
+ }
106
+
48
107
  export interface SectionHeadingProps {
49
108
  children: React.ReactNode;
50
109
  style?: StyleProp<ViewStyle>;
@@ -73,16 +132,18 @@ export interface SectionHeadingTitleProps {
73
132
  info?: string;
74
133
  }
75
134
 
76
- /** The title block — lg semibold (a section must announce itself; md blends
77
- * into control labels) + an optional muted `description` line. Grows to push
78
- * any sibling actions to the right edge. */
135
+ /** The title block — ALWAYS `##` (xl semibold): the heading ramp is the fixed
136
+ * markdown ladder `#` xxl (`RecordSummary`/`PageHeader`) `##` xl (section) →
137
+ * `###` lg (`SubsectionHeadingTitle`) over the sm body — no per-surface size
138
+ * knobs, one outline everywhere. Optional muted `description` line; grows to
139
+ * push any sibling actions to the right edge. */
79
140
  export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
80
141
  const { children, description, icon, level = 2, weight = "semibold", info } = props;
81
142
  return (
82
143
  <View style={{ flex: 1, gap: 2 }}>
83
144
  <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
84
- {icon ? <Icon name={icon} size={18} /> : null}
85
- <Text level={level} weight={weight} size="lg">
145
+ {icon ? <Icon name={icon} size={20} /> : null}
146
+ <Text level={level} weight={weight} size="xl">
86
147
  {children}
87
148
  </Text>
88
149
  {info ? <InfoPopover text={info} accessibilityLabel={useLoticsLocale().sectionHeading.info} /> : null}
@@ -0,0 +1,67 @@
1
+ import { Children, Fragment } from "react";
2
+ import { View, type StyleProp, type ViewStyle } from "react-native";
3
+ import { Divider } from "./divider";
4
+
5
+ interface StackProps {
6
+ children: React.ReactNode;
7
+ /** Hairline between blocks (default true). Turn off for a short surface
8
+ * where the beat alone is enough. */
9
+ divided?: boolean;
10
+ style?: StyleProp<ViewStyle>;
11
+ }
12
+
13
+ /** Interleave a hairline between the non-null blocks of a gap column. */
14
+ function DividedStack({ children, divided = true, gap, style }: StackProps & { gap: number }) {
15
+ const blocks = Children.toArray(children); // drops null/undefined/false
16
+ return (
17
+ <View style={[{ gap }, style]}>
18
+ {blocks.map((block, i) => (
19
+ // Children.toArray keys are stable for conditional lists; the fragment
20
+ // pairs each block with the hairline that separates it from the last.
21
+ <Fragment key={`sec_${i}`}>
22
+ {divided && i > 0 ? <Divider /> : null}
23
+ {block}
24
+ </Fragment>
25
+ ))}
26
+ </View>
27
+ );
28
+ }
29
+
30
+ export type SectionStackProps = StackProps;
31
+
32
+ /**
33
+ * The content column of a flat record/form page — it OWNS the between-section
34
+ * law: a fixed 56px beat with a hairline `Divider` between top-level blocks
35
+ * (never under a heading; the hairline separates a block from the PREVIOUS
36
+ * one). Null/false children are skipped, so a conditional section never
37
+ * leaves a stray hairline. No knobs — every flat page shares one rhythm.
38
+ *
39
+ * <SectionStack>
40
+ * {headerBand}
41
+ * <Section>…</Section>
42
+ * {canTransfer ? <Section>…</Section> : null}
43
+ * </SectionStack>
44
+ */
45
+ export function SectionStack(props: SectionStackProps) {
46
+ return <DividedStack {...props} gap={56} />;
47
+ }
48
+
49
+ export type SubsectionStackProps = StackProps;
50
+
51
+ /**
52
+ * The divided stack of `Subsection` groups INSIDE a `Section` — the same
53
+ * hairline law one step tighter: a fixed 24px beat. Compose it as the
54
+ * section's body (after the `SectionHeading`); a section's groups divide
55
+ * without hand-rolled gaps or dividers.
56
+ *
57
+ * <Section>
58
+ * <SectionHeading><SectionHeadingTitle>Delivery</SectionHeadingTitle></SectionHeading>
59
+ * <SubsectionStack>
60
+ * <Subsection>{lead rows}</Subsection>
61
+ * {carrier ? <Subsection>…</Subsection> : null}
62
+ * </SubsectionStack>
63
+ * </Section>
64
+ */
65
+ export function SubsectionStack(props: SubsectionStackProps) {
66
+ return <DividedStack {...props} gap={24} />;
67
+ }
package/src/text.css CHANGED
@@ -31,12 +31,19 @@
31
31
  line-height: 24px;
32
32
  letter-spacing: -0.005em;
33
33
  }
34
+ /* Display ramp — the heading ladder: xl = `##` (section), xxl = `#`
35
+ (page/record title), xxxl = hero numbers. */
34
36
  [data-text-size="xl"] {
37
+ font-size: 22px;
38
+ line-height: 28px;
39
+ letter-spacing: -0.01em;
40
+ }
41
+ [data-text-size="xxl"] {
35
42
  font-size: 28px;
36
43
  line-height: 34px;
37
44
  letter-spacing: -0.015em;
38
45
  }
39
- [data-text-size="xxl"] {
46
+ [data-text-size="xxxl"] {
40
47
  font-size: 32px;
41
48
  line-height: 38px;
42
49
  letter-spacing: -0.02em;
@@ -61,10 +68,14 @@
61
68
  line-height: 28px;
62
69
  }
63
70
  [data-text-size="xl"] {
71
+ font-size: 24px;
72
+ line-height: 32px;
73
+ }
74
+ [data-text-size="xxl"] {
64
75
  font-size: 32px;
65
76
  line-height: 44px;
66
77
  }
67
- [data-text-size="xxl"] {
78
+ [data-text-size="xxxl"] {
68
79
  font-size: 48px;
69
80
  line-height: 52px;
70
81
  }
package/src/text.tsx CHANGED
@@ -50,7 +50,7 @@ export interface TextProps {
50
50
 
51
51
  export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
52
52
 
53
- export type TextSize = "xs" | "sm" | "md" | "lg" | "xl" | "xxl";
53
+ export type TextSize = "xs" | "sm" | "md" | "lg" | "xl" | "xxl" | "xxxl";
54
54
  type TextAlign = "left" | "right" | "center";
55
55
  type TextDecorationLine = "underline" | "lineThrough" | "underline lineThrough";
56
56
  type TextWeight = "regular" | "medium" | "semibold";
@@ -146,11 +146,17 @@ const styles = StyleSheet.create({
146
146
  fontSize: 18,
147
147
  lineHeight: 30,
148
148
  },
149
+ // Display ramp — the heading ladder: xl = `##` (section), xxl = `#`
150
+ // (page/record title), xxxl = hero numbers.
149
151
  xl: {
152
+ fontSize: 22,
153
+ lineHeight: 28,
154
+ },
155
+ xxl: {
150
156
  fontSize: 28,
151
157
  lineHeight: 34,
152
158
  },
153
- xxl: {
159
+ xxxl: {
154
160
  fontSize: 32,
155
161
  lineHeight: 38,
156
162
  },
package/src/text_utils.ts CHANGED
@@ -10,7 +10,8 @@ export type TextColor =
10
10
  | "success"
11
11
  | "zinc-900"
12
12
  | "zinc-700"
13
- | "zinc-500";
13
+ | "zinc-500"
14
+ | "zinc-400";
14
15
 
15
16
  export function getTextColor(color?: TextColor): string {
16
17
  switch (color) {
@@ -24,6 +25,10 @@ export function getTextColor(color?: TextColor): string {
24
25
  return colors.zinc["700"];
25
26
  case "zinc-500":
26
27
  return colors.zinc["500"];
28
+ // The disabled ink — light enough that a control reads inert at a glance
29
+ // (muted/600 still reads as body ink on a zinc-50 chip).
30
+ case "zinc-400":
31
+ return colors.zinc["400"];
27
32
  case "inverted":
28
33
  return colors.white;
29
34
  // Valence set — the lightest status weight: a state WORD whose meaning is in
@@ -1,4 +1,5 @@
1
1
  import { TrendingDown, TrendingUp } from "lucide-react";
2
+ import { useLoticsLocale } from "./locale";
2
3
  import { View, StyleSheet } from "react-native";
3
4
  import { Text } from "./text";
4
5
  import { colors } from "./colors";
@@ -32,13 +33,14 @@ export function TrendFooter(props: TrendFooterProps) {
32
33
  const flat = value === 0;
33
34
  const isGood = flat ? null : (up && goodDirection === "up") || (!up && goodDirection === "down");
34
35
  const color = isGood === null ? colors.zinc[600] : isGood ? colors.green[700] : colors.red[700];
36
+ const labels = useLoticsLocale().trendFooter;
35
37
  const Icon = up ? TrendingUp : TrendingDown;
36
38
  return (
37
39
  <View style={styles.container}>
38
40
  <View style={styles.row}>
39
41
  <Icon size={16} color={color} />
40
42
  <Text size="sm" weight="medium" style={{ color }}>
41
- {up ? "Up" : "Down"} {Math.abs(value)}% {periodLabel}
43
+ {up ? labels.up : labels.down} {Math.abs(value)}% {periodLabel}
42
44
  </Text>
43
45
  </View>
44
46
  {detail && (
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState } from "react";
2
- import { Dimensions, Platform } from "react-native";
2
+ import { Dimensions } from "react-native";
3
3
 
4
4
  const breakpoints = {
5
5
  // small (mobile)
@@ -1,75 +0,0 @@
1
- import React, { useEffect, useRef } from "react";
2
- import { Animated, StyleProp, ViewStyle } from "react-native";
3
-
4
- interface AnimationHorizontalSlideProps {
5
- width: number;
6
- visible: boolean;
7
- children?: React.ReactNode;
8
- onSlideAnimationEnd?: () => void;
9
- onCollapseAnimationEnd?: () => void;
10
- style?: StyleProp<ViewStyle>;
11
- contentContainerStyle?: StyleProp<ViewStyle>;
12
- }
13
-
14
- export function AnimationHorizontalSlide(props: AnimationHorizontalSlideProps) {
15
- const {
16
- width: intrinsicWidth,
17
- visible,
18
- children,
19
- onSlideAnimationEnd,
20
- onCollapseAnimationEnd,
21
- style,
22
- contentContainerStyle,
23
- } = props;
24
- const widthAnim = useRef(new Animated.Value(visible ? intrinsicWidth : 0)).current;
25
- const opacityAnim = useRef(new Animated.Value(visible ? 1 : 0)).current;
26
-
27
- useEffect(() => {
28
- Animated.parallel([
29
- Animated.spring(widthAnim, {
30
- toValue: visible ? intrinsicWidth : 0,
31
- bounciness: 0,
32
- useNativeDriver: false,
33
- }),
34
- Animated.spring(opacityAnim, {
35
- toValue: visible ? 1 : 0,
36
- bounciness: 0,
37
- useNativeDriver: false,
38
- }),
39
- ]).start((animation) => {
40
- if (animation.finished) {
41
- if (visible) {
42
- onSlideAnimationEnd?.();
43
- } else {
44
- onCollapseAnimationEnd?.();
45
- }
46
- }
47
- });
48
- }, [
49
- widthAnim,
50
- opacityAnim,
51
- visible,
52
- intrinsicWidth,
53
- onSlideAnimationEnd,
54
- onCollapseAnimationEnd,
55
- ]);
56
-
57
- return (
58
- <Animated.View style={[{ width: widthAnim, opacity: opacityAnim }, style]}>
59
- <Animated.View
60
- style={[
61
- {
62
- display: visible ? "flex" : "none",
63
- position: "absolute",
64
- top: 0,
65
- bottom: 0,
66
- width: intrinsicWidth,
67
- },
68
- contentContainerStyle,
69
- ]}
70
- >
71
- {children}
72
- </Animated.View>
73
- </Animated.View>
74
- );
75
- }
@@ -1,22 +0,0 @@
1
- import { FormField, FormFieldProps } from "./form_field";
2
- import { TimePicker, TimePickerProps } from "./time_picker";
3
-
4
- export interface FormTimePickerProps
5
- extends Omit<FormFieldProps, "style">,
6
- TimePickerProps {}
7
-
8
- export function FormTimePicker(props: FormTimePickerProps) {
9
- const { label, description, optional, optionalLabel, error, ...timePickerProps } = props;
10
-
11
- return (
12
- <FormField
13
- label={label}
14
- description={description}
15
- error={error}
16
- optional={optional}
17
- optionalLabel={optionalLabel}
18
- >
19
- <TimePicker {...timePickerProps} />
20
- </FormField>
21
- );
22
- }
@@ -1,92 +0,0 @@
1
- import React from "react";
2
- import { Text, TextProps } from "./text";
3
- import { colors } from "./colors";
4
- import { View, StyleSheet } from "react-native";
5
-
6
- interface HighlightedTextProps extends Omit<TextProps, "children"> {
7
- text: string;
8
- searchTerm: string;
9
- singleLine?: boolean;
10
- }
11
-
12
- export function HighlightedText({
13
- text,
14
- searchTerm,
15
- singleLine = false,
16
- ...textProps
17
- }: HighlightedTextProps) {
18
- const parts = highlightSearchTerm(text || "", searchTerm || "");
19
-
20
- // Use numberOfLines prop for single line display instead of custom CSS
21
- const numberOfLines = singleLine ? 1 : undefined;
22
-
23
- return (
24
- <Text {...textProps} numberOfLines={numberOfLines}>
25
- {parts.map((part, index) => (
26
- <Text key={index} style={part.isHighlighted ? styles.highlightedText : undefined}>
27
- {part.text}
28
- </Text>
29
- ))}
30
- </Text>
31
- );
32
- }
33
-
34
- interface TextPart {
35
- text: string;
36
- isHighlighted: boolean;
37
- }
38
-
39
- function highlightSearchTerm(text: string, searchTerm: string): TextPart[] {
40
- if (!searchTerm.trim()) {
41
- return [{ text, isHighlighted: false }];
42
- }
43
-
44
- const searchRegex = new RegExp(`(${escapeRegExp(searchTerm.trim())})`, "gi");
45
- const parts: TextPart[] = [];
46
- let lastIndex = 0;
47
-
48
- text.replace(searchRegex, (match, ...args) => {
49
- const index = args[args.length - 2] as number; // offset index from regex replace
50
-
51
- // Add text before the match
52
- if (index > lastIndex) {
53
- parts.push({
54
- text: text.slice(lastIndex, index),
55
- isHighlighted: false,
56
- });
57
- }
58
-
59
- // Add the highlighted match
60
- parts.push({
61
- text: match,
62
- isHighlighted: true,
63
- });
64
-
65
- lastIndex = index + match.length;
66
- return match;
67
- });
68
-
69
- // Add any remaining text after the last match
70
- if (lastIndex < text.length) {
71
- parts.push({
72
- text: text.slice(lastIndex),
73
- isHighlighted: false,
74
- });
75
- }
76
-
77
- return parts.length > 0 ? parts : [{ text, isHighlighted: false }];
78
- }
79
-
80
- function escapeRegExp(string: string): string {
81
- return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
82
- }
83
-
84
- const styles = StyleSheet.create({
85
- highlightedText: {
86
- backgroundColor: colors.yellow[100],
87
- color: colors.yellow[900],
88
- fontWeight: "bold" as const,
89
- paddingHorizontal: 2,
90
- borderRadius: 2,
91
- },
92
- });
@@ -1,15 +0,0 @@
1
- import { Text } from "./text";
2
-
3
- export interface MenuTitleProps {
4
- title: string;
5
- }
6
-
7
- export function MenuTitle(props: MenuTitleProps) {
8
- const { title } = props;
9
-
10
- return (
11
- <Text size="lg" weight="semibold" style={{ paddingLeft: 8, lineHeight: 32, paddingBottom: 16 }}>
12
- {title}
13
- </Text>
14
- );
15
- }