@lotics/ui 11.8.4 → 11.8.9

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/docs/catalog.md CHANGED
@@ -360,7 +360,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
360
360
  useSeparator` inserts and what menus/popovers put between option groups.
361
361
  - **`container`** — `Container`: centers content at a max width (`ContainerSize` sm|md|lg,
362
362
  `CONTAINER_SIZES`).
363
- - **`page_header`** — `PageHeader`: the page's title band.
363
+ - **`page_header`** — `PageHeader`: the page's title band; `actions` puts page-level CTAs on the title row (right-aligned), while `left`/`right` form a separate nav row above.
364
364
  - **`page_content`** — `PageContent` + `PAGE_SIZES`: the page's padded, width-capped content
365
365
  region.
366
366
  - **`accordion`** — `Accordion` + `AccordionHeader`/`AccordionTitle`/`AccordionMeta`:
@@ -392,9 +392,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
392
392
  name), `color` = emphasis/risk.
393
393
  - **`icon_button`** — `IconButton`: the icon-only circular action (see
394
394
  [Actions](#actions)).
395
- - **`back_button`** — `BackButton`: the chevron-left `IconButton` (lg, secondary) heading a
396
- screen/panel: `onPress` + `accessibilityLabel` (defaults to the locale's `nav.back`
397
- "Back" / "Quay lại"); the one go-back glyph don't hand-roll it.
395
+ - **`back_button`** — `BackButton`: the one go-back control don't hand-roll it (an
396
+ `arrow-left` `MenuButton` reads as another nav ITEM, not an exit). `onPress` +
397
+ `accessibilityLabel` (defaults to `label`, else the locale's `nav.back`"Back" /
398
+ "Quay lại"). Bare, it's the chevron-left `IconButton` (lg, secondary) heading a
399
+ screen/panel; pass **`label`** to name where back GOES ("Danh sách") and it becomes ONE
400
+ pressable over glyph + words — never an icon button beside unclickable prose.
398
401
  - **`link`** — `Link`: the EXTERNAL hyperlink — fixed underline+blue + `role="link"`;
399
402
  `onPress` only (the consumer wires the opener).
400
403
  - **`text_link`** — `TextLink`: underlined text that's optionally an `onPress` action or an
package/docs/templates.md CHANGED
@@ -244,6 +244,26 @@ Top → bottom:
244
244
  - A LEFT OUTLINE RAIL (`MenuButton` + `useSectionNav`) jumps between the sections and
245
245
  scroll-spies the active one; on narrow containers it becomes the pinned current-section
246
246
  bar opening a section-picker `Modal`.
247
+ - **The page layout is the DOCS layout — the reading column is CENTRED IN THE VIEWPORT, and
248
+ the rail sits in its left gutter.** The `ScrollView` is FULL-WIDTH — **never cap it at
249
+ `CONTENT_MAX`, or the scroll container becomes the column and the gutters go wheel-dead**
250
+ (measured: a 720px scroller in a 1440px window leaves half the page ignoring the wheel).
251
+ Inside it sits one centred row (`width: "100%"`,
252
+ `maxWidth: GUTTER * 2 + CONTENT_MAX`, `alignSelf: "center"`, `flexDirection: "row"`) of
253
+ three parts: a gutter RESERVED for the rail (`width: GUTTER` — the rail's own `RAIL_W`
254
+ PLUS `RAIL_GAP`, the docs' 3rem of breathing room, so the rail never butts against the
255
+ column), the reading column (`flex: 1, minWidth: 0`), and an EMPTY right gutter of the
256
+ same `GUTTER` that BALANCES the rail — without it the column sits half a rail right of centre. (The public docs pages get
257
+ this balance for free: their right-hand TOC is the counterweight. A record surface has no
258
+ TOC, so the gutter is simply reserved.) The rail is NOT window chrome: no full-height
259
+ border, not glued to the window edge — it FLOATS over its reserved gutter
260
+ (`position: "absolute"`, `left` mirroring the row's centring maths), so it stays put while
261
+ the record scrolls **without** web-only `position: sticky` (which RN does not type —
262
+ `position` is `"absolute" | "relative" | "static"`) and without stealing the page's
263
+ scrollable width. The rail earns its place only while the centred column still has room
264
+ beside it (`pageWidth >= GUTTER * 2 + 560`); below that it collapses to the pinned bar.
265
+ Sizing the column is the SHELL's job — inner blocks are plain `width: "100%"`, never a
266
+ second `maxWidth` + `alignSelf: "center"`.
247
267
 
248
268
  ### `tpl_tasks` — the quick list
249
269
 
@@ -367,6 +367,17 @@ function InvoiceBand({
367
367
  );
368
368
  }
369
369
 
370
+ /** The section rail's width — and, mirrored as an empty right gutter, what
371
+ * keeps the reading column centred in the viewport. */
372
+ const RAIL_W = 208;
373
+ /** Breathing room between the rail and the reading column (the docs' 3rem). */
374
+ const RAIL_GAP = 48;
375
+ /** The reserved gutter: the rail plus its gap. Mirrored empty on the right, this
376
+ * is what centres the column. */
377
+ const GUTTER = RAIL_W + RAIL_GAP;
378
+ /** The reading column's ceiling. */
379
+ const CONTENT_MAX = 720;
380
+
370
381
  export function TplRecord() {
371
382
  const id = useRef(1);
372
383
  const nextId = (prefix: string) => `${prefix}_${(id.current += 1)}`;
@@ -626,21 +637,25 @@ export function TplRecord() {
626
637
  // it opens a full-page section picker (Escape / the close control dismiss).
627
638
  const [sectionsOpen, setSectionsOpen] = useState(false);
628
639
  const [pageWidth, setPageWidth] = useState<number | null>(null);
629
- const wide = pageWidth != null && pageWidth >= 760;
640
+ // The rail lives in the page's LEFT GUTTER and an equal spacer balances it on
641
+ // the right, so the reading column lands dead-centre in the viewport (the docs
642
+ // layout, where the right-hand TOC is the balancer). The rail only earns its
643
+ // place once the centred column still has room beside it: 2 × rail + a
644
+ // readable column — below that it collapses to the pinned bar.
645
+ const wide = pageWidth != null && pageWidth >= GUTTER * 2 + 560;
646
+ // The rail floats over its RESERVED gutter rather than sitting in the scroll
647
+ // row, so the ScrollView keeps the FULL page width — wheel anywhere and the
648
+ // record scrolls. (Capping the ScrollView at the column's width instead makes
649
+ // the gutters dead zones.) Mirrors the flex maths the row does below: the
650
+ // group is centred inside the 28px content padding.
651
+ const groupW = pageWidth == null ? 0 : Math.min(pageWidth - 28 * 2, GUTTER * 2 + CONTENT_MAX);
652
+ const railLeft = pageWidth == null ? 0 : 28 + Math.max(0, (pageWidth - 28 * 2 - groupW) / 2);
630
653
 
631
654
  return (
632
655
  // Measure-then-reveal: hidden until the width is known, so the rail/bar
633
656
  // choice never visibly reshuffles the page on first paint.
634
- <View style={{ flex: 1, flexDirection: wide ? "row" : "column", backgroundColor: colors.white, opacity: pageWidth == null ? 0 : 1 }} onLayout={(e) => setPageWidth(e.nativeEvent.layout.width)}>
635
- {pageWidth == null ? null : wide ? (
636
- <View style={{ width: 208, borderRightWidth: 1, borderRightColor: colors.zinc[200], padding: 8 }}>
637
- <View style={{ gap: 2 }}>
638
- {SECTIONS.map((sec) => (
639
- <MenuButton key={sec.key} icon={sec.icon} title={sec.label} selected={nav.activeKey === sec.key} onPress={() => nav.jumpTo(sec.key)} />
640
- ))}
641
- </View>
642
- </View>
643
- ) : (
657
+ <View style={{ flex: 1, backgroundColor: colors.white, opacity: pageWidth == null ? 0 : 1 }} onLayout={(e) => setPageWidth(e.nativeEvent.layout.width)}>
658
+ {pageWidth == null || wide ? null : (
644
659
  // Narrow: a pinned bar names the CURRENT section (the spy keeps it
645
660
  // honest while scrolling); tapping opens the full-page section picker —
646
661
  // no tab strip the thumb has to scroll.
@@ -653,11 +668,19 @@ export function TplRecord() {
653
668
  />
654
669
  </View>
655
670
  )}
671
+ <View style={{ flex: 1 }}>
672
+ {/* FULL-WIDTH scroller: the whole page scrolls, wherever the pointer is. */}
656
673
  <ScrollView ref={nav.scrollRef} onScroll={nav.onScroll} scrollEventThrottle={16} style={{ flex: 1 }} contentContainerStyle={{ padding: 28, paddingBottom: 96 }}>
674
+ {/* The centred group: a gutter RESERVED for the rail, the reading column,
675
+ and an equal empty gutter that balances it — so the column's centre is
676
+ the window's centre. Both gutters live INSIDE the scroller, so they
677
+ scroll like everything else. */}
678
+ <View style={{ width: "100%", maxWidth: wide ? GUTTER * 2 + CONTENT_MAX : CONTENT_MAX, alignSelf: "center", flexDirection: "row" }}>
679
+ {wide ? <View style={{ width: GUTTER, flexShrink: 0 }} /> : null}
657
680
  {/* the page column is a SectionStack — it owns the 56px beat + the
658
681
  hairline BETWEEN top-level blocks (a conditional block that renders
659
682
  null never leaves a stray divider) */}
660
- <SectionStack style={{ width: "100%", maxWidth: 720, alignSelf: "center" }}>
683
+ <SectionStack style={{ flex: 1, minWidth: 0 }}>
661
684
  {/* header band — app bar + summary + key facts read as ONE block */}
662
685
  <View style={{ gap: 32 }}>
663
686
  {/* app bar — CREATE is one click; the surface is where everything is refined */}
@@ -1342,7 +1365,25 @@ export function TplRecord() {
1342
1365
  <Button title="Issue" color="primary" onPress={() => confirmIssue && issue(confirmIssue)} />
1343
1366
  </DialogFooter>
1344
1367
  </Dialog>
1368
+ {/* Balances the rail so the reading column lands dead-centre. In the docs
1369
+ layout the right-hand TOC does this job; a record surface has no TOC,
1370
+ so the gutter is simply reserved. */}
1371
+ {wide ? <View style={{ width: GUTTER, flexShrink: 0 }} /> : null}
1372
+ </View>
1345
1373
  </ScrollView>
1374
+ {/* The rail floats over its reserved gutter — pinned while the record
1375
+ scrolls, and out of the scroll row so the page stays wheel-scrollable
1376
+ edge to edge. */}
1377
+ {pageWidth == null || !wide ? null : (
1378
+ <View style={{ position: "absolute", top: 28, left: railLeft, width: RAIL_W }}>
1379
+ <View style={{ gap: 2 }}>
1380
+ {SECTIONS.map((sec) => (
1381
+ <MenuButton key={sec.key} icon={sec.icon} title={sec.label} selected={nav.activeKey === sec.key} onPress={() => nav.jumpTo(sec.key)} />
1382
+ ))}
1383
+ </View>
1384
+ </View>
1385
+ )}
1386
+ </View>
1346
1387
  </View>
1347
1388
  );
1348
1389
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "11.8.4",
3
+ "version": "11.8.9",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -1,26 +1,64 @@
1
1
  import { IconButton } from "./icon_button";
2
- import { View } from "react-native";
2
+ import { Pressable, View } from "react-native";
3
+ import { Icon } from "./icon";
4
+ import { Text } from "./text";
5
+ import { colors } from "./colors";
3
6
  import { useLoticsLocale } from "./locale";
4
7
 
5
8
  interface BackButtonProps {
6
9
  onPress: () => void;
7
- /** Accessible name. Defaults to the locale's `nav.back` ("Back" / "Quay lại"). */
10
+ /** Names where back GOES ("Orders", "Danh sách"). Renders beside the glyph as
11
+ * part of the SAME control — a label you can't press is a lie. Omit for the
12
+ * bare glyph. */
13
+ label?: string;
14
+ /** Accessible name. Defaults to `label`, else the locale's `nav.back`
15
+ * ("Back" / "Quay lại"). */
8
16
  accessibilityLabel?: string;
9
17
  }
10
18
 
11
19
  export function BackButton(props: BackButtonProps) {
12
20
  const locale = useLoticsLocale();
13
- const { onPress, accessibilityLabel = locale.nav.back } = props;
21
+ const { onPress, label, accessibilityLabel = label ?? locale.nav.back } = props;
22
+ // Bare glyph — the circular icon IS the control.
23
+ if (label === undefined) {
24
+ return (
25
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
26
+ <IconButton
27
+ icon="chevron-left"
28
+ size="lg"
29
+ color="secondary"
30
+ accessibilityLabel={accessibilityLabel}
31
+ onPress={onPress}
32
+ style={{ alignSelf: "flex-start" }}
33
+ />
34
+ </View>
35
+ );
36
+ }
37
+ // Labelled — ONE pressable over glyph + label, so the words are a target too
38
+ // (an IconButton beside a Text would be a control next to unclickable prose).
14
39
  return (
15
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
16
- <IconButton
17
- icon="chevron-left"
18
- size="lg"
19
- color="secondary"
20
- accessibilityLabel={accessibilityLabel}
21
- onPress={onPress}
22
- style={{ alignSelf: "flex-start" }}
23
- />
24
- </View>
40
+ <Pressable
41
+ accessibilityRole="button"
42
+ accessibilityLabel={accessibilityLabel}
43
+ onPress={onPress}
44
+ style={({ pressed, hovered }) => [
45
+ {
46
+ flexDirection: "row",
47
+ alignItems: "center",
48
+ gap: 6,
49
+ alignSelf: "flex-start",
50
+ paddingVertical: 6,
51
+ paddingLeft: 6,
52
+ paddingRight: 10,
53
+ borderRadius: 8,
54
+ },
55
+ (hovered || pressed) && { backgroundColor: colors.zinc[100] },
56
+ ]}
57
+ >
58
+ <Icon name="chevron-left" size={16} color={colors.zinc[500]} />
59
+ <Text size="sm" weight="medium" color="muted" numberOfLines={1}>
60
+ {label}
61
+ </Text>
62
+ </Pressable>
25
63
  );
26
64
  }
@@ -7,13 +7,29 @@ interface PageHeaderProps {
7
7
  description?: string | null;
8
8
  left?: ReactNode;
9
9
  right?: ReactNode;
10
+ /** Page-level actions (CTAs), rendered on the title row, right-aligned —
11
+ * distinct from `left`/`right`, which form a separate nav row above. */
12
+ actions?: ReactNode;
10
13
  }
11
14
 
12
15
  export function PageHeader(props: PageHeaderProps) {
13
- const { title, description, left, right } = props;
16
+ const { title, description, left, right, actions } = props;
14
17
 
15
18
  const hasNav = !!left || !!right;
16
19
 
20
+ const titleRow = actions ? (
21
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
22
+ <Text size="xxl" weight="semibold">
23
+ {title}
24
+ </Text>
25
+ {actions}
26
+ </View>
27
+ ) : (
28
+ <Text size="xxl" weight="semibold">
29
+ {title}
30
+ </Text>
31
+ );
32
+
17
33
  return (
18
34
  <View style={{ paddingBottom: 16 }}>
19
35
  {hasNav ? (
@@ -22,14 +38,10 @@ export function PageHeader(props: PageHeaderProps) {
22
38
  {left}
23
39
  {right}
24
40
  </View>
25
- <Text size="xxl" weight="semibold">
26
- {title}
27
- </Text>
41
+ {titleRow}
28
42
  </>
29
43
  ) : (
30
- <Text size="xxl" weight="semibold">
31
- {title}
32
- </Text>
44
+ titleRow
33
45
  )}
34
46
  {!!description && (
35
47
  <Text color="zinc-500">{description}</Text>