@lotics/ui 2.4.1 → 2.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.
Files changed (69) hide show
  1. package/package.json +28 -8
  2. package/src/accordion.tsx +146 -63
  3. package/src/action_menu.tsx +72 -0
  4. package/src/allocation_row.tsx +54 -0
  5. package/src/badge.tsx +40 -9
  6. package/src/breakdown.tsx +121 -0
  7. package/src/card.tsx +150 -0
  8. package/src/cell_select.tsx +3 -2
  9. package/src/chip_group.tsx +65 -0
  10. package/src/colors.ts +61 -0
  11. package/src/column_filter.tsx +9 -24
  12. package/src/completion_state.tsx +43 -0
  13. package/src/control_surface.ts +32 -0
  14. package/src/counter.tsx +58 -0
  15. package/src/date_range_filter_field.tsx +44 -12
  16. package/src/detail_row.tsx +45 -0
  17. package/src/dialog.tsx +0 -24
  18. package/src/download.ts +2 -1
  19. package/src/drawer.tsx +94 -2
  20. package/src/empty_state.tsx +37 -0
  21. package/src/file_badge.tsx +27 -4
  22. package/src/file_dropzone.tsx +188 -0
  23. package/src/file_picker.ts +45 -0
  24. package/src/filter_pill.tsx +106 -0
  25. package/src/floating_action_bar.tsx +57 -0
  26. package/src/fonts.css +10 -13
  27. package/src/format_money.ts +38 -0
  28. package/src/heatmap.tsx +153 -0
  29. package/src/icon.tsx +2 -0
  30. package/src/icon_button.tsx +16 -2
  31. package/src/index.css +4 -3
  32. package/src/info_popover.tsx +4 -6
  33. package/src/kpi_card.tsx +19 -6
  34. package/src/kpi_strip.tsx +89 -0
  35. package/src/line_chart.tsx +61 -34
  36. package/src/link_button.tsx +50 -0
  37. package/src/metric.tsx +21 -12
  38. package/src/pagination.tsx +5 -9
  39. package/src/peek.tsx +68 -0
  40. package/src/picker.tsx +13 -1
  41. package/src/picker_menu.tsx +8 -16
  42. package/src/pie_chart.tsx +29 -8
  43. package/src/pill_button.tsx +10 -8
  44. package/src/popover.tsx +14 -4
  45. package/src/pressable_highlight.tsx +10 -1
  46. package/src/pressable_row.tsx +91 -0
  47. package/src/progress_bar.tsx +47 -17
  48. package/src/radio_picker.tsx +20 -9
  49. package/src/range_slider.tsx +185 -0
  50. package/src/remainder_meter.tsx +48 -0
  51. package/src/ring_gauge.tsx +5 -5
  52. package/src/scan_field.tsx +58 -0
  53. package/src/search_input.tsx +12 -0
  54. package/src/skeleton.tsx +47 -0
  55. package/src/sort_header.tsx +102 -0
  56. package/src/stacked_progress_bar.tsx +51 -16
  57. package/src/status_grid.tsx +187 -0
  58. package/src/step_list.tsx +128 -0
  59. package/src/step_progress.tsx +145 -0
  60. package/src/stepper.tsx +9 -4
  61. package/src/table.tsx +168 -112
  62. package/src/text.tsx +15 -0
  63. package/src/text_utils.ts +10 -0
  64. package/src/timeline.tsx +90 -57
  65. package/src/trend_footer.tsx +2 -2
  66. package/src/alert_row.tsx +0 -81
  67. package/src/table.web.tsx +0 -235
  68. package/src/table_picker.tsx +0 -305
  69. package/src/table_types.ts +0 -47
package/src/card.tsx CHANGED
@@ -1,5 +1,9 @@
1
+ import { type ReactNode } from "react";
1
2
  import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
2
3
  import { colors } from "./colors";
4
+ import { Divider } from "./divider";
5
+ import { InfoPopover } from "./info_popover";
6
+ import { Text } from "./text";
3
7
 
