@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.
- package/package.json +28 -8
- package/src/accordion.tsx +146 -63
- package/src/action_menu.tsx +72 -0
- package/src/allocation_row.tsx +54 -0
- package/src/badge.tsx +40 -9
- package/src/breakdown.tsx +121 -0
- package/src/card.tsx +150 -0
- package/src/cell_select.tsx +3 -2
- package/src/chip_group.tsx +65 -0
- package/src/colors.ts +61 -0
- package/src/column_filter.tsx +9 -24
- package/src/completion_state.tsx +43 -0
- package/src/control_surface.ts +32 -0
- package/src/counter.tsx +58 -0
- package/src/date_range_filter_field.tsx +44 -12
- package/src/detail_row.tsx +45 -0
- package/src/dialog.tsx +0 -24
- package/src/download.ts +2 -1
- package/src/drawer.tsx +94 -2
- package/src/empty_state.tsx +37 -0
- package/src/file_badge.tsx +27 -4
- package/src/file_dropzone.tsx +188 -0
- package/src/file_picker.ts +45 -0
- package/src/filter_pill.tsx +106 -0
- package/src/floating_action_bar.tsx +57 -0
- package/src/fonts.css +10 -13
- package/src/format_money.ts +38 -0
- package/src/heatmap.tsx +153 -0
- package/src/icon.tsx +2 -0
- package/src/icon_button.tsx +16 -2
- package/src/index.css +4 -3
- package/src/info_popover.tsx +4 -6
- package/src/kpi_card.tsx +19 -6
- package/src/kpi_strip.tsx +89 -0
- package/src/line_chart.tsx +61 -34
- package/src/link_button.tsx +50 -0
- package/src/metric.tsx +21 -12
- package/src/pagination.tsx +5 -9
- package/src/peek.tsx +68 -0
- package/src/picker.tsx +13 -1
- package/src/picker_menu.tsx +8 -16
- package/src/pie_chart.tsx +29 -8
- package/src/pill_button.tsx +10 -8
- package/src/popover.tsx +14 -4
- package/src/pressable_highlight.tsx +10 -1
- package/src/pressable_row.tsx +91 -0
- package/src/progress_bar.tsx +47 -17
- package/src/radio_picker.tsx +20 -9
- package/src/range_slider.tsx +185 -0
- package/src/remainder_meter.tsx +48 -0
- package/src/ring_gauge.tsx +5 -5
- package/src/scan_field.tsx +58 -0
- package/src/search_input.tsx +12 -0
- package/src/skeleton.tsx +47 -0
- package/src/sort_header.tsx +102 -0
- package/src/stacked_progress_bar.tsx +51 -16
- package/src/status_grid.tsx +187 -0
- package/src/step_list.tsx +128 -0
- package/src/step_progress.tsx +145 -0
- package/src/stepper.tsx +9 -4
- package/src/table.tsx +168 -112
- package/src/text.tsx +15 -0
- package/src/text_utils.ts +10 -0
- package/src/timeline.tsx +90 -57
- package/src/trend_footer.tsx +2 -2
- package/src/alert_row.tsx +0 -81
- package/src/table.web.tsx +0 -235
- package/src/table_picker.tsx +0 -305
- 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
|
});
|
package/src/cell_select.tsx
CHANGED
|
@@ -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
|
|
4
|
+
import { Badge } from "./badge";
|
|
4
5
|
|
|
5
6
|
export interface SelectCellOption {
|
|
6
7
|
key: string;
|
|
7
8
|
name: string;
|
|
8
|
-
color?:
|
|
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
|
+
}
|
package/src/column_filter.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
<
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/counter.tsx
ADDED
|
@@ -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 =
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
<
|
|
105
|
-
|
|
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)
|