@godxjp/ui 19.3.1 → 19.4.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.
Files changed (116) hide show
  1. package/dist/components/data-display/badge.d.ts +9 -1
  2. package/dist/components/data-display/badge.js +3 -2
  3. package/dist/components/data-display/index.d.ts +2 -0
  4. package/dist/components/data-display/index.js +2 -0
  5. package/dist/components/data-display/popover.d.ts +13 -1
  6. package/dist/components/data-display/popover.js +3 -1
  7. package/dist/components/data-display/table.d.ts +19 -2
  8. package/dist/components/data-display/table.js +4 -1
  9. package/dist/components/data-display/timeline-grid.d.ts +18 -0
  10. package/dist/components/data-display/timeline-grid.js +222 -0
  11. package/dist/components/data-entry/cascader.js +4 -6
  12. package/dist/components/data-entry/checkbox.js +1 -1
  13. package/dist/components/data-entry/search-select.js +5 -7
  14. package/dist/components/data-entry/select.js +32 -28
  15. package/dist/components/data-entry/tree-select.js +4 -6
  16. package/dist/components/general/inert-background.d.ts +5 -0
  17. package/dist/components/general/inert-background.js +48 -0
  18. package/dist/components/layout/auth-shell.d.ts +4 -2
  19. package/dist/components/layout/auth-shell.js +7 -1
  20. package/dist/components/layout/centered-shell.d.ts +2 -2
  21. package/dist/components/layout/flex.d.ts +1 -1
  22. package/dist/components/layout/flex.js +2 -1
  23. package/dist/components/layout/index.d.ts +3 -0
  24. package/dist/components/layout/index.js +2 -0
  25. package/dist/components/layout/mobile-shell.d.ts +21 -0
  26. package/dist/components/layout/mobile-shell.js +43 -0
  27. package/dist/components/layout/sidebar-link.d.ts +1 -1
  28. package/dist/components/navigation/context-menu.js +13 -9
  29. package/dist/components/navigation/dropdown-menu.js +17 -13
  30. package/dist/inertia/index.d.ts +1 -1
  31. package/dist/lib/variants.d.ts +3 -3
  32. package/dist/lib/variants.js +1 -0
  33. package/dist/props/components/data-display.prop.d.ts +65 -0
  34. package/dist/props/components/data-entry.prop.d.ts +1 -1
  35. package/dist/props/components/layout.prop.d.ts +93 -6
  36. package/dist/props/registry.d.ts +109 -2
  37. package/dist/props/registry.js +143 -0
  38. package/dist/props/vocabulary/data.prop.d.ts +6 -0
  39. package/dist/props/vocabulary/index.d.ts +3 -3
  40. package/dist/props/vocabulary/layout.prop.d.ts +17 -2
  41. package/dist/props/vocabulary/shared.prop.d.ts +7 -0
  42. package/dist/styles/control.css +18 -1
  43. package/dist/styles/data-display-layout.css +167 -0
  44. package/dist/styles/layout.css +7 -0
  45. package/dist/styles/shell-layout.css +148 -0
  46. package/dist/styles/table-layout.css +23 -4
  47. package/dist/tokens/components/control.css +1 -0
  48. package/dist/tokens/components/data-display.css +20 -0
  49. package/dist/tokens/components/shell.css +22 -0
  50. package/dist/tokens/components/table.css +2 -0
  51. package/docs/SPACING.md +19 -6
  52. package/docs/charts/compact-bar-trend.tsx +5 -6
  53. package/docs/data-display/avatar.tsx +7 -9
  54. package/docs/data-display/badge.tsx +1 -1
  55. package/docs/data-display/card/index.tsx +6 -4
  56. package/docs/data-display/data-table/examples/approval-queue.tsx +1 -1
  57. package/docs/data-display/data-table/index.tsx +1 -1
  58. package/docs/data-display/permission-matrix.tsx +2 -2
  59. package/docs/data-display/scroll-area.tsx +1 -1
  60. package/docs/data-display/table.tsx +1 -1
  61. package/docs/data-display/timeline-grid.tsx +119 -0
  62. package/docs/data-entry/branch-scope-picker.tsx +1 -1
  63. package/docs/data-entry/input-otp.tsx +1 -1
  64. package/docs/data-entry/textarea.tsx +1 -1
  65. package/docs/data-entry/toggle-count.tsx +1 -1
  66. package/docs/feedback/alert.tsx +1 -1
  67. package/docs/feedback/banner.tsx +1 -1
  68. package/docs/foundation/email-tokens.tsx +1 -1
  69. package/docs/general/activity.tsx +1 -1
  70. package/docs/layout/auth-account-summary.tsx +1 -1
  71. package/docs/layout/auth-recovery/examples/mobile-390.tsx +1 -1
  72. package/docs/layout/auth-recovery/examples/password-recovery.tsx +0 -2
  73. package/docs/layout/auth-shell-context.tsx +1 -1
  74. package/docs/layout/auth-shell-device.tsx +2 -2
  75. package/docs/layout/auth-shell-registration.tsx +1 -1
  76. package/docs/layout/auth-shell.tsx +1 -1
  77. package/docs/layout/master-detail.tsx +2 -2
  78. package/docs/layout/mobile-shell.tsx +101 -0
  79. package/docs/layout/page-container.tsx +6 -6
  80. package/docs/layout/responsive-grid.tsx +2 -2
  81. package/docs/layout/separator.tsx +2 -2
  82. package/docs/layout/service-role-panel.tsx +1 -1
  83. package/docs/layout/sidebar.tsx +6 -6
  84. package/docs/layout/split-pane.tsx +1 -1
  85. package/docs/layout/topbar.tsx +2 -8
  86. package/docs/navigation/filter-bar.tsx +2 -2
  87. package/docs/navigation/pagination.tsx +2 -2
  88. package/docs/navigation/steps.tsx +1 -1
  89. package/docs/navigation/tabs.tsx +1 -1
  90. package/docs/showcase/acme-portal.tsx +11 -12
  91. package/docs/showcase/acme-website.tsx +7 -7
  92. package/docs/showcase/case1-warehouse-dashboard.tsx +11 -12
  93. package/docs/showcase/case2-employee-me.tsx +10 -16
  94. package/docs/showcase/case4-login.tsx +144 -156
  95. package/docs/showcase/case5-shift-calendar.tsx +181 -280
  96. package/docs/showcase/case6-agency-handy.tsx +446 -443
  97. package/docs/showcase/futurelastic-web.tsx +9 -8
  98. package/docs/showcase/org-switcher.tsx +18 -11
  99. package/docs/showcase/permission-matrix.tsx +13 -13
  100. package/docs/showcase/public-landing.tsx +1 -1
  101. package/docs/showcase/settings-account-sections.tsx +2 -2
  102. package/docs/showcase/settings-security-mfa.tsx +3 -3
  103. package/docs/showcase/table-approval-queue.tsx +1 -1
  104. package/docs/showcase/table-bulk-actions.tsx +49 -52
  105. package/docs/showcase/table-conditional-format.tsx +1 -1
  106. package/docs/showcase/table-crud-list.tsx +1 -1
  107. package/docs/showcase/table-expandable-rows.tsx +101 -98
  108. package/docs/showcase/table-filter-chips.tsx +78 -66
  109. package/docs/showcase/table-footer-totals.tsx +23 -19
  110. package/docs/showcase/table-grouped-subtotals.tsx +7 -4
  111. package/docs/showcase/table-master-detail.tsx +12 -14
  112. package/docs/showcase/table-states.tsx +34 -29
  113. package/docs/showcase/table-sticky-columns.tsx +3 -2
  114. package/docs/showcase/table-tree-rows.tsx +22 -15
  115. package/docs/showcase/table-view-tabs.tsx +12 -14
  116. package/package.json +7 -6