4
8
  interface CardProps {
5
9
  children: React.ReactNode;
@@ -11,6 +15,13 @@ interface CardProps {
11
15
  * A bordered presentational surface — view only. It never handles interaction:
12
16
  * for a pressable/selectable card use CardSelectItem (a real focusable button),
13
17
  * or compose PressableHighlight for a bespoke action.
18
+ *
19
+ * Two shapes:
20
+ * - Plain: `<Card>` with the default 20px padding, author owns the content.
21
+ * - Banded: `<Card style={{ padding: 0 }}>` composed from the family slots —
22
+ * `CardHeader` (+ `CardHeaderTitle`/`CardHeaderMeta`), `CardBody`,
23
+ * `CardFooter` — separated by hairlines, mirroring the Dialog/Popover
24
+ * composable idiom.
14
25
  */
15
26
  export function Card(props: CardProps) {
16
27
  const { children, testID, style } = props;
@@ -22,10 +33,122 @@ export function Card(props: CardProps) {
22
33
  );
23
34
  }
24
35
 
36
+ export interface CardHeaderProps {
37
+ /** Usually `CardHeaderTitle` (+ optionally `CardHeaderMeta` or any
38
+ * right-side node — Badge, Button, an Avatar identity row). The band is a
39
+ * layout slot, fully replaceable, like `DialogHeader`. */
40
+ children: ReactNode;
41
+ style?: StyleProp<ViewStyle>;
42
+ }
43
+
44
+ /**
45
+ * Opening band of a banded Card, closed by the hairline:
46
+ *
47
+ * <CardHeader>
48
+ * <CardHeaderTitle info="what this data shows">Công nợ</CardHeaderTitle>
49
+ * <CardHeaderMeta>12 hóa đơn</CardHeaderMeta>
50
+ * </CardHeader>
51
+ *
52
+ * For bespoke headers (member info with Avatar, actions) put any children in
53
+ * the band — padding and rhythm stay standard. Screens must not hand-compose
54
+ * this band.
55
+ */
56
+ export function CardHeader(props: CardHeaderProps) {
57
+ return (
58
+ <>
59
+ <View style={[styles.headerBand, props.style]}>{props.children}</View>
60
+ <Divider />
61
+ </>
62
+ );
63
+ }
64
+
65
+ export interface CardHeaderTitleProps {
66
+ children: ReactNode;
67
+ /**
68
+ * What this card's data shows — clicking the ⓘ next to the title opens it
69
+ * in a popover ("Doanh thu đã xuất hóa đơn, chưa trừ chiết khấu"). A reader
70
+ * who doesn't already know the data shouldn't have to guess — provide this
71
+ * on every card whose title alone doesn't fully define the numbers.
72
+ */
73
+ info?: string;
74
+ /** One muted line under the title — what this section decides or contains.
75
+ * For settings-style cards where the title alone is too terse. */
76
+ description?: string;
77
+ }
78
+
79
+ /** Title slot for CardHeader — sm semibold + optional ⓘ explainer + optional
80
+ * description line. Grows to push siblings (meta, actions) to the right.
81
+ * Tabular numerals so id-like titles (SR-2026-0081) align across a list. */
82
+ export function CardHeaderTitle(props: CardHeaderTitleProps) {
83
+ return (
84
+ <View style={styles.headerTitle}>
85
+ <View style={styles.headerTitleRow}>
86
+ <Text size="sm" weight="semibold" tabular>
87
+ {props.children}
88
+ </Text>
89
+ {props.info ? <InfoPopover text={props.info} accessibilityLabel="Giải thích dữ liệu" /> : null}
90
+ </View>
91
+ {props.description ? (
92
+ <Text size="xs" color="muted">
93
+ {props.description}
94
+ </Text>
95
+ ) : null}
96
+ </View>
97
+ );
98
+ }
99
+
100
+ export interface CardHeaderMetaProps {
101
+ children: ReactNode;
102
+ }
103
+
104
+ /** Right-side context for CardHeader — a count ("12 hóa đơn"), unit, or
105
+ * period. xs muted, tabular for numerals. */
106
+ export function CardHeaderMeta(props: CardHeaderMetaProps) {
107
+ return (
108
+ <Text size="xs" color="muted" tabular>
109
+ {props.children}
110
+ </Text>
111
+ );
112
+ }
113
+
114
+ export interface CardBodyProps {
115
+ children: ReactNode;
116
+ style?: StyleProp<ViewStyle>;
117
+ }
118
+
119
+ /** Padded content band of a banded Card. Style override for gap/flex only —
120
+ * keep the standard padding. */
121
+ export function CardBody(props: CardBodyProps) {
122
+ return <View style={[styles.body, props.style]}>{props.children}</View>;
123
+ }
124
+
125
+ export interface CardFooterProps {
126
+ /** Typically a muted hint (flex: 1) on the left and the band's actions on
127
+ * the right — one primary per surface. */
128
+ children: ReactNode;
129
+ style?: StyleProp<ViewStyle>;
130
+ }
131
+
132
+ /** Closing action band of a banded Card, opened by the hairline. */
133
+ export function CardFooter(props: CardFooterProps) {
134
+ return (
135
+ <>
136
+ <Divider />
137
+ <View style={[styles.footerBand, props.style]}>{props.children}</View>
138
+ </>
139
+ );
140
+ }
141
+
25
142
  const styles = StyleSheet.create({
26
143
  container: {
27
144
  padding: 20,
28
145
  borderRadius: 16,
146
+ // Clip banded content (full-bleed rows, their hover/selected wash) to the
147
+ // rounded corners. The CSS box-shadow below is painted outside the border
148
+ // box, so `overflow: hidden` on the same element doesn't clip it — the lift
149
+ // survives. In-card overlays (Peek/ActionMenu/Popover/Tooltip) are
150
+ // portaled, so they aren't clipped either.
151
+ overflow: "hidden",
29
152
  backgroundColor: colors.background,
30
153
  // 1px hairline + 2-layer shadow: the research-validated default across
31
154
  // shadcn/ui, Geist, Tailwind v4. Without either, cards on a near-white
@@ -42,4 +165,31 @@ const styles = StyleSheet.create({
42
165
  "0 1px 2px 0 rgba(38,38,38,0.06), 0 4px 12px -2px rgba(38,38,38,0.06)",
43
166
  } as ViewStyle),
44
167
  },
168
+ headerBand: {
169
+ paddingHorizontal: 20,
170
+ paddingVertical: 14,
171
+ flexDirection: "row",
172
+ alignItems: "center",
173
+ gap: 12,
174
+ },
175
+ headerTitle: {
176
+ flex: 1,
177
+ gap: 2,
178
+ },
179
+ headerTitleRow: {
180
+ flexDirection: "row",
181
+ alignItems: "center",
182
+ gap: 2,
183
+ },
184
+ body: {
185
+ paddingHorizontal: 20,
186
+ paddingVertical: 16,
187
+ },
188
+ footerBand: {
189
+ paddingHorizontal: 20,
190
+ paddingVertical: 12,
191
+ flexDirection: "row",
192
+ alignItems: "center",
193
+ gap: 12,
194
+ },
45
195
  });
@@ -1,11 +1,12 @@
1
1
  import { memo, useMemo } from "react";
2
+ import { type ColorName } from "./colors";
2
3
  import { View } from "react-native";
3
- import { Badge, BadgeColor } from "./badge";
4
+ import { Badge } from "./badge";
4
5
 
5
6
  export interface SelectCellOption {
6
7
  key: string;
7
8
  name: string;
8
- color?: BadgeColor;
9
+ color?: ColorName;
9
10
  }
10
11
 
11
12
  export interface CellSelectProps {
@@ -0,0 +1,65 @@
1
+ import { View } from "react-native";
2
+ import { Text } from "./text";
3
+ import { PressableHighlight } from "./pressable_highlight";
4
+ import { pillSurfaceStyle } from "./control_surface";
5
+
6
+ // Exclusive filter chips — the view-control sibling of the form-input
7
+ // selectors. Picking between the one-of-N controls:
8
+ // - ChipGroup: a SMALL, HOT filter set (≤ ~10 short options) the user flips
9
+ // between constantly — every option stays visible, switching is one tap,
10
+ // the row wraps on narrow widths. Reads as "narrow this view".
11
+ // - Picker: many options or tight space — compact, but hides the set behind
12
+ // a click. Reads as "choose a value".
13
+ // - RadioPicker: a form input that SETS data on a record (radio circles
14
+ // signal "this writes"), not a view filter.
15
+ // Chips carry quiet zinc styling: bordered white at rest, dark fill when
16
+ // active — color stays reserved for status semantics and primary actions.
17
+
18
+ export interface ChipOption<T extends string = string> {
19
+ label: string;
20
+ value: T;
21
+ testID?: string;
22
+ }
23
+
24
+ export interface ChipGroupProps<T extends string = string> {
25
+ /**
26
+ * Group name, prefixed into each chip's accessible name
27
+ * (`"{accessibilityLabel}: {option label}"`) so assistive tech hears the
28
+ * dimension being filtered, not just a bare value.
29
+ */
30
+ accessibilityLabel: string;
31
+ options: ChipOption<T>[];
32
+ /** The active option — exactly one; include an explicit "all" option for
33
+ * the unfiltered state rather than modelling it as no selection. */
34
+ value: T;
35
+ onValueChange: (value: T) => void;
36
+ }
37
+
38
+ export function ChipGroup<T extends string = string>(props: ChipGroupProps<T>) {
39
+ const { accessibilityLabel, options, value, onValueChange } = props;
40
+ return (
41
+ <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
42
+ {options.map((option) => {
43
+ const active = option.value === value;
44
+ return (
45
+ <PressableHighlight
46
+ key={option.value}
47
+ testID={option.testID}
48
+ onPress={() => onValueChange(option.value)}
49
+ accessibilityRole="button"
50
+ accessibilityLabel={`${accessibilityLabel}: ${option.label}`}
51
+ accessibilityState={{ selected: active }}
52
+ // The shared pill-surface contract (height/border/radius/white +
53
+ // hover/press wash + the selected box-shadow ring); the chip only
54
+ // adds its horizontal padding.
55
+ style={(state) => [pillSurfaceStyle(state, { selected: active }), { paddingHorizontal: 14 }]}
56
+ >
57
+ <Text size="sm" weight={active ? "semibold" : "medium"} color={active ? "default" : "muted"}>
58
+ {option.label}
59
+ </Text>
60
+ </PressableHighlight>
61
+ );
62
+ })}
63
+ </View>
64
+ );
65
+ }
package/src/colors.ts CHANGED
@@ -298,3 +298,64 @@ export const colors = {
298
298
  background: palette.white,
299
299
  shadow: `0px 0px 6px 1px ${palette.zinc["300"]}`,
300
300
  };
301
+
302
+ /**
303
+ * A low-alpha wash of a palette color (hover tints, dimmed chart segments,
304
+ * icon discs). Handles the palette's rgba() strings plus rgb()/#RRGGBB.
305
+ */
306
+ export function withAlpha(color: string, alpha: number): string {
307
+ if (color.startsWith("rgba(")) return color.replace(/,\s*[\d.]+\s*\)\s*$/, `, ${alpha})`);
308
+ if (color.startsWith("rgb(")) return color.replace("rgb(", "rgba(").replace(/\)\s*$/, `, ${alpha})`);
309
+ if (color.startsWith("#") && color.length === 7) {
310
+ return `${color}${Math.round(alpha * 255)
311
+ .toString(16)
312
+ .padStart(2, "0")}`;
313
+ }
314
+ return color;
315
+ }
316
+
317
+ /**
318
+ * A palette FAMILY name — the single, semantic way to reference a color across
319
+ * the system (Badge, status indicators, chart series, breakdown segments).
320
+ * Reference colors by NAME and let the component resolve the shade it needs;
321
+ * never thread a raw hex through props — it drifts (one call site picks 500,
322
+ * another 600, and the same "status" renders two greens). Excludes the
323
+ * non-scale role keys (border/background/shadow/black/white).
324
+ */
325
+ export type ColorName = Exclude<keyof typeof colors, "border" | "border_shadow" | "background" | "shadow" | "black" | "white">;
326
+
327
+ /**
328
+ * The canonical SOLID shade of a family (500) — status dots, status-grid
329
+ * cells, breakdown segments, chart series. Defined ONCE so every indicator of
330
+ * the same color agrees on the shade.
331
+ */
332
+ export function solid(name: ColorName): string {
333
+ return colors[name][500];
334
+ }
335
+
336
+ /** A low-alpha wash of a family's solid shade — cell/segment tints, dimmed
337
+ * states. `tint("emerald", 0.2)` === `withAlpha(solid("emerald"), 0.2)`. */
338
+ export function tint(name: ColorName, alpha: number): string {
339
+ return withAlpha(solid(name), alpha);
340
+ }
341
+
342
+ /** Dark → light stops a `ramp` spans (the usable mid range of a scale). */
343
+ const RAMP_STOPS = [700, 600, 500, 400, 300, 200] as const;
344
+
345
+ /**
346
+ * `count` distinct shades of ONE family, strong → light — the monochrome
347
+ * "one hue family per dimension" data ramp (a Breakdown's segments, an ordered
348
+ * funnel, any multi-category breakdown of a single dimension). Use this for a
349
+ * COHERENT dimension instead of hand-picking shades or scattering hues; the
350
+ * segment LABEL carries identity, the shade only orders. (For semantic
351
+ * categories whose color carries MEANING — status — give each its own
352
+ * `ColorName` and `solid()` it, don't ramp.)
353
+ */
354
+ export function ramp(name: ColorName, count: number): string[] {
355
+ const scale = colors[name];
356
+ if (count <= 1) return [scale[600]];
357
+ return Array.from({ length: count }, (_, i) => {
358
+ const idx = Math.round((i / (count - 1)) * (RAMP_STOPS.length - 1));
359
+ return scale[RAMP_STOPS[idx]];
360
+ });
361
+ }
@@ -1,12 +1,9 @@
1
1
  import { View, StyleSheet } from "react-native";
2
2
  import { Text } from "./text";
3
- import { Icon } from "./icon";
4
- import { colors } from "./colors";
5
3
  import { TextInputField } from "./text_input_field";
6
4
  import { NumberInput } from "./number_input";
7
5
  import { PickerMenu } from "./picker_menu";
8
- import { PillButton } from "./pill_button";
9
- import { Popover, PopoverTrigger, PopoverContent } from "./popover";
6
+ import { FilterPill } from "./filter_pill";
10
7
  import type { PickerOption } from "./picker";
11
8
 
12
9
  /** A column the picker can filter on. `type` selects the control + operators. */
@@ -112,20 +109,13 @@ export function ColumnFilter(props: ColumnFilterProps) {
112
109
  const active = isColumnFilterActive(value);
113
110
 
114
111
  return (
115
- <Popover side="bottom" align="start">
116
- <PopoverTrigger>
117
- <PillButton
118
- onDismiss={active ? () => onChange(undefined) : undefined}
119
- dismissTooltip={clearLabel}
120
- >
121
- <Text size="sm" weight="medium" color={active ? "zinc-900" : "zinc-700"} numberOfLines={1}>
122
- {active ? `${column.label}: ${columnFilterSummary(column, value)}` : column.label}
123
- </Text>
124
- {!active ? <Icon name="chevron-down" size={14} color={colors.zinc["400"]} /> : null}
125
- </PillButton>
126
- </PopoverTrigger>
127
- <PopoverContent style={styles.content}>
128
- {column.type === "text" ? (
112
+ <FilterPill
113
+ label={column.label}
114
+ summary={active ? columnFilterSummary(column, value) : undefined}
115
+ onClear={() => onChange(undefined)}
116
+ clearLabel={clearLabel}
117
+ >
118
+ {column.type === "text" ? (
129
119
  <TextInputField
130
120
  autoFocus
131
121
  value={value?.kind === "text" ? value.query : ""}
@@ -160,16 +150,11 @@ export function ColumnFilter(props: ColumnFilterProps) {
160
150
  onValueChange={(selected) => onChange({ kind: "select", selected })}
161
151
  />
162
152
  )}
163
- </PopoverContent>
164
- </Popover>
153
+ </FilterPill>
165
154
  );
166
155
  }
167
156
 
168
157
  const styles = StyleSheet.create({
169
- content: {
170
- minWidth: 240,
171
- gap: 8,
172
- },
173
158
  range: {
174
159
  flexDirection: "row",
175
160
  alignItems: "center",
@@ -0,0 +1,43 @@
1
+ import { ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { solid, type ColorName } from "./colors";
4
+ import { Text } from "./text";
5
+ import { Icon, type IconName } from "./icon";
6
+
7
+ export interface CompletionStateProps {
8
+ /** The result glyph — defaults to a success check. */
9
+ icon?: IconName;
10
+ /** Accent for the glyph — defaults to emerald (success). */
11
+ tone?: ColorName;
12
+ title: string;
13
+ /** One line under the title — the result summary. */
14
+ summary?: string;
15
+ /** The what-next actions — `Button`s, rendered centred below. */
16
+ children?: ReactNode;
17
+ }
18
+
19
+ /**
20
+ * The "operation complete" panel — a result glyph, a title, a one-line summary,
21
+ * and the what-next actions. The counterpart to `EmptyState` (which frames an
22
+ * empty list); this frames a FINISHED process — a picked wave, a posted run, a
23
+ * created batch, an applied allocation. Wrap it in a `Card` for a panel.
24
+ */
25
+ export function CompletionState(props: CompletionStateProps) {
26
+ const { icon = "circle-check", tone = "emerald", title, summary, children } = props;
27
+ return (
28
+ <View style={styles.container}>
29
+ <Icon name={icon} size={44} color={solid(tone)} />
30
+ <View style={styles.text}>
31
+ <Text size="lg" weight="semibold">{title}</Text>
32
+ {summary ? <Text size="sm" color="muted" tabular>{summary}</Text> : null}
33
+ </View>
34
+ {children ? <View style={styles.actions}>{children}</View> : null}
35
+ </View>
36
+ );
37
+ }
38
+
39
+ const styles = StyleSheet.create({
40
+ container: { alignItems: "center", gap: 14, padding: 32 },
41
+ text: { alignItems: "center", gap: 2 },
42
+ actions: { flexDirection: "row", alignItems: "center", gap: 12, paddingTop: 4 },
43
+ });
@@ -0,0 +1,32 @@
1
+ import { type ViewStyle } from "react-native";
2
+ import { colors } from "./colors";
3
+
4
+ /** The system control height — every band control (chips, pills, search,
5
+ * buttons) aligns to it so a toolbar row reads as one band. */
6
+ export const CONTROL_HEIGHT = 40;
7
+
8
+ /**
9
+ * THE pill-surface contract — the single definition of the bordered, white,
10
+ * rounded interactive surface shared by `ChipGroup` chips and `PillButton` (and
11
+ * anything composed on them). Returns the style for the CURRENT pressable state,
12
+ * so the hover (white → zinc-100) and press (zinc-200) washes live in ONE place
13
+ * instead of drifting per control — and any control built on it hovers without
14
+ * touching `PressableHighlight` globally. Selected adds a box-shadow ring (a
15
+ * crisp 2px dark edge, no layout shift). Each control adds its own padding/layout
16
+ * on top.
17
+ */
18
+ export function pillSurfaceStyle(
19
+ state: { hovered?: boolean; pressed?: boolean },
20
+ opts?: { selected?: boolean },
21
+ ): ViewStyle {
22
+ const selected = opts?.selected ?? false;
23
+ return {
24
+ height: CONTROL_HEIGHT,
25
+ justifyContent: "center",
26
+ borderRadius: 999,
27
+ borderWidth: 1,
28
+ borderColor: selected ? colors.zinc[900] : colors.border,
29
+ backgroundColor: state.pressed ? colors.zinc[200] : state.hovered ? colors.zinc[100] : colors.white,
30
+ boxShadow: selected ? `0 0 0 1px ${colors.zinc[900]}` : undefined,
31
+ };
32
+ }
@@ -0,0 +1,58 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { Text } from "./text";
3
+ import { colors } from "./colors";
4
+ import { IconButton } from "./icon_button";
5
+
6
+ export interface CounterProps {
7
+ value: number;
8
+ onValueChange: (value: number) => void;
9
+ min?: number;
10
+ max?: number;
11
+ step?: number;
12
+ /** Announced name — the buttons become "Decrease/Increase {label}". */
13
+ accessibilityLabel: string;
14
+ /** Render the value with units ("3 nights", "2 guests"). Default: the number. */
15
+ format?: (value: number) => string;
16
+ }
17
+
18
+ /**
19
+ * A numeric stepper — − value + — for a small, bounded quantity: a guest count,
20
+ * a min-nights filter, an order line quantity. The Airbnb-style counter: two
21
+ * bordered round buttons flanking the value, a hover wash on each, dimmed at
22
+ * their bounds. For free numeric entry use `NumberInput`; for a wide continuous
23
+ * range use `RangeSlider`.
24
+ */
25
+ export function Counter(props: CounterProps) {
26
+ const { value, onValueChange, min = 0, max = Infinity, step = 1, accessibilityLabel, format } = props;
27
+ const clamp = (n: number) => Math.min(max, Math.max(min, n));
28
+
29
+ return (
30
+ <View style={styles.row}>
31
+ <IconButton
32
+ icon="minus"
33
+ size="md"
34
+ style={styles.btn}
35
+ accessibilityLabel={`Decrease ${accessibilityLabel}`}
36
+ disabled={value <= min}
37
+ onPress={() => onValueChange(clamp(value - step))}
38
+ />
39
+ <Text size="sm" weight="medium" tabular align="center" style={styles.value}>
40
+ {format ? format(value) : String(value)}
41
+ </Text>
42
+ <IconButton
43
+ icon="plus"
44
+ size="md"
45
+ style={styles.btn}
46
+ accessibilityLabel={`Increase ${accessibilityLabel}`}
47
+ disabled={value >= max}
48
+ onPress={() => onValueChange(clamp(value + step))}
49
+ />
50
+ </View>
51
+ );
52
+ }
53
+
54
+ const styles = StyleSheet.create({
55
+ row: { flexDirection: "row", alignItems: "center", gap: 16, alignSelf: "flex-start" },
56
+ btn: { width: 32, height: 32, borderWidth: 1, borderColor: colors.zinc[300] },
57
+ value: { minWidth: 56 },
58
+ });
@@ -56,15 +56,53 @@ function formatDate(date: Date | null, locale: string | undefined): string {
56
56
  }
57
57
  }
58
58
 
59
+ /**
60
+ * Recognized whole periods display compactly — a range that IS a calendar
61
+ * month reads "Tháng 6 năm 2026" (sentence-cased via the locale), a whole
62
+ * year "2026", a single day one date. Anything else falls back to
63
+ * "start – end". Keeps the trigger scannable where dashboards live in
64
+ * period rhythm, not date pairs.
65
+ */
66
+ function formatRangeDisplay(start: Date, end: Date, locale: string | undefined): string {
67
+ if (start.toDateString() === end.toDateString()) return formatDate(start, locale);
68
+
69
+ const wholeMonth =
70
+ start.getDate() === 1 &&
71
+ start.getMonth() === end.getMonth() &&
72
+ start.getFullYear() === end.getFullYear() &&
73
+ end.getDate() === new Date(end.getFullYear(), end.getMonth() + 1, 0).getDate();
74
+ if (wholeMonth) {
75
+ try {
76
+ const label = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(start);
77
+ return label.charAt(0).toUpperCase() + label.slice(1);
78
+ } catch {
79
+ return `${start.getMonth() + 1}/${start.getFullYear()}`;
80
+ }
81
+ }
82
+
83
+ const wholeYear =
84
+ start.getFullYear() === end.getFullYear() &&
85
+ start.getMonth() === 0 &&
86
+ start.getDate() === 1 &&
87
+ end.getMonth() === 11 &&
88
+ end.getDate() === 31;
89
+ if (wholeYear) return String(start.getFullYear());
90
+
91
+ return `${formatDate(start, locale)} – ${formatDate(end, locale)}`;
92
+ }
93
+
59
94
  export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
60
95
  const { value, onValueChange, includeTime, locale, testID } = props;
61
96
  const labels = useMemo(() => ({ ...DEFAULT_FIELD_LABELS, ...props.labels }), [props.labels]);
62
97
  const [open, setOpen] = useState(false);
63
98
 
64
99
  const hasValue = Boolean(value.start.date || value.end.date);
65
- const display = hasValue
66
- ? `${formatDate(value.start.date, locale)} – ${formatDate(value.end.date, locale)}`
67
- : labels.placeholder;
100
+ const display =
101
+ value.start.date && value.end.date
102
+ ? formatRangeDisplay(value.start.date, value.end.date, locale)
103
+ : hasValue
104
+ ? `${formatDate(value.start.date, locale)} – ${formatDate(value.end.date, locale)}`
105
+ : labels.placeholder;
68
106
 
69
107
  return (
70
108
  <Popover open={open} onOpenChange={setOpen} side="bottom" align="start">
@@ -100,15 +138,9 @@ export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
100
138
  labels={props.labels}
101
139
  locale={locale}
102
140
  />
103
- <PopoverFooter>
104
- <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
105
- {hasValue ? (
106
- <Button title={labels.clear} onPress={() => onValueChange(EMPTY_VALUE)} />
107
- ) : (
108
- <View />
109
- )}
110
- <Button title={labels.done} color="secondary" onPress={() => setOpen(false)} />
111
- </View>
141
+ <PopoverFooter align="space-between">
142
+ {hasValue ? <Button title={labels.clear} onPress={() => onValueChange(EMPTY_VALUE)} /> : <View />}
143
+ <Button title={labels.done} color="secondary" onPress={() => setOpen(false)} />
112
144
  </PopoverFooter>
113
145
  </PopoverContent>
114
146
  </Popover>
@@ -0,0 +1,45 @@
1
+ import { ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { Text } from "./text";
4
+
5
+ export interface DetailRowProps {
6
+ /** The field label — rendered `sm muted`. */
7
+ label: ReactNode;
8
+ /** The value — any node (a `Text`, a `Badge`, a stack). */
9
+ children: ReactNode;
10
+ /** Fixed label-column width (px) to align labels in a left column — for a peek
11
+ * or form-like stack. Omit for a spread row: the label takes the slack and the
12
+ * value sits at the right edge (the default drawer-detail look). */
13
+ labelWidth?: number;
14
+ /** Label text size — `xs` for a compact peek, `sm` (default) for a drawer. */
15
+ labelSize?: "xs" | "sm";
16
+ /** Min row height. Default 28. */
17
+ minHeight?: number;
18
+ }
19
+
20
+ /**
21
+ * One label + value line for a drawer / peek detail view — NOT a list row (that's
22
+ * `PressableRow` / `TableRow`). Spread by default (muted label left, value pushed
23
+ * to the right edge); pass `labelWidth` to align labels in a fixed left column
24
+ * with the values flowing immediately after instead.
25
+ */
26
+ export function DetailRow(props: DetailRowProps) {
27
+ const { label, children, labelWidth, labelSize = "sm", minHeight = 28 } = props;
28
+ return (
29
+ <View style={[styles.row, { minHeight }]}>
30
+ <Text size={labelSize} color="muted" style={labelWidth != null ? { width: labelWidth } : styles.flexLabel}>
31
+ {label}
32
+ </Text>
33
+ {children}
34
+ </View>
35
+ );
36
+ }
37
+
38
+ const styles = StyleSheet.create({
39
+ row: {
40
+ flexDirection: "row",
41
+ alignItems: "center",
42
+ gap: 12,
43
+ },
44
+ flexLabel: { flex: 1 },
45
+ });
package/src/dialog.tsx CHANGED
@@ -178,30 +178,6 @@ export function Dialog(props: DialogProps) {
178
178
  );
179
179
  }
180
180
 
181
- // ============================================================================
182
- // DialogTrigger
183
- // ============================================================================
184
-
185
- export interface DialogTriggerProps {
186
- children: React.ReactElement;
187
- }
188
-
189
- export function DialogTrigger({ children }: DialogTriggerProps) {
190
- const { open, onOpenChange } = useDialog();
191
-
192
- const handlePress = useCallback(() => {
193
- onOpenChange(!open);
194
- }, [open, onOpenChange]);
195
-
196
- return React.cloneElement(
197
- children as React.ReactElement<{
198
- onPress?: () => void;
199
- }>,
200
- {
201
- onPress: handlePress,
202
- },
203
- );
204
- }
205
181
 
206
182
  // ============================================================================
207
183
  // DialogHeader Components (Composition-based)