@godxjp/ui 19.2.0 → 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 (144) hide show
  1. package/dist/components/data-display/badge.d.ts +26 -2
  2. package/dist/components/data-display/badge.js +20 -5
  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 +72 -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 +113 -2
  37. package/dist/props/registry.js +155 -1
  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/badge-layout.css +15 -0
  43. package/dist/styles/control.css +18 -1
  44. package/dist/styles/data-display-layout.css +167 -0
  45. package/dist/styles/layout.css +7 -0
  46. package/dist/styles/shell-layout.css +148 -0
  47. package/dist/styles/table-layout.css +23 -4
  48. package/dist/tokens/components/badge.css +8 -0
  49. package/dist/tokens/components/control.css +1 -0
  50. package/dist/tokens/components/data-display.css +20 -0
  51. package/dist/tokens/components/shell.css +22 -0
  52. package/dist/tokens/components/table.css +2 -0
  53. package/docs/SPACING.md +19 -6
  54. package/docs/charts/compact-bar-trend.tsx +5 -6
  55. package/docs/data-display/avatar.tsx +7 -9
  56. package/docs/data-display/badge.tsx +37 -1
  57. package/docs/data-display/card/index.tsx +6 -4
  58. package/docs/data-display/carousel.tsx +8 -4
  59. package/docs/data-display/data-table/examples/approval-queue.tsx +1 -1
  60. package/docs/data-display/data-table/examples/grid-features.tsx +1 -1
  61. package/docs/data-display/data-table/index.tsx +1 -1
  62. package/docs/data-display/permission-matrix.tsx +15 -11
  63. package/docs/data-display/popover.tsx +2 -1
  64. package/docs/data-display/scroll-area.tsx +108 -76
  65. package/docs/data-display/table.tsx +2 -2
  66. package/docs/data-display/timeline-grid.tsx +119 -0
  67. package/docs/data-entry/branch-scope-picker.tsx +1 -1
  68. package/docs/data-entry/calendar.tsx +1 -1
  69. package/docs/data-entry/checkbox.tsx +1 -1
  70. package/docs/data-entry/country-picker-recipe.tsx +11 -6
  71. package/docs/data-entry/form-field/index.tsx +33 -29
  72. package/docs/data-entry/form-root.tsx +1 -1
  73. package/docs/data-entry/input-otp.tsx +1 -1
  74. package/docs/data-entry/password-input.tsx +1 -5
  75. package/docs/data-entry/select.tsx +7 -7
  76. package/docs/data-entry/textarea.tsx +3 -3
  77. package/docs/data-entry/toggle-count.tsx +1 -1
  78. package/docs/feedback/alert.tsx +1 -1
  79. package/docs/feedback/banner.tsx +1 -1
  80. package/docs/feedback/sheet.tsx +112 -102
  81. package/docs/feedback/skeleton.tsx +2 -2
  82. package/docs/foundation/colors.tsx +8 -6
  83. package/docs/foundation/email-tokens.tsx +83 -66
  84. package/docs/foundation/radius-shadow.tsx +13 -11
  85. package/docs/general/activity.tsx +1 -1
  86. package/docs/layout/app-shell.tsx +2 -2
  87. package/docs/layout/aspect-ratio.tsx +30 -14
  88. package/docs/layout/auth-account-summary.tsx +1 -1
  89. package/docs/layout/auth-recovery/examples/mobile-390.tsx +1 -1
  90. package/docs/layout/auth-recovery/examples/password-recovery.tsx +0 -2
  91. package/docs/layout/auth-shell-context.tsx +1 -1
  92. package/docs/layout/auth-shell-device.tsx +2 -2
  93. package/docs/layout/auth-shell-registration.tsx +1 -1
  94. package/docs/layout/auth-shell.tsx +1 -1
  95. package/docs/layout/flex.tsx +6 -2
  96. package/docs/layout/master-detail.tsx +2 -2
  97. package/docs/layout/mobile-shell.tsx +101 -0
  98. package/docs/layout/page-container.tsx +15 -7
  99. package/docs/layout/responsive-grid.tsx +12 -4
  100. package/docs/layout/separator.tsx +2 -2
  101. package/docs/layout/service-role-panel.tsx +1 -1
  102. package/docs/layout/sidebar.tsx +175 -152
  103. package/docs/layout/split-pane.tsx +45 -28
  104. package/docs/layout/topbar.tsx +19 -23
  105. package/docs/navigation/app-setting-picker.tsx +5 -4
  106. package/docs/navigation/breadcrumb.tsx +12 -5
  107. package/docs/navigation/context-menu.tsx +10 -4
  108. package/docs/navigation/filter-bar.tsx +2 -2
  109. package/docs/navigation/navigation-menu.tsx +89 -96
  110. package/docs/navigation/pagination.tsx +2 -2
  111. package/docs/navigation/steps.tsx +13 -11
  112. package/docs/navigation/tabs.tsx +27 -21
  113. package/docs/navigation/toolbar.tsx +15 -15
  114. package/docs/providers/format-date.tsx +2 -2
  115. package/docs/showcase/acme-portal.tsx +41 -28
  116. package/docs/showcase/acme-website.tsx +67 -61
  117. package/docs/showcase/case1-warehouse-dashboard.tsx +46 -31
  118. package/docs/showcase/case2-employee-me.tsx +33 -36
  119. package/docs/showcase/case3-approval-workflow.tsx +34 -32
  120. package/docs/showcase/case4-login.tsx +95 -101
  121. package/docs/showcase/case5-shift-calendar.tsx +182 -281
  122. package/docs/showcase/case6-agency-handy.tsx +514 -470
  123. package/docs/showcase/futurelastic-web.tsx +32 -29
  124. package/docs/showcase/org-switcher.tsx +20 -13
  125. package/docs/showcase/permission-matrix.tsx +20 -17
  126. package/docs/showcase/public-landing.tsx +1 -1
  127. package/docs/showcase/settings-account-sections.tsx +2 -2
  128. package/docs/showcase/settings-security-mfa.tsx +3 -3
  129. package/docs/showcase/table-approval-queue.tsx +1 -1
  130. package/docs/showcase/table-bulk-actions.tsx +49 -52
  131. package/docs/showcase/table-conditional-format.tsx +9 -9
  132. package/docs/showcase/table-crud-list.tsx +4 -4
  133. package/docs/showcase/table-density.tsx +49 -42
  134. package/docs/showcase/table-expandable-rows.tsx +112 -100
  135. package/docs/showcase/table-filter-chips.tsx +78 -71
  136. package/docs/showcase/table-footer-totals.tsx +30 -23
  137. package/docs/showcase/table-grouped-subtotals.tsx +31 -25
  138. package/docs/showcase/table-master-detail.tsx +27 -24
  139. package/docs/showcase/table-pagination.tsx +34 -27
  140. package/docs/showcase/table-states.tsx +42 -34
  141. package/docs/showcase/table-sticky-columns.tsx +11 -7
  142. package/docs/showcase/table-tree-rows.tsx +22 -15
  143. package/docs/showcase/table-view-tabs.tsx +12 -14
  144. package/package.json +7 -7
