@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 +7 -4
- package/docs/templates.md +20 -0
- package/examples/tpl_record.tsx +53 -12
- package/package.json +1 -1
- package/src/back_button.tsx +51 -13
- package/src/page_header.tsx +19 -7
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
|
|
396
|
-
|
|
397
|
-
|
|
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
|
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
635
|
-
{pageWidth == null ? null :
|
|
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={{
|
|
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
package/src/back_button.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
}
|
package/src/page_header.tsx
CHANGED
|
@@ -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
|
-
|
|
26
|
-
{title}
|
|
27
|
-
</Text>
|
|
41
|
+
{titleRow}
|
|
28
42
|
</>
|
|
29
43
|
) : (
|
|
30
|
-
|
|
31
|
-
{title}
|
|
32
|
-
</Text>
|
|
44
|
+
titleRow
|
|
33
45
|
)}
|
|
34
46
|
{!!description && (
|
|
35
47
|
<Text color="zinc-500">{description}</Text>
|