@lotics/ui 47.13.0 → 47.14.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 +16 -2
- package/docs/catalog.md +225 -27
- package/docs/composition.md +83 -4
- package/docs/data_entry.md +17 -7
- package/docs/reviewing.md +30 -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/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/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/docs/data_entry.md
CHANGED
|
@@ -284,7 +284,10 @@ field they would be both a false claim and dead buttons. The **popover** editors
|
|
|
284
284
|
(the resting box, wired as the popover trigger) with their OWN `useState` for open/draft/saving/
|
|
285
285
|
error — a pick, not a keystroke, is the edit, so `useInlineEdit` doesn't fit. `onSave` is async
|
|
286
286
|
either way: the saving spinner sits INSIDE the control at its right edge (never a sibling — that
|
|
287
|
-
reflows); an error shows inline without losing the edit.
|
|
287
|
+
reflows); an error shows inline without losing the edit. Saves overlap — the operator blurs one
|
|
288
|
+
field and edits the next while the first round trip is out — so land each result with the
|
|
289
|
+
updater form (`setForm((f) => ({ ...f, [key]: value }))`), never the form the save started from:
|
|
290
|
+
a snapshot lands the other field back at its pre-edit value while the server holds both.
|
|
288
291
|
|
|
289
292
|
To let a value be UNSET (a diff-write CLEAR — unassign, remove a due date, drop a select), pass
|
|
290
293
|
**`onClear`** to `InlineSelect` / `InlineMemberSelect` / `InlineDatePicker`. It surfaces through
|
|
@@ -332,6 +335,15 @@ in a `DataGrid`), where a cell varying with its data breaks the row rhythm the g
|
|
|
332
335
|
on a record surface; reserve in a table. Worked both ways in `dev/pages/inline_edit.tsx`
|
|
333
336
|
§ Multiline — the same value in a reserved field and a grown one, side by side.
|
|
334
337
|
|
|
338
|
+
**In a table the WIDTH is reserved too, and by the CELL — the editor never states its own.** An
|
|
339
|
+
inline editor is sized by what it holds: at rest a chip around the formatted value, focused a real
|
|
340
|
+
input at its natural width. Put one in a `TableCell` with nothing bounding it and the two states
|
|
341
|
+
are different boxes — the focused one grows sideways out of its column, over the neighbour, and
|
|
342
|
+
takes the row's height with it, on the one row somebody is working in. So wrap it in a `View` with
|
|
343
|
+
a fixed `width`, one number for the column, and both states land on the same rectangle.
|
|
344
|
+
(`NumberInput` already refuses to state a width for exactly this reason — see its `width: "100%"`
|
|
345
|
+
comment — but "fills its container" only bounds anything once a container has a size.)
|
|
346
|
+
|
|
335
347
|
### The row stack — `DetailTable` + `DetailRow`
|
|
336
348
|
|
|
337
349
|
A STACK of rows lives in a `DetailTable` (label, value laid out like a TABLE: `labelWidth` /
|
|
@@ -762,12 +774,10 @@ chronological order; each phase owns ITS OWN fee rows + phase total (the invoice
|
|
|
762
774
|
level up — money never reads as one pot); each closes with a SEQUENTIAL confirm gate that names
|
|
763
775
|
its blockers ("Confirm check-in first."). The header status chip carries the phase.
|
|
764
776
|
|
|
765
|
-
A LONG record surface pairs with a LEFT OUTLINE RAIL — `
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
becomes a PINNED bar naming the current section that opens a full-page section-picker `Modal`. A
|
|
770
|
-
per-phase dot on a rail item carries its confirmed state. A direction that is a TYPE (one record
|
|
777
|
+
A LONG record surface pairs with a LEFT OUTLINE RAIL — `SectionNav` for the chrome,
|
|
778
|
+
`useSectionNav` for the scroll-spy that feeds it `activeKey` and `jumpTo`. On narrow containers
|
|
779
|
+
the same list collapses to a pinned bar naming the current section, which opens it as a picker
|
|
780
|
+
anchored to that bar. A per-phase `issue` dot on an item carries its confirmed state. A direction that is a TYPE (one record
|
|
771
781
|
per event) is instead a discriminator chosen ONCE at creation — a `SegmentedControl` in the
|
|
772
782
|
create step, never a toggle on the record.
|
|
773
783
|
|
package/docs/reviewing.md
CHANGED
|
@@ -333,6 +333,13 @@ defect, so finding it on one panel is a reason to sweep the siblings.
|
|
|
333
333
|
that drifts across the track means each one was offset by a guess at its own width.
|
|
334
334
|
- **A row beat belongs to the PAGE** — collect every row primitive's height on one screen before
|
|
335
335
|
calling any of them right.
|
|
336
|
+
- **A bar's TRACK, not its card.** A chart whose label column and value column are FIXED spends
|
|
337
|
+
those pixels first, so a narrow pane leaves the plot itself with almost nothing — and a bar has
|
|
338
|
+
a minimum width, so every row draws the same short stub and the picture states that all the
|
|
339
|
+
entities are equal. It measures perfectly: the card is there, the rows are there, the figures
|
|
340
|
+
are right. Collect the track's width and each bar's, per row: a set of identical bar widths
|
|
341
|
+
under differing values is the finding, and so is any track under about a third of the card.
|
|
342
|
+
Same collection catches the axis underneath it, whose labels stack rather than crowd.
|
|
336
343
|
- **Cards laid side by side: collect their BOTTOM edges, not their tops.** Tops align for free —
|
|
337
344
|
the row sets them — so every horizontal check passes while the row ends on a staircase. One
|
|
338
345
|
`getBoundingClientRect().bottom` per card in a row: more than one value is the finding, and the
|
|
@@ -342,6 +349,15 @@ defect, so finding it on one panel is a reason to sweep the siblings.
|
|
|
342
349
|
neighbour, give the short card the fact it is missing, or re-pair the row. Screenshots are how
|
|
343
350
|
this is noticed and the rects are how it is aimed: a staircase is obvious on sight and invisible
|
|
344
351
|
to every probe above this one.
|
|
352
|
+
- **A scroller's CONTENT against the scroller — measure both, never just the frame.** A horizontal
|
|
353
|
+
scroll region lays its child out in a ROW, so a grid, a strip or a rail narrower than its frame
|
|
354
|
+
keeps its own content width and leaves the rest of the box empty; a `min-width: 100%` on the
|
|
355
|
+
content container does not carry to the child, which is what makes this look already fixed in
|
|
356
|
+
the source. It never appears as the blank space it is — the child sits at its floor, so what
|
|
357
|
+
shows is its CELLS wrapping their values onto two lines while a third of the card beside them is
|
|
358
|
+
drawn on by nothing. Walk up from one cell to the ancestor whose parent has `overflow-x: auto`,
|
|
359
|
+
comparing widths at each step; a child narrower than that frame is the finding, and the fix is
|
|
360
|
+
to let the child grow, never to widen the cells by hand.
|
|
345
361
|
|
|
346
362
|
→ [composition.md](./composition.md) §"Canvas & content column", §"Cards — banded and composable", §"No dead rows", §"The register's own craft".
|
|
347
363
|
|
|
@@ -583,6 +599,20 @@ explicit one, so a set where "the important ones are annotated" sheds the unanno
|
|
|
583
599
|
first — annotating some columns and not others is the failure, not the fix. If the register has
|
|
584
600
|
more than one column, either all of them carry a rank or none do.
|
|
585
601
|
|
|
602
|
+
**Then ask what the FIRST survivor is, because position outranks every rank.** Column 0 is the
|
|
603
|
+
row's identity and never drops, whatever number it carries — so if the survivor list opens on
|
|
604
|
+
something that does not identify the row (a time, a date, a sequence number), the finding is the
|
|
605
|
+
column ORDER and no amount of re-ranking touches it. It hides behind the reading convention:
|
|
606
|
+
anything read as a schedule is ordered time-first, which quietly makes the clock the one column a
|
|
607
|
+
phone cannot lose. Chasing it through the ranks finds nothing, because the rank was never what
|
|
608
|
+
kept it.
|
|
609
|
+
|
|
610
|
+
**And count each survivor as a FACT, not a column.** Two columns holding one fact-pair — declared
|
|
611
|
+
against counted, quoted against invoiced, planned against actual — mean nothing apart, so the fit
|
|
612
|
+
drops whichever it reaches first and the survivor states a number with nothing to compare it to.
|
|
613
|
+
That is worse than dropping both. Put the pair in ONE cell (`38 → 41`) and it survives or sheds
|
|
614
|
+
whole, which also halves what the width has to carry.
|
|
615
|
+
|
|
586
616
|
### 8h-bis. A cell that grows a second line lifts its first
|
|
587
617
|
**Collect** one column's first-line `y` MINUS its row's top, for every row; report the SET.
|
|
588
618
|
|
package/docs/templates.md
CHANGED
|
@@ -805,7 +805,7 @@ quick-capture templates. Top → bottom:
|
|
|
805
805
|
so a walk-back is one handoff at a time.
|
|
806
806
|
- **Danger zone** — with the full section anatomy (heading + description) like every other
|
|
807
807
|
section; the fenced red `DangerZone` card is the section's BODY.
|
|
808
|
-
- A LEFT OUTLINE RAIL (`
|
|
808
|
+
- A LEFT OUTLINE RAIL (`SectionNav` + `useSectionNav`) — **the rail is the page's COMPLETE
|
|
809
809
|
map** (every section including Danger zone; subsections never join it) — headed by the
|
|
810
810
|
bare circular `BackButton`; it scroll-spies the active section (`current={activeKey === key}`
|
|
811
811
|
on each item — navigation, not selection), and on narrow containers becomes the pinned
|
|
@@ -2,7 +2,6 @@ import { useState } from "react";
|
|
|
2
2
|
import { ScrollView, View } from "react-native";
|
|
3
3
|
import { Text } from "@lotics/ui/text";
|
|
4
4
|
import { colors, solid } from "@lotics/ui/colors";
|
|
5
|
-
import { Accordion, AccordionContent, AccordionHeader } from "@lotics/ui/accordion";
|
|
6
5
|
import { ActionMenu } from "@lotics/ui/action_menu";
|
|
7
6
|
import { Avatar } from "@lotics/ui/avatar";
|
|
8
7
|
import { Badge } from "@lotics/ui/badge";
|
|
@@ -14,6 +13,7 @@ import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
|
|
|
14
13
|
import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
|
|
15
14
|
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
16
15
|
import { Picker } from "@lotics/ui/picker";
|
|
16
|
+
import { StateMatrix, type StateMatrixState } from "@lotics/ui/state_matrix";
|
|
17
17
|
import { Tabs } from "@lotics/ui/tabs";
|
|
18
18
|
|
|
19
19
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -24,8 +24,9 @@ import { Tabs } from "@lotics/ui/tabs";
|
|
|
24
24
|
// Grammar: zinc-50 canvas, header + month Picker, KPIStrip (four counts),
|
|
25
25
|
// Tabs Today / This week. "Today" = one banded roster Card — each row
|
|
26
26
|
// press-opens the employee workspace Drawer (◀ ▶ sequencing) and carries a
|
|
27
|
-
// ⋯ ActionMenu with the day's quick fixes. "This week" =
|
|
28
|
-
//
|
|
27
|
+
// ⋯ ActionMenu with the day's quick fixes. "This week" = one StateMatrix —
|
|
28
|
+
// a person per row, the weekdays named once across the top, a glyph per day;
|
|
29
|
+
// a cell opens the same workspace Drawer.
|
|
29
30
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
31
|
|
|
31
32
|
type TrangThai = "co_mat" | "di_muon" | "vang" | "nghi_phep";
|
|
@@ -66,12 +67,13 @@ const TRANG_THAI: Record<TrangThai, { label: string; color: "emerald" | "amber"
|
|
|
66
67
|
nghi_phep: { label: "On leave", color: "blue" },
|
|
67
68
|
};
|
|
68
69
|
|
|
69
|
-
const NGAY_CONG:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
const NGAY_CONG: (StateMatrixState & { key: NgayCong })[] = [
|
|
71
|
+
{ key: "du", label: "Full day", color: "emerald" },
|
|
72
|
+
{ key: "muon", label: "Late", color: "amber" },
|
|
73
|
+
{ key: "vang", label: "Absent", color: "red", shape: "hollow" },
|
|
74
|
+
{ key: "phep", label: "On leave", color: "blue", shape: "dot" },
|
|
75
|
+
];
|
|
76
|
+
const ngayCong = (k: NgayCong) => NGAY_CONG.find((n) => n.key === k) ?? NGAY_CONG[0];
|
|
75
77
|
|
|
76
78
|
const THANG = [
|
|
77
79
|
{ label: "April 2026", value: "2026-04" },
|
|
@@ -85,12 +87,9 @@ const TAB = [
|
|
|
85
87
|
];
|
|
86
88
|
|
|
87
89
|
const NGAY_TUAN = ["T2", "T3", "T4", "T5", "T6"];
|
|
90
|
+
const NGAY_TUAN_DAY = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"];
|
|
88
91
|
|
|
89
|
-
const rowPad = { paddingHorizontal: 20, flexDirection: "row" as const, alignItems: "center" as const, gap: 12 };
|
|
90
92
|
|
|
91
|
-
// Week-view widths (the Accordion grid: day cells + week total + disclosure).
|
|
92
|
-
// The Today roster uses COLUMNS below, rendered by Table.
|
|
93
|
-
const W = { tong: 72, cell: 22, chevron: 28 };
|
|
94
93
|
|
|
95
94
|
const COLUMNS: TableColumn[] = [
|
|
96
95
|
{ key: "ten", label: "Employee", flex: 1, sortable: true },
|
|
@@ -104,12 +103,23 @@ function badgeLabel(nv: NhanVien): string {
|
|
|
104
103
|
return nv.trangThai === "di_muon" && nv.muonPhut !== undefined ? `${tt.label} ${nv.muonPhut}ph` : tt.label;
|
|
105
104
|
}
|
|
106
105
|
|
|
107
|
-
/** One weekday marker —
|
|
106
|
+
/** One weekday marker in the drawer's day list — the same glyph the week grid draws. */
|
|
108
107
|
function DayCell({ ngay }: { ngay: NgayCong }) {
|
|
109
|
-
const s =
|
|
108
|
+
const s = ngayCong(ngay);
|
|
109
|
+
const hollow = s.shape === "hollow";
|
|
110
|
+
const dot = s.shape === "dot";
|
|
110
111
|
return (
|
|
111
|
-
<View style={{ width:
|
|
112
|
-
<View
|
|
112
|
+
<View style={{ width: 22, alignItems: "center" }}>
|
|
113
|
+
<View
|
|
114
|
+
style={{
|
|
115
|
+
width: dot ? 6 : 12,
|
|
116
|
+
height: dot ? 6 : 12,
|
|
117
|
+
borderRadius: dot ? 999 : 3,
|
|
118
|
+
backgroundColor: hollow ? colors.white : solid(s.color),
|
|
119
|
+
borderWidth: hollow ? 1.5 : 0,
|
|
120
|
+
borderColor: solid(s.color),
|
|
121
|
+
}}
|
|
122
|
+
/>
|
|
113
123
|
</View>
|
|
114
124
|
);
|
|
115
125
|
}
|
|
@@ -160,38 +170,6 @@ function HomNayRow({ nv, selected, onPress }: { nv: NhanVien; selected: boolean;
|
|
|
160
170
|
);
|
|
161
171
|
}
|
|
162
172
|
|
|
163
|
-
/** Week row — read-only drill-down: expands in place to the day-by-day detail. */
|
|
164
|
-
function TuanRow({ nv }: { nv: NhanVien }) {
|
|
165
|
-
return (
|
|
166
|
-
<Accordion>
|
|
167
|
-
<AccordionHeader accessibilityLabel={`Week detail for ${nv.ten}`}>
|
|
168
|
-
<View style={{ flex: 1, gap: 2 }}>
|
|
169
|
-
<Text size="sm" weight="medium" leading="tight" numberOfLines={1}>{nv.ten}</Text>
|
|
170
|
-
{/* Body size: `medium` above already separates the pair, so the rung
|
|
171
|
-
down would be a second demotion on the line that says WHICH
|
|
172
|
-
person this is. */}
|
|
173
|
-
<Text size="sm" color="muted" leading="tight" numberOfLines={1}>{nv.chucVu}</Text>
|
|
174
|
-
</View>
|
|
175
|
-
<View style={{ flexDirection: "row", gap: 10 }}>
|
|
176
|
-
{nv.tuan.map((ngay, i) => <DayCell key={NGAY_TUAN[i]} ngay={ngay} />)}
|
|
177
|
-
</View>
|
|
178
|
-
<View style={{ width: W.tong, alignItems: "flex-end" }}>
|
|
179
|
-
<Text size="sm" weight="medium" tabular>{nv.tongTuan}</Text>
|
|
180
|
-
</View>
|
|
181
|
-
</AccordionHeader>
|
|
182
|
-
<AccordionContent>
|
|
183
|
-
{nv.tuan.map((ngay, i) => (
|
|
184
|
-
<View key={NGAY_TUAN[i]} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 32 }}>
|
|
185
|
-
<Text size="xs" color="muted" tabular style={{ width: 24 }}>{NGAY_TUAN[i]}</Text>
|
|
186
|
-
<DayCell ngay={ngay} />
|
|
187
|
-
<Text size="sm">{NGAY_CONG[ngay].label}</Text>
|
|
188
|
-
</View>
|
|
189
|
-
))}
|
|
190
|
-
</AccordionContent>
|
|
191
|
-
</Accordion>
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
173
|
function KVRow({ label, value }: { label: string; value: string }) {
|
|
196
174
|
return (
|
|
197
175
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 24 }}>
|
|
@@ -234,7 +212,7 @@ function NhanVienWorkspace({ nv }: { nv: NhanVien }) {
|
|
|
234
212
|
<View key={NGAY_TUAN[i]} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 24 }}>
|
|
235
213
|
<Text size="xs" color="muted" tabular style={{ width: 24 }}>{NGAY_TUAN[i]}</Text>
|
|
236
214
|
<DayCell ngay={ngay} />
|
|
237
|
-
<Text size="sm">{
|
|
215
|
+
<Text size="sm">{ngayCong(ngay).label}</Text>
|
|
238
216
|
</View>
|
|
239
217
|
))}
|
|
240
218
|
</View>
|
|
@@ -258,7 +236,6 @@ export function TplAttendance() {
|
|
|
258
236
|
const onSort = (key: string) => setSort(cycleSort(sort, key));
|
|
259
237
|
|
|
260
238
|
const dem = (tt: TrangThai) => NHAN_VIEN.filter((nv) => nv.trangThai === tt).length;
|
|
261
|
-
const eyebrow = (label: string) => <Text size="xs" color="muted" weight="medium">{label}</Text>;
|
|
262
239
|
|
|
263
240
|
// The Today roster, ordered by the active sort (Hours sorts numerically).
|
|
264
241
|
const sorted = sortBy(NHAN_VIEN, sort, (nv, key) =>
|
|
@@ -310,35 +287,29 @@ export function TplAttendance() {
|
|
|
310
287
|
</Table>
|
|
311
288
|
</Card>
|
|
312
289
|
) : (
|
|
313
|
-
<Card
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
))}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
<View key={k} style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
337
|
-
<DayCell ngay={k} />
|
|
338
|
-
<Text size="xs" color="muted">{NGAY_CONG[k].label}</Text>
|
|
339
|
-
</View>
|
|
340
|
-
))}
|
|
341
|
-
</View>
|
|
290
|
+
<Card>
|
|
291
|
+
{/* The grid owns its axis, its legend and its scroll; a cell is a door to the
|
|
292
|
+
same workspace the roster opens. Absent is HOLLOW and leave is a DOT so the
|
|
293
|
+
two kinds of "not here" read apart before the legend is consulted. */}
|
|
294
|
+
<StateMatrix
|
|
295
|
+
columns={NGAY_TUAN.map((d, i) => ({ key: d, label: d, current: i === 2 }))}
|
|
296
|
+
states={NGAY_CONG}
|
|
297
|
+
rows={NHAN_VIEN.map((nv) => ({
|
|
298
|
+
key: nv.id,
|
|
299
|
+
leading: <Avatar name={nv.ten} size="sm" />,
|
|
300
|
+
label: nv.ten,
|
|
301
|
+
total: nv.tongTuan,
|
|
302
|
+
cells: nv.tuan.map((ngay, i) => ({
|
|
303
|
+
state: ngay,
|
|
304
|
+
pressable: true,
|
|
305
|
+
name: `${nv.ten}, ${NGAY_TUAN_DAY[i]}: ${ngayCong(ngay).label}`,
|
|
306
|
+
})),
|
|
307
|
+
}))}
|
|
308
|
+
onPressCell={(rowKey) => setOpenId(rowKey)}
|
|
309
|
+
rowLabelWidth={220}
|
|
310
|
+
cellWidth={36}
|
|
311
|
+
labels={{ total: "Week total" }}
|
|
312
|
+
/>
|
|
342
313
|
</Card>
|
|
343
314
|
)}
|
|
344
315
|
</View>
|
package/examples/tpl_pivot.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
|
|
2
2
|
import { Pressable, ScrollView, View } from "react-native";
|
|
3
3
|
import { Text } from "@lotics/ui/text";
|
|
4
4
|
import { colors } from "@lotics/ui/colors";
|
|
5
|
+
import { Avatar } from "@lotics/ui/avatar";
|
|
5
6
|
import { Badge } from "@lotics/ui/badge";
|
|
6
7
|
import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
|
|
7
8
|
import { Divider } from "@lotics/ui/divider";
|
|
@@ -149,7 +150,15 @@ export function TplPivot() {
|
|
|
149
150
|
</CardHeader>
|
|
150
151
|
<View style={{ paddingHorizontal: 20, paddingVertical: 16 }}>
|
|
151
152
|
<Matrix
|
|
152
|
-
|
|
153
|
+
// A row is a SUBJECT, so it carries the same identity mark a
|
|
154
|
+
// warehouse wears anywhere else in the app — square, because a
|
|
155
|
+
// facility is an organization and not a person. The mark is drawn
|
|
156
|
+
// inside `rowLabelWidth`, so nothing else in the grid moves.
|
|
157
|
+
rows={WAREHOUSES.map((w) => ({
|
|
158
|
+
key: w.key,
|
|
159
|
+
label: w.label,
|
|
160
|
+
leading: <Avatar shape="square" size="sm" name={w.label} />,
|
|
161
|
+
}))}
|
|
153
162
|
cols={CATEGORIES.map((c) => ({ key: c.key, label: c.label }))}
|
|
154
163
|
value={value}
|
|
155
164
|
selected={cell}
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -23,14 +23,12 @@ import { Section, SectionHeading, SectionHeadingTitle, SectionHeadingMeta, Subse
|
|
|
23
23
|
import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
|
|
24
24
|
import { Checklist, ChecklistActions, ChecklistGroup, ChecklistItem, ChecklistNote } from "@lotics/ui/checklist";
|
|
25
25
|
import { DateStamp } from "@lotics/ui/date_stamp";
|
|
26
|
-
import {
|
|
27
|
-
import { Popover, PopoverContent } from "@lotics/ui/popover";
|
|
26
|
+
import { SectionNav } from "@lotics/ui/section_nav";
|
|
28
27
|
import { useSectionNav } from "@lotics/ui/use_section_nav";
|
|
29
28
|
import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle, DialogScrollArea } from "@lotics/ui/dialog";
|
|
30
29
|
import { formatMoney } from "@lotics/ui/format_money";
|
|
31
30
|
import { RecordSummary } from "@lotics/ui/record_summary";
|
|
32
31
|
import { SummaryLine } from "@lotics/ui/summary_line";
|
|
33
|
-
import { Badge } from "@lotics/ui/badge";
|
|
34
32
|
import { daysUntil, deadlineAnnotation } from "@lotics/ui/deadline";
|
|
35
33
|
import { useLoticsLocale } from "@lotics/ui/locale";
|
|
36
34
|
import { Skeleton } from "@lotics/ui/skeleton";
|
|
@@ -2662,26 +2660,10 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2662
2660
|
// since a reference is a field); everything after it is supplementary
|
|
2663
2661
|
// (discussion, work, documents, money, lifecycle).
|
|
2664
2662
|
//
|
|
2665
|
-
//
|
|
2666
|
-
//
|
|
2667
|
-
//
|
|
2668
|
-
//
|
|
2669
|
-
// a single surface. `ReferenceField` sat here under this same note until a
|
|
2670
|
-
// register's drawer needed it, and that is what let the extraction land on the
|
|
2671
|
-
// PEEK contract instead of on the layout.
|
|
2672
|
-
//
|
|
2673
|
-
// When a second surface wants this, the contract worth lifting is: one section
|
|
2674
|
-
// list, rendered as a RAIL where the container affords a gutter and as a pinned
|
|
2675
|
-
// CURRENT-SECTION bar where it does not, the switch reading the CONTAINER (never
|
|
2676
|
-
// the window); the picker anchored to the control that opens it, never a Modal
|
|
2677
|
-
// that escapes its container; and the per-section attention dot, which is what
|
|
2678
|
-
// makes the rail answer "where is the problem" and not merely "where am I".
|
|
2679
|
-
//
|
|
2680
|
-
// What is NOT the contract, and would poison it if lifted along: the issue-tone
|
|
2681
|
-
// MAP below (its keys are this record's domain), the comments toggle (drawer
|
|
2682
|
-
// chrome), the BackButton (page chrome), the gutter arithmetic (28 page / 20
|
|
2683
|
-
// drawer, minus the trigger's padding) and the `wide` threshold. One consumer
|
|
2684
|
-
// cannot tell those apart from the contract — two can.
|
|
2663
|
+
// `SectionNav` draws both forms; `useSectionNav` spies the scroll. What stays
|
|
2664
|
+
// HERE is what reads this record's domain or this page's layout: the issue map
|
|
2665
|
+
// below (its keys are this record's), the BackButton, the gutter arithmetic
|
|
2666
|
+
// and the `wide` threshold.
|
|
2685
2667
|
const SECTIONS = [
|
|
2686
2668
|
// PROGRESS leads. The header already IDENTIFIES the record — it titles the
|
|
2687
2669
|
// customer and carries the code and city beneath it — so the first section
|
|
@@ -2694,7 +2676,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2694
2676
|
//
|
|
2695
2677
|
// The rail lists sections in PAGE order, or the scroll-spy highlights one
|
|
2696
2678
|
// entry while the reader is looking at another.
|
|
2697
|
-
{ key: "progress", label: "Progress", icon: "
|
|
2679
|
+
{ key: "progress", label: "Progress", icon: "trending-up" },
|
|
2698
2680
|
{ key: "activity", label: "Activity", icon: "message-square" },
|
|
2699
2681
|
{ key: "general", label: "General", icon: "file-text" },
|
|
2700
2682
|
// Only a SECTION when the page is too narrow to seat the panel — so the wide
|
|
@@ -2749,15 +2731,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2749
2731
|
transport: scheduleNote !== "on-time" ? "warning" : undefined,
|
|
2750
2732
|
billing: customer === null ? "warning" : undefined,
|
|
2751
2733
|
};
|
|
2752
|
-
// Narrow: the rail becomes a pinned bar naming the CURRENT section; tapping
|
|
2753
|
-
// it opens a full-page section picker (Escape / the close control dismiss).
|
|
2754
|
-
const [sectionsOpen, setSectionsOpen] = useState(false);
|
|
2755
|
-
// The picker anchors to the BAR BUTTON, so it is scoped to whatever container
|
|
2756
|
-
// that button sits in — the page, or a drawer. A Modal escaped the container
|
|
2757
|
-
// and took the whole window, which inside a drawer covered the register behind
|
|
2758
|
-
// it. `triggerRef` (not `PopoverTrigger`) because the trigger IS a Button and a
|
|
2759
|
-
// button must not nest in another pressable.
|
|
2760
|
-
const sectionsAnchor = useRef<View>(null);
|
|
2761
2734
|
const [pageWidth, setPageWidth] = useState<number | null>(null);
|
|
2762
2735
|
// THREE COLUMNS: the outline rail in the left gutter, the record, and the
|
|
2763
2736
|
// discussion panel in the right. The right gutter used to be empty — reserved
|
|
@@ -2853,63 +2826,21 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2853
2826
|
<View style={{ flex: 1, backgroundColor: colors.white, opacity: pageWidth == null ? 0 : 1 }} onLayout={(e) => setPageWidth(e.nativeEvent.layout.width)}>
|
|
2854
2827
|
{pageWidth == null || wide ? null : (
|
|
2855
2828
|
// Narrow: a pinned bar names the CURRENT section (the spy keeps it
|
|
2856
|
-
// honest while scrolling); tapping opens the
|
|
2857
|
-
//
|
|
2858
|
-
//
|
|
2859
|
-
//
|
|
2860
|
-
//
|
|
2861
|
-
//
|
|
2862
|
-
//
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
{
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
{isDrawer ?
|
|
2869
|
-
<
|
|
2870
|
-
|
|
2871
|
-
{/* MUTED, not `secondary`: this names WHERE YOU ARE, so it is a
|
|
2872
|
-
position readout that happens to be pressable, and a filled chip
|
|
2873
|
-
competes with the record's own actions for the eye. The usual cost
|
|
2874
|
-
of a fill-less button does not apply here — it is chrome in a bar
|
|
2875
|
-
with its own gutter, not a verb aligned to a column of text, so
|
|
2876
|
-
there is nothing below it for its ink to line up with. The glyph
|
|
2877
|
-
carries the affordance: `list-collapse` says WHAT the list is,
|
|
2878
|
-
where `Switcher` uses a chevron for the same shape. */}
|
|
2879
|
-
<Button
|
|
2880
|
-
title={SECTIONS.find((sec) => sec.key === nav.activeKey)?.label ?? "Sections"}
|
|
2881
|
-
color="muted"
|
|
2882
|
-
onPress={() => setSectionsOpen(true)}
|
|
2883
|
-
/>
|
|
2884
|
-
</View>
|
|
2885
|
-
<PopoverContent style={{ width: 248 }} disableBodyScroll>
|
|
2886
|
-
<View style={{ gap: 2 }}>
|
|
2887
|
-
{SECTIONS.map((sec) => (
|
|
2888
|
-
<MenuButton
|
|
2889
|
-
key={sec.key}
|
|
2890
|
-
icon={sec.icon}
|
|
2891
|
-
title={sec.label}
|
|
2892
|
-
current={nav.activeKey === sec.key}
|
|
2893
|
-
onPress={() => {
|
|
2894
|
-
setSectionsOpen(false);
|
|
2895
|
-
nav.jumpTo(sec.key);
|
|
2896
|
-
}}
|
|
2897
|
-
/>
|
|
2898
|
-
))}
|
|
2899
|
-
</View>
|
|
2900
|
-
</PopoverContent>
|
|
2901
|
-
</Popover>
|
|
2902
|
-
<View style={{ flex: 1 }} />
|
|
2903
|
-
{/* WHERE it sits carries the meaning: sections on the left (where you
|
|
2904
|
-
ARE), the discussion on the right (what is being SAID) — the same
|
|
2905
|
-
two poles the wide layout puts in its two gutters. */}
|
|
2906
|
-
{/* A LABEL with its count, not a bare glyph: this is one of the two
|
|
2907
|
-
things the body can show, so it has to say which — and how much is
|
|
2908
|
-
waiting, which is what decides whether you look now. In drawer chrome
|
|
2909
|
-
it toggles the body; on a narrow page it still opens the sheet, which
|
|
2910
|
-
is that layout's panel. The pair reads as a switch: whichever side the
|
|
2911
|
-
body is showing wears the filled `secondary`. */}
|
|
2912
|
-
</View>
|
|
2829
|
+
// honest while scrolling); tapping opens the section picker — no tab
|
|
2830
|
+
// strip the thumb has to scroll. The bar's gutter is the CONTENT's, and
|
|
2831
|
+
// `SectionNav` takes the trigger's own padding off it so the section
|
|
2832
|
+
// NAME starts on the same left edge as every section title below.
|
|
2833
|
+
//
|
|
2834
|
+
// Narrow keeps the back standard too, EXCEPT in a drawer, which already
|
|
2835
|
+
// has one in its own header — two would be two exits.
|
|
2836
|
+
<SectionNav
|
|
2837
|
+
items={SECTIONS.map((sec) => ({ ...sec, issue: sectionIssue[sec.key] }))}
|
|
2838
|
+
activeKey={nav.activeKey}
|
|
2839
|
+
onJump={nav.jumpTo}
|
|
2840
|
+
wide={false}
|
|
2841
|
+
barGutter={isDrawer ? DRAWER_GUTTER : 28}
|
|
2842
|
+
back={isDrawer ? undefined : <BackButton onPress={() => {}} />}
|
|
2843
|
+
/>
|
|
2913
2844
|
)}
|
|
2914
2845
|
<View style={{ flex: 1 }}>
|
|
2915
2846
|
{/* FULL-WIDTH scroller: the whole page scrolls, wherever the pointer is. */}
|
|
@@ -5239,23 +5170,18 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
5239
5170
|
scrolls, and out of the scroll row so the page stays wheel-scrollable
|
|
5240
5171
|
edge to edge. */}
|
|
5241
5172
|
{pageWidth == null || !wide ? null : (
|
|
5242
|
-
<View style={{ position: "absolute", top: 28, left: railLeft
|
|
5243
|
-
|
|
5244
|
-
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
{
|
|
5249
|
-
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
right={sectionIssue[sec.key] ? <Badge variant="dot" color={sectionIssue[sec.key] === "danger" ? "red" : "amber"} /> : undefined}
|
|
5255
|
-
onPress={() => nav.jumpTo(sec.key)}
|
|
5256
|
-
/>
|
|
5257
|
-
))}
|
|
5258
|
-
</View>
|
|
5173
|
+
<View style={{ position: "absolute", top: 28, left: railLeft }}>
|
|
5174
|
+
<SectionNav
|
|
5175
|
+
/* Comments is a section only while the page is too narrow to seat the
|
|
5176
|
+
panel, so the wide rail drops it rather than offering a jump to
|
|
5177
|
+
something that is not there. */
|
|
5178
|
+
items={SECTIONS.filter((sec) => sec.key !== "comments").map((sec) => ({ ...sec, issue: sectionIssue[sec.key] }))}
|
|
5179
|
+
activeKey={activeSection}
|
|
5180
|
+
onJump={nav.jumpTo}
|
|
5181
|
+
wide
|
|
5182
|
+
railWidth={RAIL_W}
|
|
5183
|
+
back={<BackButton label="Records" onPress={() => {}} />}
|
|
5184
|
+
/>
|
|
5259
5185
|
</View>
|
|
5260
5186
|
)}
|
|
5261
5187
|
</View>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "47.
|
|
3
|
+
"version": "47.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./accordion": "./src/accordion.tsx",
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"./data_grid": "./src/data_grid.tsx",
|
|
72
72
|
"./date_calendar": "./src/date_calendar.tsx",
|
|
73
73
|
"./date_filter": "./src/date_filter.tsx",
|
|
74
|
+
"./date_filter_presets": "./src/date_filter_presets.ts",
|
|
74
75
|
"./date_picker": "./src/date_picker.tsx",
|
|
75
76
|
"./date_range_filter_field": "./src/date_range_filter_field.tsx",
|
|
76
77
|
"./date_stamp": "./src/date_stamp.tsx",
|
|
@@ -189,6 +190,8 @@
|
|
|
189
190
|
"./markdown_types": "./src/markdown_types.ts",
|
|
190
191
|
"./remark_gfm_safe": "./src/remark_gfm_safe.ts",
|
|
191
192
|
"./matrix": "./src/matrix.tsx",
|
|
193
|
+
"./month_math": "./src/month_math.ts",
|
|
194
|
+
"./month_stepper": "./src/month_stepper.tsx",
|
|
192
195
|
"./matrix_totals": "./src/matrix_totals.ts",
|
|
193
196
|
"./media_player": {
|
|
194
197
|
"react-native": "./src/media_player.tsx",
|
|
@@ -209,6 +212,7 @@
|
|
|
209
212
|
"./option_badge": "./src/option_badge.tsx",
|
|
210
213
|
"./option_list": "./src/option_list.tsx",
|
|
211
214
|
"./option_picker": "./src/option_picker.tsx",
|
|
215
|
+
"./organization_name": "./src/organization_name.ts",
|
|
212
216
|
"./overlay_layer": "./src/overlay_layer.ts",
|
|
213
217
|
"./overlay_scope": "./src/overlay_scope.ts",
|
|
214
218
|
"./page_content": "./src/page_content.tsx",
|
|
@@ -243,6 +247,7 @@
|
|
|
243
247
|
"./search_match": "./src/search_match.ts",
|
|
244
248
|
"./section_card": "./src/section_card.tsx",
|
|
245
249
|
"./section_heading": "./src/section_heading.tsx",
|
|
250
|
+
"./section_nav": "./src/section_nav.tsx",
|
|
246
251
|
"./section_stack": "./src/section_stack.tsx",
|
|
247
252
|
"./segmented_control": "./src/segmented_control.tsx",
|
|
248
253
|
"./select": "./src/select.tsx",
|
|
@@ -267,6 +272,7 @@
|
|
|
267
272
|
"./stacked_bar_chart": "./src/stacked_bar_chart.tsx",
|
|
268
273
|
"./stacked_progress_bar": "./src/stacked_progress_bar.tsx",
|
|
269
274
|
"./status_badge": "./src/status_badge.tsx",
|
|
275
|
+
"./state_matrix": "./src/state_matrix.tsx",
|
|
270
276
|
"./status_grid": "./src/status_grid.tsx",
|
|
271
277
|
"./step_progress": "./src/step_progress.tsx",
|
|
272
278
|
"./stepper": "./src/stepper.tsx",
|
|
@@ -295,6 +301,8 @@
|
|
|
295
301
|
"./time_options": "./src/time_options.ts",
|
|
296
302
|
"./time_picker": "./src/time_picker.tsx",
|
|
297
303
|
"./timeline": "./src/timeline.tsx",
|
|
304
|
+
"./timetable": "./src/timetable.tsx",
|
|
305
|
+
"./toggle_strip": "./src/toggle_strip.tsx",
|
|
298
306
|
"./tokens": "./src/tokens.ts",
|
|
299
307
|
"./tooltip": "./src/tooltip.tsx",
|
|
300
308
|
"./trend_chip": "./src/trend_chip.tsx",
|
package/src/avatar_size.ts
CHANGED
|
@@ -48,14 +48,15 @@ export const AVATAR_PX: Record<AvatarSize, number> = {
|
|
|
48
48
|
* beside it. It was 48, which anchored to nothing: the bar's other controls
|
|
49
49
|
* are 40, and a 48px circle among them reads as the one thing that got out.
|
|
50
50
|
*
|
|
51
|
-
* **In a register, the rung follows what the
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* and in a table it takes those 12 from the flexible column the name is
|
|
51
|
+
* **In a register, the rung follows what the reader AIMS AT, not the
|
|
52
|
+
* density.** The row's subject takes this rung — and so does an ORGANIZATION
|
|
53
|
+
* in its own column, because a book of movements is scanned by whose they
|
|
54
|
+
* are, and at `md` such a register reads as a list of numbers that happens to
|
|
55
|
+
* carry faces. `md` is for a mark incidental to the value the reader came for
|
|
56
|
+
* — a person in an owner or assignee column, where the face is competing with
|
|
57
|
+
* the row's own mark anyway. Budget for it: this rung is 12px wider than
|
|
58
|
+
* `md`, and in a table it takes those 12 from the flexible column the name is
|
|
59
|
+
* in, so a column too tight to seat one takes `md`. */
|
|
59
60
|
lg: 40,
|
|
60
61
|
/** A profile's own header. */
|
|
61
62
|
xl: 72,
|