@lotics/ui 31.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 CHANGED
@@ -4,6 +4,68 @@ 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
+
48
+ ## 32.0.0 — `Table`'s `align` prop is gone; rows are centred
49
+
50
+ `align?: "center" | "top"` and the `TableAlign` type are removed. `center` was the
51
+ default and nothing shipped on `top`, so a caller passing nothing is unaffected.
52
+
53
+ **Why it went, so nobody rebuilds it.** It was added to close a measured 12px spread
54
+ between the first lines of cells carrying different numbers of lines. That number was
55
+ never compared against a baseline: the kit's own canonical register (`tpl_item_list`)
56
+ ships a **15px** spread, centred, and reads correctly. The case the prop existed for
57
+ is the case the reference example already handles.
58
+
59
+ It was also wrong as built. `top` moved the CELLS to a shared top edge but left the
60
+ row's ordinal, leading checkbox and trailing menu centred — so on a tall row a row's
61
+ number sat ~24px below the name it belonged to, and the register read as scatter.
62
+ Aligning the chrome too is possible, but that yields a correct prop with no consumers.
63
+
64
+ **If a register looks misaligned, the cell is too tall for a columnar row.** Centring
65
+ works because register cells are bounded; when they cannot be, the row is meant to
66
+ stack — `computeTableFit` already drops to stacked below the register floor. Reach for
67
+ that, or move the content to a record page. Do not reintroduce a cross-axis knob.
68
+
7
69
  ## 31.0.0 — avatar `size` is a rung, not a pixel count
8
70
 
9
71
  `Avatar`, `MemberChip` and `GroupAvatar` no longer take `size?: number`. They take
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 register
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
@@ -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` (a `Drawer`'s scroll body
14
- uses 24). Cards separate from the canvas by their own border + shadow, not by a tinted
15
- background.
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
 
@@ -402,17 +411,6 @@ running total. `tpl_item_list` is the reference. Paginate OUTSIDE (slice + `Pagi
402
411
  Right-hand columns align only if every trailing element is FIXED-width — give each trailing action
403
412
  a fixed `width`, so amount/status columns don't jitter.
404
413
 
405
- **Cells whose LINE COUNTS differ need `align="top"`.** Cells are centred by default, which is
406
- right while every cell is one line. The moment they are not — an identity cell carrying a
407
- supporting line (2), a status cell with a bar over a label (3), a member chip (1) — centring four
408
- different heights puts their four FIRST lines at four different heights, and the eye steps down
409
- and back up reading across one row. Measured on a live register: a 12px spread, closed to 2px by
410
- `align="top"`, which makes cells share a top edge so first lines land on one baseline. It governs
411
- the CELLS only; the ordinal, the leading checkbox and the trailing ⋯ stay centred, being one line
412
- by construction. Note a cell whose first element is a BAR or an image still starts its text below
413
- that element — top-aligning the cell does not top-align the text inside it, so put the line you
414
- want on the baseline first.
415
-
416
414
  ## Row actions are always-visible siblings — never hover-revealed, never nested
417
415
 
418
416
  A pressable row = a role-less surface (the hover/press wash covers the whole row) + an accessible
@@ -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
- <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 20 }}>
209
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
210
- <Avatar name={nv.ten} size="lg" />
211
- <View style={{ flex: 1, gap: 2 }}>
212
- <Text size="sm" color="muted">{nv.chucVu}</Text>
213
- </View>
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
- <Text size="sm" weight="medium" tabular>{nv.tongTuan}</Text>
215
+ <Badge label={badgeLabel(nv)} color={tt.color} />
228
216
  </View>
229
217
  <View style={{ gap: 6 }}>
