@lotics/ui 46.14.0 → 47.0.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 +20 -0
- package/MIGRATION.md +58 -0
- package/docs/catalog.md +38 -9
- package/docs/composition.md +52 -0
- package/docs/reviewing.md +25 -7
- package/docs/templates.md +8 -2
- package/examples/tpl_calendar.tsx +22 -29
- package/package.json +1 -1
- package/src/board.tsx +1 -2
- package/src/calendar/agenda_view.tsx +136 -0
- package/src/calendar/calendar_toolbar.tsx +84 -0
- package/src/calendar/calendar_view.tsx +196 -103
- package/src/calendar/context.ts +47 -0
- package/src/calendar/dates.ts +36 -6
- package/src/calendar/event_chip.tsx +141 -0
- package/src/calendar/index.ts +31 -8
- package/src/calendar/layout.ts +113 -11
- package/src/calendar/month_view.tsx +194 -150
- package/src/calendar/repeat.ts +174 -0
- package/src/calendar/time_grid_view.tsx +182 -202
- package/src/calendar/types.ts +37 -11
- package/src/control_surface.ts +28 -0
- package/src/deadline.ts +10 -0
- package/src/file_row.tsx +26 -3
- package/src/gantt/gantt_view.tsx +212 -119
- package/src/gantt/index.ts +2 -2
- package/src/gantt/scale.ts +38 -2
- package/src/gantt/types.ts +34 -7
- package/src/legend_item.tsx +14 -1
- package/src/locale.tsx +30 -0
- package/src/matrix.tsx +19 -5
- package/src/option_list.tsx +11 -1
- package/src/use_option_list.ts +13 -1
package/AGENTS.md
CHANGED
|
@@ -61,6 +61,26 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
61
61
|
Worked in `examples/tpl_record.tsx` § Progress.
|
|
62
62
|
→ [catalog.md §"Tasks, to-dos, phased work"](./docs/catalog.md),
|
|
63
63
|
[catalog.md §"Tasks & checklists"](./docs/catalog.md).
|
|
64
|
+
- **A calendar is for WHEN; a Gantt is for WHO/WHAT.** Days on the axis → `CalendarView`
|
|
65
|
+
(compound: `CalendarToolbar` + `CalendarMonth`/`CalendarWeek`/`CalendarDay`/`CalendarAgenda`,
|
|
66
|
+
controlled via `date`/`view`). Resources on the axis — a channel, an owner, a vehicle →
|
|
67
|
+
`GanttView`, which draws lanes (`group`), milestones and progress. And
|
|
68
|
+
a grid is unreadable in TWO directions: too narrow OR too short, month/week fall back to the
|
|
69
|
+
agenda on their own, so never wrap one in a horizontal scroller to force it.
|
|
70
|
+
→ [catalog.md §"Scheduling & time"](./docs/catalog.md).
|
|
71
|
+
- **A repeating event is ONE row with a `repeat`, and there is no `timeZone` prop.** The
|
|
72
|
+
calendar expands a repeat to the view's own range, so a timetable is one event rather than a
|
|
73
|
+
year of them; an occurrence's id resolves back to the source row. And a stored Lotics
|
|
74
|
+
datetime is a naive WALL CLOCK — "14:30" means 14:30 to the business — so there is nothing to
|
|
75
|
+
convert and a calendar that re-projected it would move every event. Only *what day it is*
|
|
76
|
+
depends on the reader's location: that is the `now` prop.
|
|
77
|
+
→ [catalog.md §"Scheduling & time"](./docs/catalog.md).
|
|
78
|
+
- **A wash gives a surface its body; a border — or an accent stripe down one side — is that
|
|
79
|
+
same edge asserted twice.** Light ground + dark ink of one family (`Badge`'s tonal pairing),
|
|
80
|
+
never the solid shade under white text; `solid()` is for a DOT, where a few pixels carry
|
|
81
|
+
identity. And a radius is a PROPORTION of the box (`proportionalRadius`), because a component
|
|
82
|
+
sized by data renders as a pill at one height and barely a corner at another.
|
|
83
|
+
→ [composition.md §"A body, not an edge"](./docs/composition.md).
|
|
64
84
|
- **`Badge` = STATUS only; supporting detail is the muted second line.** A type / category /
|
|
65
85
|
attribute / count is not a status — it belongs under its identity as `size="xs" color="muted"`,
|
|
66
86
|
never a second chip. →
|
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,64 @@ Breaking changes, newest first — normally per major, plus the rare minor that
|
|
|
4
4
|
anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
|
|
5
5
|
this file exists only to move an app from one release to the next.
|
|
6
6
|
|
|
7
|
+
## 47.0.0
|
|
8
|
+
|
|
9
|
+
**`@lotics/ui/calendar` is a compound, and `CalendarEvent.color` is a token.**
|
|
10
|
+
|
|
11
|
+
`CalendarView` still exists and `<CalendarView events={…} />` still renders a working calendar,
|
|
12
|
+
so the drop-in call site is unchanged. Four things did change:
|
|
13
|
+
|
|
14
|
+
- **`MonthView` / `TimeGridView` are gone.** The parts are `CalendarMonth`, `CalendarWeek`,
|
|
15
|
+
`CalendarDay`, `CalendarAgenda`, `CalendarToolbar` and `CalendarBody`, and they read their
|
|
16
|
+
state from the root rather than taking `date`/`events` props. Mount them as children of
|
|
17
|
+
`CalendarView` to compose your own chrome.
|
|
18
|
+
- **`color` is a `ColorName`, not a string.** `color: solid("blue")` becomes `color: "blue"`.
|
|
19
|
+
The views derive a fill, a tint and a border from the token; the old code appended `"1f"` to
|
|
20
|
+
the string for the tint, which produced garbage for anything that was not a 6-digit hex.
|
|
21
|
+
- **`CalendarLabels` is `CalendarViewLabels`, and it is provider-wired.** Strings now resolve
|
|
22
|
+
prop → `LoticsLocale.calendarView` → English, so a localized app can delete its hand-passed
|
|
23
|
+
set. `DEFAULT_CALENDAR_LABELS` is `DEFAULT_CALENDAR_VIEW_LABELS`. (`CalendarLabels` still
|
|
24
|
+
exists and still belongs to `@lotics/ui/date_calendar`, the date PICKER's month grid — the
|
|
25
|
+
two had the same name.) Two labels are new: `agenda`, `noEvents`, `addAt`.
|
|
26
|
+
- **`onEventDrop` is gone.** Dragging an event to another day wrote through no seam an app
|
|
27
|
+
could gate. Press-to-open is the gesture: `onEventPress`, plus `onSlotPress(start, end)` on
|
|
28
|
+
an empty day cell or hour slot.
|
|
29
|
+
|
|
30
|
+
**Two behaviours changed without an API change, and both are worth looking at.** A week or day
|
|
31
|
+
grid now opens on the earliest event IN VIEW rather than an hour before the wall clock (a week
|
|
32
|
+
of 09:00 meetings viewed at 13:00 used to open on empty grid). And month/week fall back to the
|
|
33
|
+
agenda when the surface is too narrow OR too short for a grid to name a single event — if you
|
|
34
|
+
wrapped a calendar in a horizontal `ScrollView` with a `minWidth` to work around the old
|
|
35
|
+
behaviour, delete that: it now fights the fallback.
|
|
36
|
+
|
|
37
|
+
**Additive in the same release:** `CalendarEvent.repeat` expands a series to the view's own
|
|
38
|
+
range (a timetable is one row, not a year of them) and `CalendarView`'s `now` supplies the
|
|
39
|
+
reference instant for the today badge, the now line and the opening scroll — which also
|
|
40
|
+
makes "today" pinnable in a test. There is deliberately **no `timeZone` prop**: Lotics
|
|
41
|
+
stores a datetime as a naive wall clock, so a calendar that re-projected it would move
|
|
42
|
+
every event.
|
|
43
|
+
|
|
44
|
+
### `GanttView`, in the same major
|
|
45
|
+
|
|
46
|
+
It keeps the promise its type was already making, and `color` becomes a token.
|
|
47
|
+
|
|
48
|
+
- **`group` now renders.** It was declared on `GanttTask`, documented as a lane label, passed by
|
|
49
|
+
callers — and read by nothing; every task rendered in one flat list. Tasks sharing a `group`
|
|
50
|
+
now sit under one heading, in first-seen order, with ungrouped tasks leading. If you were
|
|
51
|
+
passing `group` and relying on the flat rendering, drop the field.
|
|
52
|
+
- **`color` is a `ColorName`, not a string** — `solid("blue")` becomes `"blue"`, matching
|
|
53
|
+
`CalendarEvent`.
|
|
54
|
+
- **`onTaskResize` is gone.** Dragging a bar's edge wrote through no seam an app could gate,
|
|
55
|
+
the same reason the calendar's drag went in 47.0.0. Press is the gesture.
|
|
56
|
+
- **Labels are provider-wired** — prop → `LoticsLocale.gantt` → English, so a localized app can
|
|
57
|
+
delete its hand-passed set. One label is new: `empty`.
|
|
58
|
+
|
|
59
|
+
New, additive: `milestone` draws a diamond, `progress` fills part of a bar. `buildRows` is
|
|
60
|
+
exported for anything that needs the same lane geometry.
|
|
61
|
+
|
|
62
|
+
There are deliberately **no dependency connectors**. Lane order and bar position already carry
|
|
63
|
+
the sequence, and elbow arrows over an already-ruled grid restate it as line noise.
|
|
64
|
+
|
|
7
65
|
## 46.3.0
|
|
8
66
|
|
|
9
67
|
**A `Text` with `numberOfLines` now declares `flexShrink: 1` + `minWidth: 0` for you.**
|
package/docs/catalog.md
CHANGED
|
@@ -482,7 +482,8 @@ patterns doc indexed in [AGENTS.md](../AGENTS.md)).
|
|
|
482
482
|
`ScanField` (scan/verify), `Stepper` (a guided run / progress sequence — done, current,
|
|
483
483
|
upcoming, horizontal OR vertical), `RemainderMeter` + `AllocationRow` (allocation),
|
|
484
484
|
`Timeline` (a heterogeneous event LOG — icons + expandable details, not progress),
|
|
485
|
-
`
|
|
485
|
+
`CalendarView` (the `calendar` module — days are the axis), `GanttView` (RESOURCES are the
|
|
486
|
+
axis: a channel, an owner, a vehicle), `comments_thread`.
|
|
486
487
|
|
|
487
488
|
### AI surfaces
|
|
488
489
|
|
|
@@ -2048,14 +2049,42 @@ component rather than showing it at zero.
|
|
|
2048
2049
|
|
|
2049
2050
|
### Scheduling & time
|
|
2050
2051
|
|
|
2051
|
-
- **`calendar`** —
|
|
2052
|
-
`
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
`
|
|
2058
|
-
|
|
2052
|
+
- **`calendar`** — a COMPOUND calendar of events. `CalendarView` is the root and holds the
|
|
2053
|
+
state; `CalendarToolbar`, `CalendarMonth`, `CalendarWeek`, `CalendarDay`, `CalendarAgenda`
|
|
2054
|
+
and `CalendarBody` are the parts. Mount it bare (`<CalendarView events={…} />`) for the
|
|
2055
|
+
toolbar plus the current view, or pass children to compose your own chrome — an app screen
|
|
2056
|
+
already has a header band, and a calendar that welds one on stacks two. `date` / `view` with
|
|
2057
|
+
their `on*Change` make it controlled, so a filter chip or a route can drive it. It NEVER
|
|
2058
|
+
writes: `onEventPress` and `onSlotPress` hand you the event or an empty range and your own
|
|
2059
|
+
form does the rest. `renderEvent` replaces a chip's contents while the calendar keeps the
|
|
2060
|
+
press target and the geometry. Also `EventChip`, `useCalendar`, and the pure helpers
|
|
2061
|
+
(`layoutDayColumns`, `packEventLanes`, `compareEvents`, `fitLanes`, `initialScrollMinutes`,
|
|
2062
|
+
`gridWindow`, `weekRowsInMonth`, `addDays`, `startOfWeek`, …).
|
|
2063
|
+
**A repeating event costs one row, not a year of them:** put a `repeat` on the event
|
|
2064
|
+
(`{ every: "day" | "week" | "month", interval?, on?, until?, count? }`) and the calendar
|
|
2065
|
+
expands it to the occurrences in the view's own range. Deliberately NOT RFC 5545 — a
|
|
2066
|
+
timetable, a standup, a monthly close; see `expandRepeats` for why the long tail is a
|
|
2067
|
+
platform field type rather than a wider rule. An occurrence's id encodes its source, so
|
|
2068
|
+
`onEventPress` still hands you the row the series came from (`sourceEventId`).
|
|
2069
|
+
**There is no `timeZone` prop, on purpose:** Lotics stores a datetime as a naive WALL CLOCK,
|
|
2070
|
+
so "14:30" means 14:30 to the business and there is nothing to convert — re-projecting it
|
|
2071
|
+
would move every event. What does depend on where the reader sits is *what day it is*, so
|
|
2072
|
+
that alone is the `now` prop (which also pins "today" in a test).
|
|
2073
|
+
Three things it decides for you: **which shape** — below `compactBelow` (720px) or too short
|
|
2074
|
+
for a month to name one event per row, month/week become the agenda, because seven columns on
|
|
2075
|
+
a phone can only say WHICH days have something; **how many lanes** — measured against the row,
|
|
2076
|
+
not a constant; and **the hour window** — `dayStartHour`/`dayEndHour` are a VIEWPORT, so a
|
|
2077
|
+
06:00 delivery widens the grid instead of disappearing from it. `color` is a `ColorName`, not
|
|
2078
|
+
a hex. Labels resolve prop → `LoticsLocale.calendarView` → English.
|
|
2079
|
+
- **`gantt`** — `GanttView`: a frozen label column beside a zoomable, horizontally
|
|
2080
|
+
scrollable axis. Reach for it over a calendar when the **rows** are the subject (a phase, an
|
|
2081
|
+
owner, a vehicle, a channel) and dates are the horizontal axis. **Lanes** come from `group`,
|
|
2082
|
+
**milestones** render as diamonds, and `progress` fills part of a bar. No dependency
|
|
2083
|
+
connectors: lane order and bar position already carry the sequence, and elbow arrows over an
|
|
2084
|
+
already-ruled grid restate it as line noise. The bar carries no label, because the frozen
|
|
2085
|
+
column beside it already names the task. Helpers: `buildRows`, `barGeometry`, `axisRange`,
|
|
2086
|
+
`buildTicks`, `pxPerDay`; types `GanttTask`/`GanttRow`/`GanttLabels`. `color` is a
|
|
2087
|
+
`ColorName`; labels resolve prop → `LoticsLocale.gantt` → English.
|
|
2059
2088
|
- **`timeline`** — `Timeline`: a heterogeneous event LOG — per-row icon + expandable
|
|
2060
2089
|
details; models the past, NOT progress. Three things it decides for you, because a row
|
|
2061
2090
|
cannot be trusted to a caller's data: the **label clamps to two lines WHILE COLLAPSED**
|
package/docs/composition.md
CHANGED
|
@@ -360,6 +360,33 @@ badge. Every period-dependent number MUST follow the selection. Pass `includeTim
|
|
|
360
360
|
time-of-day matters: the trigger previews the chosen time (locale-aware) and the `labels` prop
|
|
361
361
|
translates the presets, footer buttons, placeholder, and the time-segment editors.
|
|
362
362
|
|
|
363
|
+
## A calendar answers WHEN — and only when the position on a timeline is the question
|
|
364
|
+
|
|
365
|
+
A calendar is not a prettier register. Reach for one when the reader's question is *when does
|
|
366
|
+
this happen* and the answer is read off a POSITION — which day, how long, what else is running
|
|
367
|
+
at the same time. When the question is *which of these needs me*, the answer is a register with
|
|
368
|
+
a date column, and a calendar buries it: thirty rows of a table are scannable, thirty rows
|
|
369
|
+
scattered across a month grid are a search.
|
|
370
|
+
|
|
371
|
+
Three rules follow from that, and each of them is about legibility rather than taste.
|
|
372
|
+
|
|
373
|
+
**A grid is unreadable in two directions.** Seven equal columns need roughly 110px each to hold
|
|
374
|
+
a real title; below that every event reads "Cont…" and the grid can only say WHICH days have
|
|
375
|
+
something. Height fails the same way — a month too short to give each week row one event plus
|
|
376
|
+
its overflow chip renders nothing but "+3 more" on all thirty-one days. Both are the same
|
|
377
|
+
finding, and the answer to both is a different SHAPE: `CalendarView` falls back to the agenda on
|
|
378
|
+
its own. Never wrap a month grid in a horizontal scroller to force it — that trades a readable
|
|
379
|
+
list for a wide thing the reader has to drag.
|
|
380
|
+
|
|
381
|
+
**A time window is a viewport, never a filter.** Declaring the working day as 08:00–18:00 sets
|
|
382
|
+
where the grid OPENS. An event outside it widens the grid; a calendar that silently refuses to
|
|
383
|
+
draw a 06:00 delivery is a calendar that lies, and nothing on screen says so.
|
|
384
|
+
|
|
385
|
+
**Rows of days, or rows of resources — pick by what the reader is comparing.** Comparing days
|
|
386
|
+
is a calendar. Comparing a channel against a channel, an owner against an owner, a vehicle
|
|
387
|
+
against a vehicle — with days as the horizontal axis — is `GanttView`. A campaign plan is
|
|
388
|
+
usually the second one, however much it is called a calendar.
|
|
389
|
+
|
|
363
390
|
## Keyboard & focus
|
|
364
391
|
|
|
365
392
|
- **Use `tabIndex`, never `focusable`.** React Native Web's `Pressable` silently ignores
|
|
@@ -1827,6 +1854,31 @@ the ring. Collapsing them into one "active" style makes the selected item pixel-
|
|
|
1827
1854
|
hovered one, so "which did I pick" is answerable only by moving the mouse away. The tell in code
|
|
1828
1855
|
is a single boolean OR-ing selection together with hover, press and focus.
|
|
1829
1856
|
|
|
1857
|
+
## A body, not an edge — and a corner proportionate to the box
|
|
1858
|
+
|
|
1859
|
+
Two rules that decide how any small coloured surface is drawn: an event chip, a board card, a
|
|
1860
|
+
timeline bar, a status pill.
|
|
1861
|
+
|
|
1862
|
+
**A wash gives it a body; a border on top is the same edge asserted twice.** A transparent
|
|
1863
|
+
rectangle on a white canvas *is* its border — so the fill is what makes it an object, and once
|
|
1864
|
+
it has one, the outline says nothing new. This matters most where the surface already sits on a
|
|
1865
|
+
ruled ground (a calendar grid, a timeline, a table): every extra hairline competes with the
|
|
1866
|
+
rules that carry the structure. The same goes for an accent stripe down one side — it is a
|
|
1867
|
+
border wearing a colour, and the wash it sits on has already stated where the thing begins.
|
|
1868
|
+
|
|
1869
|
+
**The wash is the family's light ground with its dark ink, never the solid shade with white on
|
|
1870
|
+
it.** A saturated fill per item turns a dense surface into a paint chart, and a screen with a
|
|
1871
|
+
dozen categories on it stops being something anyone can look at. The pairing is the one `Badge`
|
|
1872
|
+
uses for `tonal`. The solid shade keeps exactly one job: a DOT — a few pixels carrying identity
|
|
1873
|
+
rather than area, where the contrast is the point.
|
|
1874
|
+
|
|
1875
|
+
**A radius is a proportion of the box, not a constant.** A component whose height is data —
|
|
1876
|
+
a chip sized to its duration, a bar sized to its span — cannot take one number: at 22px the
|
|
1877
|
+
control rung renders a pill, and the same value on a tall block is barely a corner, so two
|
|
1878
|
+
boxes of one family read as two vocabularies. Scale by height and cap at the container rung
|
|
1879
|
+
(`proportionalRadius`). Keep the ratio under a third, or the corner eats the side and the shape
|
|
1880
|
+
stops being a rounded rectangle.
|
|
1881
|
+
|
|
1830
1882
|
## `Badge` is for STATUS only — everything else is text
|
|
1831
1883
|
|
|
1832
1884
|
A `Badge` means STATE that must read at a GLANCE — a lifecycle status, a risk level, a quality
|
package/docs/reviewing.md
CHANGED
|
@@ -397,7 +397,7 @@ A destination named for two things is two destinations, and the second strip is
|
|
|
397
397
|
gets taken apart again. The same grep over the source (`<Tabs` per file) finds it without a
|
|
398
398
|
running app: more than one file rendering `Tabs` in one app is the same defect at rest.
|
|
399
399
|
|
|
400
|
-
→ [composition.md](./composition.md) §"
|
|
400
|
+
→ [composition.md](./composition.md) §"One view-control vocabulary" (one strip per surface).
|
|
401
401
|
|
|
402
402
|
### 8b. Does each tab earn its slot — count its rows AS A SCOPED READER
|
|
403
403
|
|
|
@@ -417,7 +417,7 @@ permission check; the screen moves to the app its owner already opens.
|
|
|
417
417
|
**Source-side signature:** a screen whose list query filters by the viewer's membership, over a
|
|
418
418
|
table the domain describes as organisation-wide.
|
|
419
419
|
|
|
420
|
-
→ [composition.md](./composition.md) §"
|
|
420
|
+
→ [composition.md](./composition.md) §"One view-control vocabulary".
|
|
421
421
|
|
|
422
422
|
### 8c. First paint — count the controls the task actually needs
|
|
423
423
|
- **On any screen whose job is ENTRY, count `input`/`select`/`textarea` on first paint.** Zero is
|
|
@@ -450,7 +450,7 @@ filter in its dependency array. Typecheck, lint and a screenshot all passed it.)
|
|
|
450
450
|
**The cheap source-side signature:** a `SummaryLine` item carrying `tone` in a file whose filter
|
|
451
451
|
band has only a `SearchInput`.
|
|
452
452
|
|
|
453
|
-
→ [composition.md](./composition.md) §"
|
|
453
|
+
→ [composition.md](./composition.md) §"One view-control vocabulary"; the rule itself is in
|
|
454
454
|
`src/summary_line.tsx`'s contract.
|
|
455
455
|
|
|
456
456
|
### 8e. Derivable columns — every column another cell already answers
|
|
@@ -503,8 +503,9 @@ the aggregates no control states: the money, the extreme, the ratio.
|
|
|
503
503
|
hook's array) where the register renders a filtered derivative of it — and the two sitting far
|
|
504
504
|
apart in the file, which is what lets them drift.
|
|
505
505
|
|
|
506
|
-
→ [composition.md](./composition.md) §"
|
|
507
|
-
written there; this probe exists because reading it is not the same as obeying
|
|
506
|
+
→ [composition.md](./composition.md) §"Summaries — two altitudes, never mixed" — the rule this
|
|
507
|
+
measures is already written there; this probe exists because reading it is not the same as obeying
|
|
508
|
+
it.
|
|
508
509
|
|
|
509
510
|
### 8g. Truncation — every string the layout cut, at the width a phone actually is
|
|
510
511
|
|
|
@@ -739,17 +740,34 @@ Snapshot the SAME selector four times — at rest, hovered, focused, and while e
|
|
|
739
740
|
const r = e.getBoundingClientRect(), cs = getComputedStyle(e);
|
|
740
741
|
const inner = e.querySelector('input, textarea, [contenteditable]') || e;
|
|
741
742
|
const ir = inner.getBoundingClientRect();
|
|
743
|
+
// The PAINTED node, which is often not the one you selected: a press target
|
|
744
|
+
// is frequently transparent with its ground on a child (a chip's body, a
|
|
745
|
+
// bar's fill). Reading the target's own `backgroundColor` returns
|
|
746
|
+
// `rgba(0,0,0,0)` at rest AND hovered, so a component with a perfectly good
|
|
747
|
+
// hover reports as having none — a probe that fails silently in the
|
|
748
|
+
// reassuring direction.
|
|
749
|
+
const opaque = (x) => { const b = getComputedStyle(x).backgroundColor; return b && b !== 'rgba(0, 0, 0, 0)' ? b : null; };
|
|
750
|
+
const painted = opaque(e) || [...e.querySelectorAll('*')].map(opaque).find(Boolean) || 'none';
|
|
742
751
|
return {
|
|
743
752
|
box: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)],
|
|
744
753
|
text: [Math.round(ir.x), Math.round(ir.y)],
|
|
745
754
|
border: `${cs.borderTopWidth} ${cs.borderTopColor}`,
|
|
746
|
-
bg:
|
|
755
|
+
bg: painted,
|
|
747
756
|
shadow: cs.boxShadow,
|
|
748
757
|
};
|
|
749
758
|
})
|
|
750
759
|
```
|
|
751
760
|
|
|
752
|
-
`border`/`bg`/`shadow` differing is the design; `box`/`text` differing is the defect.
|
|
761
|
+
`border`/`bg`/`shadow` differing is the design; `box`/`text` differing is the defect.
|
|
762
|
+
|
|
763
|
+
**A control that differs in NOTHING is the finding this catches most often**, and it does not
|
|
764
|
+
look like a bug in a screenshot — the thing renders correctly, presses correctly, announces
|
|
765
|
+
correctly, and simply never says it can be pressed. Run it against anything the eye reads as a
|
|
766
|
+
card, chip or bar: identical rest and hover means the affordance was never built. What should
|
|
767
|
+
change is the surface's OWN signature (a chip's fill, a row's wash, a field's border) one rung
|
|
768
|
+
deeper, never a different colour and never the cursor — see composition.md §"The pointer
|
|
769
|
+
cursor". And a surface with no press handler must NOT light up: an affordance on something
|
|
770
|
+
inert advertises an action that does not exist. The usual
|
|
753
771
|
cause is two components spending the same inset or the same padding — a wrapper AND the input both
|
|
754
772
|
paying `CONTROL_TEXT_INSET`, a focus ring owned by both a shell and a nested pressable.
|
|
755
773
|
|
package/docs/templates.md
CHANGED
|
@@ -1015,8 +1015,14 @@ when you want them in the context of a whole record surface, and here when money
|
|
|
1015
1015
|
One real `CalendarView` week grid over the current week's events, then a "Today" agenda card
|
|
1016
1016
|
of pressable rows. Every slot is a door: pressing a grid event or an agenda row opens the
|
|
1017
1017
|
sequenced workspace `Drawer`; agenda rows also carry the `⋯` quick-actions menu. Event colors
|
|
1018
|
-
carry meaning (one `ColorName` per event kind
|
|
1019
|
-
week so the copied
|
|
1018
|
+
carry meaning (one `ColorName` per event kind, stated ONCE — the chip's colour and the agenda
|
|
1019
|
+
row's swatch read the same field). The mock data anchors to the real current week so the copied
|
|
1020
|
+
screen is evergreen.
|
|
1021
|
+
|
|
1022
|
+
**Read the header band for the controlled pattern.** The screen owns `date`, so its own "Today"
|
|
1023
|
+
button sets it — the calendar has no second toolbar of its own to fight, and nothing is
|
|
1024
|
+
remounted to force a jump. That is the shape to copy whenever a calendar sits under a header
|
|
1025
|
+
band you already have.
|
|
1020
1026
|
|
|
1021
1027
|
### `tpl_attendance` — the attendance desk
|
|
1022
1028
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { ScrollView, View } from "react-native";
|
|
3
3
|
import { Text } from "@lotics/ui/text";
|
|
4
|
-
import { colors,
|
|
4
|
+
import { colors, type ColorName } from "@lotics/ui/colors";
|
|
5
5
|
import { ActionMenu } from "@lotics/ui/action_menu";
|
|
6
6
|
import { Badge } from "@lotics/ui/badge";
|
|
7
7
|
import { Button } from "@lotics/ui/button";
|
|
@@ -52,47 +52,48 @@ function evt(
|
|
|
52
52
|
m: number,
|
|
53
53
|
durMin: number,
|
|
54
54
|
title: string,
|
|
55
|
-
color: string,
|
|
56
55
|
data: AgendaMeta,
|
|
57
56
|
): CalendarEvent<AgendaMeta> {
|
|
58
57
|
const start = new Date(day.getFullYear(), day.getMonth(), day.getDate(), h, m);
|
|
59
|
-
|
|
58
|
+
// The chip's colour IS the row's colour — `data.mau` is the one statement of
|
|
59
|
+
// it, so the two surfaces cannot drift apart.
|
|
60
|
+
return { id, title, start, end: new Date(start.getTime() + durMin * 60_000), color: data.mau, data };
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
const EVENTS: CalendarEvent<AgendaMeta>[] = [
|
|
63
64
|
// Today — the dispatch desk's three slots.
|
|
64
|
-
evt("hn-1", TODAY, 8, 0, 90, "Deliver APEX PLASTICS — VTD063518",
|
|
65
|
+
evt("hn-1", TODAY, 8, 0, 90, "Deliver APEX PLASTICS — VTD063518", {
|
|
65
66
|
diaDiem: "Eastport industrial zone",
|
|
66
67
|
trangThai: "Truck loaded",
|
|
67
68
|
mau: "blue",
|
|
68
69
|
}),
|
|
69
|
-
evt("hn-2", TODAY, 10, 30, 60, "Reconcile delivery notes — HANDAN",
|
|
70
|
+
evt("hn-2", TODAY, 10, 30, 60, "Reconcile delivery notes — HANDAN", {
|
|
70
71
|
diaDiem: "Head office",
|
|
71
72
|
trangThai: "Prepared",
|
|
72
73
|
mau: "emerald",
|
|
73
74
|
}),
|
|
74
|
-
evt("hn-3", TODAY, 15, 0, 60, "Customer sample review — VITTORIA",
|
|
75
|
+
evt("hn-3", TODAY, 15, 0, 60, "Customer sample review — VITTORIA", {
|
|
75
76
|
diaDiem: "Factory sample room",
|
|
76
77
|
trangThai: "Awaiting confirmation",
|
|
77
78
|
mau: "amber",
|
|
78
79
|
}),
|
|
79
80
|
// The rest of the week.
|
|
80
|
-
evt("t-1", addDays(WEEK_START, SLOTS[0]), 8, 0, 90, "Deliver KOMASPEC — blanks 675×325",
|
|
81
|
+
evt("t-1", addDays(WEEK_START, SLOTS[0]), 8, 0, 90, "Deliver KOMASPEC — blanks 675×325", {
|
|
81
82
|
diaDiem: "Northgate industrial park",
|
|
82
83
|
trangThai: "Truck loaded",
|
|
83
84
|
mau: "blue",
|
|
84
85
|
}),
|
|
85
|
-
evt("t-2", addDays(WEEK_START, SLOTS[1]), 14, 0, 60, "Reconcile delivery notes — NEWTECONS",
|
|
86
|
+
evt("t-2", addDays(WEEK_START, SLOTS[1]), 14, 0, 60, "Reconcile delivery notes — NEWTECONS", {
|
|
86
87
|
diaDiem: "Head office",
|
|
87
88
|
trangThai: "Prepared",
|
|
88
89
|
mau: "emerald",
|
|
89
90
|
}),
|
|
90
|
-
evt("t-3", addDays(WEEK_START, SLOTS[2]), 10, 0, 60, "Kick off the VITTORIA order",
|
|
91
|
+
evt("t-3", addDays(WEEK_START, SLOTS[2]), 10, 0, 60, "Kick off the VITTORIA order", {
|
|
91
92
|
diaDiem: "Online meeting",
|
|
92
93
|
trangThai: "Awaiting confirmation",
|
|
93
94
|
mau: "amber",
|
|
94
95
|
}),
|
|
95
|
-
evt("t-4", addDays(WEEK_START, SLOTS[3]), 8, 30, 90, "Deliver BRIGHTCELL BATTERIES — TS1250",
|
|
96
|
+
evt("t-4", addDays(WEEK_START, SLOTS[3]), 8, 30, 90, "Deliver BRIGHTCELL BATTERIES — TS1250", {
|
|
96
97
|
diaDiem: "Brightcell plant, Eastport",
|
|
97
98
|
trangThai: "Truck loaded",
|
|
98
99
|
mau: "blue",
|
|
@@ -104,20 +105,10 @@ const EVENTS: CalendarEvent<AgendaMeta>[] = [
|
|
|
104
105
|
start: addDays(WEEK_START, 2),
|
|
105
106
|
end: addDays(WEEK_START, 4),
|
|
106
107
|
allDay: true,
|
|
107
|
-
color:
|
|
108
|
+
color: "zinc",
|
|
108
109
|
},
|
|
109
110
|
];
|
|
110
111
|
|
|
111
|
-
const CAL_LABELS = {
|
|
112
|
-
today: "Today",
|
|
113
|
-
month: "Month",
|
|
114
|
-
week: "Week",
|
|
115
|
-
day: "Day",
|
|
116
|
-
previous: "Previous",
|
|
117
|
-
next: "Next",
|
|
118
|
-
allDay: "all day",
|
|
119
|
-
more: (n: number) => `+${n} more`,
|
|
120
|
-
};
|
|
121
112
|
|
|
122
113
|
const p2 = (n: number) => String(n).padStart(2, "0");
|
|
123
114
|
const fmtTime = (d: Date) => `${p2(d.getHours())}:${p2(d.getMinutes())}`;
|
|
@@ -187,9 +178,10 @@ function SlotWorkspace({ e }: { e: CalendarEvent<AgendaMeta> }) {
|
|
|
187
178
|
}
|
|
188
179
|
|
|
189
180
|
export function TplCalendar() {
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
181
|
+
// The screen owns the date, so its own header band can drive the calendar —
|
|
182
|
+
// no remount, no second toolbar. This used to be `key={calKey}`, forcing a
|
|
183
|
+
// fresh mount because the calendar's date was private to it.
|
|
184
|
+
const [date, setDate] = useState(TODAY);
|
|
193
185
|
const [openId, setOpenId] = useState<string | null>(null);
|
|
194
186
|
|
|
195
187
|
const openEvent = EVENTS.find((e) => e.id === openId) ?? null;
|
|
@@ -215,21 +207,22 @@ export function TplCalendar() {
|
|
|
215
207
|
<Text size="sm" color="muted">This week — deliveries, reconciliations and customer visits</Text>
|
|
216
208
|
</View>
|
|
217
209
|
<Text size="sm" color="muted" tabular>
|
|
218
|
-
{viewTitle("week",
|
|
210
|
+
{viewTitle("week", date, 1, "vi")}
|
|
219
211
|
</Text>
|
|
220
|
-
<Button title="Today" color="secondary" onPress={() =>
|
|
212
|
+
<Button title="Today" color="secondary" onPress={() => setDate(TODAY)} />
|
|
221
213
|
</View>
|
|
222
214
|
|
|
223
215
|
{/* the calendar — real week grid, internal toolbar + scroll */}
|
|
224
216
|
<Card style={{ padding: 0, height: 520, overflow: "hidden" }}>
|
|
225
217
|
<CalendarView<AgendaMeta>
|
|
226
|
-
key={calKey}
|
|
227
218
|
events={EVENTS}
|
|
228
219
|
defaultView="week"
|
|
229
|
-
|
|
220
|
+
date={date}
|
|
221
|
+
onDateChange={setDate}
|
|
230
222
|
weekStartsOn={1}
|
|
231
223
|
locale="vi"
|
|
232
|
-
|
|
224
|
+
dayStartHour={7}
|
|
225
|
+
dayEndHour={19}
|
|
233
226
|
onEventPress={(e) => setOpenId(e.id)}
|
|
234
227
|
/>
|
|
235
228
|
</Card>
|
package/package.json
CHANGED
package/src/board.tsx
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "react";
|
|
11
11
|
import { Platform, ScrollView, StyleSheet, View } from "react-native";
|
|
12
12
|
import { colors, tint } from "./colors";
|
|
13
|
-
import { CONTROL_RADIUS, MIN_CONTROL_WIDTH } from "./control_surface";
|
|
13
|
+
import { CONTROL_RADIUS, MIN_CONTROL_WIDTH, CARD_RADIUS } from "./control_surface";
|
|
14
14
|
import { EmptyState } from "./empty_state";
|
|
15
15
|
import { Icon } from "./icon";
|
|
16
16
|
import { IconButton } from "./icon_button";
|
|
@@ -40,7 +40,6 @@ import { usePointerDrag } from "./use_pointer_drag";
|
|
|
40
40
|
/** A card is a container, so it sits on the container rung of the radius ladder
|
|
41
41
|
* — the same 16 a `Card` takes. Kept here rather than imported so the two
|
|
42
42
|
* cannot drift apart silently; if the ladder moves, both move. */
|
|
43
|
-
const CARD_RADIUS = 16;
|
|
44
43
|
|
|
45
44
|
/** Half the difference between an `sm` IconButton's box (24) and its glyph (14).
|
|
46
45
|
* It exists so a control cluster can be pulled out by exactly the amount that
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { ScrollView, View, StyleSheet } from "react-native";
|
|
3
|
+
import { Text } from "../text";
|
|
4
|
+
import { colors, solid } from "../colors";
|
|
5
|
+
import { CARD_RADIUS } from "../control_surface";
|
|
6
|
+
import { ListItem } from "../list_item";
|
|
7
|
+
import { useCalendar } from "./context";
|
|
8
|
+
import { compareEvents, isBanner } from "./layout";
|
|
9
|
+
import { dayDiff, dayHeading, daysInView, formatTime, isToday, startOfDay } from "./dates";
|
|
10
|
+
import type { CalendarEvent } from "./types";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The agenda: days as headings, their events as rows.
|
|
14
|
+
*
|
|
15
|
+
* **This is the narrow-screen answer, and it is a different SHAPE rather than a
|
|
16
|
+
* smaller month grid.** Seven equal columns on a 375px screen leave 53px per
|
|
17
|
+
* day for titles that need 160 — every event reads "Cont…" and the calendar can
|
|
18
|
+
* only tell you WHICH days have something, never what. Below `compactBelow` the
|
|
19
|
+
* root mounts this instead, and the one app that hit this had already reached
|
|
20
|
+
* the same conclusion by hand, wrapping the grid in a horizontal scroller with
|
|
21
|
+
* a 760px floor.
|
|
22
|
+
*
|
|
23
|
+
* Empty days are omitted. A month is mostly empty days, and printing thirty
|
|
24
|
+
* "nothing scheduled" rows to find four with content is not a list, it is a
|
|
25
|
+
* haystack.
|
|
26
|
+
*/
|
|
27
|
+
export function CalendarAgenda() {
|
|
28
|
+
const { events, date, weekStartsOn, locale, labels, now, onEventPress } = useCalendar();
|
|
29
|
+
const days = useMemo(() => daysInView("agenda", date, weekStartsOn), [date, weekStartsOn]);
|
|
30
|
+
|
|
31
|
+
const grouped = useMemo(() => {
|
|
32
|
+
const first = days[0];
|
|
33
|
+
const byDay = days.map((day) => ({
|
|
34
|
+
day,
|
|
35
|
+
items: events
|
|
36
|
+
.filter((e) => {
|
|
37
|
+
const from = dayDiff(startOfDay(e.start), day);
|
|
38
|
+
const to = dayDiff(day, startOfDay(e.end ?? e.start));
|
|
39
|
+
if (from < 0 || to < 0) return false;
|
|
40
|
+
// A multi-day banner is listed ONCE, on the day it starts — or on the
|
|
41
|
+
// first day of the range when it started before it, so something
|
|
42
|
+
// already running is still visible. Repeating it per day put a
|
|
43
|
+
// 28-day campaign on twenty-eight rows and buried everything that
|
|
44
|
+
// only happens once.
|
|
45
|
+
if (!isBanner(e)) return true;
|
|
46
|
+
const startsHere = startOfDay(e.start).getTime() === day.getTime();
|
|
47
|
+
const ongoingAtOpen =
|
|
48
|
+
day.getTime() === first.getTime() && startOfDay(e.start).getTime() < first.getTime();
|
|
49
|
+
return startsHere || ongoingAtOpen;
|
|
50
|
+
})
|
|
51
|
+
.sort(compareEvents),
|
|
52
|
+
}));
|
|
53
|
+
return byDay.filter((g) => g.items.length > 0);
|
|
54
|
+
}, [events, days]);
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if (grouped.length === 0) {
|
|
58
|
+
return (
|
|
59
|
+
<View style={styles.empty}>
|
|
60
|
+
<Text size="sm" color="muted">{labels.noEvents}</Text>
|
|
61
|
+
</View>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<ScrollView style={styles.root} contentContainerStyle={styles.content}>
|
|
67
|
+
{grouped.map(({ day, items }) => {
|
|
68
|
+
const today = isToday(day, now);
|
|
69
|
+
return (
|
|
70
|
+
<View key={day.toISOString()} style={styles.group}>
|
|
71
|
+
<View style={styles.heading}>
|
|
72
|
+
<Text size="sm" weight={today ? "semibold" : "medium"} style={today ? styles.todayInk : undefined}>
|
|
73
|
+
{dayHeading(day, locale)}
|
|
74
|
+
</Text>
|
|
75
|
+
{today ? <View style={styles.todayDot} /> : null}
|
|
76
|
+
</View>
|
|
77
|
+
<View style={styles.card}>
|
|
78
|
+
{items.map((e) => (
|
|
79
|
+
<ListItem
|
|
80
|
+
key={e.id}
|
|
81
|
+
left={<View style={[styles.dot, { backgroundColor: solid(e.color ?? "teal") }]} />}
|
|
82
|
+
title={e.title}
|
|
83
|
+
description={describe(e, labels.allDay, locale)}
|
|
84
|
+
onPress={onEventPress ? () => onEventPress(e.id) : undefined}
|
|
85
|
+
/>
|
|
86
|
+
))}
|
|
87
|
+
</View>
|
|
88
|
+
</View>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
91
|
+
</ScrollView>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The row's second line: when it is, then whatever the event carries. Never
|
|
96
|
+
* the title again — the row above it already said that. */
|
|
97
|
+
function describe(event: CalendarEvent<unknown>, allDayLabel: string, locale?: string): string {
|
|
98
|
+
const when = isBanner(event)
|
|
99
|
+
? bannerSpan(event, allDayLabel, locale)
|
|
100
|
+
: event.end
|
|
101
|
+
? `${formatTime(event.start, locale)} – ${formatTime(event.end, locale)}`
|
|
102
|
+
: formatTime(event.start, locale);
|
|
103
|
+
return event.meta ? `${when} · ${event.meta}` : when;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A banner listed once has to say how far it runs, or the row claims the whole
|
|
107
|
+
* thing happens on the day it is filed under. */
|
|
108
|
+
function bannerSpan(event: CalendarEvent<unknown>, allDayLabel: string, locale?: string): string {
|
|
109
|
+
const end = event.end ? startOfDay(event.end) : null;
|
|
110
|
+
if (!end || dayDiff(event.start, end) <= 0) return allDayLabel;
|
|
111
|
+
const md = (d: Date) => new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" }).format(d);
|
|
112
|
+
return `${md(event.start)} – ${md(end)}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const styles = StyleSheet.create({
|
|
116
|
+
root: { flex: 1, backgroundColor: colors.white },
|
|
117
|
+
content: { paddingBottom: 16 },
|
|
118
|
+
group: { paddingTop: 0, paddingHorizontal: 8 },
|
|
119
|
+
card: { borderRadius: CARD_RADIUS, overflow: "hidden", backgroundColor: colors.zinc[50] },
|
|
120
|
+
// A day heading is separated by SPACE and weight, not a rule — the rule was
|
|
121
|
+
// a second statement of a boundary the gap already makes.
|
|
122
|
+
heading: {
|
|
123
|
+
flexDirection: "row",
|
|
124
|
+
alignItems: "center",
|
|
125
|
+
gap: 6,
|
|
126
|
+
paddingHorizontal: 14,
|
|
127
|
+
paddingTop: 16,
|
|
128
|
+
paddingBottom: 6,
|
|
129
|
+
},
|
|
130
|
+
todayInk: { color: colors.teal[700] },
|
|
131
|
+
todayDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.teal[600] },
|
|
132
|
+
// A dot, not a bar down the row's left edge: an edge is a border, and the
|
|
133
|
+
// row's own wash already states where it begins.
|
|
134
|
+
dot: { width: 8, height: 8, borderRadius: 4 },
|
|
135
|
+
empty: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.white },
|
|
136
|
+
});
|