@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/docs/composition.md
CHANGED
|
@@ -398,6 +398,38 @@ badge. Every period-dependent number MUST follow the selection. Pass `includeTim
|
|
|
398
398
|
time-of-day matters: the trigger previews the chosen time (locale-aware) and the `labels` prop
|
|
399
399
|
translates the presets, footer buttons, placeholder, and the time-segment editors.
|
|
400
400
|
|
|
401
|
+
**One field, every grain — a `SegmentedControl` of grains beside it re-adds the mode the field
|
|
402
|
+
removed.** The presets already run day → week → month → quarter → year, and the trigger folds a
|
|
403
|
+
whole period back to its own name ("Tháng 9 năm 2026", "Quý 3 năm 2026", "2026"), so the grain is
|
|
404
|
+
something the reader SETS and READS in one control. Putting *Tháng | Quý | Năm* next to it splits
|
|
405
|
+
one fact across two controls that can disagree — pick "Quý" beside a range of eleven days and the
|
|
406
|
+
screen states a grain nothing on it is computed at.
|
|
407
|
+
|
|
408
|
+
**A report's comparator is DERIVED from the selected range, never chosen.** "So với kỳ trước" is
|
|
409
|
+
`previousPeriod(value)` from `@lotics/ui/date_filter_presets` — a whole month steps back a whole
|
|
410
|
+
month, a quarter a quarter, a year a year, and anything else steps back by its own length. Feed
|
|
411
|
+
that range to the same query the selection feeds and put the result ON the figure: a
|
|
412
|
+
`KPIStripItem`'s `trend` / `caption`, `Metric`'s `previousValue`, a second `LineChart` series
|
|
413
|
+
(`series` is what makes two periods one chart on one scale), `TrendFooter` under a chart card. A
|
|
414
|
+
second date field, a "compare to" picker or a comparison toggle beside the filter is the same
|
|
415
|
+
mistake in three shapes — it lets a reader select a comparator that is not the period they are
|
|
416
|
+
looking at, and every figure on the screen then answers a question nobody asked. `null` back means
|
|
417
|
+
the range is open-ended: show the figure with no delta rather than inventing one.
|
|
418
|
+
|
|
419
|
+
**A period still RUNNING is compared TO DATE — `periodToDate(value, now)`.** Six days of this
|
|
420
|
+
month set beside a whole previous month reads as a collapse that never happened, and it lands on
|
|
421
|
+
the first figure anyone looks at, so both sides are cut to the days that have elapsed: it hands
|
|
422
|
+
back the selection ending today, the comparator cut to the same count from its own first day
|
|
423
|
+
(clamped to that period's end, so 30 days of March meet all 28 of February), and
|
|
424
|
+
`running` / `elapsedDays` / `totalDays` for the caption that says how far in the reader is. A
|
|
425
|
+
closed period keeps both sides whole.
|
|
426
|
+
|
|
427
|
+
**A surface keyed by a whole month takes a `MonthStepper`, not a range field.** A monthly plan, a
|
|
428
|
+
roster, a month's attendance cannot take "the 3rd to the 19th", so a range control offers moves
|
|
429
|
+
that do not exist. The stepper's bound is its contract: pass `max` as this month on any surface
|
|
430
|
+
about what has happened, or the reader walks into a month nobody has planned and reads it as a
|
|
431
|
+
plan. Two hand-rolled steppers in one app disagreed on exactly this.
|
|
432
|
+
|
|
401
433
|
## A calendar answers WHEN — and only when the position on a timeline is the question
|
|
402
434
|
|
|
403
435
|
A calendar is not a prettier register. Reach for one when the reader's question is *when does
|
|
@@ -406,6 +438,19 @@ at the same time. When the question is *which of these needs me*, the answer is
|
|
|
406
438
|
a date column, and a calendar buries it: thirty rows of a table are scannable, thirty rows
|
|
407
439
|
scattered across a month grid are a search.
|
|
408
440
|
|
|
441
|
+
**A weekly PATTERN with no dates is a `Timetable`, not a calendar.** Which days a departure
|
|
442
|
+
runs, which hours a lane is open: nothing has a date to be placed on, so a week view pages
|
|
443
|
+
between identical weeks and draws the same four facts seven times. Rows are the slots, the
|
|
444
|
+
fixed positions are columns named once in the header, today is a tinted column, and a mark is
|
|
445
|
+
its own editor.
|
|
446
|
+
|
|
447
|
+
**A subject-by-date sheet of STATES is a `StateMatrix`.** Who was in on which day, which vehicle
|
|
448
|
+
ran on the 14th: the reader compares a subject against a date and reads a category, not a
|
|
449
|
+
number. Rows are the subjects with a pinned name and figure, the dates are columns named once,
|
|
450
|
+
a glyph per cell says the state, and the legend is derived from the same list the cells draw
|
|
451
|
+
from. A calendar would scatter thirty subjects across a month grid; a `Matrix` would put a
|
|
452
|
+
number where a blank means "nothing was due".
|
|
453
|
+
|
|
409
454
|
Three rules follow from that, and each of them is about legibility rather than taste.
|
|
410
455
|
|
|
411
456
|
**A grid is unreadable in two directions.** Seven equal columns need roughly 110px each to hold
|
|
@@ -647,6 +692,11 @@ form: per container, collect its own `row-gap` and the **max `row-gap` of its vi
|
|
|
647
692
|
different quantity. `ChargeLines` takes `formatMoney` and `formatQuantity` for this reason, both
|
|
648
693
|
at the BAND so two lines cannot disagree, and threads them to the locked value and the inline
|
|
649
694
|
editor alike. Any new component that displays a caller's number owes the same hook.
|
|
695
|
+
- **An axis over whole things has no half.** A count of vehicles, contracts or trips cannot be
|
|
696
|
+
0,5, so gridlines at `0 · 0,5 · 1 · 1,5 · 2` state something false about the SUBJECT rather than
|
|
697
|
+
about the layout, and they appear exactly where the numbers are smallest — a five-tick target
|
|
698
|
+
starts reaching below the unit at a maximum of two. The kit's charts derive this from the values
|
|
699
|
+
themselves; anywhere you draw a scale by hand, the step comes from what the data is made of.
|
|
650
700
|
- **A column states its unit ONCE — which is only possible if it holds ONE unit.** State it in the
|
|
651
701
|
header band and leave the cells bare. The corollary is the one that bites: a label/value list
|
|
652
702
|
whose rows are a date range, a sum of money and a COUNT has no single unit to state, so each
|
|
@@ -667,7 +717,8 @@ form: per container, collect its own `row-gap` and the **max `row-gap` of its vi
|
|
|
667
717
|
| `medium` | `22 thg 5, 2026` |
|
|
668
718
|
| `long` | `22 tháng 5, 2026` |
|
|
669
719
|
| `dayMonth` | `22 thg 5` |
|
|
670
|
-
| `monthYear` | `Tháng 5 2026` |
|
|
720
|
+
| `monthYear` | `Tháng 5 năm 2026` |
|
|
721
|
+
| `quarterYear` | `Quý 2 năm 2026` (`Q2 2026` outside Vietnamese) |
|
|
671
722
|
|
|
672
723
|
**`time: true` is orthogonal** — it prepends the 24h time to ANY style (`14:30 22/05/2026`,
|
|
673
724
|
`14:30 22 tháng 5, 2026`); `compact` drops the year on the `date` style; `emptyLabel` renders
|
|
@@ -1123,6 +1174,15 @@ When a region genuinely must sit beside the list (a source/remainder summary, a
|
|
|
1123
1174
|
make it a TOP summary header or move it into a `Popover`/sheet at the decision point — not a
|
|
1124
1175
|
standing column.
|
|
1125
1176
|
|
|
1177
|
+
**A screen is cut by the JOB, never by the subject.** Two jobs done at different hours — watching
|
|
1178
|
+
exceptions all day, signing a count once at its end — are two surfaces even when both are about
|
|
1179
|
+
the same day; a screen that stacks them puts the act done once under the list read least, and the
|
|
1180
|
+
one fact they share travels as a single line. The work screen opens on what the person DOES, then
|
|
1181
|
+
what they look up; an end-of-period act sits with the book it lands in — the register of periods
|
|
1182
|
+
above, the picked period's form below. A working screen summarises with `SummaryLine`; `KPIStrip`
|
|
1183
|
+
is a dashboard band. And a screen keyed by a period is named for the period it shows ("Trong
|
|
1184
|
+
ngày"), never for the default its filter happens to hold ("Hôm nay").
|
|
1185
|
+
|
|
1126
1186
|
## One control radius
|
|
1127
1187
|
|
|
1128
1188
|
Every interactive control (`Button`, the inputs/selects/pickers, `SearchInput`, `MenuButton`,
|
|
@@ -1594,6 +1654,19 @@ next step. When the state is already derivable from a neighbour, the badge is a
|
|
|
1594
1654
|
fact, free to disagree with the first, and it costs a column on every screen to say what the
|
|
1595
1655
|
reader already read.
|
|
1596
1656
|
|
|
1657
|
+
**A state the system DERIVES from an absence is pending until a person confirms it.** No chuyến
|
|
1658
|
+
past the slot, no reply past the deadline, no scan past the cut-off: the system can say the thing
|
|
1659
|
+
did not happen, never WHY, and the why is the only fact anyone acts on. Label such a row as the
|
|
1660
|
+
question ("Chưa xác nhận"), tone it as attention rather than failure, and put the confirming act
|
|
1661
|
+
on the row; the verdict word ("Nghỉ") appears only once the reason is on record. Painting the
|
|
1662
|
+
derived absence with the verdict spends the register's one alarm colour on the commonest row of
|
|
1663
|
+
the morning and accuses a row nobody has looked at yet.
|
|
1664
|
+
|
|
1665
|
+
**A strip whose every option shows the same register with fewer rows is a FILTER**, so it is
|
|
1666
|
+
`ChipGroup` with counts and `overflow="scroll"`, opening on the partition that owes the act.
|
|
1667
|
+
`Tabs` is for sections whose content differs; `PipelineTabs` for stages that change what the
|
|
1668
|
+
register under them IS.
|
|
1669
|
+
|
|
1597
1670
|
The clean discriminator is what the badge would say that the row does not:
|
|
1598
1671
|
|
|
1599
1672
|
- A gate register whose status reads *"paperwork not closed"* keeps its column. No other cell
|
|
@@ -1867,7 +1940,7 @@ information.
|
|
|
1867
1940
|
first word.** `avatarInitials` takes a name's first (and last) letters, which is right for a
|
|
1868
1941
|
person and wrong for an ORGANIZATION: an organization's name opens with its legal form, so a
|
|
1869
1942
|
register of companies marks the same letter on every row — and it is the same failure the derived
|
|
1870
|
-
hue was written to fix, arriving through the glyph instead of the ground. Strip the form from
|
|
1943
|
+
hue was written to fix, arriving through the glyph instead of the ground. Strip the form (`stripLegalForm`, `@lotics/ui/organization_name`) from
|
|
1871
1944
|
both ends before the name reaches the mark, and use the stripped name for the row's TITLE too:
|
|
1872
1945
|
whatever eats the first letters of a mark is also eating the first 60px of the column beside it,
|
|
1873
1946
|
where the words that tell two customers apart fall off the end. The legal name belongs on the
|
|
@@ -1904,8 +1977,14 @@ information.
|
|
|
1904
1977
|
subject. Keep the ordinal where order is the thing being EDITED, and then as text in the
|
|
1905
1978
|
supporting line — never as the mark. A surface that needs to state position has its own readout
|
|
1906
1979
|
(a drawer's `3/8`, a stepper's rail).
|
|
1907
|
-
- **The rung follows what the
|
|
1908
|
-
|
|
1980
|
+
- **The rung follows what the reader AIMS AT**: `lg` where the mark is the thing the eye lands on
|
|
1981
|
+
— the row's subject, or an ORGANIZATION in its own column, whose logo is what a reader scans a
|
|
1982
|
+
book of movements by. `md` only where the mark is incidental to a value the reader came for (see
|
|
1983
|
+
`avatar_size`). Read as "subject ⇒ `lg`, field column ⇒ `md`" this drew a 28px disc in a carrier
|
|
1984
|
+
column and the register was reported as flat — the same complaint the leading-mark rule above
|
|
1985
|
+
names, arriving through the rung instead of the absence. The budget is the real constraint: `lg`
|
|
1986
|
+
is 12px wider and takes them from the flexible column the name is in, so a column too tight to
|
|
1987
|
+
seat one takes `md` and says so.
|
|
1909
1988
|
- **A column of faces must not draw an UNBOUNDED audience as faces.** Where a cell answers "who
|
|
1910
1989
|
can reach this", the commonest state is often *everyone* — and three sampled avatars assert
|
|
1911
1990
|
something false about it. Draw the
|
package/docs/data_entry.md
CHANGED
|
@@ -335,6 +335,15 @@ in a `DataGrid`), where a cell varying with its data breaks the row rhythm the g
|
|
|
335
335
|
on a record surface; reserve in a table. Worked both ways in `dev/pages/inline_edit.tsx`
|
|
336
336
|
§ Multiline — the same value in a reserved field and a grown one, side by side.
|
|
337
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
|
+
|
|
338
347
|
### The row stack — `DetailTable` + `DetailRow`
|
|
339
348
|
|
|
340
349
|
A STACK of rows lives in a `DetailTable` (label, value laid out like a TABLE: `labelWidth` /
|
|
@@ -765,12 +774,10 @@ chronological order; each phase owns ITS OWN fee rows + phase total (the invoice
|
|
|
765
774
|
level up — money never reads as one pot); each closes with a SEQUENTIAL confirm gate that names
|
|
766
775
|
its blockers ("Confirm check-in first."). The header status chip carries the phase.
|
|
767
776
|
|
|
768
|
-
A LONG record surface pairs with a LEFT OUTLINE RAIL — `
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
becomes a PINNED bar naming the current section that opens a full-page section-picker `Modal`. A
|
|
773
|
-
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
|
|
774
781
|
per event) is instead a discriminator chosen ONCE at creation — a `SegmentedControl` in the
|
|
775
782
|
create step, never a toggle on the record.
|
|
776
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
|
|
|
@@ -639,6 +669,47 @@ reports two cells. And a `Text` wrapping another `Text` keeps its own words in a
|
|
|
639
669
|
so a walk over leaf ELEMENTS measures only the nested span and reports the cell starting a
|
|
640
670
|
hundred pixels in; measure text nodes with a `Range`.
|
|
641
671
|
|
|
672
|
+
### 8k. Opinions the reader cannot operate — authored bands, and pills beside a search
|
|
673
|
+
|
|
674
|
+
Two defects of one kind: the screen holds a view the reader can neither inspect nor put down. Both
|
|
675
|
+
render perfectly, and neither shows up in any measurement of treatment.
|
|
676
|
+
|
|
677
|
+
**Measure, bands.** Count the group bands a register renders, then look in the filter band for a
|
|
678
|
+
control that produced them. Bands with no `group by` control are AUTHORED — the screen decided the
|
|
679
|
+
cut. Then ask the harder half: is the value those bands group on also a COLUMN? A register that
|
|
680
|
+
bands by something it never shows as a column leaves that fact unsortable and unfilterable, and it
|
|
681
|
+
disappears the moment the reader wants a different cut.
|
|
682
|
+
|
|
683
|
+
```js
|
|
684
|
+
// bands rendered vs. a control that could have produced them
|
|
685
|
+
const bands = document.querySelectorAll('[data-table-group], [role="rowgroup"] > [role="presentation"]').length
|
|
686
|
+
const controls = [...document.querySelectorAll('button, select')].map(e => e.textContent.trim())
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
**Measure, pills.** In one filter band, count `SearchInput`s and count pill rows (`ChipGroup`). Both
|
|
690
|
+
above zero is the finding: a row of pills next to a search box is two controls competing to be read
|
|
691
|
+
first, and the pills win on ink while the search wins on habit.
|
|
692
|
+
|
|
693
|
+
```js
|
|
694
|
+
const band = document.querySelector('[data-filter-band]')
|
|
695
|
+
band && { search: band.querySelectorAll('input[type="search"], input[type="text"]').length,
|
|
696
|
+
pills: band.querySelectorAll('[role="radiogroup"], [data-chip-group]').length }
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
**The source-side signature is cheaper than either.** A file rendering band headings whose label
|
|
700
|
+
comes from the row's own data, with no state driving it — a `groupBy`/`lens` param absent from the
|
|
701
|
+
url or component state. And an import of both `search_input` and `chip_group` in one screen.
|
|
702
|
+
|
|
703
|
+
**What the fix is not.** Deleting the bands is not the fix, and neither is keeping them and adding a
|
|
704
|
+
chip. Ship the value as a column first, then offer grouping as a control defaulting to none, and
|
|
705
|
+
drop that column while its own grouping is on — the band states it once, the column would repeat it
|
|
706
|
+
on every row beneath it.
|
|
707
|
+
|
|
708
|
+
→ [composition.md](./composition.md) §"A register that TRIAGES — `TableGroup`" (flat is the
|
|
709
|
+
default) and §"One view-control vocabulary" (a `ChipGroup` lens only where the band has no search).
|
|
710
|
+
Both rules predate this probe; it exists because reading them is not the same as noticing you broke
|
|
711
|
+
them.
|
|
712
|
+
|
|
642
713
|
### 9. The absence pass — what catches "bland"
|
|
643
714
|
Every probe above reads an element that EXISTS. None fires on a screen where nothing is wrong and
|
|
644
715
|
nothing is right, which is the screen reported as bland. Defect-removal converges on *inoffensive*;
|
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}
|