@lotics/ui 47.13.1 → 47.15.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/AGENTS.md +17 -3
- package/docs/catalog.md +228 -29
- package/docs/composition.md +83 -4
- package/docs/data_entry.md +13 -6
- package/docs/reviewing.md +71 -0
- package/docs/templates.md +1 -1
- package/examples/tpl_attendance.tsx +51 -80
- package/examples/tpl_pivot.tsx +10 -1
- package/examples/tpl_record.tsx +33 -107
- package/package.json +9 -1
- package/src/avatar_size.ts +9 -8
- package/src/axis_ticks.ts +63 -0
- package/src/bar_chart.tsx +69 -50
- package/src/calendar/calendar_view.tsx +8 -2
- package/src/cell_stack.tsx +43 -7
- package/src/chip_group.tsx +12 -6
- package/src/danger_zone.tsx +23 -10
- package/src/date_filter.tsx +17 -7
- package/src/date_filter_presets.ts +209 -1
- package/src/date_range_filter_field.tsx +14 -4
- package/src/detail_row.tsx +18 -18
- package/src/drawer.tsx +66 -28
- package/src/format_date.ts +31 -3
- package/src/gantt/gantt_view.tsx +6 -2
- package/src/index.css +18 -0
- package/src/ledger.tsx +23 -15
- package/src/line_chart.tsx +194 -61
- package/src/list_item.tsx +22 -3
- package/src/locale.tsx +25 -0
- package/src/matrix.tsx +116 -16
- package/src/matrix_totals.ts +71 -3
- package/src/month_math.ts +16 -0
- package/src/month_stepper.tsx +62 -0
- package/src/organization_name.ts +94 -0
- package/src/search_match.ts +19 -0
- package/src/section_nav.tsx +163 -0
- package/src/state_matrix.tsx +265 -0
- package/src/table.tsx +67 -5
- package/src/timetable.tsx +167 -0
- package/src/toggle_strip.tsx +259 -0
- package/src/vite.d.mts +7 -0
- package/src/vite.mjs +47 -15
package/src/drawer.tsx
CHANGED
|
@@ -104,6 +104,30 @@ export function Drawer(props: DrawerProps) {
|
|
|
104
104
|
return () => document.removeEventListener("keydown", handler);
|
|
105
105
|
}, [open, onPrev, onNext]);
|
|
106
106
|
|
|
107
|
+
// ONE definition for both placements. Written twice, the wide bar and the
|
|
108
|
+
// narrow line would drift the first time either gained a control.
|
|
109
|
+
const RecordNav = () => (
|
|
110
|
+
<View style={styles.nav}>
|
|
111
|
+
<IconButton
|
|
112
|
+
icon="chevron-left"
|
|
113
|
+
accessibilityLabel={loc.previous}
|
|
114
|
+
onPress={onPrev ?? (() => {})}
|
|
115
|
+
disabled={!onPrev}
|
|
116
|
+
/>
|
|
117
|
+
{position ? (
|
|
118
|
+
<Text size="xs" color="muted" tabular>
|
|
119
|
+
{position}
|
|
120
|
+
</Text>
|
|
121
|
+
) : null}
|
|
122
|
+
<IconButton
|
|
123
|
+
icon="chevron-right"
|
|
124
|
+
accessibilityLabel={loc.next}
|
|
125
|
+
onPress={onNext ?? (() => {})}
|
|
126
|
+
disabled={!onNext}
|
|
127
|
+
/>
|
|
128
|
+
</View>
|
|
129
|
+
);
|
|
130
|
+
|
|
107
131
|
// Mounted only while OPEN — see `overlay_layer.ts`. react-native-web appends a
|
|
108
132
|
// `Modal`'s body-level div on first render and never re-orders it, so an
|
|
109
133
|
// always-mounted overlay claims its slot ahead of one opened later and covers
|
|
@@ -117,36 +141,32 @@ export function Drawer(props: DrawerProps) {
|
|
|
117
141
|
<Pressable style={styles.scrim} onPress={handleClose} accessibilityLabel={loc.close} tabIndex={-1} />
|
|
118
142
|
<View style={[styles.panel, { width: screenSize.small ? "100%" : width }]}>
|
|
119
143
|
<PortalHost>
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
{
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
onPress={onNext ?? (() => {})}
|
|
145
|
-
disabled={!onNext}
|
|
146
|
-
/>
|
|
144
|
+
{/* THE TITLE IS THE LAST THING THE BAR GIVES UP.
|
|
145
|
+
It names the record — whose file is open — and on one bar with a
|
|
146
|
+
pager and three icon buttons it is the only element that CAN
|
|
147
|
+
give way, because every one of those is a fixed width. At 375
|
|
148
|
+
that made the drawer cut its own subject ("Hộ kinh doanh Vận tải
|
|
149
|
+
Đức Thắng" needed 261px and got 206) while the chrome beside it
|
|
150
|
+
sat whole. So below the split width the record navigation drops
|
|
151
|
+
to its own line and the name takes the full bar; the close stays
|
|
152
|
+
up top, where a reader reaches for it. */}
|
|
153
|
+
<View style={[styles.header, screenSize.small && hasNav ? styles.headerStacked : null]}>
|
|
154
|
+
<View style={styles.headerTopLine}>
|
|
155
|
+
{typeof title === "string" ? (
|
|
156
|
+
<Text size="lg" weight="semibold" style={{ flex: 1 }}>
|
|
157
|
+
{title}
|
|
158
|
+
</Text>
|
|
159
|
+
) : (
|
|
160
|
+
<View style={{ flex: 1, minWidth: 0 }}>{title}</View>
|
|
161
|
+
)}
|
|
162
|
+
{hasNav && !screenSize.small ? <RecordNav /> : null}
|
|
163
|
+
<IconButton icon="x" size="lg" accessibilityLabel={loc.close} onPress={handleClose} />
|
|
164
|
+
</View>
|
|
165
|
+
{hasNav && screenSize.small ? (
|
|
166
|
+
<View style={styles.navLine}>
|
|
167
|
+
<RecordNav />
|
|
147
168
|
</View>
|
|
148
169
|
) : null}
|
|
149
|
-
<IconButton icon="x" size="lg" accessibilityLabel={loc.close} onPress={handleClose} />
|
|
150
170
|
</View>
|
|
151
171
|
<SizeBoundary testID={testID} style={styles.body}>
|
|
152
172
|
{children}
|
|
@@ -263,6 +283,24 @@ const styles = StyleSheet.create({
|
|
|
263
283
|
borderLeftColor: colors.border,
|
|
264
284
|
boxShadow: "-8px 0 24px rgba(0, 0, 0, 0.08)",
|
|
265
285
|
},
|
|
286
|
+
// The narrow bar is a COLUMN: the title's line, then the record navigation
|
|
287
|
+
// under it. Two lines cost 32px; a cut record name costs the reader the one
|
|
288
|
+
// fact that says which file they are in.
|
|
289
|
+
headerStacked: {
|
|
290
|
+
flexDirection: "column",
|
|
291
|
+
alignItems: "stretch",
|
|
292
|
+
gap: 4,
|
|
293
|
+
},
|
|
294
|
+
headerTopLine: {
|
|
295
|
+
flexDirection: "row",
|
|
296
|
+
alignItems: "center",
|
|
297
|
+
gap: 8,
|
|
298
|
+
minWidth: 0,
|
|
299
|
+
},
|
|
300
|
+
navLine: {
|
|
301
|
+
flexDirection: "row",
|
|
302
|
+
justifyContent: "flex-start",
|
|
303
|
+
},
|
|
266
304
|
header: {
|
|
267
305
|
flexDirection: "row",
|
|
268
306
|
alignItems: "center",
|
package/src/format_date.ts
CHANGED
|
@@ -9,7 +9,8 @@ export type DateFormatStyle =
|
|
|
9
9
|
| "medium" // 22 thg 5, 2026 / Sep 22, 2026 — readable, abbreviated month
|
|
10
10
|
| "long" // 22 tháng 5, 2026 / September 22, 2026 — readable, full month
|
|
11
11
|
| "dayMonth" // 22 thg 5 / Sep 22 — day + abbreviated month, no year
|
|
12
|
-
| "monthYear"
|
|
12
|
+
| "monthYear" // Tháng 5 năm 2026 / May 2026 — a period label (sentence-cased)
|
|
13
|
+
| "quarterYear"; // Quý 2 năm 2026 / Q2 2026 — the quarter the value falls in
|
|
13
14
|
|
|
14
15
|
export interface FormatDateOptions {
|
|
15
16
|
/** The date style. Default "date". */
|
|
@@ -34,6 +35,20 @@ const READABLE_OPTS: Record<"medium" | "long" | "dayMonth" | "monthYear", Intl.D
|
|
|
34
35
|
monthYear: { month: "long", year: "numeric" },
|
|
35
36
|
};
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* How a quarter is SAID, by language.
|
|
40
|
+
*
|
|
41
|
+
* A table rather than `Intl`: CLDR carries no quarter pattern for
|
|
42
|
+
* `DateTimeFormat`, so there is nothing to ask it for. Every other language
|
|
43
|
+
* falls to the international "Q2 2026" — which is what an unlocalized surface
|
|
44
|
+
* should print, rather than a Vietnamese sentence it never asked for.
|
|
45
|
+
*/
|
|
46
|
+
const QUARTER_YEAR: Record<string, (quarter: number, year: number) => string> = {
|
|
47
|
+
vi: (quarter, year) => `Quý ${quarter} năm ${year}`,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const defaultQuarterYear = (quarter: number, year: number): string => `Q${quarter} ${year}`;
|
|
51
|
+
|
|
37
52
|
const ISO_YEAR = /^(\d{4})$/;
|
|
38
53
|
const ISO_YEAR_MONTH = /^(\d{4})-(\d{2})$/;
|
|
39
54
|
const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
|
|
@@ -104,7 +119,8 @@ export function toISODate(value: Date | string | null | undefined): string {
|
|
|
104
119
|
* THE date-value formatter — the date sibling of `formatMoney`. Accepts a `Date` OR an ISO string
|
|
105
120
|
* and returns a localized display string; defaults to the home market. `format` picks the date
|
|
106
121
|
* style: `date` → `22/05/2026`; `medium` → `22 thg 5, 2026`; `long` → `22 tháng 5, 2026`;
|
|
107
|
-
* `dayMonth` → `22 thg 5`, `monthYear` → `Tháng 5 2026
|
|
122
|
+
* `dayMonth` → `22 thg 5`, `monthYear` → `Tháng 5 năm 2026`, `quarterYear` → `Quý 2 năm 2026`
|
|
123
|
+
* (`Q2 2026` outside Vietnamese). **`time: true` prepends the 24h time to
|
|
108
124
|
* ANY style** — `14:30 22/05/2026`, `14:30 22 tháng 5, 2026` (time-first). `compact` drops the
|
|
109
125
|
* year on the numeric `date` style. Never hand-roll a date with `padStart` / `getMonth` / a raw
|
|
110
126
|
* `Intl.DateTimeFormat` for VALUE display — call this. (Component-internal chrome is exempt — see
|
|
@@ -124,6 +140,16 @@ export function formatDate(value: Date | string | null | undefined, options: For
|
|
|
124
140
|
if (precision === "year") {
|
|
125
141
|
return String(date.getFullYear());
|
|
126
142
|
}
|
|
143
|
+
// A quarter is read off the MONTH, so a month-precision value serves it
|
|
144
|
+
// exactly as a full date does — and, being a calendar period, it carries no
|
|
145
|
+
// day and no clock whatever `time` says.
|
|
146
|
+
if (format === "quarterYear") {
|
|
147
|
+
const language = locale.split("-")[0].toLowerCase();
|
|
148
|
+
return (QUARTER_YEAR[language] ?? defaultQuarterYear)(
|
|
149
|
+
Math.floor(date.getMonth() / 3) + 1,
|
|
150
|
+
date.getFullYear(),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
127
153
|
if (precision === "month") {
|
|
128
154
|
return formatMonthYear(date, format, locale, compact, emptyLabel);
|
|
129
155
|
}
|
|
@@ -166,7 +192,9 @@ export function formatDate(value: Date | string | null | undefined, options: For
|
|
|
166
192
|
*/
|
|
167
193
|
function formatMonthYear(
|
|
168
194
|
date: Date,
|
|
169
|
-
|
|
195
|
+
// `quarterYear` never arrives here — it answers before precision is consulted,
|
|
196
|
+
// and it takes no month options to strip a day out of.
|
|
197
|
+
format: Exclude<DateFormatStyle, "quarterYear">,
|
|
170
198
|
locale: string,
|
|
171
199
|
compact: boolean,
|
|
172
200
|
emptyLabel: string,
|
package/src/gantt/gantt_view.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { colors, solid } from "../colors";
|
|
|
5
5
|
import { proportionalRadius } from "../control_surface";
|
|
6
6
|
import { FocusRingPressable } from "../focus_ring_pressable";
|
|
7
7
|
import { SegmentedControl, type SegmentOption } from "../segmented_control";
|
|
8
|
-
import { useLoticsLocale } from "../locale";
|
|
8
|
+
import { useLoticsLocale, useLocaleTag } from "../locale";
|
|
9
9
|
import { dayDiff } from "../calendar/dates";
|
|
10
10
|
import { axisRange, barGeometry, buildRows, buildTicks, pxPerDay } from "./scale";
|
|
11
11
|
import type { GanttLabels, GanttScale, GanttTask } from "./types";
|
|
@@ -23,6 +23,8 @@ export interface GanttViewProps<T = unknown> {
|
|
|
23
23
|
defaultScale?: GanttScale;
|
|
24
24
|
/** What counts as today for the marker and the opening scroll position. */
|
|
25
25
|
today?: Date;
|
|
26
|
+
/** BCP-47 tag for the axis tick names. Defaults to the active
|
|
27
|
+
* `LoticsLocaleProvider` pack's `bcp47`; pass one only to override it. */
|
|
26
28
|
locale?: string;
|
|
27
29
|
/** Optional toolbar caption shown left of the zoom switch. */
|
|
28
30
|
title?: string;
|
|
@@ -48,9 +50,11 @@ export interface GanttViewProps<T = unknown> {
|
|
|
48
50
|
* Renders at its natural height — wrap in a `ScrollView` for very long lists.
|
|
49
51
|
*/
|
|
50
52
|
export function GanttView<T = unknown>(props: GanttViewProps<T>) {
|
|
51
|
-
const { tasks, defaultScale = "week", today = new Date(),
|
|
53
|
+
const { tasks, defaultScale = "week", today = new Date(), title, onTaskPress } = props;
|
|
52
54
|
const pack = useLoticsLocale();
|
|
53
55
|
const L: GanttLabels = { ...pack.gantt, ...props.labels };
|
|
56
|
+
// The axis tick names come from Intl, so the active pack alone localizes them.
|
|
57
|
+
const locale = useLocaleTag(props.locale);
|
|
54
58
|
const [scale, setScale] = useState<GanttScale>(defaultScale);
|
|
55
59
|
|
|
56
60
|
const rows = useMemo(() => buildRows(tasks), [tasks]);
|
package/src/index.css
CHANGED
|
@@ -351,6 +351,24 @@ html {
|
|
|
351
351
|
outline: none;
|
|
352
352
|
}
|
|
353
353
|
|
|
354
|
+
/* Native UA number-stepper reset — subtractive, like the focus reset above.
|
|
355
|
+
`NumberInput` renders `type="number"` for the numeric keypad and the parse,
|
|
356
|
+
and the browser throws in a stepper: two arrows that sit ON the right edge of
|
|
357
|
+
a right-aligned figure and take width out of the field, so a numeric column is
|
|
358
|
+
one width at rest and a narrower one the moment somebody focuses a cell.
|
|
359
|
+
Nothing in the kit steps a value by pressing an arrow — `Counter` draws its
|
|
360
|
+
own — so it is a control that costs layout and does nothing. */
|
|
361
|
+
input[type="number"]::-webkit-outer-spin-button,
|
|
362
|
+
input[type="number"]::-webkit-inner-spin-button {
|
|
363
|
+
-webkit-appearance: none;
|
|
364
|
+
margin: 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
input[type="number"] {
|
|
368
|
+
-moz-appearance: textfield;
|
|
369
|
+
appearance: textfield;
|
|
370
|
+
}
|
|
371
|
+
|
|
354
372
|
/* @font-face declarations are NOT included here — each app provides its own
|
|
355
373
|
font loading because paths differ per platform:
|
|
356
374
|
- Frontend: /fonts/Inter_*.woff2 (served from public/)
|
package/src/ledger.tsx
CHANGED
|
@@ -256,24 +256,27 @@ export function LedgerRow(props: LedgerRowProps) {
|
|
|
256
256
|
const { label, meta, value, tone = "default", peek, peekWidth = 300, reference, accessibilityLabel } = props;
|
|
257
257
|
const rowDetails = useLoticsLocale().ledger.rowDetails;
|
|
258
258
|
const { format, dropMeta } = useLedger();
|
|
259
|
-
//
|
|
260
|
-
// (
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
259
|
+
// Below `META_MIN_ROW_WIDTH` the caption goes entirely rather than surviving as
|
|
260
|
+
// a sliver. Yielding first (see `yield`) is the right ORDER but not a floor: at
|
|
261
|
+
// phone width a caption clipped to two syllables is noise, and the space reads
|
|
262
|
+
// better spent on nothing. Two separate authors had already worked around this
|
|
263
|
+
// by dropping `meta` at small widths in their own apps, which is the
|
|
264
|
+
// component's job.
|
|
265
265
|
const showMeta = meta != null && meta !== "" && !dropMeta;
|
|
266
266
|
const content = (
|
|
267
267
|
<>
|
|
268
268
|
<Text size="sm" numberOfLines={1} style={styles.shrink}>
|
|
269
269
|
{label}
|
|
270
270
|
</Text>
|
|
271
|
+
{/* The caption IS the row's filler — it takes what the label and the figure
|
|
272
|
+
leave. With no caption to hold it open, an empty one does the same job. */}
|
|
271
273
|
{showMeta ? (
|
|
272
274
|
<Text size="xs" color="muted" numberOfLines={1} style={styles.yield}>
|
|
273
275
|
{meta}
|
|
274
276
|
</Text>
|
|
275
|
-
) :
|
|
276
|
-
|
|
277
|
+
) : (
|
|
278
|
+
<View style={styles.grow} />
|
|
279
|
+
)}
|
|
277
280
|
{!peek && reference ? (
|
|
278
281
|
<Link size="xs" onPress={reference.onPress} accessibilityLabel={reference.label}>
|
|
279
282
|
{reference.label}
|
|
@@ -389,13 +392,18 @@ const styles = StyleSheet.create({
|
|
|
389
392
|
rowHovered: { backgroundColor: colors.zinc[50] },
|
|
390
393
|
grow: { flexGrow: 1, flexShrink: 1 },
|
|
391
394
|
shrink: { flexShrink: 1 },
|
|
392
|
-
// The META yields
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
|
|
395
|
+
// The META yields ENTIRELY before the label yields at all. A shrink RATIO (this
|
|
396
|
+
// was 3 against the label's 1) buys an order of magnitude, not an order of
|
|
397
|
+
// service: flexbox scales a shrink factor by the item's own width, so a caption
|
|
398
|
+
// longer than the label still handed part of the loss back to it, and a 375px
|
|
399
|
+
// ledger read "Ghi công … / nằm trong số còn phải t…" — both halves clipped,
|
|
400
|
+
// and the FIGURE squeezed along with them. Sizing the caption off the leftovers
|
|
401
|
+
// instead (`flexBasis: 0` + `flexGrow: 1`, so it is also the row's filler) makes
|
|
402
|
+
// the order exact: the label and the figure take their content width first, the
|
|
403
|
+
// caption gets what remains, and only once it is down to nothing does the label
|
|
404
|
+
// start clipping. A caption qualifies a number; the label names it, and the name
|
|
405
|
+
// is what a reader needs when the row is too tight for both.
|
|
406
|
+
yield: { flexGrow: 1, flexShrink: 1, flexBasis: 0 },
|
|
399
407
|
total: { gap: 6 },
|
|
400
408
|
// The BASIS row, the only one whose two sides can differ in height: its label may
|
|
401
409
|
// carry a stacked `meta` while the figure stays one line. Centring then measures the
|