@exxatdesignux/ui 0.2.17 → 0.2.19

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 (162) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/consumer-extras/AGENTS.md +76 -0
  3. package/consumer-extras/README.md +5 -1
  4. package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +14 -3
  5. package/consumer-extras/cursor-skills/exxat-consumer-app/SKILL.md +37 -0
  6. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +22 -7
  7. package/consumer-extras/cursor-skills/exxat-focused-workflow-page/SKILL.md +57 -0
  8. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
  9. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +10 -3
  10. package/consumer-extras/patterns/consumer-app-pattern.md +39 -0
  11. package/consumer-extras/patterns/consumer-upgrade-checklist.md +20 -0
  12. package/consumer-extras/patterns/data-views-pattern.md +42 -3
  13. package/consumer-extras/patterns/focused-workflow-page-pattern.md +84 -0
  14. package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
  15. package/consumer-extras/patterns/shell-surface-elevation-pattern.md +54 -0
  16. package/package.json +2 -1
  17. package/src/components/ui/button-group.tsx +81 -0
  18. package/src/components/ui/button.tsx +4 -4
  19. package/src/components/ui/sidebar.tsx +2 -2
  20. package/src/globals.css +7 -1807
  21. package/src/theme.css +10 -1126
  22. package/src/tokens/README.md +15 -0
  23. package/src/tokens/base.css +337 -0
  24. package/src/tokens/high-contrast.css +1195 -0
  25. package/src/tokens/layers.css +224 -0
  26. package/src/tokens/tailwind-bridge.css +118 -0
  27. package/src/tokens/themes.css +201 -0
  28. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
  29. package/template/AGENTS.md +66 -21
  30. package/template/app/(app)/dashboard/loading.tsx +3 -15
  31. package/template/app/(app)/dashboard/page.tsx +2 -14
  32. package/template/app/(app)/data-list/layout.tsx +43 -0
  33. package/template/app/(app)/data-list/page.tsx +2 -2
  34. package/template/app/(app)/error.tsx +22 -6
  35. package/template/app/(app)/examples/focused-workflow/page.tsx +5 -0
  36. package/template/app/(app)/examples/page.tsx +1 -0
  37. package/template/app/(app)/layout.tsx +13 -6
  38. package/template/app/(app)/loading.tsx +1 -18
  39. package/template/app/(app)/question-bank/find/page.tsx +2 -1
  40. package/template/app/(app)/question-bank/library/page.tsx +2 -1
  41. package/template/app/(app)/question-bank/list/page.tsx +2 -1
  42. package/template/app/(app)/question-bank/new/page.tsx +15 -23
  43. package/template/app/(app)/question-bank/page.tsx +2 -1
  44. package/template/app/(app)/settings/page.tsx +4 -5
  45. package/template/app/global-error.tsx +63 -0
  46. package/template/app/globals.css +7 -1934
  47. package/template/app/layout.tsx +2 -0
  48. package/template/components/app-route-loading.tsx +14 -0
  49. package/template/components/app-sidebar.tsx +71 -55
  50. package/template/components/data-table/index.tsx +31 -67
  51. package/template/components/data-table/use-table-state.ts +33 -6
  52. package/template/components/data-views/index.ts +37 -9
  53. package/template/components/data-views/list-page-calendar-view.tsx +593 -0
  54. package/template/components/data-views/list-page-connected-view-body.tsx +66 -0
  55. package/template/components/data-views/list-page-folder-columns-panel.tsx +345 -0
  56. package/template/components/data-views/list-page-split-hub-chrome.tsx +8 -0
  57. package/template/components/dev-chunk-load-recovery.tsx +41 -0
  58. package/template/components/examples/focused-workflow-showcase.tsx +183 -0
  59. package/template/components/exxat-product-logo.tsx +2 -6
  60. package/template/components/key-metrics.tsx +54 -22
  61. package/template/components/list-hub-board-view.tsx +68 -0
  62. package/template/components/list-hub-client.tsx +186 -0
  63. package/template/components/list-hub-list-view.tsx +36 -0
  64. package/template/components/list-hub-panel-activator.tsx +8 -0
  65. package/template/components/list-hub-secondary-nav.tsx +121 -0
  66. package/template/components/list-hub-table.tsx +336 -0
  67. package/template/components/new-question-composer.tsx +6 -24
  68. package/template/components/product-switcher.tsx +5 -5
  69. package/template/components/product-wordmark.tsx +4 -7
  70. package/template/components/question-bank-client.tsx +4 -1
  71. package/template/components/question-bank-folder-columns-panel.tsx +104 -0
  72. package/template/components/question-bank-hub-client.tsx +2 -5
  73. package/template/components/question-bank-table.tsx +155 -509
  74. package/template/components/secondary-panel/nav-link-rows.tsx +83 -0
  75. package/template/components/secondary-panel.tsx +4 -44
  76. package/template/components/secondary-panels/list-hub-panel.tsx +39 -0
  77. package/template/components/secondary-panels/question-bank-panel.tsx +39 -0
  78. package/template/components/secondary-panels/registry.tsx +15 -0
  79. package/template/components/settings-appearance-card.tsx +3 -2
  80. package/template/components/settings-client.tsx +59 -15
  81. package/template/components/settings-form-row.tsx +9 -4
  82. package/template/components/sidebar-shell.tsx +2 -1
  83. package/template/components/table-properties/drawer-button.tsx +51 -20
  84. package/template/components/table-properties/drawer.tsx +81 -17
  85. package/template/components/templates/focused-workflow-layouts.tsx +448 -0
  86. package/template/components/templates/focused-workflow-page-template.tsx +69 -0
  87. package/template/components/templates/list-page.tsx +40 -13
  88. package/template/components/templates/nested-secondary-panel-shell.tsx +3 -2
  89. package/template/components/templates/page-loading-shell.tsx +262 -0
  90. package/template/components/ui/button-group.tsx +1 -0
  91. package/template/contexts/product-context.tsx +21 -2
  92. package/template/docs/consumer-app-pattern.md +39 -0
  93. package/template/docs/data-views-pattern.md +42 -3
  94. package/template/docs/drawer-vs-dialog-pattern.md +3 -1
  95. package/template/docs/focused-workflow-page-pattern.md +84 -0
  96. package/template/docs/kpi-flat-band-pattern.md +57 -0
  97. package/template/docs/kpi-strip-max-four-pattern.md +1 -0
  98. package/template/docs/shell-surface-elevation-pattern.md +54 -0
  99. package/template/lib/chunk-load-error.ts +13 -0
  100. package/template/lib/command-menu-search-data.ts +11 -27
  101. package/template/lib/conditional-rule-match.ts +87 -22
  102. package/template/lib/data-list-display-options.ts +16 -2
  103. package/template/lib/data-list-view-registry.ts +104 -0
  104. package/template/lib/data-list-view-surface.ts +15 -1
  105. package/template/lib/data-list-view.ts +16 -1
  106. package/template/lib/data-view-dashboard-storage.ts +38 -35
  107. package/template/lib/hub-connected-view-renderers.ts +58 -0
  108. package/template/lib/list-hub-nav.ts +121 -0
  109. package/template/lib/list-hub-supported-views.ts +10 -0
  110. package/template/lib/list-page-table-properties.ts +3 -7
  111. package/template/lib/list-status-badges.ts +4 -97
  112. package/template/lib/mock/list-hub-directory.ts +27 -0
  113. package/template/lib/mock/list-hub-kpi.ts +27 -0
  114. package/template/lib/mock/navigation.tsx +1 -0
  115. package/template/lib/page-loading-variant.ts +40 -0
  116. package/template/lib/question-bank-supported-views.ts +13 -0
  117. package/template/lib/sidebar-state-cookie.ts +9 -0
  118. package/template/lib/table-state-lifecycle.ts +60 -13
  119. package/template/app/(app)/data-list/[id]/page.tsx +0 -44
  120. package/template/app/(app)/data-list/new/page.tsx +0 -34
  121. package/template/components/compliance-board-view.tsx +0 -142
  122. package/template/components/compliance-client.tsx +0 -92
  123. package/template/components/compliance-list-view.tsx +0 -54
  124. package/template/components/compliance-page-header.tsx +0 -89
  125. package/template/components/compliance-table.tsx +0 -632
  126. package/template/components/data-view-dashboard-charts-compliance.tsx +0 -963
  127. package/template/components/data-view-dashboard-charts-team.tsx +0 -971
  128. package/template/components/data-view-dashboard-charts.tsx +0 -1503
  129. package/template/components/new-placement-back-btn.tsx +0 -28
  130. package/template/components/new-placement-form.tsx +0 -1068
  131. package/template/components/placement-board-card.tsx +0 -262
  132. package/template/components/placement-detail.tsx +0 -438
  133. package/template/components/placements-board-view.tsx +0 -404
  134. package/template/components/placements-client.tsx +0 -252
  135. package/template/components/placements-list-view.tsx +0 -171
  136. package/template/components/placements-page-header.tsx +0 -166
  137. package/template/components/placements-table-cells.test.tsx +0 -22
  138. package/template/components/placements-table-cells.tsx +0 -173
  139. package/template/components/placements-table-columns.tsx +0 -640
  140. package/template/components/placements-table.tsx +0 -1675
  141. package/template/components/rotations-empty-state.tsx +0 -50
  142. package/template/components/rotations-panel-activator.tsx +0 -8
  143. package/template/components/sites-all-client.tsx +0 -154
  144. package/template/components/sites-board-view.tsx +0 -67
  145. package/template/components/sites-list-view.tsx +0 -42
  146. package/template/components/sites-table.tsx +0 -402
  147. package/template/components/team-board-view.tsx +0 -122
  148. package/template/components/team-client.tsx +0 -100
  149. package/template/components/team-list-view.tsx +0 -59
  150. package/template/components/team-page-header.tsx +0 -92
  151. package/template/components/team-table.tsx +0 -714
  152. package/template/lib/data-view-dashboard-placements-layout.ts +0 -215
  153. package/template/lib/mock/compliance-kpi.ts +0 -61
  154. package/template/lib/mock/compliance.ts +0 -146
  155. package/template/lib/mock/placements-kpi.ts +0 -134
  156. package/template/lib/mock/placements.ts +0 -183
  157. package/template/lib/mock/sites-directory.ts +0 -16
  158. package/template/lib/mock/sites-kpi.ts +0 -25
  159. package/template/lib/mock/team-kpi.ts +0 -60
  160. package/template/lib/mock/team.ts +0 -118
  161. package/template/lib/placement-board-card-layout.ts +0 -79
  162. package/template/lib/placement-lifecycle.ts +0 -5