@@ -43,7 +43,7 @@ import {
43
43
  } from "lucide-react";
44
44
 
45
45
  import { Button, Heading, Text } from "@godxjp/ui/general";
46
- import { Flex, ResponsiveGrid } from "@godxjp/ui/layout";
46
+ import { Flex, MobileShell, ResponsiveGrid } from "@godxjp/ui/layout";
47
47
  import {
48
48
  Badge,
49
49
  type BadgeProps,
@@ -52,14 +52,7 @@ import {
52
52
  Descriptions,
53
53
  EmptyState,
54
54
  } from "@godxjp/ui/data-display";
55
- import {
56
- Checkbox,
57
- Input,
58
- RadioGroupRoot,
59
- RadioItem,
60
- ToggleGroup,
61
- ToggleGroupItem,
62
- } from "@godxjp/ui/data-entry";
55
+ import { Checkbox, Input, RadioGroup, ToggleGroup, ToggleGroupItem } from "@godxjp/ui/data-entry";
63
56
  import {
64
57
  AlertDialog,
65
58
  Alert,
@@ -324,7 +317,7 @@ function PackingListCard({ packing, onTap }: { packing: Packing; onTap?: () => v
324
317
  /** Three uppercase muted section header + right-aligned mono count. */
325
318
  function SectionHeader({ children, count }: { children: React.ReactNode; count?: number }) {
326
319
  return (
327
- <Flex align="center" justify="between" className="px-0.5">
320
+ <Flex align="center" justify="between">
328
321
  <Text size="2xs" weight="medium" tone="muted" className="tracking-wider uppercase">
329
322
  {children}
330
323
  </Text>
@@ -339,35 +332,32 @@ function SectionHeader({ children, count }: { children: React.ReactNode; count?:
339
332
 
340
333
  // ── Phone shell ──────────────────────────────────────────────────────────────
341
334
 
342
- function PhoneFrame({ children }: { children: React.ReactNode }) {
343
- return (
344
- <div className="mx-auto w-96 max-w-full shrink-0">
345
- <Flex
346
- direction="col"
347
- className="bg-background max-h-[calc(100vh-2rem)] min-h-[844px] overflow-hidden rounded-2xl border"
348
- >
349
- {children}
350
- </Flex>
351
- </div>
352
- );
353
- }
335
+ /**
336
+ * gh#354 §6 — this file used to BUILD the shell: a `Card` + `CardContent flush` phone frame, a
337
+ * `ui-card-inset-x h-9` status row, an `h-14 border-b` header, a `flex-1 overflow-y-auto` body, a
338
+ * `shrink-0 border-t` action strip and a `ResponsiveGrid` tab bar. It reproduced the look and
339
+ * neither behaviour that matters on a device — the document still scrolled, and nothing padded out
340
+ * of `env(safe-area-inset-*)`. All five bands are now `MobileShell` slots.
341
+ */
354
342
 
355
343
  function StatusBar() {
356
344
  return (
357
- <Flex align="center" justify="between" gap="xs" className="h-9 shrink-0 px-5">
345
+ <>
358
346
  <Text size="sm" weight="medium" tabular>
359
347
  9:41
360
348
  </Text>
361
349
  <Text size="sm" weight="medium" tabular tone="muted">
362
350
  Acme Handy
363
351
  </Text>
364
- </Flex>
352
+ </>
365
353
  );
366
354
  }
367
355
 
368
356
  function TabBar({ active, onChange }: { active: string; onChange: (id: string) => void }) {
357
+ // The tab bar's own tiling — equal width, no seam, no gutter — is the `tabBar` slot's contract,
358
+ // so there is no grid and no column count here any more.
369
359
  return (
370
- <nav className="grid shrink-0 grid-cols-3 border-t" aria-label="ワークフロー">
360
+ <>
371
361
  {TABS.map((t) => {
372
362
  const isActive = t.id === active;
373
363
  const Icon = t.icon;
@@ -378,7 +368,7 @@ function TabBar({ active, onChange }: { active: string; onChange: (id: string) =
378
368
  onClick={() => onChange(t.id)}
379
369
  aria-current={isActive ? "page" : undefined}
380
370
  className={
381
- "h-16 flex-col gap-1 rounded-none font-medium text-[var(--font-size-2xs)] " +
371
+ "h-full flex-col rounded-none font-medium text-[var(--font-size-2xs)] " +
382
372
  (isActive ? "text-primary" : "text-muted-foreground")
383
373
  }
384
374
  >
@@ -387,7 +377,7 @@ function TabBar({ active, onChange }: { active: string; onChange: (id: string) =
387
377
  </Button>
388
378
  );
389
379
  })}
390
- </nav>
380
+ </>
391
381
  );
392
382
  }
393
383
 
@@ -410,22 +400,20 @@ function ItemLookupSheet({
410
400
  <SheetBody>
411
401
  <Flex direction="col" gap="md">
412
402
  {/* Viewfinder placeholder — a Card surface, not a hand-rolled illustration */}
413
- <Flex
414
- align="center"
415
- justify="center"
416
- className="border-border bg-secondary/40 h-40 rounded-xl border-2 border-dashed"
417
- >
418
- <Flex direction="col" align="center" gap="sm">
419
- <ScanLine
420
- className="size-7 text-[color:var(--attention,var(--warning))]"
421
- aria-hidden="true"
422
- strokeWidth={1.5}
423
- />
424
- <Text size="sm" tone="muted">
425
- Đưa mã vạch vào khung
426
- </Text>
427
- </Flex>
428
- </Flex>
403
+ <Card variant="muted" className="border-2 border-dashed">
404
+ <CardContent solo>
405
+ <Flex direction="col" align="center" gap="sm" className="h-40 justify-center">
406
+ <ScanLine
407
+ className="size-7 text-[color:var(--attention,var(--warning))]"
408
+ aria-hidden="true"
409
+ strokeWidth={1.5}
410
+ />
411
+ <Text size="sm" tone="muted">
412
+ Đưa mã vạch vào khung
413
+ </Text>
414
+ </Flex>
415
+ </CardContent>
416
+ </Card>
429
417
  <Flex direction="col" gap="sm">
430
418
  <Text size="sm" weight="medium">
431
419
  Hoặc nhập mã thủ công
@@ -567,38 +555,21 @@ function ItemFormSheet({
567
555
  <Text size="sm" weight="medium">
568
556
  Đích đến
569
557
  </Text>
570
- <RadioGroupRoot value={dest} onValueChange={setDest} className="flex flex-col gap-2">
571
- {DESTINATIONS.map((d) => {
572
- const checked = dest === d.id;
573
- const rowId = `handy-dest-${d.id}`;
574
- return (
575
- <label
576
- key={d.id}
577
- htmlFor={rowId}
578
- className={
579
- "flex cursor-pointer items-center gap-3 rounded-xl border p-3 transition-colors " +
580
- (checked
581
- ? "border-primary bg-[color-mix(in_oklch,var(--primary)_8%,transparent)]"
582
- : "hover:border-primary")
583
- }
584
- >
585
- <RadioItem id={rowId} value={d.id} className="mt-0.5" />
586
- <span className="min-w-0">
587
- <Text as="span" size="sm" weight="medium" className="block">
588
- {d.label}
589
- </Text>
590
- <Text as="span" size="xs" tone="muted" className="block">
591
- {d.hint}
592
- </Text>
593
- </span>
594
- </label>
595
- );
596
- })}
597
- </RadioGroupRoot>
558
+ <RadioGroup
559
+ id="handy-dest"
560
+ value={dest}
561
+ onValueChange={setDest}
562
+ orientation="vertical"
563
+ options={DESTINATIONS.map((d) => ({
564
+ value: d.id,
565
+ label: d.label,
566
+ description: d.hint,
567
+ }))}
568
+ />
598
569
  </Flex>
599
570
  </Flex>
600
571
  </SheetBody>
601
- <SheetFooter className="flex-row gap-2">
572
+ <SheetFooter className="flex-row">
602
573
  <Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>
603
574
  Huỷ
604
575
  </Button>
@@ -621,26 +592,24 @@ function ItemFormSheet({
621
592
 
622
593
  type ListState = "ready" | "loading" | "empty" | "error";
623
594
 
595
+ /**
596
+ * The SCROLL REGION of the inbound screen — and nothing else. The sticky bar and the tab bar are
597
+ * `MobileShell` bands now, so this component no longer owns a scroll box, a `flex-1`, or a border.
598
+ */
624
599
  function InboundTab({
625
600
  state,
626
601
  onRetry,
627
602
  selectMode,
628
- setSelectMode,
629
603
  selected,
630
604
  setSelected,
631
605
  onScan,
632
- onAdd,
633
- onAssign,
634
606
  }: {
635
607
  state: ListState;
636
608
  onRetry: () => void;
637
609
  selectMode: boolean;
638
- setSelectMode: (v: boolean) => void;
639
610
  selected: Set<string>;
640
611
  setSelected: React.Dispatch<React.SetStateAction<Set<string>>>;
641
612
  onScan: () => void;
642
- onAdd: () => void;
643
- onAssign: () => void;
644
613
  }) {
645
614
  const [filter, setFilter] = React.useState<string>("all");
646
615
 
@@ -653,298 +622,317 @@ function InboundTab({
653
622
  );
654
623
 
655
624
  return (
656
- <>
657
- <Flex direction="col" gap="md" className="flex-1 overflow-y-auto p-4">
658
- {/* Filter chips — horizontal scroll, count pills */}
659
- <ToggleGroup
660
- type="single"
661
- value={filter}
662
- onValueChange={(v) => {
663
- if (v) setFilter(v);
664
- }}
665
- className="w-full overflow-x-auto"
666
- >
667
- <Flex justify="start" gap="sm">
668
- {FILTERS.map((f) => (
669
- <ToggleGroupItem
670
- key={f.id}
671
- value={f.id}
672
- className="h-9 shrink-0 gap-1.5 rounded-full px-3 whitespace-nowrap text-[var(--font-size-xs)]"
673
- >
674
- {f.label}
675
- <Text size="xs" mono tabular className="opacity-70">
676
- {f.count}
677
- </Text>
678
- </ToggleGroupItem>
679
- ))}
680
- </Flex>
681
- </ToggleGroup>
625
+ <Flex direction="col" gap="md">
626
+ {/* Filter chips horizontal scroll, count pills */}
627
+ <ToggleGroup
628
+ type="single"
629
+ value={filter}
630
+ onValueChange={(v) => {
631
+ if (v) setFilter(v);
632
+ }}
633
+ className="w-full overflow-x-auto"
634
+ >
635
+ <Flex justify="start" gap="sm">
636
+ {FILTERS.map((f) => (
637
+ <ToggleGroupItem
638
+ key={f.id}
639
+ value={f.id}
640
+ className="h-9 shrink-0 rounded-full whitespace-nowrap text-[var(--font-size-xs)]"
641
+ >
642
+ {f.label}
643
+ <Text size="xs" mono tabular className="opacity-70">
644
+ {f.count}
645
+ </Text>
646
+ </ToggleGroupItem>
647
+ ))}
648
+ </Flex>
649
+ </ToggleGroup>
682
650
 
683
- {state === "loading" ? (
684
- <Flex direction="col" gap="md" aria-busy="true">
685
- {Array.from({ length: 3 }).map((_, i) => (
686
- <Card key={i} density="tight" className="rounded-xl">
687
- <CardContent solo>
688
- <Flex direction="col" gap="md">
689
- <Skeleton className="h-4 w-3/5" />
690
- <Skeleton className="h-3 w-2/5" />
691
- <Skeleton className="h-5 w-24 rounded-full" />
692
- </Flex>
693
- </CardContent>
694
- </Card>
695
- ))}
696
- </Flex>
697
- ) : state === "error" ? (
698
- <Alert tone="warning">
699
- <AlertTitle>Không tải được danh sách</AlertTitle>
700
- <AlertDescription>
701
- <Flex direction="col" align="start" gap="sm">
702
- Kiểm tra kết nối rồi thử lại.
703
- <Button variant="outline" size="sm" onClick={onRetry}>
704
- <RefreshCw aria-hidden="true" />
705
- Thử lại
706
- </Button>
707
- </Flex>
708
- </AlertDescription>
709
- </Alert>
710
- ) : state === "empty" || visible.length === 0 ? (
711
- <EmptyState
712
- icon={Boxes}
713
- title="Chưa có hàng trong mục này"
714
- description="Quét mã để nhập hàng mới vào kho."
715
- action={
716
- <Button size="sm" onClick={onScan}>
717
- <ScanLine aria-hidden="true" />
718
- Quét / Tìm mã
651
+ {state === "loading" ? (
652
+ <Flex direction="col" gap="md" aria-busy="true">
653
+ {Array.from({ length: 3 }).map((_, i) => (
654
+ <Card key={i} density="tight" className="rounded-xl">
655
+ <CardContent solo>
656
+ <Flex direction="col" gap="md">
657
+ <Skeleton className="h-4 w-3/5" />
658
+ <Skeleton className="h-3 w-2/5" />
659
+ <Skeleton className="h-5 w-24 rounded-full" />
660
+ </Flex>
661
+ </CardContent>
662
+ </Card>
663
+ ))}
664
+ </Flex>
665
+ ) : state === "error" ? (
666
+ <Alert tone="warning">
667
+ <AlertTitle>Không tải được danh sách</AlertTitle>
668
+ <AlertDescription>
669
+ <Flex direction="col" align="start" gap="sm">
670
+ Kiểm tra kết nối rồi thử lại.
671
+ <Button variant="outline" size="sm" onClick={onRetry}>
672
+ <RefreshCw aria-hidden="true" />
673
+ Thử lại
719
674
  </Button>
720
- }
721
- />
722
- ) : (
723
- <Flex direction="col" gap="md">
724
- {visible.map((item) => (
725
- <ItemListCard
726
- key={item.id}
727
- item={item}
728
- selectMode={selectMode}
729
- selected={selected.has(item.id)}
730
- onToggle={() =>
731
- setSelected((prev) => {
732
- const next = new Set(prev);
733
- if (next.has(item.id)) next.delete(item.id);
734
- else next.add(item.id);
735
- return next;
736
- })
737
- }
738
- />
739
- ))}
740
- </Flex>
741
- )}
742
- </Flex>
743
-
744
- {/* Sticky action bar (scan-first) OR select-mode contextual bar */}
745
- {selectMode ? (
746
- <Flex
747
- direction="col"
748
- gap="sm"
749
- className="shrink-0 border-t bg-[color-mix(in_oklch,var(--primary)_5%,var(--background))] p-3"
750
- >
751
- <Flex align="center" justify="between">
752
- <Text size="sm" tabular>
753
- <Text as="span" weight="bold">
754
- {selected.size}
755
- </Text>{" "}
756
- item đã chọn
757
- </Text>
758
- <Button
759
- variant="ghost"
760
- size="sm"
761
- onClick={() => {
762
- setSelectMode(false);
763
- setSelected(new Set());
764
- }}
765
- >
766
- Xong
767
- </Button>
768
- </Flex>
769
- <Flex gap="sm">
770
- <Button className="flex-[2]" disabled={selected.size === 0} onClick={onAssign}>
771
- <Package aria-hidden="true" />
772
- Gán vào kiện…
773
- </Button>
774
- <Button variant="outline" className="flex-1" disabled={selected.size === 0}>
775
- Kiện mới
675
+ </Flex>
676
+ </AlertDescription>
677
+ </Alert>
678
+ ) : state === "empty" || visible.length === 0 ? (
679
+ <EmptyState
680
+ icon={Boxes}
681
+ title="Chưa có hàng trong mục này"
682
+ description="Quét mã để nhập hàng mới vào kho."
683
+ action={
684
+ <Button size="sm" onClick={onScan}>
685
+ <ScanLine aria-hidden="true" />
686
+ Quét / Tìm mã
776
687
  </Button>
777
- </Flex>
778
- </Flex>
688
+ }
689
+ />
779
690
  ) : (
780
- <Flex gap="sm" className="shrink-0 border-t p-3">
781
- <Button className="flex-[2]" onClick={onScan}>
782
- <ScanLine aria-hidden="true" />
783
- Quét / Tìm mã
784
- </Button>
785
- <Button variant="outline" className="flex-1" onClick={onAdd}>
786
- <Plus aria-hidden="true" />
787
- Thêm hàng
788
- </Button>
691
+ <Flex direction="col" gap="md">
692
+ {visible.map((item) => (
693
+ <ItemListCard
694
+ key={item.id}
695
+ item={item}
696
+ selectMode={selectMode}
697
+ selected={selected.has(item.id)}
698
+ onToggle={() =>
699
+ setSelected((prev) => {
700
+ const next = new Set(prev);
701
+ if (next.has(item.id)) next.delete(item.id);
702
+ else next.add(item.id);
703
+ return next;
704
+ })
705
+ }
706
+ />
707
+ ))}
789
708
  </Flex>
790
709
  )}
791
- </>
710
+ </Flex>
792
711
  );
793
712
  }
794
713
 
795
- function PackingTab({ onScan }: { onScan: () => void }) {
714
+ /**
715
+ * The inbound screen's `actions` band: the scan-first pair at rest, the contextual pair in select
716
+ * mode. It lives OUTSIDE the scroll region, so it needs no `position: sticky` and no bottom padding
717
+ * on the list to clear it, and `MobileShell` gives it the home-indicator inset when no tab bar
718
+ * follows.
719
+ */
720
+ function InboundActions({
721
+ selectMode,
722
+ selected,
723
+ onExitSelect,
724
+ onScan,
725
+ onAdd,
726
+ onAssign,
727
+ }: {
728
+ selectMode: boolean;
729
+ selected: Set<string>;
730
+ onExitSelect: () => void;
731
+ onScan: () => void;
732
+ onAdd: () => void;
733
+ onAssign: () => void;
734
+ }) {
735
+ if (!selectMode) {
736
+ return (
737
+ <>
738
+ <Button className="flex-[2]" onClick={onScan}>
739
+ <ScanLine aria-hidden="true" />
740
+ Quét / Tìm mã
741
+ </Button>
742
+ <Button variant="outline" className="flex-1" onClick={onAdd}>
743
+ <Plus aria-hidden="true" />
744
+ Thêm hàng
745
+ </Button>
746
+ </>
747
+ );
748
+ }
749
+ return (
750
+ <Flex direction="col" gap="sm" className="w-full">
751
+ <Flex align="center" justify="between">
752
+ <Text size="sm" tabular>
753
+ <Text as="span" weight="bold">
754
+ {selected.size}
755
+ </Text>{" "}
756
+ item đã chọn
757
+ </Text>
758
+ <Button variant="ghost" size="sm" onClick={onExitSelect}>
759
+ Xong
760
+ </Button>
761
+ </Flex>
762
+ <Flex gap="sm">
763
+ <Button className="flex-[2]" disabled={selected.size === 0} onClick={onAssign}>
764
+ <Package aria-hidden="true" />
765
+ Gán vào kiện…
766
+ </Button>
767
+ <Button variant="outline" className="flex-1" disabled={selected.size === 0}>
768
+ Kiện mới
769
+ </Button>
770
+ </Flex>
771
+ </Flex>
772
+ );
773
+ }
774
+
775
+ function PackingTab() {
796
776
  const active = PACKINGS.find((p) => p.status === "active");
797
777
  const others = PACKINGS.filter((p) => p.status !== "active");
798
778
  return (
799
- <>
800
- <Flex direction="col" gap="md" className="flex-1 overflow-y-auto p-4">
801
- {active ? (
802
- <Card
803
- density="tight"
804
- accent="primary"
805
- className="border-primary rounded-xl bg-[color-mix(in_oklch,var(--primary)_6%,transparent)]"
779
+ <Flex direction="col" gap="md">
780
+ {active ? (
781
+ <Card
782
+ density="tight"
783
+ accent="primary"
784
+ className="border-primary bg-[color-mix(in_oklch,var(--primary)_6%,transparent)]"
785
+ >
786
+ <CardContent solo>
787
+ <Flex direction="col" gap="xs">
788
+ <Flex align="center" justify="between">
789
+ <Text size="2xs" weight="medium" tone="muted" className="tracking-wider uppercase">
790
+ Kiện đang làm
791
+ </Text>
792
+ <Badge tone="info" variant="outline" className="rounded-full">
793
+ {PACKING_STATUS.active.label}
794
+ </Badge>
795
+ </Flex>
796
+ <Text as="div" size="lg" weight="bold" mono tabular>
797
+ {active.code}
798
+ </Text>
799
+ <Text size="xs" tone="muted" tabular as="div">
800
+ {active.customer} · {active.city} · ×{active.items} · {active.slot}
801
+ </Text>
802
+ </Flex>
803
+ </CardContent>
804
+ </Card>
805
+ ) : null}
806
+
807
+ <SectionHeader count={others.length}>Kiện đang mở</SectionHeader>
808
+ <Flex direction="col" gap="md">
809
+ {others.map((p) => (
810
+ <PackingListCard key={p.id} packing={p} onTap={() => undefined} />
811
+ ))}
812
+ </Flex>
813
+ </Flex>
814
+ );
815
+ }
816
+
817
+ function PackingActions({ onScan }: { onScan: () => void }) {
818
+ return (
819
+ <Flex direction="col" gap="sm" className="w-full">
820
+ <Button onClick={onScan}>
821
+ <ScanLine aria-hidden="true" />
822
+ Quét item vào kiện
823
+ </Button>
824
+ <Button variant="outline">
825
+ <Plus aria-hidden="true" />
826
+ Tạo kiện trống
827
+ </Button>
828
+ </Flex>
829
+ );
830
+ }
831
+
832
+ /**
833
+ * `seg` is lifted to the page: the segmented control lives in the scroll region while the verb it
834
+ * selects lives in the `actions` band, and the two are different MobileShell slots.
835
+ */
836
+ function OutboundTab({ seg, setSeg }: { seg: string; setSeg: (v: string) => void }) {
837
+ return (
838
+ <Flex direction="col" gap="md">
839
+ {/* Segmented — outbound status */}
840
+ <ToggleGroup
841
+ type="single"
842
+ value={seg}
843
+ onValueChange={(v) => {
844
+ if (v) setSeg(v);
845
+ }}
846
+ className="bg-secondary/60 w-full rounded-xl"
847
+ >
848
+ <ResponsiveGrid columns={3} gap="xs">
849
+ <ToggleGroupItem
850
+ value="seal"
851
+ className="h-9 rounded-xl whitespace-nowrap text-[var(--font-size-xs)]"
852
+ >
853
+ Chờ niêm phong
854
+ </ToggleGroupItem>
855
+ <ToggleGroupItem
856
+ value="handoff"
857
+ className="h-9 rounded-xl whitespace-nowrap text-[var(--font-size-xs)]"
806
858
  >
859
+ Chờ bàn giao
860
+ </ToggleGroupItem>
861
+ <ToggleGroupItem
862
+ value="done"
863
+ className="h-9 rounded-xl whitespace-nowrap text-[var(--font-size-xs)]"
864
+ >
865
+ Đã bàn giao
866
+ </ToggleGroupItem>
867
+ </ResponsiveGrid>
868
+ </ToggleGroup>
869
+
870
+ <SectionHeader count={OUTBOUND.length}>
871
+ {seg === "seal"
872
+ ? "Sẵn sàng niêm phong"
873
+ : seg === "handoff"
874
+ ? "Chờ bàn giao"
875
+ : "Đã bàn giao"}
876
+ </SectionHeader>
877
+ <Flex direction="col" gap="md">
878
+ {OUTBOUND.map((p) => (
879
+ <Card key={p.id} density="tight" className="rounded-xl">
807
880
  <CardContent solo>
808
- <Flex direction="col" gap="xs">
809
- <Flex align="center" justify="between">
810
- <Text
811
- size="2xs"
812
- weight="medium"
813
- tone="muted"
814
- className="tracking-wider uppercase"
815
- >
816
- Kiện đang làm
881
+ <Flex direction="col" gap="md">
882
+ <Flex align="center" justify="between" gap="sm">
883
+ <Text as="code" size="sm" weight="bold" tabular>
884
+ {p.code}
817
885
  </Text>
818
- <Badge tone="info" variant="outline" className="rounded-full">
819
- {PACKING_STATUS.active.label}
886
+ <Badge tone="success" variant="outline" className="rounded-full">
887
+ Sẵn sàng niêm phong
820
888
  </Badge>
821
889
  </Flex>
822
- <Text as="div" size="lg" weight="bold" mono tabular>
823
- {active.code}
824
- </Text>
825
- <Text size="xs" tone="muted" tabular as="div">
826
- {active.customer} · {active.city} · ×{active.items} · {active.slot}
827
- </Text>
890
+ <Flex align="center" justify="between" gap="sm">
891
+ <Text size="xs" tone="muted" tabular truncate>
892
+ {p.customer} · {p.city}
893
+ </Text>
894
+ <Text size="xs" tone="muted" tabular className="shrink-0">
895
+ ×{p.items} · {p.slot}
896
+ </Text>
897
+ </Flex>
898
+ <div>
899
+ <Descriptions columns={1}>
900
+ <Descriptions.Item label="Vị trí" mono>
901
+ {p.slot}
902
+ </Descriptions.Item>
903
+ <Descriptions.Item label="Số kiện" mono>
904
+ ×{p.items}
905
+ </Descriptions.Item>
906
+ </Descriptions>
907
+ </div>
828
908
  </Flex>
829
909
  </CardContent>
830
910
  </Card>
831
- ) : null}
832
-
833
- <SectionHeader count={others.length}>Kiện đang mở</SectionHeader>
834
- <Flex direction="col" gap="md">
835
- {others.map((p) => (
836
- <PackingListCard key={p.id} packing={p} onTap={() => undefined} />
837
- ))}
838
- </Flex>
911
+ ))}
839
912
  </Flex>
840
- <Flex direction="col" gap="sm" className="shrink-0 border-t p-3">
841
- <Button onClick={onScan}>
842
- <ScanLine aria-hidden="true" />
843
- Quét item vào kiện
844
- </Button>
845
- <Button variant="outline">
846
- <Plus aria-hidden="true" />
847
- Tạo kiện trống
848
- </Button>
849
- </Flex>
850
- </>
913
+ </Flex>
851
914
  );
852
915
  }
853
916
 
854
- function OutboundTab({ onSeal, onHandoff }: { onSeal: () => void; onHandoff: () => void }) {
855
- const [seg, setSeg] = React.useState<string>("seal");
856
- return (
857
- <>
858
- <Flex direction="col" gap="md" className="flex-1 overflow-y-auto p-4">
859
- {/* Segmented — outbound status */}
860
- <ToggleGroup
861
- type="single"
862
- value={seg}
863
- onValueChange={(v) => {
864
- if (v) setSeg(v);
865
- }}
866
- className="bg-secondary/60 w-full rounded-xl p-1"
867
- >
868
- <ResponsiveGrid columns={3} gap="xs">
869
- <ToggleGroupItem
870
- value="seal"
871
- className="h-9 rounded-xl whitespace-nowrap text-[var(--font-size-xs)]"
872
- >
873
- Chờ niêm phong
874
- </ToggleGroupItem>
875
- <ToggleGroupItem
876
- value="handoff"
877
- className="h-9 rounded-xl whitespace-nowrap text-[var(--font-size-xs)]"
878
- >
879
- Chờ bàn giao
880
- </ToggleGroupItem>
881
- <ToggleGroupItem
882
- value="done"
883
- className="h-9 rounded-xl whitespace-nowrap text-[var(--font-size-xs)]"
884
- >
885
- Đã bàn giao
886
- </ToggleGroupItem>
887
- </ResponsiveGrid>
888
- </ToggleGroup>
889
-
890
- <SectionHeader count={OUTBOUND.length}>
891
- {seg === "seal"
892
- ? "Sẵn sàng niêm phong"
893
- : seg === "handoff"
894
- ? "Chờ bàn giao"
895
- : "Đã bàn giao"}
896
- </SectionHeader>
897
- <Flex direction="col" gap="md">
898
- {OUTBOUND.map((p) => (
899
- <Card key={p.id} density="tight" className="rounded-xl">
900
- <CardContent solo>
901
- <Flex direction="col" gap="md">
902
- <Flex align="center" justify="between" gap="sm">
903
- <Text as="code" size="sm" weight="bold" tabular>
904
- {p.code}
905
- </Text>
906
- <Badge tone="success" variant="outline" className="rounded-full">
907
- Sẵn sàng niêm phong
908
- </Badge>
909
- </Flex>
910
- <Flex align="center" justify="between" gap="sm">
911
- <Text size="xs" tone="muted" tabular truncate>
912
- {p.customer} · {p.city}
913
- </Text>
914
- <Text size="xs" tone="muted" tabular className="shrink-0">
915
- ×{p.items} · {p.slot}
916
- </Text>
917
- </Flex>
918
- <div>
919
- <Descriptions columns={1} className="gap-y-1">
920
- <Descriptions.Item label="Vị trí" mono>
921
- {p.slot}
922
- </Descriptions.Item>
923
- <Descriptions.Item label="Số kiện" mono>
924
- ×{p.items}
925
- </Descriptions.Item>
926
- </Descriptions>
927
- </div>
928
- </Flex>
929
- </CardContent>
930
- </Card>
931
- ))}
932
- </Flex>
933
- </Flex>
934
- <Flex gap="sm" className="shrink-0 border-t p-3">
935
- {seg === "handoff" ? (
936
- <Button className="flex-1" onClick={onHandoff}>
937
- <Truck aria-hidden="true" />
938
- Xác nhận bàn giao
939
- </Button>
940
- ) : (
941
- <Button className="flex-1" onClick={onSeal}>
942
- <Check aria-hidden="true" />
943
- Quét mã kiện · Niêm phong
944
- </Button>
945
- )}
946
- </Flex>
947
- </>
917
+ function OutboundActions({
918
+ seg,
919
+ onSeal,
920
+ onHandoff,
921
+ }: {
922
+ seg: string;
923
+ onSeal: () => void;
924
+ onHandoff: () => void;
925
+ }) {
926
+ return seg === "handoff" ? (
927
+ <Button className="flex-1" onClick={onHandoff}>
928
+ <Truck aria-hidden="true" />
929
+ Xác nhận bàn giao
930
+ </Button>
931
+ ) : (
932
+ <Button className="flex-1" onClick={onSeal}>
933
+ <Check aria-hidden="true" />
934
+ Quét kiện · Niêm phong
935
+ </Button>
948
936
  );
949
937
  }
950
938
 
@@ -956,6 +944,7 @@ export default function AgencyHandyShowcase() {
956
944
 
957
945
  const [selectMode, setSelectMode] = React.useState(false);
958
946
  const [selected, setSelected] = React.useState<Set<string>>(new Set());
947
+ const [seg, setSeg] = React.useState<string>("seal");
959
948
 
960
949
  const [lookupOpen, setLookupOpen] = React.useState(false);
961
950
  const [formOpen, setFormOpen] = React.useState(false);
@@ -966,119 +955,133 @@ export default function AgencyHandyShowcase() {
966
955
 
967
956
  const headerTitle = tab === "inbound" ? "Nhập kho" : tab === "packing" ? "Đóng gói" : "Xuất kho";
968
957
 
969
- return (
970
- <div className="bg-secondary/30 min-h-screen py-4">
971
- <Toaster />
972
- <PhoneFrame>
973
- <StatusBar />
958
+ const exitSelect = () => {
959
+ setSelectMode(false);
960
+ setSelected(new Set());
961
+ };
974
962
 
975
- {/* App header (52px)title + iOS text-action select-mode entry (inbound only) */}
976
- <header className="h-14 shrink-0 border-b px-4">
977
- <Flex align="center" justify="between" gap="xs">
978
- <Heading level={3} as="h1" className="whitespace-nowrap">
979
- {headerTitle}
980
- </Heading>
981
- <Flex align="center" gap="xs">
982
- {tab === "inbound" && !selectMode ? (
983
- <Button
984
- variant="ghost"
985
- size="sm"
986
- onClick={() => {
987
- setSelectMode(true);
988
- setSelected(new Set());
989
- }}
990
- >
991
- <Check aria-hidden="true" />
992
- Chọn
993
- </Button>
994
- ) : null}
995
- {/* State switcher — exposes the 4 list states at rest (showcase affordance) */}
996
- {tab === "inbound" && !selectMode ? (
997
- <ToggleGroup
998
- type="single"
999
- value={listState}
1000
- onValueChange={(v) => {
1001
- if (v) setListState(v as ListState);
1002
- }}
1003
- aria-label="List state (showcase)"
963
+ // SELECT MODE REPLACES THE APP BAR it does not stack a second strip under it. That is the
964
+ // platform pattern on both iOS and Android, and it is what the `header` slot is for: one bar to
965
+ // read at a time. Before gh#354 this file rendered the title bar AND a contextual strip, because
966
+ // there was no bar to swap.
967
+ const header = selectMode ? (
968
+ <Flex align="center" justify="between" gap="xs" className="w-full">
969
+ <Button variant="ghost" size="icon-sm" aria-label="Thoát chế độ chọn" onClick={exitSelect}>
970
+ <X className="size-4" aria-hidden="true" strokeWidth={1.5} />
971
+ </Button>
972
+ <Text size="sm" mono tabular>
973
+ {selected.size} đã chọn
974
+ </Text>
975
+ <Button
976
+ variant="ghost"
977
+ size="sm"
978
+ onClick={() => setSelected(new Set(ITEMS.map((i) => i.id)))}
979
+ >
980
+ Chọn tất cả
981
+ </Button>
982
+ </Flex>
983
+ ) : (
984
+ <Flex align="center" justify="between" gap="xs" className="w-full">
985
+ <Heading level={3} as="h1" className="whitespace-nowrap">
986
+ {headerTitle}
987
+ </Heading>
988
+ <Flex align="center" gap="xs">
989
+ {tab === "inbound" ? (
990
+ <Button
991
+ variant="ghost"
992
+ size="sm"
993
+ onClick={() => {
994
+ setSelectMode(true);
995
+ setSelected(new Set());
996
+ }}
997
+ >
998
+ <Check aria-hidden="true" />
999
+ Chọn
1000
+ </Button>
1001
+ ) : null}
1002
+ {/* State switcher — exposes the 4 list states at rest (showcase affordance) */}
1003
+ {tab === "inbound" ? (
1004
+ <ToggleGroup
1005
+ type="single"
1006
+ value={listState}
1007
+ onValueChange={(v) => {
1008
+ if (v) setListState(v as ListState);
1009
+ }}
1010
+ aria-label="List state (showcase)"
1011
+ >
1012
+ <Flex gap="xs">
1013
+ {(["ready", "loading", "empty", "error"] as const).map((s) => (
1014
+ <ToggleGroupItem
1015
+ key={s}
1016
+ value={s}
1017
+ className="size-7 rounded-md text-[var(--font-size-2xs)] uppercase"
1018
+ title={s}
1004
1019
  >
1005
- <Flex gap="xs">
1006
- {(["ready", "loading", "empty", "error"] as const).map((s) => (
1007
- <ToggleGroupItem
1008
- key={s}
1009
- value={s}
1010
- className="size-7 rounded-md p-0 text-[var(--font-size-2xs)] uppercase"
1011
- title={s}
1012
- >
1013
- {s[0]}
1014
- </ToggleGroupItem>
1015
- ))}
1016
- </Flex>
1017
- </ToggleGroup>
1018
- ) : null}
1020
+ {s[0]}
1021
+ </ToggleGroupItem>
1022
+ ))}
1019
1023
  </Flex>
1020
- </Flex>
1021
- </header>
1022
-
1023
- {/* Select-mode header strip — replaces the standard header context (inbound) */}
1024
- {selectMode ? (
1025
- <Flex
1026
- align="center"
1027
- justify="between"
1028
- gap="xs"
1029
- className="h-10 shrink-0 border-b bg-[color-mix(in_oklch,var(--primary)_5%,var(--background))] px-3"
1030
- >
1031
- <Button
1032
- variant="ghost"
1033
- size="icon-sm"
1034
- aria-label="閉じる"
1035
- onClick={() => {
1036
- setSelectMode(false);
1037
- setSelected(new Set());
1038
- }}
1039
- >
1040
- <X className="size-4" aria-hidden="true" strokeWidth={1.5} />
1041
- </Button>
1042
- <Text size="sm" mono tabular>
1043
- {selected.size} đã chọn
1044
- </Text>
1045
- <Button
1046
- variant="ghost"
1047
- size="sm"
1048
- onClick={() => setSelected(new Set(ITEMS.map((i) => i.id)))}
1049
- >
1050
- Chọn tất cả
1051
- </Button>
1052
- </Flex>
1024
+ </ToggleGroup>
1053
1025
  ) : null}
1026
+ </Flex>
1027
+ </Flex>
1028
+ );
1029
+
1030
+ const actions =
1031
+ tab === "inbound" ? (
1032
+ <InboundActions
1033
+ selectMode={selectMode}
1034
+ selected={selected}
1035
+ onExitSelect={exitSelect}
1036
+ onScan={() => setLookupOpen(true)}
1037
+ onAdd={() => setFormOpen(true)}
1038
+ onAssign={() => setPickerOpen(true)}
1039
+ />
1040
+ ) : tab === "packing" ? (
1041
+ <PackingActions onScan={() => setLookupOpen(true)} />
1042
+ ) : (
1043
+ <OutboundActions
1044
+ seg={seg}
1045
+ onSeal={() => setSealOpen(true)}
1046
+ onHandoff={() => setHandoffOpen(true)}
1047
+ />
1048
+ );
1054
1049
 
1050
+ return (
1051
+ <>
1052
+ <Toaster />
1053
+ {/* The whole phone is ONE primitive now: five bands, no Card frame, no hand-rolled scroll box,
1054
+ * no `min-h-screen` wrapper. The shell is the document's only scroll container and every band
1055
+ * pads itself out of the device safe areas. */}
1056
+ <MobileShell
1057
+ statusBar={<StatusBar />}
1058
+ header={header}
1059
+ actions={actions}
1060
+ tabBar={
1061
+ <TabBar
1062
+ active={tab}
1063
+ onChange={(id) => {
1064
+ setTab(id);
1065
+ exitSelect();
1066
+ }}
1067
+ />
1068
+ }
1069
+ >
1055
1070
  {tab === "inbound" ? (
1056
1071
  <InboundTab
1057
1072
  state={listState}
1058
1073
  onRetry={() => setListState("ready")}
1059
1074
  selectMode={selectMode}
1060
- setSelectMode={setSelectMode}
1061
1075
  selected={selected}
1062
1076
  setSelected={setSelected}
1063
1077
  onScan={() => setLookupOpen(true)}
1064
- onAdd={() => setFormOpen(true)}
1065
- onAssign={() => setPickerOpen(true)}
1066
1078
  />
1067
1079
  ) : tab === "packing" ? (
1068
- <PackingTab onScan={() => setLookupOpen(true)} />
1080
+ <PackingTab />
1069
1081
  ) : (
1070
- <OutboundTab onSeal={() => setSealOpen(true)} onHandoff={() => setHandoffOpen(true)} />
1082
+ <OutboundTab seg={seg} setSeg={setSeg} />
1071
1083
  )}
1072
-
1073
- <TabBar
1074
- active={tab}
1075
- onChange={(id) => {
1076
- setTab(id);
1077
- setSelectMode(false);
1078
- setSelected(new Set());
1079
- }}
1080
- />
1081
- </PhoneFrame>
1084
+ </MobileShell>
1082
1085
 
1083
1086
  {/* Sheets */}
1084
1087
  <ItemLookupSheet open={lookupOpen} onOpenChange={setLookupOpen} />
@@ -1108,6 +1111,6 @@ export default function AgencyHandyShowcase() {
1108
1111
  toast.success("Đã bàn giao PKG-000037 · 16:24");
1109
1112
  }}
1110
1113
  />
1111
- </div>
1114
+ </>
1112
1115
  );
1113
1116
  }