@lotics/ui 2.6.1 → 3.1.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 +2 -15
- package/src/react_native.d.ts +2 -2
- package/src/segmented_control.tsx +201 -0
- package/src/cell_date.tsx +0 -30
- package/src/cell_date_format.test.ts +0 -32
- package/src/cell_date_format.ts +0 -73
- package/src/cell_number.test.ts +0 -42
- package/src/cell_number.tsx +0 -25
- package/src/cell_number_format.ts +0 -42
- package/src/cell_select.tsx +0 -68
- package/src/cell_text.tsx +0 -45
- package/src/grid/data_grid.tsx +0 -2003
- package/src/grid/data_grid_columns.test.ts +0 -72
- package/src/grid/data_grid_columns.ts +0 -30
- package/src/grid/data_grid_context.ts +0 -119
- package/src/grid/dispatch_safely.ts +0 -39
- package/src/grid/engine.module.css +0 -114
- package/src/grid/engine.tsx +0 -1042
- package/src/grid/helpers.ts +0 -205
- package/src/grid/layout.test.ts +0 -515
- package/src/grid/layout.ts +0 -425
- package/src/grid/recycling.test.ts +0 -236
- package/src/grid/recycling.ts +0 -172
- package/src/grid/row_cell.module.css +0 -105
- package/src/grid/row_cell.tsx +0 -313
- package/src/grid/search_highlight.ts +0 -71
- package/src/grid/select_cell.tsx +0 -58
- package/src/grid/select_group_summary_cell.tsx +0 -76
- package/src/grid/select_header_cell.tsx +0 -32
- package/src/grid/skeleton_row.module.css +0 -34
- package/src/grid/skeleton_row.tsx +0 -20
- package/src/grid/use_grid_groups.ts +0 -311
- package/src/grid/use_scroll_to_cell.ts +0 -135
- package/src/grid/use_virtual_grid.ts +0 -383
- package/src/grid/visibility.test.ts +0 -208
- package/src/grid/visibility.ts +0 -77
- package/src/kanban/constants.ts +0 -18
- package/src/kanban/default_renderers.tsx +0 -160
- package/src/kanban/drag_preview.tsx +0 -157
- package/src/kanban/index.ts +0 -13
- package/src/kanban/insert_card_zone.tsx +0 -135
- package/src/kanban/kanban_board.tsx +0 -635
- package/src/kanban/kanban_card.tsx +0 -321
- package/src/kanban/kanban_column.tsx +0 -499
- package/src/kanban/placeholders.tsx +0 -54
- package/src/kanban/types.ts +0 -116
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
@@ -33,7 +33,6 @@
|
|
|
33
33
|
"./empty_state": "./src/empty_state.tsx",
|
|
34
34
|
"./format_date": "./src/format_date.ts",
|
|
35
35
|
"./format_money": "./src/format_money.ts",
|
|
36
|
-
"./kanban": "./src/kanban/index.ts",
|
|
37
36
|
"./calendar": "./src/calendar/index.ts",
|
|
38
37
|
"./gantt": "./src/gantt/index.ts",
|
|
39
38
|
"./ring_gauge": "./src/ring_gauge.tsx",
|
|
@@ -128,6 +127,7 @@
|
|
|
128
127
|
"./stepper": "./src/stepper.tsx",
|
|
129
128
|
"./step_progress": "./src/step_progress.tsx",
|
|
130
129
|
"./tabs": "./src/tabs.tsx",
|
|
130
|
+
"./segmented_control": "./src/segmented_control.tsx",
|
|
131
131
|
"./auto_sizer": "./src/auto_sizer.tsx",
|
|
132
132
|
"./animation_horizontal_slide": "./src/animation_horizontal_slide.tsx",
|
|
133
133
|
"./group_avatar": "./src/group_avatar.tsx",
|
|
@@ -165,19 +165,6 @@
|
|
|
165
165
|
"./landmark": "./src/landmark.tsx",
|
|
166
166
|
"./skip_link": "./src/skip_link.tsx",
|
|
167
167
|
"./text_utils": "./src/text_utils.ts",
|
|
168
|
-
"./cell_text": "./src/cell_text.tsx",
|
|
169
|
-
"./cell_number": "./src/cell_number.tsx",
|
|
170
|
-
"./cell_number_format": "./src/cell_number_format.ts",
|
|
171
|
-
"./cell_date": "./src/cell_date.tsx",
|
|
172
|
-
"./cell_date_format": "./src/cell_date_format.ts",
|
|
173
|
-
"./cell_select": "./src/cell_select.tsx",
|
|
174
|
-
"./grid/engine": "./src/grid/engine.tsx",
|
|
175
|
-
"./grid/layout": "./src/grid/layout.ts",
|
|
176
|
-
"./grid/use_virtual_grid": "./src/grid/use_virtual_grid.ts",
|
|
177
|
-
"./grid/skeleton_row": "./src/grid/skeleton_row.tsx",
|
|
178
|
-
"./grid/data_grid": "./src/grid/data_grid.tsx",
|
|
179
|
-
"./grid/data_grid_context": "./src/grid/data_grid_context.ts",
|
|
180
|
-
"./grid/search_highlight": "./src/grid/search_highlight.ts",
|
|
181
168
|
"./column_filter": "./src/column_filter.tsx",
|
|
182
169
|
"./chip_group": "./src/chip_group.tsx"
|
|
183
170
|
},
|
package/src/react_native.d.ts
CHANGED
|
@@ -2,9 +2,9 @@ import "react-native";
|
|
|
2
2
|
|
|
3
3
|
// Augments react-native's types with the web-only fields @lotics/ui consumes:
|
|
4
4
|
// Pressable's `hovered` callback state, plus web-only ViewStyle / TextStyle
|
|
5
|
-
// properties (cursor, outline, boxShadow, etc.) used by
|
|
5
|
+
// properties (cursor, outline, boxShadow, etc.) used by its primitives.
|
|
6
6
|
//
|
|
7
|
-
// Each consumer (frontend,
|
|
7
|
+
// Each consumer (frontend, container-invoices, future iframe
|
|
8
8
|
// apps) ships its own copy of this file in its `src/`. TypeScript doesn't
|
|
9
9
|
// auto-pick-up `.d.ts` files inside node_modules dependencies; the augmentation
|
|
10
10
|
// has to be visible in the consumer's `include`. The CLI starter template
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
import { colors, withAlpha } from "./colors";
|
|
6
|
+
|
|
7
|
+
export interface SegmentOption<T extends string> {
|
|
8
|
+
label: string;
|
|
9
|
+
value: T;
|
|
10
|
+
testID?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SegmentedControlProps<T extends string> {
|
|
14
|
+
/** Accessible name of the group (it renders no visible label of its own). */
|
|
15
|
+
accessibilityLabel: string;
|
|
16
|
+
/** 2–4 mutually-exclusive peers. Above ~4, use Picker — segments stop fitting. */
|
|
17
|
+
options: SegmentOption<T>[];
|
|
18
|
+
value: T;
|
|
19
|
+
onValueChange: (value: T) => void;
|
|
20
|
+
/** Disables the whole control (e.g. while a mode-specific action runs). */
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
/** The track hugs its content by default (`alignSelf: flex-start`); pass
|
|
23
|
+
* `{ alignSelf: "stretch" }` to fill the parent's width. */
|
|
24
|
+
style?: StyleProp<ViewStyle>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A segmented control: 2–4 visible peer segments in an inset track, the
|
|
29
|
+
* selected one raised as a white card. Semantically a **radiogroup** — a
|
|
30
|
+
* single choice among peers, all visible — NOT a tablist (no panels) and not
|
|
31
|
+
* a Switch (not on/off). Reach for it on a binary/few-way *mode* switch where
|
|
32
|
+
* both options should be one-tap and on screen (a generator mode, a list/grid
|
|
33
|
+
* view, a day/week/month range). Above ~4 options, use `Picker`.
|
|
34
|
+
*
|
|
35
|
+
* Keyboard follows the WAI-ARIA radio pattern: roving tabindex (only the
|
|
36
|
+
* selected segment is a tab stop), arrows move focus AND select (wrapping),
|
|
37
|
+
* Space/Enter select the focused segment.
|
|
38
|
+
*/
|
|
39
|
+
export function SegmentedControl<T extends string>(props: SegmentedControlProps<T>) {
|
|
40
|
+
const { accessibilityLabel, options, value, onValueChange, disabled = false, style } = props;
|
|
41
|
+
const segmentRefs = useRef<Array<View | null>>([]);
|
|
42
|
+
|
|
43
|
+
const select = useCallback(
|
|
44
|
+
(index: number) => {
|
|
45
|
+
const option = options[index];
|
|
46
|
+
if (!option || option.value === value) return;
|
|
47
|
+
onValueChange(option.value);
|
|
48
|
+
},
|
|
49
|
+
[options, value, onValueChange],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Arrows wrap and select per the radio pattern; Space/Enter select the
|
|
53
|
+
// focused segment. Focus follows selection so the new segment stays reachable.
|
|
54
|
+
const handleKeyDown = useCallback(
|
|
55
|
+
(event: { key: string; preventDefault?: () => void }, index: number) => {
|
|
56
|
+
if (disabled) return;
|
|
57
|
+
const last = options.length - 1;
|
|
58
|
+
let next = index;
|
|
59
|
+
switch (event.key) {
|
|
60
|
+
case "ArrowRight":
|
|
61
|
+
case "ArrowDown":
|
|
62
|
+
next = index === last ? 0 : index + 1;
|
|
63
|
+
break;
|
|
64
|
+
case "ArrowLeft":
|
|
65
|
+
case "ArrowUp":
|
|
66
|
+
next = index === 0 ? last : index - 1;
|
|
67
|
+
break;
|
|
68
|
+
case "Enter":
|
|
69
|
+
case " ":
|
|
70
|
+
event.preventDefault?.();
|
|
71
|
+
select(index);
|
|
72
|
+
return;
|
|
73
|
+
default:
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
event.preventDefault?.();
|
|
77
|
+
onValueChange(options[next].value);
|
|
78
|
+
segmentRefs.current[next]?.focus();
|
|
79
|
+
},
|
|
80
|
+
[disabled, options, onValueChange, select],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Roving tabindex: the selected segment is the tab stop. When `value` matches
|
|
84
|
+
// no option, the first segment is the fallback so the group stays reachable.
|
|
85
|
+
const selectedIndex = options.findIndex((option) => option.value === value);
|
|
86
|
+
const tabStopIndex = selectedIndex === -1 ? 0 : selectedIndex;
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<View
|
|
90
|
+
style={[styles.track, disabled && styles.trackDisabled, style]}
|
|
91
|
+
accessibilityRole="radiogroup"
|
|
92
|
+
aria-label={accessibilityLabel}
|
|
93
|
+
aria-disabled={disabled}
|
|
94
|
+
>
|
|
95
|
+
{options.map((option, index) => (
|
|
96
|
+
<Segment
|
|
97
|
+
ref={(node: View | null) => {
|
|
98
|
+
segmentRefs.current[index] = node;
|
|
99
|
+
}}
|
|
100
|
+
key={option.value}
|
|
101
|
+
option={option}
|
|
102
|
+
selected={option.value === value}
|
|
103
|
+
isTabStop={index === tabStopIndex}
|
|
104
|
+
disabled={disabled}
|
|
105
|
+
onPress={() => select(index)}
|
|
106
|
+
onKeyDown={(event) => handleKeyDown(event, index)}
|
|
107
|
+
/>
|
|
108
|
+
))}
|
|
109
|
+
</View>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface SegmentProps<T extends string> {
|
|
114
|
+
ref: (node: View | null) => void;
|
|
115
|
+
option: SegmentOption<T>;
|
|
116
|
+
selected: boolean;
|
|
117
|
+
isTabStop: boolean;
|
|
118
|
+
disabled: boolean;
|
|
119
|
+
onPress: () => void;
|
|
120
|
+
onKeyDown: (event: { key: string; preventDefault?: () => void }) => void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function Segment<T extends string>(props: SegmentProps<T>) {
|
|
124
|
+
const { ref, option, selected, isTabStop, disabled, onPress, onKeyDown } = props;
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<PressableHighlight
|
|
128
|
+
ref={ref}
|
|
129
|
+
// Own the per-state background: the track is zinc-100, so an unselected
|
|
130
|
+
// segment hovering must lift TOWARD the selected white (a half-white wash),
|
|
131
|
+
// not to PressableHighlight's default zinc-100 (invisible on the track).
|
|
132
|
+
style={(state) => {
|
|
133
|
+
const hovered = (state as { hovered?: boolean }).hovered;
|
|
134
|
+
return [
|
|
135
|
+
styles.segment,
|
|
136
|
+
selected
|
|
137
|
+
? styles.segmentSelected
|
|
138
|
+
: {
|
|
139
|
+
backgroundColor: state.pressed
|
|
140
|
+
? colors.white
|
|
141
|
+
: hovered
|
|
142
|
+
? withAlpha(colors.white, 0.5)
|
|
143
|
+
: "transparent",
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
}}
|
|
147
|
+
onPress={disabled ? undefined : onPress}
|
|
148
|
+
onKeyDown={onKeyDown}
|
|
149
|
+
testID={option.testID}
|
|
150
|
+
accessibilityRole="radio"
|
|
151
|
+
accessibilityLabel={option.label}
|
|
152
|
+
aria-checked={selected}
|
|
153
|
+
aria-disabled={disabled}
|
|
154
|
+
// Roving tabindex: only the selected segment (or the fallback) is a tab
|
|
155
|
+
// stop; the rest are reached with arrow keys.
|
|
156
|
+
focusable={isTabStop && !disabled}
|
|
157
|
+
>
|
|
158
|
+
{(state) => {
|
|
159
|
+
const hovered = (state as { hovered?: boolean }).hovered;
|
|
160
|
+
return (
|
|
161
|
+
<Text
|
|
162
|
+
size="sm"
|
|
163
|
+
weight={selected ? "medium" : "regular"}
|
|
164
|
+
color={selected || hovered ? "zinc-900" : "zinc-700"}
|
|
165
|
+
numberOfLines={1}
|
|
166
|
+
userSelect="none"
|
|
167
|
+
>
|
|
168
|
+
{option.label}
|
|
169
|
+
</Text>
|
|
170
|
+
);
|
|
171
|
+
}}
|
|
172
|
+
</PressableHighlight>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const styles = StyleSheet.create({
|
|
177
|
+
track: {
|
|
178
|
+
flexDirection: "row",
|
|
179
|
+
alignSelf: "flex-start",
|
|
180
|
+
gap: 2,
|
|
181
|
+
padding: 3,
|
|
182
|
+
borderRadius: 10,
|
|
183
|
+
backgroundColor: colors.zinc["100"],
|
|
184
|
+
},
|
|
185
|
+
trackDisabled: {
|
|
186
|
+
opacity: 0.55,
|
|
187
|
+
},
|
|
188
|
+
segment: {
|
|
189
|
+
flex: 1,
|
|
190
|
+
minHeight: 30,
|
|
191
|
+
paddingVertical: 6,
|
|
192
|
+
paddingHorizontal: 14,
|
|
193
|
+
borderRadius: 7,
|
|
194
|
+
alignItems: "center",
|
|
195
|
+
justifyContent: "center",
|
|
196
|
+
},
|
|
197
|
+
segmentSelected: {
|
|
198
|
+
backgroundColor: colors.white,
|
|
199
|
+
boxShadow: colors.border_shadow,
|
|
200
|
+
},
|
|
201
|
+
});
|
package/src/cell_date.tsx
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { memo, useMemo } from "react";
|
|
2
|
-
import { Text } from "./text";
|
|
3
|
-
import { DateFormatOptions, formatDateValue } from "./cell_date_format";
|
|
4
|
-
|
|
5
|
-
export interface CellDateProps extends DateFormatOptions {
|
|
6
|
-
value: string | null | undefined;
|
|
7
|
-
/** Label to render when the value parses as Invalid Date. Defaults to "Invalid date". */
|
|
8
|
-
invalidLabel?: string;
|
|
9
|
-
userSelect?: "none" | "auto";
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Pure-presentational date cell. Renders an empty Text for null/empty values,
|
|
14
|
-
* a danger-coloured Text for unparseable values, and a locale-formatted Text
|
|
15
|
-
* otherwise. Locale and format are explicit so iframe apps don't need to wire
|
|
16
|
-
* a translation layer.
|
|
17
|
-
*/
|
|
18
|
-
export const CellDate = memo(function CellDate(props: CellDateProps) {
|
|
19
|
-
const { value, format, locale, dateStyle, timeStyle, invalidLabel = "Invalid date", userSelect } = props;
|
|
20
|
-
|
|
21
|
-
const content = useMemo(() => {
|
|
22
|
-
const formatted = formatDateValue(value, { format, locale, dateStyle, timeStyle });
|
|
23
|
-
if (formatted === null) {
|
|
24
|
-
return <Text color="danger">{invalidLabel}</Text>;
|
|
25
|
-
}
|
|
26
|
-
return formatted;
|
|
27
|
-
}, [value, format, locale, dateStyle, timeStyle, invalidLabel]);
|
|
28
|
-
|
|
29
|
-
return <Text userSelect={userSelect}>{content}</Text>;
|
|
30
|
-
});
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
// Force a positive-offset zone (UTC+7) before importing the formatter: the old
|
|
2
|
-
// `new Date("2024-03-15")` UTC-parse would surface a phantom "07:00" here, and a
|
|
3
|
-
// negative-offset zone would shift the date a day. The wall-clock parse must keep
|
|
4
|
-
// the value naive so output is timezone-independent.
|
|
5
|
-
process.env.TZ = "Asia/Ho_Chi_Minh";
|
|
6
|
-
|
|
7
|
-
import { describe, it, expect } from "vitest";
|
|
8
|
-
import { formatDateValue } from "./cell_date_format";
|
|
9
|
-
|
|
10
|
-
describe("formatDateValue — wall-clock (no timezone shift)", () => {
|
|
11
|
-
it("renders a date-only value as midnight, not the zone offset", () => {
|
|
12
|
-
const out = formatDateValue("2024-03-15", { format: "datetime", locale: "en-US" });
|
|
13
|
-
expect(out).toMatch(/3\/15\/2024/);
|
|
14
|
-
expect(out).toMatch(/12:00\s?AM/i); // midnight — not 7:00 AM
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("keeps the same calendar day for a date field in any zone", () => {
|
|
18
|
-
expect(formatDateValue("2024-03-15", { format: "date", locale: "en-US" })).toBe("3/15/2024");
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("renders a datetime value at its literal wall-clock time", () => {
|
|
22
|
-
expect(formatDateValue("2024-03-15T09:30", { format: "datetime", locale: "en-US" })).toMatch(
|
|
23
|
-
/9:30\s?AM/i,
|
|
24
|
-
);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("returns '' for empty and null (sentinel) for unparseable input", () => {
|
|
28
|
-
expect(formatDateValue("")).toBe("");
|
|
29
|
-
expect(formatDateValue(null)).toBe("");
|
|
30
|
-
expect(formatDateValue("not-a-date")).toBeNull();
|
|
31
|
-
});
|
|
32
|
-
});
|
package/src/cell_date_format.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
export type DateCellFormat = "date" | "datetime";
|
|
2
|
-
|
|
3
|
-
export interface DateFormatOptions {
|
|
4
|
-
format?: DateCellFormat;
|
|
5
|
-
/** BCP-47 locale tag for Intl.DateTimeFormat. Defaults to "en-US". */
|
|
6
|
-
locale?: string;
|
|
7
|
-
dateStyle?: "full" | "long" | "medium" | "short";
|
|
8
|
-
timeStyle?: "full" | "long" | "medium" | "short";
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Parse a canonical, timezone-naive ISO value as wall-clock time in the local zone.
|
|
15
|
-
* `new Date("2024-03-15")` parses date-only strings as **UTC** midnight, which then
|
|
16
|
-
* shifts under Intl's local formatting — a phantom time on date-only values and an
|
|
17
|
-
* off-by-one date in negative-offset zones. Building the Date from its parts keeps
|
|
18
|
-
* it naive, matching how the picker reads the same value. Non-ISO input falls back
|
|
19
|
-
* to the native parser.
|
|
20
|
-
*/
|
|
21
|
-
function parseWallClock(value: string): Date | null {
|
|
22
|
-
const m = ISO_DATE_TIME.exec(value.trim());
|
|
23
|
-
const date = m
|
|
24
|
-
? new Date(
|
|
25
|
-
Number(m[1]),
|
|
26
|
-
Number(m[2]) - 1,
|
|
27
|
-
Number(m[3]),
|
|
28
|
-
Number(m[4] ?? 0),
|
|
29
|
-
Number(m[5] ?? 0),
|
|
30
|
-
Number(m[6] ?? 0),
|
|
31
|
-
)
|
|
32
|
-
: new Date(value);
|
|
33
|
-
return isNaN(date.getTime()) ? null : date;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Format an ISO date string to a localized display string.
|
|
38
|
-
* Returns "" for null/empty input. Returns null (sentinel) for unparseable input
|
|
39
|
-
* so callers can render an error state.
|
|
40
|
-
*/
|
|
41
|
-
export function formatDateValue(
|
|
42
|
-
value: string | null | undefined,
|
|
43
|
-
options: DateFormatOptions = {},
|
|
44
|
-
): string | null {
|
|
45
|
-
if (!value) return "";
|
|
46
|
-
|
|
47
|
-
const date = parseWallClock(value);
|
|
48
|
-
if (!date) return null;
|
|
49
|
-
|
|
50
|
-
const locale = options.locale ?? "en-US";
|
|
51
|
-
const { format = "date", dateStyle = "short", timeStyle = "short" } = options;
|
|
52
|
-
const includeTime = format === "datetime";
|
|
53
|
-
|
|
54
|
-
// Intl's `dateStyle: "short"` yields a 2-digit year in many locales (en-US: "5/22/26",
|
|
55
|
-
// vi-VN: "22/05/26"); for data cells we want a compact numeric format with the full year.
|
|
56
|
-
// When we override to explicit date parts, the time parts must also be explicit — Intl
|
|
57
|
-
// forbids mixing `dateStyle`/`timeStyle` with `year`/`month`/`day`/`hour`/`minute`/etc.
|
|
58
|
-
const intlOptions: Intl.DateTimeFormatOptions =
|
|
59
|
-
dateStyle === "short"
|
|
60
|
-
? {
|
|
61
|
-
year: "numeric",
|
|
62
|
-
month: "numeric",
|
|
63
|
-
day: "numeric",
|
|
64
|
-
...(includeTime ? { hour: "numeric", minute: "numeric" } : {}),
|
|
65
|
-
}
|
|
66
|
-
: { dateStyle, ...(includeTime ? { timeStyle } : {}) };
|
|
67
|
-
|
|
68
|
-
try {
|
|
69
|
-
return new Intl.DateTimeFormat(locale, intlOptions).format(date);
|
|
70
|
-
} catch {
|
|
71
|
-
return value;
|
|
72
|
-
}
|
|
73
|
-
}
|
package/src/cell_number.test.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { formatNumberValue } from "./cell_number_format";
|
|
3
|
-
|
|
4
|
-
describe("formatNumberValue", () => {
|
|
5
|
-
it("returns empty string for null", () => {
|
|
6
|
-
expect(formatNumberValue(null, { format: "number" })).toBe("");
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
it("returns empty string for undefined", () => {
|
|
10
|
-
expect(formatNumberValue(undefined, { format: "number" })).toBe("");
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it("formats plain number as toString", () => {
|
|
14
|
-
expect(formatNumberValue(42, { format: "number" })).toBe("42");
|
|
15
|
-
expect(formatNumberValue(3.14, {})).toBe("3.14");
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("formats percentage with % suffix", () => {
|
|
19
|
-
expect(formatNumberValue(75, { format: "percentage" })).toBe("75%");
|
|
20
|
-
expect(formatNumberValue(0, { format: "percentage" })).toBe("0%");
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("formats currency with explicit currency code", () => {
|
|
24
|
-
const result = formatNumberValue(1000, { format: "currency", currency: "USD" });
|
|
25
|
-
expect(result).toContain("US");
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("defaults to VND when currency format has no currency code", () => {
|
|
29
|
-
const result = formatNumberValue(1000, { format: "currency" });
|
|
30
|
-
expect(result).toBeTruthy();
|
|
31
|
-
expect(result.length).toBeGreaterThan(0);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("respects an explicit locale override", () => {
|
|
35
|
-
const result = formatNumberValue(1000, {
|
|
36
|
-
format: "currency",
|
|
37
|
-
currency: "USD",
|
|
38
|
-
locale: "en-US",
|
|
39
|
-
});
|
|
40
|
-
expect(result).toMatch(/^\$/);
|
|
41
|
-
});
|
|
42
|
-
});
|
package/src/cell_number.tsx
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { memo } from "react";
|
|
2
|
-
import { Text } from "./text";
|
|
3
|
-
import { formatNumberValue, NumberFormatOptions } from "./cell_number_format";
|
|
4
|
-
|
|
5
|
-
export interface CellNumberProps extends NumberFormatOptions {
|
|
6
|
-
value: number | null | undefined;
|
|
7
|
-
userSelect?: "none" | "auto";
|
|
8
|
-
align?: "left" | "right" | "center";
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Pure-presentational number cell. Format and locale are explicit, so iframe
|
|
13
|
-
* apps consuming `@lotics/ui` without `@lotics/shared` can render numbers the
|
|
14
|
-
* same way the records grid does.
|
|
15
|
-
*/
|
|
16
|
-
export const CellNumber = memo(function CellNumber(props: CellNumberProps) {
|
|
17
|
-
const { value, format, currency, locale, userSelect, align } = props;
|
|
18
|
-
const text = formatNumberValue(value, { format, currency, locale });
|
|
19
|
-
|
|
20
|
-
return (
|
|
21
|
-
<Text numberOfLines={1} userSelect={userSelect} align={align}>
|
|
22
|
-
{text}
|
|
23
|
-
</Text>
|
|
24
|
-
);
|
|
25
|
-
});
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
export type NumberCellFormat = "number" | "currency" | "percentage";
|
|
2
|
-
|
|
3
|
-
export const NUMERIC_FORMATS: ReadonlySet<string> = new Set<NumberCellFormat>([
|
|
4
|
-
"number",
|
|
5
|
-
"currency",
|
|
6
|
-
"percentage",
|
|
7
|
-
]);
|
|
8
|
-
|
|
9
|
-
export function isNumericFormat(value: string | undefined): value is NumberCellFormat {
|
|
10
|
-
return value !== undefined && NUMERIC_FORMATS.has(value);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface NumberFormatOptions {
|
|
14
|
-
format?: NumberCellFormat;
|
|
15
|
-
currency?: string;
|
|
16
|
-
/** BCP-47 locale tag passed to Number.prototype.toLocaleString. Defaults to "vi-VN". */
|
|
17
|
-
locale?: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function formatNumberValue(
|
|
21
|
-
value: number | null | undefined,
|
|
22
|
-
options: NumberFormatOptions,
|
|
23
|
-
): string {
|
|
24
|
-
if (value === null || value === undefined) {
|
|
25
|
-
return "";
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const locale = options.locale ?? "vi-VN";
|
|
29
|
-
|
|
30
|
-
if (options.format === "currency") {
|
|
31
|
-
return value.toLocaleString(locale, {
|
|
32
|
-
style: "currency",
|
|
33
|
-
currency: options.currency || "VND",
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (options.format === "percentage") {
|
|
38
|
-
return `${value}%`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return String(value);
|
|
42
|
-
}
|
package/src/cell_select.tsx
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { memo, useMemo } from "react";
|
|
2
|
-
import { type ColorName } from "./colors";
|
|
3
|
-
import { View } from "react-native";
|
|
4
|
-
import { Badge } from "./badge";
|
|
5
|
-
|
|
6
|
-
export interface SelectCellOption {
|
|
7
|
-
key: string;
|
|
8
|
-
name: string;
|
|
9
|
-
color?: ColorName;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface CellSelectProps {
|
|
13
|
-
/**
|
|
14
|
-
* Selected key(s). Accepts a single string, an array of keys, or null/undefined.
|
|
15
|
-
* Unknown keys are silently dropped (no matching option = nothing rendered).
|
|
16
|
-
*/
|
|
17
|
-
value: string | string[] | null | undefined;
|
|
18
|
-
options: ReadonlyArray<SelectCellOption>;
|
|
19
|
-
/** Multi-select renders every selected key. Single-select renders only the first. */
|
|
20
|
-
multi?: boolean;
|
|
21
|
-
/** Force multi-select rendering even for single-select fields (filter previews). */
|
|
22
|
-
showAll?: boolean;
|
|
23
|
-
userSelect?: "none" | "auto";
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Pure-presentational select cell. Maps selected keys against the supplied
|
|
28
|
-
* options[] to produce coloured Badges. Iframe apps that already have an
|
|
29
|
-
* options table can render the same Lotics select-badge UI without depending
|
|
30
|
-
* on `@lotics/shared` field schemas.
|
|
31
|
-
*/
|
|
32
|
-
export const CellSelect = memo(function CellSelect(props: CellSelectProps) {
|
|
33
|
-
const { value, options, multi = false, showAll = false, userSelect = "none" } = props;
|
|
34
|
-
|
|
35
|
-
const content = useMemo(() => {
|
|
36
|
-
const selected: string[] =
|
|
37
|
-
value === null || value === undefined
|
|
38
|
-
? []
|
|
39
|
-
: Array.isArray(value)
|
|
40
|
-
? value
|
|
41
|
-
: [value];
|
|
42
|
-
|
|
43
|
-
const renderBadge = (key: string) => {
|
|
44
|
-
const option = options.find((o) => o.key === key);
|
|
45
|
-
if (!option) return null;
|
|
46
|
-
return <Badge key={key} color={option.color} label={option.name} userSelect={userSelect} />;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
if (multi || showAll) {
|
|
50
|
-
return selected.map(renderBadge).filter(Boolean);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const first = selected[0];
|
|
54
|
-
return first === undefined ? null : renderBadge(first);
|
|
55
|
-
}, [value, options, multi, showAll, userSelect]);
|
|
56
|
-
|
|
57
|
-
return (
|
|
58
|
-
<View
|
|
59
|
-
style={{
|
|
60
|
-
flexDirection: "row",
|
|
61
|
-
gap: 4,
|
|
62
|
-
alignItems: "center",
|
|
63
|
-
}}
|
|
64
|
-
>
|
|
65
|
-
{content}
|
|
66
|
-
</View>
|
|
67
|
-
);
|
|
68
|
-
});
|
package/src/cell_text.tsx
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { memo } from "react";
|
|
2
|
-
import { Platform } from "react-native";
|
|
3
|
-
import { Text } from "./text";
|
|
4
|
-
import { colors } from "./colors";
|
|
5
|
-
|
|
6
|
-
export type TextCellFormat = "text" | "link";
|
|
7
|
-
|
|
8
|
-
export interface CellTextProps {
|
|
9
|
-
value: string | null | undefined;
|
|
10
|
-
format?: TextCellFormat;
|
|
11
|
-
userSelect?: "none" | "auto";
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Pure-presentational text cell. `format: "link"` renders as a clickable
|
|
16
|
-
* anchor on web (preserving right-click / middle-click behavior); on native
|
|
17
|
-
* it falls through to plain Text — link navigation is the caller's job there.
|
|
18
|
-
*/
|
|
19
|
-
export const CellText = memo(function CellText(props: CellTextProps) {
|
|
20
|
-
const { value, format = "text", userSelect } = props;
|
|
21
|
-
|
|
22
|
-
if (format === "link" && value && Platform.OS === "web") {
|
|
23
|
-
const href = /^https?:\/\//i.test(value) ? value : `https://${value}`;
|
|
24
|
-
return (
|
|
25
|
-
<a
|
|
26
|
-
href={href}
|
|
27
|
-
target="_blank"
|
|
28
|
-
rel="noopener noreferrer"
|
|
29
|
-
onClick={(e) => {
|
|
30
|
-
e.stopPropagation();
|
|
31
|
-
}}
|
|
32
|
-
>
|
|
33
|
-
<Text numberOfLines={1} style={{ color: colors.blue["600"] }} userSelect={userSelect}>
|
|
34
|
-
{value}
|
|
35
|
-
</Text>
|
|
36
|
-
</a>
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return (
|
|
41
|
-
<Text numberOfLines={1} userSelect={userSelect}>
|
|
42
|
-
{value}
|
|
43
|
-
</Text>
|
|
44
|
-
);
|
|
45
|
-
});
|