@@ -26,7 +26,10 @@ import {
26
26
  SelectTrigger,
27
27
  SelectValue,
28
28
  } from "@/components/ui/select"
29
- import type { DataListDisplayOptions } from "@/lib/data-list-display-options"
29
+ import {
30
+ CALENDAR_MAIN_VIEW_TILES,
31
+ type DataListDisplayOptions,
32
+ } from "@/lib/data-list-display-options"
30
33
  import { Tip } from "@/components/ui/tip"
31
34
  import { ToggleSwitch } from "@/components/ui/toggle-switch"
32
35
  import { Button } from "@/components/ui/button"
@@ -95,6 +98,11 @@ export interface TablePropertiesDrawerProps {
95
98
  // View type
96
99
  currentView?: DataListViewType
97
100
  onViewChange?: (view: DataListViewType) => void
101
+ /**
102
+ * View-type tiles in Properties — defaults to all `DATA_LIST_VIEW_TILES`.
103
+ * Pass hub-filtered options (same set as `ListPageTemplate` `supportedViewTypes`).
104
+ */
105
+ viewTypeOptions?: readonly { value: DataListViewType; label: string; icon: string }[]
98
106
  /** Lifecycle context (e.g. tab filter) — shown in the drawer header */
99
107
  lifecycleTabLabel?: string
100
108
  /**
@@ -117,6 +125,9 @@ export interface TablePropertiesDrawerProps {
117
125
 
118
126
  type SheetPanel = "main" | "table-display" | "filter" | "sort" | "group" | "columns" | "conditional-rules"
119
127
 
128
+ /** Properties sheet uses `z-[80]`; default portaled menus are `z-50` and sit underneath. */
129
+ const PROPERTIES_SHEET_PORTAL_Z = "z-[90]"
130
+
120
131
  export function TablePropertiesDrawer({
121
132
  open,
122
133
  onOpenChange,
@@ -158,6 +169,7 @@ export function TablePropertiesDrawer({
158
169
  filterFields = FILTER_FIELDS,
159
170
  currentView,
160
171
  onViewChange,
172
+ viewTypeOptions = DATA_LIST_VIEW_TILES,
161
173
  lifecycleTabLabel,
162
174
  fieldDefinitions,
163
175
  resolveColumnLabel: resolveColumnLabelProp,
@@ -197,6 +209,7 @@ export function TablePropertiesDrawer({
197
209
 
198
210
  const viewSurface = currentView ?? "table"
199
211
  const isBoardView = viewSurface === "board"
212
+ const isCalendarView = viewSurface === "calendar"
200
213
  const boardGroupByLabel =
201
214
  boardGroupByColumnOptions?.find(o => o.key === displayOptions.boardGroupByColumnKey)?.label
202
215
  const viewDisplayLabel = dataListViewLabel(viewSurface)
@@ -219,10 +232,16 @@ export function TablePropertiesDrawer({
219
232
  if (viewSurface === "dashboard") {
220
233
  return "Charts · KPI metrics"
221
234
  }
235
+ if (viewSurface === "calendar") {
236
+ return [
237
+ displayOptions.showCalendarSummaryPanel ? "Summary panel" : "No summary",
238
+ displayOptions.calendarMainView === "week" ? "Week layout" : "Month layout",
239
+ ].join(" · ")
240
+ }
222
241
  return [showGridlines ? "Gridlines" : null, pagination ? "Paginated" : null].filter(Boolean).join(" · ") || "Default"
223
242
  })()
224
243
  const viewDisplayIcon =
225
- DATA_LIST_VIEW_TILES.find(t => t.value === viewSurface)?.icon ?? "fa-table"
244
+ viewTypeOptions.find(t => t.value === viewSurface)?.icon ?? "fa-table"
226
245
 
227
246
  // ── Sort drag-and-drop ────────────────────────────────────────────────────
228
247
  const sortDrag = useDraggableList(sortRules, r => r.id, onSortRulesChange)
@@ -243,7 +262,7 @@ export function TablePropertiesDrawer({
243
262
  : "—"
244
263
 
245
264
  return (
246
- <Sheet open={open} onOpenChange={onOpenChange}>
265
+ <Sheet open={open} onOpenChange={onOpenChange} modal={false}>
247
266
  <SheetContent
248
267
  side="right"
249
268
  showCloseButton={false}
@@ -284,7 +303,7 @@ export function TablePropertiesDrawer({
284
303
  <div className="px-4 pb-3">
285
304
  <SelectionTileGrid<DataListViewType>
286
305
  sectionLabel="View type"
287
- options={DATA_LIST_VIEW_TILES}
306
+ options={viewTypeOptions}
288
307
  columns={4}
289
308
  value={currentView}
290
309
  onValueChange={onViewChange}
@@ -435,6 +454,12 @@ export function TablePropertiesDrawer({
435
454
  </p>
436
455
  ) : null}
437
456
 
457
+ {isCalendarView ? (
458
+ <p className="text-xs text-muted-foreground leading-relaxed">
459
+ {dataListViewLabel("calendar")} uses the same filtered rows as table and board. Scroll the main calendar vertically to move between months; use the summary panel for a mini month picker and event list.
460
+ </p>
461
+ ) : null}
462
+
438
463
  {isBoardView && boardGroupByColumnOptions && boardGroupByColumnOptions.length > 1 ? (
439
464
  <div className="flex items-center justify-between gap-3 py-2">
440
465
  <div className="flex items-center gap-2.5 min-w-0 flex-1">
@@ -462,7 +487,7 @@ export function TablePropertiesDrawer({
462
487
  >
463
488
  <SelectValue />
464
489
  </SelectTrigger>
465
- <SelectContent align="end">
490
+ <SelectContent align="end" className={PROPERTIES_SHEET_PORTAL_Z}>
466
491
  {boardGroupByColumnOptions.map(o => (
467
492
  <SelectItem key={o.key} value={o.key}>
468
493
  {o.label}
@@ -510,7 +535,8 @@ export function TablePropertiesDrawer({
510
535
  <div
511
536
  className={cn(
512
537
  "space-y-3",
513
- (viewSurface === "board" || viewSurface === "table") && "border-t border-border pt-4",
538
+ (viewSurface === "board" || viewSurface === "table" || isCalendarView) &&
539
+ "border-t border-border pt-4",
514
540
  )}
515
541
  >
516
542
  <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Display options</p>
@@ -533,7 +559,7 @@ export function TablePropertiesDrawer({
533
559
  <SelectTrigger size="sm" className="w-[6.5rem] shrink-0" id="board-line-count" aria-label="Line count">
534
560
  <SelectValue />
535
561
  </SelectTrigger>
536
- <SelectContent align="end">
562
+ <SelectContent align="end" className={PROPERTIES_SHEET_PORTAL_Z}>
537
563
  <SelectItem value="1">1 line</SelectItem>
538
564
  <SelectItem value="2">2 lines</SelectItem>
539
565
  <SelectItem value="3">3 lines</SelectItem>
@@ -618,6 +644,44 @@ export function TablePropertiesDrawer({
618
644
  </>
619
645
  )}
620
646
 
647
+ {isCalendarView && (
648
+ <>
649
+ <div className="flex items-center justify-between gap-2 py-2">
650
+ <div className="flex items-center gap-2.5 min-w-0 flex-1">
651
+ <span className="inline-flex items-center justify-center size-9 rounded-lg bg-secondary border border-border shrink-0">
652
+ <i className="fa-light fa-sidebar text-[15px] text-secondary-foreground" aria-hidden="true" />
653
+ </span>
654
+ <div className="min-w-0">
655
+ <p className="text-sm font-medium text-foreground leading-tight">Summary panel</p>
656
+ <p className="text-xs text-muted-foreground mt-0.5">
657
+ Mini month, event list, and layout on the left.
658
+ </p>
659
+ </div>
660
+ </div>
661
+ <ToggleSwitch
662
+ id="toggle-calendar-summary"
663
+ checked={displayOptions.showCalendarSummaryPanel}
664
+ onChange={v => onDisplayOptionsChange({ showCalendarSummaryPanel: v })}
665
+ />
666
+ </div>
667
+ <div className="pt-2">
668
+ <SelectionTileGrid
669
+ sectionLabel="Main calendar layout"
670
+ options={CALENDAR_MAIN_VIEW_TILES.map(t => ({
671
+ value: t.value,
672
+ label: t.label,
673
+ icon: t.icon,
674
+ }))}
675
+ columns={2}
676
+ value={displayOptions.calendarMainView}
677
+ onValueChange={v => onDisplayOptionsChange({ calendarMainView: v })}
678
+ interaction="button"
679
+ idPrefix="props-calendar-main-view"
680
+ />
681
+ </div>
682
+ </>
683
+ )}
684
+
621
685
  {(viewSurface === "table" || viewSurface === "list") && (
622
686
  <div className="flex items-center justify-between gap-2 py-2">
623
687
  <div className="flex items-center gap-2.5 min-w-0 flex-1">
@@ -659,7 +723,7 @@ export function TablePropertiesDrawer({
659
723
  {[
660
724
  { icon: "fa-circle-1", text: "Click \"Add filter\" below" },
661
725
  { icon: "fa-circle-2", text: "Choose a field to filter by" },
662
- { icon: "fa-circle-3", text: "Pick a condition and value" },
726
+ { icon: "fa-circle-3", text: "Pick at least one value — the grid updates immediately" },
663
727
  ].map(step => (
664
728
  <div key={step.icon} className="flex items-center gap-2 text-xs text-muted-foreground">
665
729
  <i className={`fa-light ${step.icon} text-muted-foreground text-xs shrink-0`} aria-hidden="true" />
@@ -726,7 +790,7 @@ export function TablePropertiesDrawer({
726
790
 
727
791
  {/* Add filter + Remove all */}
728
792
  <div className="flex items-center gap-2 pt-2">
729
- <DropdownMenu>
793
+ <DropdownMenu modal={false}>
730
794
  <DropdownMenuTrigger asChild>
731
795
  <Button
732
796
  type="button"
@@ -737,11 +801,11 @@ export function TablePropertiesDrawer({
737
801
  Add filter
738
802
  </Button>
739
803
  </DropdownMenuTrigger>
740
- <DropdownMenuContent align="start">
804
+ <DropdownMenuContent align="start" className={PROPERTIES_SHEET_PORTAL_Z}>
741
805
  <DropdownMenuLabel className="text-xs">Filter by field</DropdownMenuLabel>
742
806
  <DropdownMenuSeparator />
743
807
  {filterFields.map(f => (
744
- <DropdownMenuItem key={f.key} onClick={() => onAddFilter(f.key)}>
808
+ <DropdownMenuItem key={f.key} onSelect={() => onAddFilter(f.key)}>
745
809
  <i className={`fa-light ${f.icon}`} aria-hidden="true" />
746
810
  {f.label}
747
811
  </DropdownMenuItem>
@@ -833,7 +897,7 @@ export function TablePropertiesDrawer({
833
897
 
834
898
  {/* Add sort + Remove all */}
835
899
  <div className="flex items-center gap-2 pt-2">
836
- <DropdownMenu>
900
+ <DropdownMenu modal={false}>
837
901
  <DropdownMenuTrigger asChild>
838
902
  <Button
839
903
  type="button"
@@ -844,11 +908,11 @@ export function TablePropertiesDrawer({
844
908
  Add sort
845
909
  </Button>
846
910
  </DropdownMenuTrigger>
847
- <DropdownMenuContent align="start">
911
+ <DropdownMenuContent align="start" className={PROPERTIES_SHEET_PORTAL_Z}>
848
912
  <DropdownMenuLabel className="text-xs">Sort by field</DropdownMenuLabel>
849
913
  <DropdownMenuSeparator />
850
914
  {sortFieldList.filter(f => !sortRules.some(r => r.fieldKey === f.key)).map(col => (
851
- <DropdownMenuItem key={col.key} onClick={() => onAddSortRule(col.key)}>
915
+ <DropdownMenuItem key={col.key} onSelect={() => onAddSortRule(col.key)}>
852
916
  <i className="fa-light fa-arrow-up-arrow-down text-xs" aria-hidden="true" />
853
917
  {col.label}
854
918
  </DropdownMenuItem>
@@ -1052,7 +1116,7 @@ function ConditionalRulesPanel({
1052
1116
  )}
1053
1117
 
1054
1118
  <div className="flex items-center gap-2 pt-2">
1055
- <DropdownMenu>
1119
+ <DropdownMenu modal={false}>
1056
1120
  <DropdownMenuTrigger asChild>
1057
1121
  <Button
1058
1122
  type="button"
@@ -1063,13 +1127,13 @@ function ConditionalRulesPanel({
1063
1127
  Add rule
1064
1128
  </Button>
1065
1129
  </DropdownMenuTrigger>
1066
- <DropdownMenuContent align="start">
1130
+ <DropdownMenuContent align="start" className={PROPERTIES_SHEET_PORTAL_Z}>
1067
1131
  <DropdownMenuLabel className="text-xs">Rule for column</DropdownMenuLabel>
1068
1132
  <DropdownMenuSeparator />
1069
1133
  {filterFields.map(f => (
1070
1134
  <DropdownMenuItem
1071
1135
  key={f.key}
1072
- onClick={() => onAdd({
1136
+ onSelect={() => onAdd({
1073
1137
  fieldKey: f.key,
1074
1138
  operator: f.operators[0],
1075
1139
  values: [],
@@ -0,0 +1,448 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Body layouts for `FocusedWorkflowPageTemplate` — single column, stepped wizard,
5
+ * sectioned sidebar (settings-style), and empty placeholder.
6
+ */
7
+
8
+ import * as React from "react"
9
+
10
+ import { Button } from "@/components/ui/button"
11
+ import { Kbd, KbdGroup } from "@/components/ui/kbd"
12
+ import { Shortcut } from "@/components/ui/dropdown-menu"
13
+ import { cn } from "@/lib/utils"
14
+ import { useModKeyLabel } from "@/hooks/use-mod-key-label"
15
+ import { useAltKeyLabel } from "@/hooks/use-mod-key-label"
16
+
17
+ export interface FocusedWorkflowStep {
18
+ id: string
19
+ label: string
20
+ description?: string
21
+ }
22
+
23
+ export interface FocusedWorkflowSingleColumnProps {
24
+ children: React.ReactNode
25
+ className?: string
26
+ }
27
+
28
+ /** Default body — stack header, form sections, and actions in one column. */
29
+ export function FocusedWorkflowSingleColumn({
30
+ children,
31
+ className,
32
+ }: FocusedWorkflowSingleColumnProps) {
33
+ return <div className={cn("flex min-h-0 flex-1 flex-col gap-6", className)}>{children}</div>
34
+ }
35
+
36
+ export interface FocusedWorkflowStepIndicatorProps {
37
+ steps: readonly FocusedWorkflowStep[]
38
+ currentIndex: number
39
+ className?: string
40
+ /** When set, step buttons call this instead of being decorative only. */
41
+ onStepSelect?: (index: number) => void
42
+ }
43
+
44
+ export function FocusedWorkflowStepIndicator({
45
+ steps,
46
+ currentIndex,
47
+ className,
48
+ onStepSelect,
49
+ }: FocusedWorkflowStepIndicatorProps) {
50
+ const progress =
51
+ steps.length > 0 ? ((currentIndex + 1) / steps.length) * 100 : 0
52
+
53
+ return (
54
+ <nav
55
+ className={cn("flex flex-col gap-3", className)}
56
+ aria-label="Workflow progress"
57
+ >
58
+ <div className="flex flex-col gap-1">
59
+ <p className="text-xs font-medium text-muted-foreground">
60
+ Step{" "}
61
+ <span className="tabular-nums text-foreground">{currentIndex + 1}</span>{" "}
62
+ of <span className="tabular-nums">{steps.length}</span>
63
+ </p>
64
+ <div
65
+ className="h-2 w-full overflow-hidden rounded-full bg-muted"
66
+ aria-hidden="true"
67
+ >
68
+ <div
69
+ className="h-full rounded-full transition-all duration-300"
70
+ style={{ width: `${progress}%`, background: "var(--brand-color)" }}
71
+ />
72
+ </div>
73
+ </div>
74
+ <ol className="flex flex-col gap-2 sm:gap-1.5">
75
+ {steps.map((step, index) => {
76
+ const isComplete = index < currentIndex
77
+ const isCurrent = index === currentIndex
78
+ const rowClass = cn(
79
+ "flex w-full items-start gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors",
80
+ isCurrent
81
+ ? "border-[var(--brand-color)]/40 bg-muted/50"
82
+ : "border-border bg-card",
83
+ onStepSelect && !isCurrent && "hover:bg-muted/30",
84
+ )
85
+ const inner = (
86
+ <>
87
+ {isComplete ? (
88
+ <span
89
+ className="mt-0.5 inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-emerald-300/70 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/15 dark:text-emerald-300"
90
+ aria-hidden="true"
91
+ >
92
+ <i className="fa-light fa-check text-xs" aria-hidden="true" />
93
+ </span>
94
+ ) : (
95
+ <span
96
+ className={cn(
97
+ "mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums",
98
+ isCurrent
99
+ ? "border-transparent bg-[var(--brand-color)] text-primary-foreground"
100
+ : "border-border bg-background text-muted-foreground",
101
+ )}
102
+ aria-hidden="true"
103
+ >
104
+ {index + 1}
105
+ </span>
106
+ )}
107
+ <span className="min-w-0 flex-1">
108
+ <span className="block text-sm font-semibold text-foreground">
109
+ {step.label}
110
+ </span>
111
+ {step.description && isCurrent ? (
112
+ <span className="mt-1 block text-xs leading-snug text-muted-foreground sm:text-sm">
113
+ {step.description}
114
+ </span>
115
+ ) : null}
116
+ </span>
117
+ </>
118
+ )
119
+ return (
120
+ <li key={step.id} aria-current={isCurrent ? "step" : undefined}>
121
+ {onStepSelect ? (
122
+ <button
123
+ type="button"
124
+ onClick={() => onStepSelect(index)}
125
+ className={cn(
126
+ rowClass,
127
+ "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
128
+ )}
129
+ >
130
+ {inner}
131
+ </button>
132
+ ) : (
133
+ <div className={rowClass}>{inner}</div>
134
+ )}
135
+ </li>
136
+ )
137
+ })}
138
+ </ol>
139
+ </nav>
140
+ )
141
+ }
142
+
143
+ export interface FocusedWorkflowStepFormProps {
144
+ steps: readonly FocusedWorkflowStep[]
145
+ currentIndex: number
146
+ onStepSelect?: (index: number) => void
147
+ children: React.ReactNode
148
+ footer: React.ReactNode
149
+ className?: string
150
+ }
151
+
152
+ /** Multi-step wizard body — step list, active panel, sticky action footer. */
153
+ export function FocusedWorkflowStepForm({
154
+ steps,
155
+ currentIndex,
156
+ onStepSelect,
157
+ children,
158
+ footer,
159
+ className,
160
+ }: FocusedWorkflowStepFormProps) {
161
+ return (
162
+ <div className={cn("flex min-h-0 flex-1 flex-col gap-8", className)}>
163
+ <FocusedWorkflowStepIndicator
164
+ steps={steps}
165
+ currentIndex={currentIndex}
166
+ onStepSelect={onStepSelect}
167
+ />
168
+ <div className="min-h-0 flex-1">{children}</div>
169
+ <div className="sticky bottom-0 z-10 -mx-4 border-t border-border bg-background/95 px-4 py-4 backdrop-blur-sm sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
170
+ {footer}
171
+ </div>
172
+ </div>
173
+ )
174
+ }
175
+
176
+ export interface FocusedWorkflowSidebarSection {
177
+ id: string
178
+ label: string
179
+ }
180
+
181
+ export interface FocusedWorkflowSidebarSectionsProps {
182
+ sections: readonly FocusedWorkflowSidebarSection[]
183
+ activeSectionId?: string
184
+ onSectionSelect?: (id: string) => void
185
+ /** Full-width block above the nav + content grid (e.g. `PageHeader`). */
186
+ header?: React.ReactNode
187
+ children: React.ReactNode
188
+ className?: string
189
+ navLabel?: string
190
+ }
191
+
192
+ /** Grid for settings-style section nav + body (shared with route loading skeleton). */
193
+ export const FOCUSED_WORKFLOW_SIDEBAR_SECTIONS_GRID_CLASS =
194
+ "lg:grid lg:grid-cols-[minmax(10rem,12rem)_minmax(0,1fr)] lg:gap-x-12 lg:gap-y-8 lg:items-start"
195
+
196
+ /**
197
+ * Sectioned form with a **left nav rail** (settings-style). Use `id` on each
198
+ * `<section>` in `children` matching `sections[].id` for in-page anchors.
199
+ */
200
+ export function FocusedWorkflowSidebarSections({
201
+ sections,
202
+ activeSectionId,
203
+ onSectionSelect,
204
+ header,
205
+ children,
206
+ className,
207
+ navLabel = "Sections",
208
+ }: FocusedWorkflowSidebarSectionsProps) {
209
+ return (
210
+ <div
211
+ className={cn(
212
+ "flex min-h-0 flex-1 flex-col gap-8",
213
+ FOCUSED_WORKFLOW_SIDEBAR_SECTIONS_GRID_CLASS,
214
+ className,
215
+ )}
216
+ >
217
+ {header ? <div className="min-w-0 lg:col-span-2">{header}</div> : null}
218
+ <nav
219
+ className="flex shrink-0 flex-col gap-0.5 lg:sticky lg:top-6 lg:self-start"
220
+ aria-label={navLabel}
221
+ >
222
+ {sections.map(section => {
223
+ const isActive = section.id === activeSectionId
224
+ return (
225
+ <button
226
+ key={section.id}
227
+ type="button"
228
+ onClick={() => onSectionSelect?.(section.id)}
229
+ className={cn(
230
+ "rounded-md py-2 text-left text-sm transition-colors",
231
+ "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
232
+ isActive
233
+ ? "bg-muted font-medium text-foreground"
234
+ : "text-muted-foreground hover:bg-muted/60 hover:text-foreground",
235
+ )}
236
+ aria-current={isActive ? "true" : undefined}
237
+ >
238
+ {section.label}
239
+ </button>
240
+ )
241
+ })}
242
+ </nav>
243
+ <div className="min-w-0 flex-1 flex flex-col gap-16">{children}</div>
244
+ </div>
245
+ )
246
+ }
247
+
248
+ export interface FocusedWorkflowEmptyStateProps {
249
+ iconClass?: string
250
+ title: string
251
+ description?: string
252
+ action?: React.ReactNode
253
+ className?: string
254
+ }
255
+
256
+ export function FocusedWorkflowEmptyState({
257
+ iconClass = "fa-layer-group",
258
+ title,
259
+ description,
260
+ action,
261
+ className,
262
+ }: FocusedWorkflowEmptyStateProps) {
263
+ return (
264
+ <div
265
+ className={cn(
266
+ "flex min-h-[min(24rem,50vh)] flex-col items-center justify-center gap-4 px-4 text-center",
267
+ className,
268
+ )}
269
+ role="status"
270
+ >
271
+ <span
272
+ className="flex size-14 items-center justify-center rounded-xl bg-muted text-muted-foreground"
273
+ aria-hidden="true"
274
+ >
275
+ <i className={cn("fa-light text-xl", iconClass)} aria-hidden="true" />
276
+ </span>
277
+ <div className="max-w-md space-y-2">
278
+ <h2 className="text-lg font-semibold text-foreground">{title}</h2>
279
+ {description ? (
280
+ <p className="text-sm text-muted-foreground">{description}</p>
281
+ ) : null}
282
+ </div>
283
+ {action}
284
+ </div>
285
+ )
286
+ }
287
+
288
+ export interface FocusedWorkflowActionFooterProps {
289
+ onCancel: () => void
290
+ cancelLabel?: string
291
+ cancelDisabled?: boolean
292
+ primary: React.ReactNode
293
+ secondary?: React.ReactNode
294
+ className?: string
295
+ }
296
+
297
+ /** Workflow footer — Cancel (Esc) + primary slot (usually submit with ⏎ Kbd). */
298
+ export function FocusedWorkflowActionFooter({
299
+ onCancel,
300
+ cancelLabel = "Cancel",
301
+ cancelDisabled,
302
+ primary,
303
+ secondary,
304
+ className,
305
+ }: FocusedWorkflowActionFooterProps) {
306
+ return (
307
+ <>
308
+ <Shortcut keys="Escape" disabled={cancelDisabled} onInvoke={onCancel} />
309
+ <div className={cn("flex flex-wrap items-center gap-2", className)}>
310
+ <Button
311
+ type="button"
312
+ variant="outline"
313
+ className="flex-1 min-w-[8rem] sm:flex-none"
314
+ disabled={cancelDisabled}
315
+ onClick={onCancel}
316
+ >
317
+ {cancelLabel}
318
+ <KbdGroup className="ml-1.5">
319
+ <Kbd variant="bare">Esc</Kbd>
320
+ </KbdGroup>
321
+ </Button>
322
+ {secondary}
323
+ <div className="flex flex-1 min-w-[8rem] justify-end sm:flex-none">{primary}</div>
324
+ </div>
325
+ </>
326
+ )
327
+ }
328
+
329
+ export interface FocusedWorkflowWizardFooterProps {
330
+ stepIndex: number
331
+ stepCount: number
332
+ onBack: () => void
333
+ onCancel: () => void
334
+ onNext: () => void
335
+ onSubmit?: () => void
336
+ isFirstStep?: boolean
337
+ isLastStep?: boolean
338
+ nextLabel?: string
339
+ submitLabel?: string
340
+ cancelLabel?: string
341
+ disabled?: boolean
342
+ submitting?: boolean
343
+ }
344
+
345
+ /** Step wizard footer — Back (⌘⌥←), Cancel (Esc), Next (⌘⏎) or Submit (⏎). */
346
+ export function FocusedWorkflowWizardFooter({
347
+ stepIndex,
348
+ stepCount,
349
+ onBack,
350
+ onCancel,
351
+ onNext,
352
+ onSubmit,
353
+ isFirstStep,
354
+ isLastStep,
355
+ nextLabel = "Next",
356
+ submitLabel = "Submit",
357
+ cancelLabel = "Cancel",
358
+ disabled,
359
+ submitting,
360
+ }: FocusedWorkflowWizardFooterProps) {
361
+ const mod = useModKeyLabel()
362
+ const alt = useAltKeyLabel()
363
+ const first = isFirstStep ?? stepIndex === 0
364
+ const last = isLastStep ?? stepIndex >= stepCount - 1
365
+
366
+ return (
367
+ <>
368
+ <Shortcut keys="Escape" disabled={disabled || submitting} onInvoke={onCancel} />
369
+ {!first ? (
370
+ <Shortcut
371
+ keys={`${mod}${alt}←`}
372
+ disabled={disabled || submitting}
373
+ onInvoke={onBack}
374
+ />
375
+ ) : null}
376
+ {last ? (
377
+ <Shortcut keys="Enter" disabled={disabled || submitting} onInvoke={() => onSubmit?.()} />
378
+ ) : (
379
+ <Shortcut keys={`${mod}Enter`} disabled={disabled || submitting} onInvoke={onNext} />
380
+ )}
381
+ <div className="flex flex-wrap items-center gap-2">
382
+ <Button
383
+ type="button"
384
+ variant="outline"
385
+ disabled={disabled || submitting}
386
+ onClick={onCancel}
387
+ >
388
+ {cancelLabel}
389
+ <KbdGroup className="ml-1.5">
390
+ <Kbd variant="bare">Esc</Kbd>
391
+ </KbdGroup>
392
+ </Button>
393
+ {!first ? (
394
+ <Button
395
+ type="button"
396
+ variant="outline"
397
+ disabled={disabled || submitting}
398
+ onClick={onBack}
399
+ >
400
+ Back
401
+ <KbdGroup className="ml-1.5">
402
+ <Kbd variant="bare">
403
+ {mod}
404
+ {alt}←
405
+ </Kbd>
406
+ </KbdGroup>
407
+ </Button>
408
+ ) : null}
409
+ <div className="ms-auto flex flex-1 min-w-[8rem] justify-end sm:flex-none">
410
+ {last ? (
411
+ <Button
412
+ type="button"
413
+ disabled={disabled || submitting}
414
+ aria-busy={submitting}
415
+ onClick={() => onSubmit?.()}
416
+ >
417
+ {submitting ? (
418
+ <>
419
+ <i
420
+ className="fa-light fa-spinner-third fa-spin text-[13px]"
421
+ aria-hidden="true"
422
+ />
423
+ Saving…
424
+ </>
425
+ ) : (
426
+ <>
427
+ {submitLabel}
428
+ <KbdGroup className="ml-1.5">
429
+ <Kbd variant="bare">⏎</Kbd>
430
+ </KbdGroup>
431
+ </>
432
+ )}
433
+ </Button>
434
+ ) : (
435
+ <Button type="button" disabled={disabled || submitting} onClick={onNext}>
436
+ {nextLabel}
437
+ <KbdGroup className="ml-1.5">
438
+ <Kbd variant="bare">
439
+ {mod}⏎
440
+ </Kbd>
441
+ </KbdGroup>
442
+ </Button>
443
+ )}
444
+ </div>
445
+ </div>
446
+ </>
447
+ )
448
+ }