@@ -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 } 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,
@@ -249,32 +242,36 @@ function ItemListCard({
249
242
  : "hover:border-primary")
250
243
  }
251
244
  >
252
- <CardContent solo className="flex items-start gap-3">
253
- {selectMode ? (
254
- <Checkbox
255
- checked={selected}
256
- onCheckedChange={onToggle}
257
- aria-label={`選択 ${item.name}`}
258
- className="mt-0.5 size-5"
259
- />
260
- ) : null}
261
- <div className="min-w-0 flex-1">
262
- <Text as="div" size="sm" weight="bold" truncate>
263
- {item.name}
264
- </Text>
265
- <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
266
- <MonoCode>{item.rc}</MonoCode>
267
- <MonoCode>JAN {item.jan}</MonoCode>
268
- </div>
269
- <Flex align="center" justify="between" gap="sm" className="mt-1.5">
270
- <Badge tone={st.tone} variant="outline" className="rounded-full">
271
- {st.label}
272
- </Badge>
273
- <Text size="xs" tone="muted" tabular className="shrink-0">
274
- ×{item.qty} · {item.receivedAt}
275
- </Text>
245
+ <CardContent solo>
246
+ <Flex align="start" gap="md">
247
+ <Flex direction="col" gap="xs">
248
+ {selectMode ? (
249
+ <Checkbox
250
+ checked={selected}
251
+ onCheckedChange={onToggle}
252
+ aria-label={`選択 ${item.name}`}
253
+ className="size-5"
254
+ />
255
+ ) : null}
256
+ <Flex direction="col" gap="sm" className="min-w-0 flex-1">
257
+ <Text as="div" size="sm" weight="bold" truncate>
258
+ {item.name}
259
+ </Text>
260
+ <Flex wrap align="center" gap="sm">
261
+ <MonoCode>{item.rc}</MonoCode>
262
+ <MonoCode>JAN {item.jan}</MonoCode>
263
+ </Flex>
264
+ <Flex align="center" justify="between" gap="sm">
265
+ <Badge tone={st.tone} variant="outline" className="rounded-full">
266
+ {st.label}
267
+ </Badge>
268
+ <Text size="xs" tone="muted" tabular className="shrink-0">
269
+ ×{item.qty} · {item.receivedAt}
270
+ </Text>
271
+ </Flex>
272
+ </Flex>
276
273
  </Flex>
277
- </div>
274
+ </Flex>
278
275
  </CardContent>
279
276
  </Card>
280
277
  );
