@lotics/ui 32.0.0 → 33.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/MIGRATION.md +41 -0
- package/docs/catalog.md +10 -3
- package/docs/composition.md +12 -3
- package/examples/tpl_attendance.tsx +29 -27
- package/examples/tpl_calendar.tsx +13 -11
- package/examples/tpl_dashboard.tsx +20 -18
- package/examples/tpl_item_list.tsx +33 -31
- package/examples/tpl_pivot.tsx +14 -12
- package/examples/tpl_record.tsx +3 -2
- package/examples/tpl_rollup.tsx +26 -24
- package/examples/tpl_shifts.tsx +52 -50
- package/examples/tpl_stock.tsx +17 -15
- package/examples/tpl_tower.tsx +30 -28
- package/package.json +1 -1
- package/src/drawer.tsx +63 -6
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,47 @@ 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
|
+
## 33.0.0 — `DrawerScrollArea`
|
|
8
|
+
|
|
9
|
+
A `Drawer` gets the content primitive its siblings already had, named to match
|
|
10
|
+
`DialogScrollArea`. **Wrap your drawer body in it and delete the padding you were
|
|
11
|
+
applying:**
|
|
12
|
+
|
|
13
|
+
```diff
|
|
14
|
+
<Drawer open onOpenChange={setOpen} title={name}>
|
|
15
|
+
- <View style={{ flex: 1, padding: 24, gap: 12 }}>
|
|
16
|
+
+ <DrawerScrollArea>
|
|
17
|
+
+ <View style={{ gap: 12 }}>
|
|
18
|
+
…
|
|
19
|
+
</View>
|
|
20
|
+
</Drawer>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Why a component and not a default.** The gutter is a contract: content has to
|
|
24
|
+
land on the same left edge as the header's title and the footer's actions, and
|
|
25
|
+
only the panel knows that number. Left to callers it was answered three ways
|
|
26
|
+
across the templates and the live apps — `24` six times (4px past the drawer's own
|
|
27
|
+
title), `20` three times, and in one shipped app not at all, which put a comment
|
|
28
|
+
thread flush against the panel edge while its title was inset 20.
|
|
29
|
+
|
|
30
|
+
The `Drawer`'s own slot stays BARE, which is how `Modal` and `Dialog` already
|
|
31
|
+
work. A drawer that padded its slot would be the one overlay of the three that
|
|
32
|
+
does, and would force every full-bleed child to cancel it with a negative margin.
|
|
33
|
+
|
|
34
|
+
**Two bodies do NOT wrap in it**, and both take the bare slot instead:
|
|
35
|
+
|
|
36
|
+
- **Full-bleed** — a band spanning the panel, a record screen carrying its own
|
|
37
|
+
gutters. Nothing to do; the slot already reaches the edge.
|
|
38
|
+
- **Pinned + scrolling** — a heading, count or summary held above a list that
|
|
39
|
+
scrolls under it. Put `DRAWER_GUTTER` on the pinned part and on the scroller's
|
|
40
|
+
`contentContainerStyle`. **Do not** wrap the pair in a padded box: a box with
|
|
41
|
+
padding insets the scroller's VIEWPORT, so the list ends short of the panel with
|
|
42
|
+
dead space beneath it and the last row clipped. Measured: a 400px panel ends its
|
|
43
|
+
scroll at 380.
|
|
44
|
+
|
|
45
|
+
Import `DRAWER_GUTTER` in both cases; a hardcoded `20` stops matching the day it
|
|
46
|
+
moves.
|
|
47
|
+
|
|
7
48
|
## 32.0.0 — `Table`'s `align` prop is gone; rows are centred
|
|
8
49
|
|
|
9
50
|
`align?: "center" | "top"` and the `TableAlign` type are removed. `center` was the
|
package/docs/catalog.md
CHANGED
|
@@ -1157,9 +1157,16 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1157
1157
|
- **`dialog`** — `Dialog` (+ `DialogHeader`/`DialogHeaderTitle`/`DialogFooter`, `useDialog`,
|
|
1158
1158
|
`useDialogNavigation`): the centered card over a scrim; BAKES a screen router in
|
|
1159
1159
|
(`<Dialog><Screen route="">…` — see `screen_router`).
|
|
1160
|
-
- **`drawer`** — `Drawer` + `DrawerFooter`: the docked side panel with scrim; the
|
|
1161
|
-
row's edit surface. ONE standard width (600px) — pass `width` only for a genuinely
|
|
1162
|
-
exceptional panel; full-width on small screens.
|
|
1160
|
+
- **`drawer`** — `Drawer` + `DrawerScrollArea` + `DrawerFooter`: the docked side panel with scrim; the
|
|
1161
|
+
register row's edit surface. ONE standard width (600px) — pass `width` only for a genuinely
|
|
1162
|
+
exceptional panel; full-width on small screens. `DrawerScrollArea` is the guttered content
|
|
1163
|
+
region — `DialogScrollArea`'s counterpart — so never hand-pad a drawer body. It always
|
|
1164
|
+
scrolls: a padded box around a `flex:1` scroller insets that scroller's viewport, ending the
|
|
1165
|
+
list short of the panel with the last row clipped. Two bodies skip it and take the `Drawer`'s
|
|
1166
|
+
bare slot: a FULL-BLEED one (a band spanning the panel, a record screen with its own gutters),
|
|
1167
|
+
and a PINNED one (a heading or summary above a scrolling list), which puts the exported
|
|
1168
|
+
`DRAWER_GUTTER` on the pinned part and on the scroller's `contentContainerStyle` rather than
|
|
1169
|
+
copying `20`.
|
|
1163
1170
|
- **`modal`** — `Modal` + `ModalHeader` + `ModalBody` + `ModalFooter` — the full-bleed,
|
|
1164
1171
|
edge-to-edge takeover: an OPAQUE surface that COVERS THE WHOLE SCREEN, so unlike Dialog
|
|
1165
1172
|
(centered card WITH scrim) and Drawer (docked panel WITH scrim) there is nothing behind it
|
package/docs/composition.md
CHANGED
|
@@ -10,9 +10,18 @@ them produces off-system UI.
|
|
|
10
10
|
|
|
11
11
|
## Canvas & content column
|
|
12
12
|
|
|
13
|
-
- **Canvas**: a full-bleed `colors.white` ScrollView with `padding: 28
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
- **Canvas**: a full-bleed `colors.white` ScrollView with `padding: 28`. Cards separate from the
|
|
14
|
+
canvas by their own border + shadow, not by a tinted background.
|
|
15
|
+
- **An overlay's gutter belongs to a CONTENT primitive, never to the caller.** `ModalBody`,
|
|
16
|
+
`DialogScrollArea` and `DrawerScrollArea` each carry their surface's inset, so content lands on
|
|
17
|
+
the same left edge as the title above it and the actions below it. Never hand-pad inside one,
|
|
18
|
+
and never hand-roll one: the three surfaces disagree on the number (24 / responsive / 20) and
|
|
19
|
+
only the surface knows which it is.
|
|
20
|
+
- **Never wrap a scroller in a padded box.** The box insets the scroller's VIEWPORT, so the list
|
|
21
|
+
ends short of the surface with dead space beneath it and its last row clipped — measured, a
|
|
22
|
+
400px panel ends its scroll at 380. A pinned heading above a scrolling list therefore takes the
|
|
23
|
+
overlay's BARE slot and puts the gutter on the pinned part and on the scroller's
|
|
24
|
+
`contentContainerStyle`. Same for a full-bleed band, which wants no gutter at all.
|
|
16
25
|
- **Content column**: one centered column — `{ width: "100%", maxWidth, alignSelf: "center" }` —
|
|
17
26
|
sized to the job, with `gap: 16` between top-level bands/cards:
|
|
18
27
|
|
|
@@ -9,7 +9,7 @@ import { Badge } from "@lotics/ui/badge";
|
|
|
9
9
|
import { Button } from "@lotics/ui/button";
|
|
10
10
|
import { Card } from "@lotics/ui/card";
|
|
11
11
|
import { Divider } from "@lotics/ui/divider";
|
|
12
|
-
import { Drawer } from "@lotics/ui/drawer";
|
|
12
|
+
import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer";
|
|
13
13
|
import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
|
|
14
14
|
import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
|
|
15
15
|
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
@@ -205,38 +205,40 @@ function NhanVienWorkspace({ nv }: { nv: NhanVien }) {
|
|
|
205
205
|
const tt = TRANG_THAI[nv.trangThai];
|
|
206
206
|
return (
|
|
207
207
|
<>
|
|
208
|
-
<
|
|
209
|
-
<View style={{
|
|
210
|
-
<
|
|
211
|
-
|
|
212
|
-
<
|
|
213
|
-
|
|
214
|
-
<Badge label={badgeLabel(nv)} color={tt.color} />
|
|
215
|
-
</View>
|
|
216
|
-
<View style={{ gap: 6 }}>
|
|
217
|
-
<KVRow label="Clock-in" value={nv.vao ?? "Not clocked"} />
|
|
218
|
-
<KVRow label="Clock-out" value={nv.ra ?? "Not clocked"} />
|
|
219
|
-
<KVRow label="Hours today" value={nv.tongGio ?? "—"} />
|
|
220
|
-
</View>
|
|
221
|
-
<Divider />
|
|
222
|
-
<View style={{ gap: 10 }}>
|
|
223
|
-
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
|
224
|
-
<View style={{ flex: 1 }}>
|
|
225
|
-
<Text size="xs" color="muted" weight="medium">This week</Text>
|
|
208
|
+
<DrawerScrollArea>
|
|
209
|
+
<View style={{ gap: 20 }}>
|
|
210
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
211
|
+
<Avatar name={nv.ten} size="lg" />
|
|
212
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
213
|
+
<Text size="sm" color="muted">{nv.chucVu}</Text>
|
|
226
214
|
</View>
|
|
227
|
-
<
|
|
215
|
+
<Badge label={badgeLabel(nv)} color={tt.color} />
|
|
228
216
|
</View>
|
|
229
217
|
<View style={{ gap: 6 }}>
|
|
230
|
-
{nv.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
218
|
+
<KVRow label="Clock-in" value={nv.vao ?? "Not clocked"} />
|
|
219
|
+
<KVRow label="Clock-out" value={nv.ra ?? "Not clocked"} />
|
|
220
|
+
<KVRow label="Hours today" value={nv.tongGio ?? "—"} />
|
|
221
|
+
</View>
|
|
222
|
+
<Divider />
|
|
223
|
+
<View style={{ gap: 10 }}>
|
|
224
|
+
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
|
225
|
+
<View style={{ flex: 1 }}>
|
|
226
|
+
<Text size="xs" color="muted" weight="medium">This week</Text>
|
|
235
227
|
</View>
|
|
236
|
-
|
|
228
|
+
<Text size="sm" weight="medium" tabular>{nv.tongTuan}</Text>
|
|
229
|
+
</View>
|
|
230
|
+
<View style={{ gap: 6 }}>
|
|
231
|
+
{nv.tuan.map((ngay, i) => (
|
|
232
|
+
<View key={NGAY_TUAN[i]} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 24 }}>
|
|
233
|
+
<Text size="xs" color="muted" tabular style={{ width: 24 }}>{NGAY_TUAN[i]}</Text>
|
|
234
|
+
<DayCell ngay={ngay} />
|
|
235
|
+
<Text size="sm">{NGAY_CONG[ngay].label}</Text>
|
|
236
|
+
</View>
|
|
237
|
+
))}
|
|
238
|
+
</View>
|
|
237
239
|
</View>
|
|
238
240
|
</View>
|
|
239
|
-
</
|
|
241
|
+
</DrawerScrollArea>
|
|
240
242
|
{/* pinned footer — the workspace's one primary fix action */}
|
|
241
243
|
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: 20, paddingVertical: 14, flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
242
244
|
<Text size="xs" color="muted" style={{ flex: 1 }}>Missed punches get fixed here — every change is audited.</Text>
|
|
@@ -7,7 +7,7 @@ import { Badge } from "@lotics/ui/badge";
|
|
|
7
7
|
import { Button } from "@lotics/ui/button";
|
|
8
8
|
import { Card, CardHeader, CardHeaderTitle, CardHeaderMeta } from "@lotics/ui/card";
|
|
9
9
|
import { Divider } from "@lotics/ui/divider";
|
|
10
|
-
import { Drawer } from "@lotics/ui/drawer";
|
|
10
|
+
import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer";
|
|
11
11
|
import { ListItem } from "@lotics/ui/list_item";
|
|
12
12
|
import {
|
|
13
13
|
CalendarView,
|
|
@@ -154,17 +154,19 @@ function SlotWorkspace({ e }: { e: CalendarEvent<AgendaMeta> }) {
|
|
|
154
154
|
];
|
|
155
155
|
return (
|
|
156
156
|
<>
|
|
157
|
-
<
|
|
158
|
-
{
|
|
159
|
-
|
|
160
|
-
{
|
|
161
|
-
|
|
162
|
-
<
|
|
163
|
-
|
|
157
|
+
<DrawerScrollArea>
|
|
158
|
+
<View style={{ gap: 10 }}>
|
|
159
|
+
{rows.map(([label, value], i) => (
|
|
160
|
+
<View key={label} style={{ gap: 10 }}>
|
|
161
|
+
{i > 0 ? <Divider /> : null}
|
|
162
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 24 }}>
|
|
163
|
+
<Text size="sm" color="muted" style={{ width: 104 }}>{label}</Text>
|
|
164
|
+
{value}
|
|
165
|
+
</View>
|
|
164
166
|
</View>
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
</
|
|
167
|
+
))}
|
|
168
|
+
</View>
|
|
169
|
+
</DrawerScrollArea>
|
|
168
170
|
<View
|
|
169
171
|
style={{
|
|
170
172
|
borderTopWidth: 1,
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from "@lotics/ui/date_range_filter_field";
|
|
27
27
|
import { type DateFilterValue } from "@lotics/ui/date_filter";
|
|
28
28
|
import { Divider } from "@lotics/ui/divider";
|
|
29
|
-
import { Drawer } from "@lotics/ui/drawer";
|
|
29
|
+
import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer";
|
|
30
30
|
import { PressableRow } from "@lotics/ui/pressable_row";
|
|
31
31
|
import { type IconName } from "@lotics/ui/icon";
|
|
32
32
|
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
@@ -530,25 +530,27 @@ export function TplDashboard() {
|
|
|
530
530
|
onNext={openRec.idx < recAlert.records.length - 1 ? () => setOpenRec({ alertKey: openRec.alertKey, idx: openRec.idx + 1 }) : undefined}
|
|
531
531
|
position={`${openRec.idx + 1}/${recAlert.records.length}`}
|
|
532
532
|
>
|
|
533
|
-
<
|
|
534
|
-
<
|
|
535
|
-
|
|
536
|
-
<View style={{
|
|
537
|
-
<
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
<
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
<
|
|
548
|
-
|
|
533
|
+
<DrawerScrollArea key={rec.id}>
|
|
534
|
+
<View style={{ gap: 12 }}>
|
|
535
|
+
<Badge label={recAlert.label} color={recAlert.tone === "warning" ? "amber" : "red"} />
|
|
536
|
+
<View style={{ gap: 10, paddingTop: 4 }}>
|
|
537
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
538
|
+
<Text size="sm" color="muted" style={{ flex: 1 }}>Customer</Text>
|
|
539
|
+
<Text size="sm" weight="medium">{rec.name}</Text>
|
|
540
|
+
</View>
|
|
541
|
+
<Divider />
|
|
542
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
543
|
+
<Text size="sm" color="muted" style={{ flex: 1 }}>Amount / due</Text>
|
|
544
|
+
<Text size="sm" tabular>{rec.value}</Text>
|
|
545
|
+
</View>
|
|
546
|
+
<Divider />
|
|
547
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
548
|
+
<Text size="sm" color="muted" style={{ flex: 1 }}>Status</Text>
|
|
549
|
+
<Text size="sm" color={rec.noteTone} tabular>{rec.note}</Text>
|
|
550
|
+
</View>
|
|
549
551
|
</View>
|
|
550
552
|
</View>
|
|
551
|
-
</
|
|
553
|
+
</DrawerScrollArea>
|
|
552
554
|
<View
|
|
553
555
|
style={{
|
|
554
556
|
borderTopWidth: 1,
|
|
@@ -10,7 +10,7 @@ import { Button } from "@lotics/ui/button";
|
|
|
10
10
|
import { CheckboxInput } from "@lotics/ui/checkbox_input";
|
|
11
11
|
import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
|
|
12
12
|
import { Divider } from "@lotics/ui/divider";
|
|
13
|
-
import { Drawer, DrawerFooter } from "@lotics/ui/drawer";
|
|
13
|
+
import { Drawer, DrawerScrollArea, DrawerFooter } from "@lotics/ui/drawer";
|
|
14
14
|
import { EmptyState } from "@lotics/ui/empty_state";
|
|
15
15
|
import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
|
|
16
16
|
import { FormField } from "@lotics/ui/form_field";
|
|
@@ -350,37 +350,39 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
|
|
|
350
350
|
if (!r) return null;
|
|
351
351
|
return (
|
|
352
352
|
<>
|
|
353
|
-
<
|
|
354
|
-
<View style={{
|
|
355
|
-
<
|
|
356
|
-
|
|
353
|
+
<DrawerScrollArea>
|
|
354
|
+
<View style={{ gap: 16 }}>
|
|
355
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
|
356
|
+
<Badge label={TRANG_THAI[r.trangThai].label} color={TRANG_THAI[r.trangThai].color} />
|
|
357
|
+
<Text size="sm" color="muted">{`Linked record: ${r.khach}`}</Text>
|
|
358
|
+
</View>
|
|
359
|
+
<DetailTable labelWidth={120}>
|
|
360
|
+
<DetailRow label="Customer"><InlineTextInput value={khach} onSave={persist(setKhach)} accessibilityLabel="Customer" /></DetailRow>
|
|
361
|
+
<DetailRow label="Phone"><InlineTextInput value={dienThoai} onSave={persist(setDienThoai)} accessibilityLabel="Phone" /></DetailRow>
|
|
362
|
+
<DetailRow label="Assignee">
|
|
363
|
+
<InlineMemberSelect members={MEMBERS} value={assignee} onSave={persist(setAssignee)} placeholder="Assign…" accessibilityLabel="Assignee" />
|
|
364
|
+
</DetailRow>
|
|
365
|
+
<DetailRow label="Received"><InlineDatePicker value={ngayNhan} onSave={persist(setNgayNhan)} accessibilityLabel="Received date" /></DetailRow>
|
|
366
|
+
<DetailRow label="Fee">
|
|
367
|
+
<InlineNumberInput value={fee} onSave={persist(setFee)} min={0} format={(v) => (v == null ? "" : formatMoney(v))} accessibilityLabel="Fee" />
|
|
368
|
+
</DetailRow>
|
|
369
|
+
<DetailRow label="Collected">
|
|
370
|
+
<InlineSelect
|
|
371
|
+
value={collected}
|
|
372
|
+
onSave={persist(setCollected)}
|
|
373
|
+
options={[{ value: "yes", label: "Collected" }, { value: "no", label: "Outstanding" }]}
|
|
374
|
+
accessibilityLabel="Collected"
|
|
375
|
+
renderOptionContent={(o) => (
|
|
376
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
377
|
+
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: o.value === "yes" ? colors.emerald[500] : colors.amber[500] }} />
|
|
378
|
+
<Text size="sm">{o.label}</Text>
|
|
379
|
+
</View>
|
|
380
|
+
)}
|
|
381
|
+
/>
|
|
382
|
+
</DetailRow>
|
|
383
|
+
</DetailTable>
|
|
357
384
|
</View>
|
|
358
|
-
|
|
359
|
-
<DetailRow label="Customer"><InlineTextInput value={khach} onSave={persist(setKhach)} accessibilityLabel="Customer" /></DetailRow>
|
|
360
|
-
<DetailRow label="Phone"><InlineTextInput value={dienThoai} onSave={persist(setDienThoai)} accessibilityLabel="Phone" /></DetailRow>
|
|
361
|
-
<DetailRow label="Assignee">
|
|
362
|
-
<InlineMemberSelect members={MEMBERS} value={assignee} onSave={persist(setAssignee)} placeholder="Assign…" accessibilityLabel="Assignee" />
|
|
363
|
-
</DetailRow>
|
|
364
|
-
<DetailRow label="Received"><InlineDatePicker value={ngayNhan} onSave={persist(setNgayNhan)} accessibilityLabel="Received date" /></DetailRow>
|
|
365
|
-
<DetailRow label="Fee">
|
|
366
|
-
<InlineNumberInput value={fee} onSave={persist(setFee)} min={0} format={(v) => (v == null ? "" : formatMoney(v))} accessibilityLabel="Fee" />
|
|
367
|
-
</DetailRow>
|
|
368
|
-
<DetailRow label="Collected">
|
|
369
|
-
<InlineSelect
|
|
370
|
-
value={collected}
|
|
371
|
-
onSave={persist(setCollected)}
|
|
372
|
-
options={[{ value: "yes", label: "Collected" }, { value: "no", label: "Outstanding" }]}
|
|
373
|
-
accessibilityLabel="Collected"
|
|
374
|
-
renderOptionContent={(o) => (
|
|
375
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
376
|
-
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: o.value === "yes" ? colors.emerald[500] : colors.amber[500] }} />
|
|
377
|
-
<Text size="sm">{o.label}</Text>
|
|
378
|
-
</View>
|
|
379
|
-
)}
|
|
380
|
-
/>
|
|
381
|
-
</DetailRow>
|
|
382
|
-
</DetailTable>
|
|
383
|
-
</ScrollView>
|
|
385
|
+
</DrawerScrollArea>
|
|
384
386
|
{/* the pushed record's OWN footer — different CTAs from the root case */}
|
|
385
387
|
<DrawerFooter>
|
|
386
388
|
<Text size="xs" color="muted" style={{ flex: 1 }}>Changes save in place on this linked record.</Text>
|
package/examples/tpl_pivot.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { colors } from "@lotics/ui/colors";
|
|
|
5
5
|
import { Badge } from "@lotics/ui/badge";
|
|
6
6
|
import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
|
|
7
7
|
import { Divider } from "@lotics/ui/divider";
|
|
8
|
-
import { Drawer } from "@lotics/ui/drawer";
|
|
8
|
+
import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer";
|
|
9
9
|
import { EmptyState } from "@lotics/ui/empty_state";
|
|
10
10
|
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
11
11
|
import { Chip } from "@lotics/ui/chip";
|
|
@@ -230,18 +230,20 @@ export function TplPivot() {
|
|
|
230
230
|
onNext={openIdx < Math.min(items.length, 10) - 1 ? () => setOpenIdx(openIdx + 1) : undefined}
|
|
231
231
|
position={`${openIdx + 1}/${Math.min(items.length, 10)}`}
|
|
232
232
|
>
|
|
233
|
-
<
|
|
234
|
-
<
|
|
235
|
-
|
|
236
|
-
<
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
233
|
+
<DrawerScrollArea key={open.code}>
|
|
234
|
+
<View style={{ gap: 12 }}>
|
|
235
|
+
<Badge variant="dot" label={STATUS[open.status].label} color={STATUS[open.status].color} />
|
|
236
|
+
<View style={{ gap: 6, paddingTop: 4 }}>
|
|
237
|
+
<KVRow label="Item"><Text size="sm">{open.name}</Text></KVRow>
|
|
238
|
+
<Divider />
|
|
239
|
+
<KVRow label="Warehouse"><Text size="sm">{whOf(open.warehouse).label}</Text></KVRow>
|
|
240
|
+
<Divider />
|
|
241
|
+
<KVRow label="Category"><Text size="sm">{catOf(open.category).label}</Text></KVRow>
|
|
242
|
+
<Divider />
|
|
243
|
+
<KVRow label="Units on hand"><Text size="sm" tabular>{count(open.units)}</Text></KVRow>
|
|
244
|
+
</View>
|
|
243
245
|
</View>
|
|
244
|
-
</
|
|
246
|
+
</DrawerScrollArea>
|
|
245
247
|
</Drawer>
|
|
246
248
|
) : null}
|
|
247
249
|
</ScrollView>
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { Fragment, useEffect, useRef, useState, type ReactNode, useMemo } from "
|
|
|
2
2
|
import { ScrollView, View } from "react-native";
|
|
3
3
|
import { Text } from "@lotics/ui/text";
|
|
4
4
|
import { colors } from "@lotics/ui/colors";
|
|
5
|
+
import { DRAWER_GUTTER } from "@lotics/ui/drawer";
|
|
5
6
|
import { Button } from "@lotics/ui/button";
|
|
6
7
|
import { BackButton } from "@lotics/ui/back_button";
|
|
7
8
|
import { Divider } from "@lotics/ui/divider";
|
|
@@ -2011,7 +2012,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2011
2012
|
// no box, so its ink is what the eye lines up — 18 + 10 = 28. Set the bar,
|
|
2012
2013
|
// not a negative margin on the button: the gutter belongs to the bar, and
|
|
2013
2014
|
// one number here cannot drift out of step with itself.
|
|
2014
|
-
<View style={{ borderBottomWidth: 1, borderBottomColor: colors.zinc[200], paddingHorizontal: (isDrawer ?
|
|
2015
|
+
<View style={{ borderBottomWidth: 1, borderBottomColor: colors.zinc[200], paddingHorizontal: (isDrawer ? DRAWER_GUTTER : 28) - 10, paddingVertical: 6, flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
2015
2016
|
{/* narrow keeps the standard too — the bare back glyph beside the
|
|
2016
2017
|
section bar (the rail that would carry it is collapsed). A DRAWER
|
|
2017
2018
|
already has one in its own header; two would be two exits. */}
|
|
@@ -2066,7 +2067,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2066
2067
|
{/* The gutter is CHROME's, not the record's: 28 is the page canvas, 20 is what
|
|
2067
2068
|
a `Drawer` pads its header with — so in a drawer the content lines up with
|
|
2068
2069
|
the record NAME in the header above it instead of sitting 8px inside it. */}
|
|
2069
|
-
<ScrollView ref={nav.scrollRef} onScroll={nav.onScroll} scrollEventThrottle={16} style={{ flex: 1 }} contentContainerStyle={{ padding: isDrawer ?
|
|
2070
|
+
<ScrollView ref={nav.scrollRef} onScroll={nav.onScroll} scrollEventThrottle={16} style={{ flex: 1 }} contentContainerStyle={{ padding: isDrawer ? DRAWER_GUTTER : 28, paddingBottom: 96 }}>
|
|
2070
2071
|
{/* The centred group: a gutter RESERVED for the rail, the reading column,
|
|
2071
2072
|
and an equal empty gutter that balances it — so the column's centre is
|
|
2072
2073
|
the window's centre. Both gutters live INSIDE the scroller, so they
|
package/examples/tpl_rollup.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { Accordion, AccordionContent, AccordionHeader, AccordionTitle } from "@l
|
|
|
6
6
|
import { Button } from "@lotics/ui/button";
|
|
7
7
|
import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
|
|
8
8
|
import { Divider } from "@lotics/ui/divider";
|
|
9
|
-
import { Drawer } from "@lotics/ui/drawer";
|
|
9
|
+
import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer";
|
|
10
10
|
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
11
11
|
import { PressableHighlight } from "@lotics/ui/pressable_highlight";
|
|
12
12
|
import { Sparkline } from "@lotics/ui/sparkline";
|
|
@@ -255,30 +255,32 @@ export function TplRollup() {
|
|
|
255
255
|
onNext={open.idx < open.site.lanes.length - 1 ? () => setOpen({ site: open.site, idx: open.idx + 1 }) : undefined}
|
|
256
256
|
position={`${open.idx + 1}/${open.site.lanes.length}`}
|
|
257
257
|
>
|
|
258
|
-
<
|
|
259
|
-
<View style={{ gap:
|
|
260
|
-
<
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
<
|
|
266
|
-
{
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
258
|
+
<DrawerScrollArea key={openLane.id}>
|
|
259
|
+
<View style={{ gap: 16 }}>
|
|
260
|
+
<View style={{ gap: 6 }}>
|
|
261
|
+
<KVRow label="Route"><Text size="sm">{`${open.site.label} → ${openLane.dest}`}</Text></KVRow>
|
|
262
|
+
<Divider />
|
|
263
|
+
<KVRow label="Volume this month"><Text size="sm" tabular>{openLane.volume.toLocaleString("en-US")}</Text></KVRow>
|
|
264
|
+
<Divider />
|
|
265
|
+
<KVRow label="On-time">
|
|
266
|
+
<Text size="sm" tabular color={openLane.ontime < ONTIME_TARGET ? "danger" : "default"}>
|
|
267
|
+
{`${openLane.ontime}% (target ${ONTIME_TARGET}%)`}
|
|
268
|
+
</Text>
|
|
269
|
+
</KVRow>
|
|
270
|
+
<Divider />
|
|
271
|
+
<KVRow label="Margin"><Text size="sm" tabular>{`${openLane.margin}%`}</Text></KVRow>
|
|
272
|
+
</View>
|
|
273
|
+
<View style={{ gap: 8 }}>
|
|
274
|
+
<Text size="xs" color="muted" weight="medium">Volume (last 12 weeks)</Text>
|
|
275
|
+
<Sparkline
|
|
276
|
+
data={Array.from({ length: 12 }, (_, w) => openLane.volume / 4 + (hash(w, openLane.volume) - 500) * (openLane.volume / 12000))}
|
|
277
|
+
width={200}
|
|
278
|
+
height={36}
|
|
279
|
+
color={openLane.ontime < ONTIME_TARGET ? solid("amber") : solid("blue")}
|
|
280
|
+
/>
|
|
281
|
+
</View>
|
|
271
282
|
</View>
|
|
272
|
-
|
|
273
|
-
<Text size="xs" color="muted" weight="medium">Volume (last 12 weeks)</Text>
|
|
274
|
-
<Sparkline
|
|
275
|
-
data={Array.from({ length: 12 }, (_, w) => openLane.volume / 4 + (hash(w, openLane.volume) - 500) * (openLane.volume / 12000))}
|
|
276
|
-
width={200}
|
|
277
|
-
height={36}
|
|
278
|
-
color={openLane.ontime < ONTIME_TARGET ? solid("amber") : solid("blue")}
|
|
279
|
-
/>
|
|
280
|
-
</View>
|
|
281
|
-
</View>
|
|
283
|
+
</DrawerScrollArea>
|
|
282
284
|
<View
|
|
283
285
|
style={{
|
|
284
286
|
borderTopWidth: 1,
|
package/examples/tpl_shifts.tsx
CHANGED
|
@@ -9,7 +9,7 @@ import { Button } from "@lotics/ui/button";
|
|
|
9
9
|
import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
|
|
10
10
|
import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
|
|
11
11
|
import { Divider } from "@lotics/ui/divider";
|
|
12
|
-
import { Drawer } from "@lotics/ui/drawer";
|
|
12
|
+
import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer";
|
|
13
13
|
import { EmptyState } from "@lotics/ui/empty_state";
|
|
14
14
|
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
15
15
|
import { MenuListItem } from "@lotics/ui/menu_list_item";
|
|
@@ -328,58 +328,60 @@ export function TplShifts() {
|
|
|
328
328
|
onNext={openIdx < allSlots.length - 1 ? () => setOpenSlot(slotId(allSlots[openIdx + 1].day, allSlots[openIdx + 1].shift)) : undefined}
|
|
329
329
|
position={`${openIdx + 1}/${allSlots.length}`}
|
|
330
330
|
>
|
|
331
|
-
<
|
|
332
|
-
<
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
<Text size="sm" weight="semibold">On this shift</Text>
|
|
342
|
-
{openAssigned.length === 0 ? (
|
|
343
|
-
<Text size="sm" color="muted">Nobody yet — accept a signup or add a member below.</Text>
|
|
344
|
-
) : (
|
|
345
|
-
openAssigned.map((m) => (
|
|
346
|
-
<View key={m} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 36 }}>
|
|
347
|
-
<Avatar name={m} size="md" />
|
|
348
|
-
<Text size="sm" style={{ flex: 1 }}>{m}</Text>
|
|
349
|
-
<Text size="xs" color="muted" tabular>{`${hoursOf(m)}h this week`}</Text>
|
|
350
|
-
<ActionMenu
|
|
351
|
-
accessibilityLabel={`Actions for ${m}`}
|
|
352
|
-
items={[{ key: "remove", label: "Remove from shift", icon: "ban", danger: true, onPress: () => unassign(openSlot, m) }]}
|
|
353
|
-
/>
|
|
354
|
-
</View>
|
|
355
|
-
))
|
|
356
|
-
)}
|
|
357
|
-
</View>
|
|
358
|
-
{openSignups.length > 0 ? (
|
|
331
|
+
<DrawerScrollArea key={openSlot}>
|
|
332
|
+
<View style={{ gap: 16 }}>
|
|
333
|
+
<ProgressBar
|
|
334
|
+
title="Coverage"
|
|
335
|
+
value={openAssigned.length}
|
|
336
|
+
max={openRequired}
|
|
337
|
+
format="fraction"
|
|
338
|
+
color={solid("amber")}
|
|
339
|
+
completeColor={solid("emerald")}
|
|
340
|
+
/>
|
|
359
341
|
<View style={{ gap: 8 }}>
|
|
360
|
-
<Text size="sm" weight="semibold">
|
|
361
|
-
{
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
<
|
|
367
|
-
<Text size="
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
342
|
+
<Text size="sm" weight="semibold">On this shift</Text>
|
|
343
|
+
{openAssigned.length === 0 ? (
|
|
344
|
+
<Text size="sm" color="muted">Nobody yet — accept a signup or add a member below.</Text>
|
|
345
|
+
) : (
|
|
346
|
+
openAssigned.map((m) => (
|
|
347
|
+
<View key={m} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 36 }}>
|
|
348
|
+
<Avatar name={m} size="md" />
|
|
349
|
+
<Text size="sm" style={{ flex: 1 }}>{m}</Text>
|
|
350
|
+
<Text size="xs" color="muted" tabular>{`${hoursOf(m)}h this week`}</Text>
|
|
351
|
+
<ActionMenu
|
|
352
|
+
accessibilityLabel={`Actions for ${m}`}
|
|
353
|
+
items={[{ key: "remove", label: "Remove from shift", icon: "ban", danger: true, onPress: () => unassign(openSlot, m) }]}
|
|
354
|
+
/>
|
|
373
355
|
</View>
|
|
374
|
-
)
|
|
375
|
-
|
|
356
|
+
))
|
|
357
|
+
)}
|
|
376
358
|
</View>
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
359
|
+
{openSignups.length > 0 ? (
|
|
360
|
+
<View style={{ gap: 8 }}>
|
|
361
|
+
<Text size="sm" weight="semibold">Asked for this shift</Text>
|
|
362
|
+
{openSignups.map((su) => {
|
|
363
|
+
const hoursAfter = hoursOf(su.member) + SHIFT_HOURS;
|
|
364
|
+
return (
|
|
365
|
+
<View key={su.id} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 36 }}>
|
|
366
|
+
<Avatar name={su.member} size="md" />
|
|
367
|
+
<Text size="sm" style={{ flex: 1 }}>{su.member}</Text>
|
|
368
|
+
<Text size="xs" color={hoursAfter > WEEK_LIMIT ? "danger" : "muted"} tabular>{`→ ${hoursAfter}h`}</Text>
|
|
369
|
+
{hoursAfter > WEEK_LIMIT ? (
|
|
370
|
+
<OvertimeGate label={su.member} hoursAfter={hoursAfter} onConfirm={() => accept(su)} />
|
|
371
|
+
) : (
|
|
372
|
+
<Button title="Accept" color="secondary" onPress={() => accept(su)} />
|
|
373
|
+
)}
|
|
374
|
+
</View>
|
|
375
|
+
);
|
|
376
|
+
})}
|
|
377
|
+
</View>
|
|
378
|
+
) : null}
|
|
379
|
+
<Divider />
|
|
380
|
+
<KVRow label="Required">
|
|
381
|
+
<Text size="sm" tabular>{`${openRequired} members`}</Text>
|
|
382
|
+
</KVRow>
|
|
383
|
+
</View>
|
|
384
|
+
</DrawerScrollArea>
|
|
383
385
|
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: 20, paddingVertical: 14, flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 12 }}>
|
|
384
386
|
{/* the dispatch fit-gate: every candidate names its week; the
|
|
385
387
|
slot's own members are excluded, overtime is visible */}
|
package/examples/tpl_stock.tsx
CHANGED
|
@@ -8,7 +8,7 @@ import { Breakdown } from "@lotics/ui/breakdown";
|
|
|
8
8
|
import { Button } from "@lotics/ui/button";
|
|
9
9
|
import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
|
|
10
10
|
import { Divider } from "@lotics/ui/divider";
|
|
11
|
-
import { Drawer } from "@lotics/ui/drawer";
|
|
11
|
+
import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer";
|
|
12
12
|
import { EmptyState } from "@lotics/ui/empty_state";
|
|
13
13
|
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
14
14
|
import { Pagination } from "@lotics/ui/pagination";
|
|
@@ -358,21 +358,23 @@ export function TplStock() {
|
|
|
358
358
|
onNext={openIdx < pageRows.length - 1 ? () => setOpenIdx(openIdx + 1) : undefined}
|
|
359
359
|
position={`${page * PAGE_SIZE + openIdx + 1}/${filtered.length.toLocaleString("en-US")}`}
|
|
360
360
|
>
|
|
361
|
-
<
|
|
362
|
-
<View style={{
|
|
363
|
-
<
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
<
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
361
|
+
<DrawerScrollArea key={open.id}>
|
|
362
|
+
<View style={{ gap: 12 }}>
|
|
363
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
364
|
+
<Badge label={statusOf(open.status).label} color={statusOf(open.status).color} />
|
|
365
|
+
<Badge label={typeOf(open.type).label} />
|
|
366
|
+
</View>
|
|
367
|
+
<View style={{ gap: 6, paddingTop: 4 }}>
|
|
368
|
+
<KVRow label="Depot"><Text size="sm">{depotOf(open.depot).label}</Text></KVRow>
|
|
369
|
+
<Divider />
|
|
370
|
+
<KVRow label="Last movement"><Text size="sm" tabular>{open.lastMove}</Text></KVRow>
|
|
371
|
+
<Divider />
|
|
372
|
+
<KVRow label="Age"><Text size="sm" tabular>{`${open.ageYears} years`}</Text></KVRow>
|
|
373
|
+
<Divider />
|
|
374
|
+
<KVRow label="Book value"><Text size="sm" tabular>{formatMoney(open.value)}</Text></KVRow>
|
|
375
|
+
</View>
|
|
374
376
|
</View>
|
|
375
|
-
</
|
|
377
|
+
</DrawerScrollArea>
|
|
376
378
|
<View
|
|
377
379
|
style={{
|
|
378
380
|
borderTopWidth: 1,
|
package/examples/tpl_tower.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import { Badge } from "@lotics/ui/badge";
|
|
|
7
7
|
import { Button } from "@lotics/ui/button";
|
|
8
8
|
import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
|
|
9
9
|
import { Divider } from "@lotics/ui/divider";
|
|
10
|
-
import { Drawer } from "@lotics/ui/drawer";
|
|
10
|
+
import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer";
|
|
11
11
|
import { Heatmap, type HeatmapCell } from "@lotics/ui/heatmap";
|
|
12
12
|
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
13
13
|
import { PressableRow } from "@lotics/ui/pressable_row";
|
|
@@ -306,34 +306,36 @@ export function TplTower() {
|
|
|
306
306
|
onNext={open.idx < walk.length - 1 ? () => setOpen({ ...open, idx: open.idx + 1 }) : undefined}
|
|
307
307
|
position={`${open.idx + 1}/${walk.length}`}
|
|
308
308
|
>
|
|
309
|
-
<
|
|
310
|
-
<View style={{
|
|
311
|
-
<
|
|
312
|
-
|
|
309
|
+
<DrawerScrollArea key={unit.key}>
|
|
310
|
+
<View style={{ gap: 16 }}>
|
|
311
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
312
|
+
<Badge label={stateOf(unit.state).label} color={stateOf(unit.state).color} />
|
|
313
|
+
<Badge label={siteOf(unit.site).label} />
|
|
314
|
+
</View>
|
|
315
|
+
{unit.message ? (
|
|
316
|
+
<Text size="sm" color="danger">{`${unit.message} (${formatAge(unit.ageMin ?? 0)} ago)`}</Text>
|
|
317
|
+
) : null}
|
|
318
|
+
<View style={{ gap: 6 }}>
|
|
319
|
+
<KVRow label="Site"><Text size="sm">{siteOf(unit.site).label}</Text></KVRow>
|
|
320
|
+
<Divider />
|
|
321
|
+
<KVRow label="Model"><Text size="sm" tabular>{unit.model}</Text></KVRow>
|
|
322
|
+
<Divider />
|
|
323
|
+
<KVRow label="Hours since service"><Text size="sm" tabular>{unit.serviceHours.toLocaleString("en-US")}</Text></KVRow>
|
|
324
|
+
</View>
|
|
325
|
+
<View style={{ gap: 8, paddingTop: 4 }}>
|
|
326
|
+
<Text size="sm" weight="semibold">Recent events</Text>
|
|
327
|
+
<Timeline
|
|
328
|
+
items={[
|
|
329
|
+
...(unit.message
|
|
330
|
+
? [{ id: "alarm", icon: "circle-alert" as const, iconColor: solid(stateOf(unit.state).color), label: unit.message, right: <Text size="xs" color="muted" tabular>{formatAge(unit.ageMin ?? 0)} ago</Text> }]
|
|
331
|
+
: []),
|
|
332
|
+
{ id: "cycle", icon: "activity" as const, iconColor: solid("blue"), label: `Completed ${120 + hash(unit.serviceHours, 9) % 300} cycles today`, right: <Text size="xs" color="muted" tabular>today</Text> },
|
|
333
|
+
{ id: "service", icon: "settings" as const, iconColor: colors.zinc[500], label: "Scheduled service completed", right: <Text size="xs" color="muted" tabular>{`${1 + hash(unit.serviceHours, 10) % 20}d ago`}</Text> },
|
|
334
|
+
]}
|
|
335
|
+
/>
|
|
336
|
+
</View>
|
|
313
337
|
</View>
|
|
314
|
-
|
|
315
|
-
<Text size="sm" color="danger">{`${unit.message} (${formatAge(unit.ageMin ?? 0)} ago)`}</Text>
|
|
316
|
-
) : null}
|
|
317
|
-
<View style={{ gap: 6 }}>
|
|
318
|
-
<KVRow label="Site"><Text size="sm">{siteOf(unit.site).label}</Text></KVRow>
|
|
319
|
-
<Divider />
|
|
320
|
-
<KVRow label="Model"><Text size="sm" tabular>{unit.model}</Text></KVRow>
|
|
321
|
-
<Divider />
|
|
322
|
-
<KVRow label="Hours since service"><Text size="sm" tabular>{unit.serviceHours.toLocaleString("en-US")}</Text></KVRow>
|
|
323
|
-
</View>
|
|
324
|
-
<View style={{ gap: 8, paddingTop: 4 }}>
|
|
325
|
-
<Text size="sm" weight="semibold">Recent events</Text>
|
|
326
|
-
<Timeline
|
|
327
|
-
items={[
|
|
328
|
-
...(unit.message
|
|
329
|
-
? [{ id: "alarm", icon: "circle-alert" as const, iconColor: solid(stateOf(unit.state).color), label: unit.message, right: <Text size="xs" color="muted" tabular>{formatAge(unit.ageMin ?? 0)} ago</Text> }]
|
|
330
|
-
: []),
|
|
331
|
-
{ id: "cycle", icon: "activity" as const, iconColor: solid("blue"), label: `Completed ${120 + hash(unit.serviceHours, 9) % 300} cycles today`, right: <Text size="xs" color="muted" tabular>today</Text> },
|
|
332
|
-
{ id: "service", icon: "settings" as const, iconColor: colors.zinc[500], label: "Scheduled service completed", right: <Text size="xs" color="muted" tabular>{`${1 + hash(unit.serviceHours, 10) % 20}d ago`}</Text> },
|
|
333
|
-
]}
|
|
334
|
-
/>
|
|
335
|
-
</View>
|
|
336
|
-
</View>
|
|
338
|
+
</DrawerScrollArea>
|
|
337
339
|
<View
|
|
338
340
|
style={{
|
|
339
341
|
borderTopWidth: 1,
|
package/package.json
CHANGED
package/src/drawer.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ReactNode, useEffect } from "react";
|
|
2
|
-
import { Modal, Pressable, StyleSheet, View } from "react-native";
|
|
2
|
+
import { Modal, Pressable, ScrollView, StyleSheet, View } from "react-native";
|
|
3
3
|
import { useScreenSize } from "@lotics/ui/use_screen_size";
|
|
4
4
|
import { SizeBoundary } from "@lotics/ui/size_boundary";
|
|
5
5
|
import { PortalHost } from "@lotics/ui/portal";
|
|
@@ -9,6 +9,19 @@ import { Text } from "@lotics/ui/text";
|
|
|
9
9
|
import { useOverlayScope } from "@lotics/ui/overlay_scope";
|
|
10
10
|
import { useLoticsLocale } from "@lotics/ui/locale";
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The panel's inset — the ONE left edge the header, `DrawerScrollArea` and the footer
|
|
14
|
+
* all sit on.
|
|
15
|
+
*
|
|
16
|
+
* `DrawerScrollArea` applies it, so an ordinary body never names this. It is
|
|
17
|
+
* exported for the surfaces that cannot use it: a MIXED body (a full-bleed ruled
|
|
18
|
+
* bar over guttered content) and a PINNED one (a heading or summary held above a
|
|
19
|
+
* scrolling list, which needs the gutter on both parts). A hardcoded `20` there
|
|
20
|
+
* silently stops matching the day this one moves — same reason `AVATAR_PX` is
|
|
21
|
+
* exported.
|
|
22
|
+
*/
|
|
23
|
+
export const DRAWER_GUTTER = 20;
|
|
24
|
+
|
|
12
25
|
export interface DrawerProps {
|
|
13
26
|
open: boolean;
|
|
14
27
|
onOpenChange: (open: boolean) => void;
|
|
@@ -32,9 +45,12 @@ export interface DrawerProps {
|
|
|
32
45
|
/** Position caption between the chevrons ("3/24"). */
|
|
33
46
|
position?: string;
|
|
34
47
|
/**
|
|
35
|
-
* Drawer body
|
|
36
|
-
*
|
|
37
|
-
*
|
|
48
|
+
* Drawer body — a BARE flex column filling the panel below the header, so a
|
|
49
|
+
* full-bleed child reaches the panel edge.
|
|
50
|
+
*
|
|
51
|
+
* Wrap content in `DrawerScrollArea` to get the panel's gutter; that is where
|
|
52
|
+
* the padding lives, and hand-rolling it is how a body ends up on a different
|
|
53
|
+
* left edge from the title above it. A `DrawerFooter` goes last, after it.
|
|
38
54
|
*/
|
|
39
55
|
children: ReactNode;
|
|
40
56
|
testID?: string;
|
|
@@ -134,6 +150,41 @@ export function Drawer(props: DrawerProps) {
|
|
|
134
150
|
);
|
|
135
151
|
}
|
|
136
152
|
|
|
153
|
+
export interface DrawerScrollAreaProps {
|
|
154
|
+
children: ReactNode;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The scrolling, guttered content region of a `Drawer` — the exact counterpart of
|
|
159
|
+
* `DialogScrollArea`, and named to match it.
|
|
160
|
+
*
|
|
161
|
+
* It exists because the gutter is a CONTRACT, not a convenience: the content has
|
|
162
|
+
* to sit on the same left edge as the header's title and the footer's actions, and
|
|
163
|
+
* the panel is the only thing that knows that number. Left to callers, it was
|
|
164
|
+
* answered three different ways across the templates and the live apps — `24` six
|
|
165
|
+
* times (4px past the drawer's own title), `20` three times, and in one shipped
|
|
166
|
+
* app not at all, which put a comment thread flush against the panel edge while
|
|
167
|
+
* its title was inset 20.
|
|
168
|
+
*
|
|
169
|
+
* It ALWAYS scrolls, and there is deliberately no flag to stop it. A padded box
|
|
170
|
+
* holding a `flex:1` scroller insets that scroller's VIEWPORT — measured, a 400px
|
|
171
|
+
* panel ends its scroll at 380 — so the list clips short of the panel with dead
|
|
172
|
+
* space beneath it. A surface that genuinely needs a pinned region above a
|
|
173
|
+
* scrolling one is not this component: it goes in the `Drawer`'s bare slot and
|
|
174
|
+
* puts `DRAWER_GUTTER` on the pinned part's padding AND on its scroller's
|
|
175
|
+
* `contentContainerStyle`, which is the only arrangement where content still
|
|
176
|
+
* reaches the panel edge.
|
|
177
|
+
*
|
|
178
|
+
* A body that must reach the panel edge — a full-bleed band, a whole record screen
|
|
179
|
+
* carrying its own gutters — likewise does NOT wrap in this, which is why the
|
|
180
|
+
* `Drawer` leaves its slot bare. That is also how `Modal` and `Dialog` work; a
|
|
181
|
+
* `Drawer` that padded its own slot would be the one overlay of the three that
|
|
182
|
+
* does, and would force every full-bleed child to cancel it with a negative margin.
|
|
183
|
+
*/
|
|
184
|
+
export function DrawerScrollArea(props: DrawerScrollAreaProps) {
|
|
185
|
+
return <ScrollView style={styles.body} contentContainerStyle={styles.bodyContent}>{props.children}</ScrollView>;
|
|
186
|
+
}
|
|
187
|
+
|
|
137
188
|
export interface DrawerFooterProps {
|
|
138
189
|
/** The action(s) — typically right-aligned `Button`s. Prepend a
|
|
139
190
|
* `<Text style={{ flex: 1 }}>` hint to push them right with a summary on the left. */
|
|
@@ -155,7 +206,7 @@ const styles = StyleSheet.create({
|
|
|
155
206
|
footer: {
|
|
156
207
|
borderTopWidth: 1,
|
|
157
208
|
borderTopColor: colors.border,
|
|
158
|
-
paddingHorizontal:
|
|
209
|
+
paddingHorizontal: DRAWER_GUTTER,
|
|
159
210
|
paddingVertical: 14,
|
|
160
211
|
flexDirection: "row",
|
|
161
212
|
alignItems: "center",
|
|
@@ -183,7 +234,10 @@ const styles = StyleSheet.create({
|
|
|
183
234
|
flexDirection: "row",
|
|
184
235
|
alignItems: "center",
|
|
185
236
|
gap: 8,
|
|
186
|
-
paddingLeft:
|
|
237
|
+
paddingLeft: DRAWER_GUTTER,
|
|
238
|
+
// Short of the gutter by design: the close IconButton carries its own
|
|
239
|
+
// padding, so a full gutter here would push its GLYPH inside the edge the
|
|
240
|
+
// title sits on.
|
|
187
241
|
paddingRight: 12,
|
|
188
242
|
paddingTop: 16,
|
|
189
243
|
paddingBottom: 8,
|
|
@@ -197,4 +251,7 @@ const styles = StyleSheet.create({
|
|
|
197
251
|
body: {
|
|
198
252
|
flex: 1,
|
|
199
253
|
},
|
|
254
|
+
bodyContent: {
|
|
255
|
+
padding: DRAWER_GUTTER,
|
|
256
|
+
},
|
|
200
257
|
});
|