230
- {nv.tuan.map((ngay, i) => (
231
- <View key={NGAY_TUAN[i]} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 24 }}>
232
- <Text size="xs" color="muted" tabular style={{ width: 24 }}>{NGAY_TUAN[i]}</Text>
233
- <DayCell ngay={ngay} />
234
- <Text size="sm">{NGAY_CONG[ngay].label}</Text>
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
- </ScrollView>
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
- <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 10 }}>
158
- {rows.map(([label, value], i) => (
159
- <View key={label} style={{ gap: 10 }}>
160
- {i > 0 ? <Divider /> : null}
161
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 24 }}>
162
- <Text size="sm" color="muted" style={{ width: 104 }}>{label}</Text>
163
- {value}
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
- </View>
166
- ))}
167
- </ScrollView>
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
- <View key={rec.id} style={{ flex: 1, padding: 24, gap: 12 }}>
534
- <Badge label={recAlert.label} color={recAlert.tone === "warning" ? "amber" : "red"} />
535
- <View style={{ gap: 10, paddingTop: 4 }}>
536
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
537
- <Text size="sm" color="muted" style={{ flex: 1 }}>Customer</Text>
538
- <Text size="sm" weight="medium">{rec.name}</Text>
539
- </View>
540
- <Divider />
541
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
542
- <Text size="sm" color="muted" style={{ flex: 1 }}>Amount / due</Text>
543
- <Text size="sm" tabular>{rec.value}</Text>
544
- </View>
545
- <Divider />
546
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
547
- <Text size="sm" color="muted" style={{ flex: 1 }}>Status</Text>
548
- <Text size="sm" color={rec.noteTone} tabular>{rec.note}</Text>
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
- </View>
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
- <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 16 }}>
354
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
355
- <Badge label={TRANG_THAI[r.trangThai].label} color={TRANG_THAI[r.trangThai].color} />
356
- <Text size="sm" color="muted">{`Linked record: ${r.khach}`}</Text>
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
- <DetailTable labelWidth={120}>
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>
@@ -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
- <View key={open.code} style={{ flex: 1, padding: 24, gap: 12 }}>
234
- <Badge variant="dot" label={STATUS[open.status].label} color={STATUS[open.status].color} />
235
- <View style={{ gap: 6, paddingTop: 4 }}>
236
- <KVRow label="Item"><Text size="sm">{open.name}</Text></KVRow>
237
- <Divider />
238
- <KVRow label="Warehouse"><Text size="sm">{whOf(open.warehouse).label}</Text></KVRow>
239
- <Divider />
240
- <KVRow label="Category"><Text size="sm">{catOf(open.category).label}</Text></KVRow>
241
- <Divider />
242
- <KVRow label="Units on hand"><Text size="sm" tabular>{count(open.units)}</Text></KVRow>
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
- </View>
246
+ </DrawerScrollArea>
245
247
  </Drawer>
246
248
  ) : null}
247
249
  </ScrollView>