@@ -293,22 +290,24 @@ function PackingListCard({ packing, onTap }: { packing: Packing; onTap?: () => v
293
290
  }
294
291
  >
295
292
  <CardContent solo>
296
- <Flex align="center" justify="between" gap="sm">
297
- <Text as="code" size="sm" weight="bold" tabular>
298
- {packing.code}
299
- </Text>
300
- <Badge tone={st.tone} variant="outline" className="rounded-full">
301
- {st.label}
302
- </Badge>
303
- </Flex>
304
- <Flex align="center" justify="between" gap="sm" className="mt-1">
305
- <Text size="xs" tone="muted" truncate>
306
- {packing.customer} · {packing.city}
307
- </Text>
308
- <Text size="xs" tone="muted" tabular className="shrink-0">
309
- ×{packing.items}
310
- {packing.slot ? ` · ${packing.slot}` : ""}
311
- </Text>
293
+ <Flex direction="col" gap="xs">
294
+ <Flex align="center" justify="between" gap="sm">
295
+ <Text as="code" size="sm" weight="bold" tabular>
296
+ {packing.code}
297
+ </Text>
298
+ <Badge tone={st.tone} variant="outline" className="rounded-full">
299
+ {st.label}
300
+ </Badge>
301
+ </Flex>
302
+ <Flex align="center" justify="between" gap="sm">
303
+ <Text size="xs" tone="muted" truncate>
304
+ {packing.customer} · {packing.city}
305
+ </Text>
306
+ <Text size="xs" tone="muted" tabular className="shrink-0">
307
+ ×{packing.items}
308
+ {packing.slot ? ` · ${packing.slot}` : ""}
309
+ </Text>
310
+ </Flex>
312
311
  </Flex>
313
312
  </CardContent>
314
313
  </Card>
@@ -318,7 +317,7 @@ function PackingListCard({ packing, onTap }: { packing: Packing; onTap?: () => v
318
317
  /** Three uppercase muted section header + right-aligned mono count. */
319
318
  function SectionHeader({ children, count }: { children: React.ReactNode; count?: number }) {
320
319
  return (
321
- <Flex align="center" justify="between" className="px-0.5">
320
+ <Flex align="center" justify="between">
322
321
  <Text size="2xs" weight="medium" tone="muted" className="tracking-wider uppercase">
323
322
  {children}
324
323
  </Text>
@@ -333,35 +332,32 @@ function SectionHeader({ children, count }: { children: React.ReactNode; count?:
333
332
 
334
333
  // ── Phone shell ──────────────────────────────────────────────────────────────
335
334
 
336
- function PhoneFrame({ children }: { children: React.ReactNode }) {
337
- return (
338
- <div className="mx-auto w-96 max-w-full shrink-0">
339
- <Flex
340
- direction="col"
341
- className="bg-background max-h-[calc(100vh-2rem)] min-h-[844px] overflow-hidden rounded-2xl border"
342
- >
343
- {children}
344
- </Flex>
345
- </div>
346
- );
347
- }
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
+ */
348
342
 
349
343
  function StatusBar() {
350
344
  return (
351
- <div className="flex h-9 shrink-0 items-center justify-between px-5">
345
+ <>
352
346
  <Text size="sm" weight="medium" tabular>
353
347
  9:41
354
348
  </Text>
355
349
  <Text size="sm" weight="medium" tabular tone="muted">
356
350
  Acme Handy
357
351
  </Text>
358
- </div>
352
+ </>
359
353
  );
360
354
  }
361
355
 
362
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.
363
359
  return (
364
- <nav className="grid shrink-0 grid-cols-3 border-t" aria-label="ワークフロー">
360
+ <>
365
361
  {TABS.map((t) => {
366
362
  const isActive = t.id === active;
367
363
  const Icon = t.icon;
@@ -372,7 +368,7 @@ function TabBar({ active, onChange }: { active: string; onChange: (id: string) =
372
368
  onClick={() => onChange(t.id)}
373
369
  aria-current={isActive ? "page" : undefined}
374
370
  className={
375
- "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)] " +
376
372
  (isActive ? "text-primary" : "text-muted-foreground")
377
373
  }
378
374
  >
@@ -381,7 +377,7 @@ function TabBar({ active, onChange }: { active: string; onChange: (id: string) =
381
377
  </Button>
382
378
  );
383
379
  })}
384
- </nav>
380
+ </>
385
381
  );
386
382
  }
387
383
 
@@ -401,48 +397,48 @@ function ItemLookupSheet({
401
397
  <SheetHeader>
402
398
  <SheetTitle>Quét hoặc nhập mã</SheetTitle>
403
399
  </SheetHeader>
404
- <SheetBody className="flex flex-col gap-4">
405
- {/* Viewfinder placeholder — a Card surface, not a hand-rolled illustration */}
406
- <Flex
407
- align="center"
408
- justify="center"
409
- className="border-border bg-secondary/40 h-40 rounded-xl border-2 border-dashed"
410
- >
411
- <Flex direction="col" align="center" gap="sm">
412
- <ScanLine
413
- className="size-7 text-[color:var(--attention,var(--warning))]"
414
- aria-hidden="true"
415
- strokeWidth={1.5}
416
- />
417
- <Text size="sm" tone="muted">
418
- Đưa mã vạch vào khung
400
+ <SheetBody>
401
+ <Flex direction="col" gap="md">
402
+ {/* Viewfinder placeholder — a Card surface, not a hand-rolled illustration */}
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>
417
+ <Flex direction="col" gap="sm">
418
+ <Text size="sm" weight="medium">
419
+ Hoặc nhập mã thủ công
419
420
  </Text>
420
- </Flex>
421
- </Flex>
422
- <Flex direction="col" gap="sm">
423
- <Text size="sm" weight="medium">
424
- Hoặc nhập thủ công
425
- </Text>
426
- <Flex gap="sm">
427
- <Input
428
- value={code}
429
- onChange={(e) => setCode(e.target.value)}
430
- placeholder="RC- / PKG- / JAN"
431
- className="font-mono"
432
- inputMode="text"
433
- autoComplete="off"
434
- />
435
- <Button
436
- onClick={() => {
437
- onOpenChange(false);
438
- setCode("");
439
- toast.success("Đã tìm thấy RC-204881 · Sữa rửa mặt Hada Labo");
440
- }}
441
- disabled={code.trim() === ""}
442
- >
443
- <Search aria-hidden="true" />
444
- Tìm
445
- </Button>
421
+ <Flex gap="sm">
422
+ <Input
423
+ value={code}
424
+ onChange={(e) => setCode(e.target.value)}
425
+ placeholder="RC- / PKG- / JAN"
426
+ className="font-mono"
427
+ inputMode="text"
428
+ autoComplete="off"
429
+ />
430
+ <Button
431
+ onClick={() => {
432
+ onOpenChange(false);
433
+ setCode("");
434
+ toast.success("Đã tìm thấy RC-204881 · Sữa rửa mặt Hada Labo");
435
+ }}
436
+ disabled={code.trim() === ""}
437
+ >
438
+ <Search aria-hidden="true" />
439
+ Tìm
440
+ </Button>
441
+ </Flex>
446
442
  </Flex>
447
443
  </Flex>
448
444
  </SheetBody>
@@ -471,29 +467,31 @@ function PackagePickerSheet({
471
467
  <SheetHeader>
472
468
  <SheetTitle>Gán vào kiện</SheetTitle>
473
469
  </SheetHeader>
474
- <SheetBody className="flex flex-col gap-2">
475
- <SectionHeader count={PACKINGS.length}>Kiện đang mở</SectionHeader>
476
- {PACKINGS.map((p) => (
477
- <PackingListCard
478
- key={p.id}
479
- packing={p}
480
- onTap={() => {
470
+ <SheetBody>
471
+ <Flex direction="col" gap="sm">
472
+ <SectionHeader count={PACKINGS.length}>Kiện đang mở</SectionHeader>
473
+ {PACKINGS.map((p) => (
474
+ <PackingListCard
475
+ key={p.id}
476
+ packing={p}
477
+ onTap={() => {
478
+ onOpenChange(false);
479
+ toast.success(`Đã gán ${count} item vào ${p.code}`);
480
+ }}
481
+ />
482
+ ))}
483
+ <Button
484
+ variant="outline"
485
+ onClick={() => {
481
486
  onOpenChange(false);
482
- toast.success(`Đã gán ${count} item vào ${p.code}`);
487
+ toast.success(`Đã tạo kiện mới với ${count} item`);
483
488
  }}
484
- />
485
- ))}
486
- <Button
487
- variant="outline"
488
- onClick={() => {
489
- onOpenChange(false);
490
- toast.success(`Đã tạo kiện mới với ${count} item`);
491
- }}
492
- className="text-primary hover:border-primary h-11 w-full border-2 border-dashed"
493
- >
494
- <PackagePlus aria-hidden="true" strokeWidth={1.5} />
495
- Tạo kiện mới với {count} item này
496
- </Button>
489
+ className="text-primary hover:border-primary h-11 w-full border-2 border-dashed"
490
+ >
491
+ <PackagePlus aria-hidden="true" strokeWidth={1.5} />
492
+ Tạo kiện mới với {count} item này
493
+ </Button>
494
+ </Flex>
497
495
  </SheetBody>
498
496
  <SheetFooter>
499
497
  <Button variant="outline" onClick={() => onOpenChange(false)}>
@@ -527,66 +525,51 @@ function ItemFormSheet({
527
525
  <SheetHeader>
528
526
  <SheetTitle>Thêm hàng mới</SheetTitle>
529
527
  </SheetHeader>
530
- <SheetBody className="flex flex-col gap-4">
531
- <Flex direction="col" gap="xs">
532
- <Text size="sm" weight="medium">
533
- vạch
534
- </Text>
535
- <Input defaultValue="JAN 4987241135219" readOnly className="font-mono" />
536
- </Flex>
537
- <Flex direction="col" gap="xs">
538
- <Text size="sm" weight="medium">
539
- Tên hàng (tùy chọn)
540
- </Text>
541
- <Input placeholder="Nhập tên hàng" autoComplete="off" />
542
- </Flex>
543
- <Flex direction="col" gap="xs">
544
- <Text size="sm" weight="medium">
545
- Số lượng
546
- </Text>
547
- <Input
548
- type="number"
549
- value={qty}
550
- min={1}
551
- onChange={(e) => setQty(e.target.value)}
552
- className="w-24 tabular-nums"
553
- />
554
- </Flex>
555
- <Flex direction="col" gap="sm">
556
- <Text size="sm" weight="medium">
557
- Đích đến
558
- </Text>
559
- <RadioGroupRoot value={dest} onValueChange={setDest} className="flex flex-col gap-2">
560
- {DESTINATIONS.map((d) => {
561
- const checked = dest === d.id;
562
- const rowId = `handy-dest-${d.id}`;
563
- return (
564
- <label
565
- key={d.id}
566
- htmlFor={rowId}
567
- className={
568
- "flex cursor-pointer items-center gap-3 rounded-xl border p-3 transition-colors " +
569
- (checked
570
- ? "border-primary bg-[color-mix(in_oklch,var(--primary)_8%,transparent)]"
571
- : "hover:border-primary")
572
- }
573
- >
574
- <RadioItem id={rowId} value={d.id} className="mt-0.5" />
575
- <span className="min-w-0">
576
- <Text as="span" size="sm" weight="medium" className="block">
577
- {d.label}
578
- </Text>
579
- <Text as="span" size="xs" tone="muted" className="block">
580
- {d.hint}
581
- </Text>
582
- </span>
583
- </label>
584
- );
585
- })}
586
- </RadioGroupRoot>
528
+ <SheetBody>
529
+ <Flex direction="col" gap="md">
530
+ <Flex direction="col" gap="xs">
531
+ <Text size="sm" weight="medium">
532
+ Mã vạch
533
+ </Text>
534
+ <Input defaultValue="JAN 4987241135219" readOnly className="font-mono" />
535
+ </Flex>
536
+ <Flex direction="col" gap="xs">
537
+ <Text size="sm" weight="medium">
538
+ Tên hàng (tùy chọn)
539
+ </Text>
540
+ <Input placeholder="Nhập tên hàng" autoComplete="off" />
541
+ </Flex>
542
+ <Flex direction="col" gap="xs">
543
+ <Text size="sm" weight="medium">
544
+ Số lượng
545
+ </Text>
546
+ <Input
547
+ type="number"
548
+ value={qty}
549
+ min={1}
550
+ onChange={(e) => setQty(e.target.value)}
551
+ className="w-24 tabular-nums"
552
+ />
553
+ </Flex>
554
+ <Flex direction="col" gap="sm">
555
+ <Text size="sm" weight="medium">
556
+ Đích đến
557
+ </Text>
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
+ />
569
+ </Flex>
587
570
  </Flex>
588
571
  </SheetBody>
589
- <SheetFooter className="flex-row gap-2">
572
+ <SheetFooter className="flex-row">
590
573
  <Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>
591
574
  Huỷ
592
575
  </Button>
@@ -609,26 +592,24 @@ function ItemFormSheet({
609
592
 
610
593
  type ListState = "ready" | "loading" | "empty" | "error";
611
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
+ */
612
599
  function InboundTab({
613
600
  state,
614
601
  onRetry,
615
602
  selectMode,
616
- setSelectMode,
617
603
  selected,
618
604
  setSelected,
619
605
  onScan,
620
- onAdd,
621
- onAssign,
622
606
  }: {
623
607
  state: ListState;
624
608
  onRetry: () => void;
625
609
  selectMode: boolean;
626
- setSelectMode: (v: boolean) => void;
627
610
  selected: Set<string>;
628
611
  setSelected: React.Dispatch<React.SetStateAction<Set<string>>>;
629
612
  onScan: () => void;
630
- onAdd: () => void;
631
- onAssign: () => void;
632
613
  }) {
633
614
  const [filter, setFilter] = React.useState<string>("all");
634
615
 
@@ -641,22 +622,22 @@ function InboundTab({
641
622
  );
642
623
 
643
624
  return (
644
- <>
645
- <div className="flex flex-1 flex-col gap-3 overflow-y-auto p-4">
646
- {/* Filter chips — horizontal scroll, count pills */}
647
- <ToggleGroup
648
- type="single"
649
- value={filter}
650
- onValueChange={(v) => {
651
- if (v) setFilter(v);
652
- }}
653
- className="flex w-full justify-start gap-2 overflow-x-auto"
654
- >
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">
655
636
  {FILTERS.map((f) => (
656
637
  <ToggleGroupItem
657
638
  key={f.id}
658
639
  value={f.id}
659
- className="h-9 shrink-0 gap-1.5 rounded-full px-3 whitespace-nowrap text-[var(--font-size-xs)]"
640
+ className="h-9 shrink-0 rounded-full whitespace-nowrap text-[var(--font-size-xs)]"
660
641
  >
661
642
  {f.label}
662
643
  <Text size="xs" mono tabular className="opacity-70">
@@ -664,125 +645,146 @@ function InboundTab({
664
645
  </Text>
665
646
  </ToggleGroupItem>
666
647
  ))}
667
- </ToggleGroup>
648
+ </Flex>
649
+ </ToggleGroup>
668
650
 
669
- {state === "loading" ? (
670
- <Flex direction="col" gap="md" aria-busy="true">
671
- {Array.from({ length: 3 }).map((_, i) => (
672
- <Card key={i} density="tight" className="rounded-xl">
673
- <CardContent solo>
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">
674
657
  <Skeleton className="h-4 w-3/5" />
675
- <Skeleton className="mt-2 h-3 w-2/5" />
676
- <Skeleton className="mt-3 h-5 w-24 rounded-full" />
677
- </CardContent>
678
- </Card>
679
- ))}
680
- </Flex>
681
- ) : state === "error" ? (
682
- <Alert tone="warning">
683
- <AlertTitle>Không tải được danh sách</AlertTitle>
684
- <AlertDescription className="flex flex-col items-start gap-2">
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">
685
670
  Kiểm tra kết nối rồi thử lại.
686
671
  <Button variant="outline" size="sm" onClick={onRetry}>
687
672
  <RefreshCw aria-hidden="true" />
688
673
  Thử lại
689
674
  </Button>
690
- </AlertDescription>
691
- </Alert>
692
- ) : state === "empty" || visible.length === 0 ? (
693
- <EmptyState
694
- icon={Boxes}
695
- title="Chưa có hàng trong mục này"
696
- description="Quét để nhập hàng mới vào kho."
697
- action={
698
- <Button size="sm" onClick={onScan}>
699
- <ScanLine aria-hidden="true" />
700
- Quét / Tìm mã
701
- </Button>
702
- }
703
- />
704
- ) : (
705
- <Flex direction="col" gap="md">
706
- {visible.map((item) => (
707
- <ItemListCard
708
- key={item.id}
709
- item={item}
710
- selectMode={selectMode}
711
- selected={selected.has(item.id)}
712
- onToggle={() =>
713
- setSelected((prev) => {
714
- const next = new Set(prev);
715
- if (next.has(item.id)) next.delete(item.id);
716
- else next.add(item.id);
717
- return next;
718
- })
719
- }
720
- />
721
- ))}
722
- </Flex>
723
- )}
724
- </div>
725
-
726
- {/* Sticky action bar (scan-first) OR select-mode contextual bar */}
727
- {selectMode ? (
728
- <div className="shrink-0 border-t bg-[color-mix(in_oklch,var(--primary)_5%,var(--background))] p-3">
729
- <Flex align="center" justify="between" className="mb-2">
730
- <Text size="sm" tabular>
731
- <Text as="span" weight="bold">
732
- {selected.size}
733
- </Text>{" "}
734
- item đã chọn
735
- </Text>
736
- <Button
737
- variant="ghost"
738
- size="sm"
739
- onClick={() => {
740
- setSelectMode(false);
741
- setSelected(new Set());
742
- }}
743
- >
744
- Xong
745
- </Button>
746
- </Flex>
747
- <Flex gap="sm">
748
- <Button className="flex-[2]" disabled={selected.size === 0} onClick={onAssign}>
749
- <Package aria-hidden="true" />
750
- Gán vào kiện…
751
- </Button>
752
- <Button variant="outline" className="flex-1" disabled={selected.size === 0}>
753
- 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 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ã
754
687
  </Button>
755
- </Flex>
756
- </div>
688
+ }
689
+ />
757
690
  ) : (
758
- <div className="flex shrink-0 gap-2 border-t p-3">
759
- <Button className="flex-[2]" onClick={onScan}>
760
- <ScanLine aria-hidden="true" />
761
- Quét / Tìm mã
762
- </Button>
763
- <Button variant="outline" className="flex-1" onClick={onAdd}>
764
- <Plus aria-hidden="true" />
765
- Thêm hàng
766
- </Button>
767
- </div>
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
+ ))}
708
+ </Flex>
768
709
  )}
769
- </>
710
+ </Flex>
711
+ );
712
+ }
713
+
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>
770
772
  );
771
773
  }
772
774
 
773
- function PackingTab({ onScan }: { onScan: () => void }) {
775
+ function PackingTab() {
774
776
  const active = PACKINGS.find((p) => p.status === "active");
775
777
  const others = PACKINGS.filter((p) => p.status !== "active");
776
778
  return (
777
- <>
778
- <div className="flex flex-1 flex-col gap-3 overflow-y-auto p-4">
779
- {active ? (
780
- <Card
781
- density="tight"
782
- accent="primary"
783
- className="border-primary rounded-xl bg-[color-mix(in_oklch,var(--primary)_6%,transparent)]"
784
- >
785
- <CardContent solo>
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">
786
788
  <Flex align="center" justify="between">
787
789
  <Text size="2xs" weight="medium" tone="muted" className="tracking-wider uppercase">
788
790
  Kiện đang làm
@@ -791,51 +793,59 @@ function PackingTab({ onScan }: { onScan: () => void }) {
791
793
  {PACKING_STATUS.active.label}
792
794
  </Badge>
793
795
  </Flex>
794
- <Text as="div" size="lg" weight="bold" mono tabular className="mt-1">
796
+ <Text as="div" size="lg" weight="bold" mono tabular>
795
797
  {active.code}
796
798
  </Text>
797
- <Text size="xs" tone="muted" tabular as="div" className="mt-0.5">
799
+ <Text size="xs" tone="muted" tabular as="div">
798
800
  {active.customer} · {active.city} · ×{active.items} · {active.slot}
799
801
  </Text>
800
- </CardContent>
801
- </Card>
802
- ) : null}
802
+ </Flex>
803
+ </CardContent>
804
+ </Card>
805
+ ) : null}
803
806
 
804
- <SectionHeader count={others.length}>Kiện đang mở</SectionHeader>
805
- <Flex direction="col" gap="md">
806
- {others.map((p) => (
807
- <PackingListCard key={p.id} packing={p} onTap={() => undefined} />
808
- ))}
809
- </Flex>
810
- </div>
811
- <div className="flex shrink-0 flex-col gap-2 border-t p-3">
812
- <Button onClick={onScan}>
813
- <ScanLine aria-hidden="true" />
814
- Quét item vào kiện
815
- </Button>
816
- <Button variant="outline">
817
- <Plus aria-hidden="true" />
818
- Tạo kiện trống
819
- </Button>
820
- </div>
821
- </>
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>
822
814
  );
823
815
  }
824
816
 
825
- function OutboundTab({ onSeal, onHandoff }: { onSeal: () => void; onHandoff: () => void }) {
826
- const [seg, setSeg] = React.useState<string>("seal");
817
+ function PackingActions({ onScan }: { onScan: () => void }) {
827
818
  return (
828
- <>
829
- <div className="flex flex-1 flex-col gap-3 overflow-y-auto p-4">
830
- {/* Segmented — outbound status */}
831
- <ToggleGroup
832
- type="single"
833
- value={seg}
834
- onValueChange={(v) => {
835
- if (v) setSeg(v);
836
- }}
837
- className="bg-secondary/60 grid w-full grid-cols-3 gap-1 rounded-xl p-1"
838
- >
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">
839
849
  <ToggleGroupItem
840
850
  value="seal"
841
851
  className="h-9 rounded-xl whitespace-nowrap text-[var(--font-size-xs)]"
@@ -854,19 +864,21 @@ function OutboundTab({ onSeal, onHandoff }: { onSeal: () => void; onHandoff: ()
854
864
  >
855
865
  Đã bàn giao
856
866
  </ToggleGroupItem>
857
- </ToggleGroup>
867
+ </ResponsiveGrid>
868
+ </ToggleGroup>
858
869
 
859
- <SectionHeader count={OUTBOUND.length}>
860
- {seg === "seal"
861
- ? "Sẵn sàng niêm phong"
862
- : seg === "handoff"
863
- ? "Chờ bàn giao"
864
- : "Đã bàn giao"}
865
- </SectionHeader>
866
- <Flex direction="col" gap="md">
867
- {OUTBOUND.map((p) => (
868
- <Card key={p.id} density="tight" className="rounded-xl">
869
- <CardContent solo>
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">
880
+ <CardContent solo>
881
+ <Flex direction="col" gap="md">
870
882
  <Flex align="center" justify="between" gap="sm">
871
883
  <Text as="code" size="sm" weight="bold" tabular>
872
884
  {p.code}
@@ -875,7 +887,7 @@ function OutboundTab({ onSeal, onHandoff }: { onSeal: () => void; onHandoff: ()
875
887
  Sẵn sàng niêm phong
876
888
  </Badge>
877
889
  </Flex>
878
- <Flex align="center" justify="between" gap="sm" className="mt-1">
890
+ <Flex align="center" justify="between" gap="sm">
879
891
  <Text size="xs" tone="muted" tabular truncate>
880
892
  {p.customer} · {p.city}
881
893
  </Text>
@@ -883,8 +895,8 @@ function OutboundTab({ onSeal, onHandoff }: { onSeal: () => void; onHandoff: ()
883
895
  ×{p.items} · {p.slot}
884
896
  </Text>
885
897
  </Flex>
886
- <div className="mt-3">
887
- <Descriptions columns={1} className="gap-y-1">
898
+ <div>
899
+ <Descriptions columns={1}>
888
900
  <Descriptions.Item label="Vị trí" mono>
889
901
  {p.slot}
890
902
  </Descriptions.Item>
@@ -893,25 +905,34 @@ function OutboundTab({ onSeal, onHandoff }: { onSeal: () => void; onHandoff: ()
893
905
  </Descriptions.Item>
894
906
  </Descriptions>
895
907
  </div>
896
- </CardContent>
897
- </Card>
898
- ))}
899
- </Flex>
900
- </div>
901
- <div className="flex shrink-0 gap-2 border-t p-3">
902
- {seg === "handoff" ? (
903
- <Button className="flex-1" onClick={onHandoff}>
904
- <Truck aria-hidden="true" />
905
- Xác nhận bàn giao
906
- </Button>
907
- ) : (
908
- <Button className="flex-1" onClick={onSeal}>
909
- <Check aria-hidden="true" />
910
- Quét mã kiện · Niêm phong
911
- </Button>
912
- )}
913
- </div>
914
- </>
908
+ </Flex>
909
+ </CardContent>
910
+ </Card>
911
+ ))}
912
+ </Flex>
913
+ </Flex>
914
+ );
915
+ }
916
+
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 mã kiện · Niêm phong
935
+ </Button>
915
936
  );
916
937
  }
917
938
 
@@ -923,6 +944,7 @@ export default function AgencyHandyShowcase() {
923
944
 
924
945
  const [selectMode, setSelectMode] = React.useState(false);
925
946
  const [selected, setSelected] = React.useState<Set<string>>(new Set());
947
+ const [seg, setSeg] = React.useState<string>("seal");
926
948
 
927
949
  const [lookupOpen, setLookupOpen] = React.useState(false);
928
950
  const [formOpen, setFormOpen] = React.useState(false);
@@ -933,111 +955,133 @@ export default function AgencyHandyShowcase() {
933
955
 
934
956
  const headerTitle = tab === "inbound" ? "Nhập kho" : tab === "packing" ? "Đóng gói" : "Xuất kho";
935
957
 
936
- return (
937
- <div className="bg-secondary/30 min-h-screen py-4">
938
- <Toaster />
939
- <PhoneFrame>
940
- <StatusBar />
958
+ const exitSelect = () => {
959
+ setSelectMode(false);
960
+ setSelected(new Set());
961
+ };
941
962
 
942
- {/* App header (52px)title + iOS text-action select-mode entry (inbound only) */}
943
- <header className="flex h-14 shrink-0 items-center justify-between border-b px-4">
944
- <Heading level={3} as="h1" className="whitespace-nowrap">
945
- {headerTitle}
946
- </Heading>
947
- <Flex align="center" gap="xs">
948
- {tab === "inbound" && !selectMode ? (
949
- <Button
950
- variant="ghost"
951
- size="sm"
952
- onClick={() => {
953
- setSelectMode(true);
954
- setSelected(new Set());
955
- }}
956
- >
957
- <Check aria-hidden="true" />
958
- Chọn
959
- </Button>
960
- ) : null}
961
- {/* State switcher — exposes the 4 list states at rest (showcase affordance) */}
962
- {tab === "inbound" && !selectMode ? (
963
- <ToggleGroup
964
- type="single"
965
- value={listState}
966
- onValueChange={(v) => {
967
- if (v) setListState(v as ListState);
968
- }}
969
- aria-label="List state (showcase)"
970
- className="flex gap-0.5"
971
- >
972
- {(["ready", "loading", "empty", "error"] as const).map((s) => (
973
- <ToggleGroupItem
974
- key={s}
975
- value={s}
976
- className="size-7 rounded-md p-0 text-[var(--font-size-2xs)] uppercase"
977
- title={s}
978
- >
979
- {s[0]}
980
- </ToggleGroupItem>
981
- ))}
982
- </ToggleGroup>
983
- ) : null}
984
- </Flex>
985
- </header>
986
-
987
- {/* Select-mode header strip — replaces the standard header context (inbound) */}
988
- {selectMode ? (
989
- <div className="flex h-10 shrink-0 items-center justify-between border-b bg-[color-mix(in_oklch,var(--primary)_5%,var(--background))] px-3">
990
- <Button
991
- variant="ghost"
992
- size="icon-sm"
993
- aria-label="閉じる"
994
- onClick={() => {
995
- setSelectMode(false);
996
- setSelected(new Set());
997
- }}
998
- >
999
- <X className="size-4" aria-hidden="true" strokeWidth={1.5} />
1000
- </Button>
1001
- <Text size="sm" mono tabular>
1002
- {selected.size} đã chọn
1003
- </Text>
1004
- <Button
1005
- variant="ghost"
1006
- size="sm"
1007
- onClick={() => setSelected(new Set(ITEMS.map((i) => i.id)))}
1008
- >
1009
- Chọn tất cả
1010
- </Button>
1011
- </div>
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>
1012
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}
1019
+ >
1020
+ {s[0]}
1021
+ </ToggleGroupItem>
1022
+ ))}
1023
+ </Flex>
1024
+ </ToggleGroup>
1025
+ ) : null}
1026
+ </Flex>
1027
+ </Flex>
1028
+ );
1013
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
+ );
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
+ >
1014
1070
  {tab === "inbound" ? (
1015
1071
  <InboundTab
1016
1072
  state={listState}
1017
1073
  onRetry={() => setListState("ready")}
1018
1074
  selectMode={selectMode}
1019
- setSelectMode={setSelectMode}
1020
1075
  selected={selected}
1021
1076
  setSelected={setSelected}
1022
1077
  onScan={() => setLookupOpen(true)}
1023
- onAdd={() => setFormOpen(true)}
1024
- onAssign={() => setPickerOpen(true)}
1025
1078
  />
1026
1079
  ) : tab === "packing" ? (
1027
- <PackingTab onScan={() => setLookupOpen(true)} />
1080
+ <PackingTab />
1028
1081
  ) : (
1029
- <OutboundTab onSeal={() => setSealOpen(true)} onHandoff={() => setHandoffOpen(true)} />
1082
+ <OutboundTab seg={seg} setSeg={setSeg} />
1030
1083
  )}
1031
-
1032
- <TabBar
1033
- active={tab}
1034
- onChange={(id) => {
1035
- setTab(id);
1036
- setSelectMode(false);
1037
- setSelected(new Set());
1038
- }}
1039
- />
1040
- </PhoneFrame>
1084
+ </MobileShell>
1041
1085
 
1042
1086
  {/* Sheets */}
1043
1087
  <ItemLookupSheet open={lookupOpen} onOpenChange={setLookupOpen} />
@@ -1067,6 +1111,6 @@ export default function AgencyHandyShowcase() {
1067
1111
  toast.success("Đã bàn giao PKG-000037 · 16:24");
1068
1112
  }}
1069
1113
  />
1070
- </div>
1114
+ </>
1071
1115
  );
1072
1116
  }