@@ -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 ? 20 : 28) - 10, paddingVertical: 6, flexDirection: "row", alignItems: "center", gap: 8 }}>
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 ? 20 : 28, paddingBottom: 96 }}>
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
@@ -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
- <View key={openLane.id} style={{ flex: 1, padding: 24, gap: 16 }}>
259
- <View style={{ gap: 6 }}>
260
- <KVRow label="Route"><Text size="sm">{`${open.site.label} ${openLane.dest}`}</Text></KVRow>
261
- <Divider />
262
- <KVRow label="Volume this month"><Text size="sm" tabular>{openLane.volume.toLocaleString("en-US")}</Text></KVRow>
263
- <Divider />
264
- <KVRow label="On-time">
265
- <Text size="sm" tabular color={openLane.ontime < ONTIME_TARGET ? "danger" : "default"}>
266
- {`${openLane.ontime}% (target ${ONTIME_TARGET}%)`}
267
- </Text>
268
- </KVRow>
269
- <Divider />
270
- <KVRow label="Margin"><Text size="sm" tabular>{`${openLane.margin}%`}</Text></KVRow>
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
- <View style={{ gap: 8 }}>
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,
@@ -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
- <ScrollView key={openSlot} style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 16 }}>
332
- <ProgressBar
333
- title="Coverage"
334
- value={openAssigned.length}
335
- max={openRequired}
336
- format="fraction"
337
- color={solid("amber")}
338
- completeColor={solid("emerald")}
339
- />
340
- <View style={{ gap: 8 }}>
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">Asked for this shift</Text>
361
- {openSignups.map((su) => {
362
- const hoursAfter = hoursOf(su.member) + SHIFT_HOURS;
363
- return (
364
- <View key={su.id} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 36 }}>
365
- <Avatar name={su.member} size="md" />
366
- <Text size="sm" style={{ flex: 1 }}>{su.member}</Text>
367
- <Text size="xs" color={hoursAfter > WEEK_LIMIT ? "danger" : "muted"} tabular>{`→ ${hoursAfter}h`}</Text>
368
- {hoursAfter > WEEK_LIMIT ? (
369
- <OvertimeGate label={su.member} hoursAfter={hoursAfter} onConfirm={() => accept(su)} />
370
- ) : (
371
- <Button title="Accept" color="secondary" onPress={() => accept(su)} />
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
- ) : null}
378
- <Divider />
379
- <KVRow label="Required">
380
- <Text size="sm" tabular>{`${openRequired} members`}</Text>
381
- </KVRow>
382
- </ScrollView>
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 */}
@@ -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
- <View key={open.id} style={{ flex: 1, padding: 24, gap: 12 }}>
362
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
363
- <Badge label={statusOf(open.status).label} color={statusOf(open.status).color} />
364
- <Badge label={typeOf(open.type).label} />
365
- </View>
366
- <View style={{ gap: 6, paddingTop: 4 }}>
367
- <KVRow label="Depot"><Text size="sm">{depotOf(open.depot).label}</Text></KVRow>
368
- <Divider />
369
- <KVRow label="Last movement"><Text size="sm" tabular>{open.lastMove}</Text></KVRow>
370
- <Divider />
371
- <KVRow label="Age"><Text size="sm" tabular>{`${open.ageYears} years`}</Text></KVRow>
372
- <Divider />
373
- <KVRow label="Book value"><Text size="sm" tabular>{formatMoney(open.value)}</Text></KVRow>
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
- </View>
377
+ </DrawerScrollArea>
376
378
  <View
377
379
  style={{
378
380
  borderTopWidth: 1,
@@ -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
- <View key={unit.key} style={{ flex: 1, padding: 24, gap: 16 }}>
310
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
311
- <Badge label={stateOf(unit.state).label} color={stateOf(unit.state).color} />
312
- <Badge label={siteOf(unit.site).label} />
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
- {unit.message ? (
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "31.0.0",
3
+ "version": "33.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
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. Fills the panel below the header as a plain flex column — lay
36
- * out your own scroll area and pinned footer (e.g. a thread that scrolls above
37
- * a fixed composer).
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: 20,
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: 20,
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
  });
package/src/table.tsx CHANGED
@@ -35,29 +35,6 @@ export interface TableColumn extends TableFitColumn {
35
35
  sortable?: boolean;
36
36
  }
37
37
 
38
- /**
39
- * How cells sit on the row's CROSS axis.
40
- *
41
- * `center` (the default) — every cell centred in the row. Right for a register
42
- * whose cells are one line each, which is most of them, and the reason this is
43
- * not being changed underneath anyone.
44
- *
45
- * `top` — cells share a top edge, so the FIRST LINE of every cell lands on one
46
- * baseline. Reach for it the moment cells carry different numbers of lines: an
47
- * identity cell with a supporting line (2), a status cell with a bar over a
48
- * label (3), a member chip (1) and a wrapped cell (2) centred together put four
49
- * first lines at four different heights, and the eye steps down and back up
50
- * across a single row. Measured on a live register: a 12px spread.
51
- *
52
- * It governs the CELLS only. The ordinal, the leading checkbox and the trailing
53
- * ⋯ stay centred in the row — they are chrome, they are one line by
54
- * construction, and pinning them to the top of a tall row would strand them.
55
- *
56
- * It applies to EVERY column row — pressable and read-only alike — because a
57
- * table that mixed the two would otherwise align its own rows differently from
58
- * each other. A STACKED row (tier 3) is unaffected: it has no columns to line up.
59
- */
60
- export type TableAlign = "center" | "top";
61
38
 
62
39
  interface TableCtx {
63
40
  columns: TableColumn[];
@@ -67,7 +44,6 @@ interface TableCtx {
67
44
  trailing: number;
68
45
  visibleKeys: ReadonlySet<string>;
69
46
  stacked: boolean;
70
- align: TableAlign;
71
47
  }
72
48
  const TableContext = createContext<TableCtx | null>(null);
73
49
 
@@ -148,9 +124,6 @@ export interface TableProps {
148
124
  count?: number;
149
125
  /** Reserve a trailing gutter (px) for rows that render a `trailing` slot (a ⋯ / button). */
150
126
  trailing?: number;
151
- /** How cells sit on the row's cross axis — see {@link TableAlign}. Default
152
- * `center`; pass `top` when cells carry different numbers of lines. */
153
- align?: TableAlign;
154
127
  /** The `TableRow`s. */
155
128
  children: ReactNode;
156
129
  }
@@ -170,7 +143,7 @@ export interface TableProps {
170
143
  * keeps its current order). See `computeTableFit`.
171
144
  */
172
145
  export function Table(props: TableProps) {
173
- const { columns, sort, onSort, sortLabels, selectAll, leading = 0, count, trailing = 0, align = "center", children } = props;
146
+ const { columns, sort, onSort, sortLabels, selectAll, leading = 0, count, trailing = 0, children } = props;
174
147
  const ordinal = count != null ? ORDINAL_W : 0;
175
148
  const rows = Children.toArray(children).filter(isValidElement);
176
149
 
@@ -188,7 +161,7 @@ export function Table(props: TableProps) {
188
161
  const visibleColumns = columns.filter((c) => fit.visibleKeys.has(c.key));
189
162
 
190
163
  return (
191
- <TableContext.Provider value={{ columns, leading, ordinal, trailing, visibleKeys: fit.visibleKeys, stacked: fit.stacked, align }}>
164
+ <TableContext.Provider value={{ columns, leading, ordinal, trailing, visibleKeys: fit.visibleKeys, stacked: fit.stacked }}>
192
165
  {/* ONE layout node: without this wrapper the header band + body land as two
193
166
  direct flex children of the app's container, and a parent column `gap`
194
167
  (the standard section spacing) opens a hole between the header and rows. */}
@@ -341,10 +314,8 @@ export function TableRow(props: TableRowProps) {
341
314
  .map(({ cell, column }, i) => cloneElement(cell, { _column: column, _stacked: ctx.stacked, _primary: i === 0 }));
342
315
 
343
316
  // ONE style for the column row, shared by the pressable and read-only paths
344
- // below. They rendered near-identical Views and `align` was added to only one,
345
- // so a table mixing the two aligned its own rows differently — computing it
346
- // once is what makes that divergence impossible rather than merely fixed.
347
- const cellsStyle = [styles.cells, { minHeight }, ctx.align === "top" ? styles.cellsTop : null];
317
+ // below, which render near-identical Views.
318
+ const cellsStyle = [styles.cells, { minHeight }];
348
319
 
349
320
  const body = ctx.stacked ? (
350
321
  <View style={styles.stackedBody}>
@@ -529,11 +500,6 @@ const styles = StyleSheet.create({
529
500
  gap: COLUMN_GAP,
530
501
  zIndex: 1,
531
502
  },
532
- // `align="top"` — see `TableAlign`. Overrides the centring above so unequal
533
- // cells share a first-line baseline; the row's own chrome stays centred.
534
- cellsTop: {
535
- alignItems: "flex-start",
536
- },
537
503
  // Leading/trailing gutters carry their own controls — lifted above the door
538
504
  // like the cells.
539
505
  slot: {