@exxatdesignux/ui 0.2.8 → 0.2.10

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 (125) hide show
  1. package/consumer-extras/cursor-skills/exxat-card-vs-list-rows/SKILL.md +20 -0
  2. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +33 -0
  3. package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +45 -0
  4. package/consumer-extras/cursor-skills/exxat-drawer-vs-dialog/SKILL.md +20 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +31 -5
  6. package/consumer-extras/cursor-skills/exxat-kpi-max-four/SKILL.md +19 -0
  7. package/consumer-extras/cursor-skills/exxat-kpi-trends/SKILL.md +27 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +1 -0
  9. package/consumer-extras/patterns/collaboration-access-pattern.md +114 -0
  10. package/consumer-extras/patterns/data-views-pattern.md +12 -4
  11. package/package.json +17 -4
  12. package/src/components/ui/banner.tsx +20 -7
  13. package/src/components/ui/date-picker-field.tsx +3 -3
  14. package/src/components/ui/dropdown-menu.tsx +17 -6
  15. package/src/components/ui/input-group.tsx +1 -1
  16. package/src/components/ui/input.tsx +1 -1
  17. package/src/components/ui/select.tsx +1 -1
  18. package/src/components/ui/separator.tsx +2 -2
  19. package/src/components/ui/sidebar.tsx +31 -3
  20. package/src/components/ui/textarea.tsx +1 -1
  21. package/src/globals.css +0 -1
  22. package/src/index.ts +1 -0
  23. package/src/lib/date-filter.ts +13 -4
  24. package/src/lib/dropdown-menu-surface.ts +13 -0
  25. package/template/.claude/skills/exxat-ds-skill/SKILL.md +27 -9
  26. package/template/.cursor/rules/exxat-data-tables.mdc +1 -0
  27. package/template/AGENTS.md +82 -27
  28. package/template/app/(app)/examples/page.tsx +2 -1
  29. package/template/app/(app)/help/page.tsx +6 -0
  30. package/template/app/(app)/layout.tsx +7 -4
  31. package/template/app/(app)/question-bank/find/page.tsx +12 -0
  32. package/template/app/(app)/question-bank/layout.tsx +46 -0
  33. package/template/app/(app)/question-bank/library/page.tsx +11 -0
  34. package/template/app/(app)/question-bank/list/page.tsx +12 -0
  35. package/template/app/(app)/question-bank/page.tsx +4 -3
  36. package/template/app/globals.css +1 -2
  37. package/template/components/app-sidebar.tsx +51 -13
  38. package/template/components/ask-leo-composer.tsx +173 -45
  39. package/template/components/ask-leo-sidebar.tsx +9 -1
  40. package/template/components/chart-area-interactive.tsx +3 -13
  41. package/template/components/charts-overview.tsx +33 -6
  42. package/template/components/collaboration-access-flow.tsx +144 -0
  43. package/template/components/compliance-page-header.tsx +1 -1
  44. package/template/components/compliance-table.tsx +2 -2
  45. package/template/components/dashboard-tabs.tsx +4 -3
  46. package/template/components/data-list-table-cells.tsx +1 -1
  47. package/template/components/data-list-table.tsx +1 -1
  48. package/template/components/data-table/index.tsx +5 -5
  49. package/template/components/data-table/use-table-state.ts +18 -2
  50. package/template/components/data-view-dashboard-charts-compliance.tsx +8 -5
  51. package/template/components/data-view-dashboard-charts-team.tsx +8 -5
  52. package/template/components/data-view-dashboard-charts.tsx +62 -227
  53. package/template/components/dedicated-search-recents.tsx +96 -0
  54. package/template/components/dedicated-search-url-composer.tsx +112 -0
  55. package/template/components/getting-started.tsx +1 -1
  56. package/template/components/hub-tree-panel-view.tsx +10 -26
  57. package/template/components/invite-collaborators-drawer.tsx +453 -0
  58. package/template/components/key-metrics.tsx +54 -8
  59. package/template/components/nav-documents.tsx +1 -1
  60. package/template/components/new-placement-form.tsx +3 -3
  61. package/template/components/page-header.tsx +76 -59
  62. package/template/components/placements-board-view.tsx +3 -3
  63. package/template/components/placements-page-header.tsx +1 -1
  64. package/template/components/placements-table-columns.tsx +3 -2
  65. package/template/components/product-switcher.tsx +0 -1
  66. package/template/components/question-bank-board-view.tsx +35 -47
  67. package/template/components/question-bank-client.tsx +293 -81
  68. package/template/components/question-bank-dashboard-charts.tsx +174 -0
  69. package/template/components/question-bank-favorite-button.tsx +46 -0
  70. package/template/components/question-bank-hub-client.tsx +436 -0
  71. package/template/components/question-bank-list-view.tsx +26 -19
  72. package/template/components/question-bank-new-folder-sheet.tsx +56 -42
  73. package/template/components/question-bank-os-folder-view.tsx +3 -14
  74. package/template/components/question-bank-page-header.tsx +85 -53
  75. package/template/components/question-bank-panel-activator.tsx +3 -4
  76. package/template/components/question-bank-secondary-nav.tsx +523 -65
  77. package/template/components/question-bank-table.tsx +125 -343
  78. package/template/components/secondary-panel.tsx +130 -63
  79. package/template/components/settings-client.tsx +3 -1
  80. package/template/components/sidebar-shell.tsx +2 -0
  81. package/template/components/sites-all-client.tsx +1 -1
  82. package/template/components/sites-table.tsx +1 -1
  83. package/template/components/system-banner-slot.tsx +2 -1
  84. package/template/components/table-properties/drawer.tsx +3 -3
  85. package/template/components/table-properties/sort-card.tsx +1 -1
  86. package/template/components/team-page-header.tsx +1 -1
  87. package/template/components/team-table.tsx +8 -4
  88. package/template/components/templates/dedicated-search-landing-template.tsx +58 -0
  89. package/template/components/templates/dedicated-search-results-template.tsx +19 -0
  90. package/template/components/templates/discovery-hub-template.tsx +273 -0
  91. package/template/components/templates/list-page.tsx +11 -4
  92. package/template/components/templates/nested-secondary-panel-shell.tsx +57 -0
  93. package/template/components/templates/secondary-panel-hub-template.tsx +54 -0
  94. package/template/docs/card-vs-rows-pattern.md +36 -0
  95. package/template/docs/collaboration-access-pattern.md +114 -0
  96. package/template/docs/data-views-pattern.md +12 -4
  97. package/template/docs/drawer-vs-dialog-pattern.md +50 -0
  98. package/template/docs/kpi-strip-max-four-pattern.md +29 -0
  99. package/template/docs/kpi-trend-pattern.md +43 -0
  100. package/template/fontawesome-subset.manifest.json +2 -2
  101. package/template/hooks/use-location-hash.ts +14 -8
  102. package/template/hooks/use-secondary-panel-hub-nav.ts +98 -0
  103. package/template/lib/ask-leo-route-context.ts +24 -0
  104. package/template/lib/collaborator-access.ts +92 -0
  105. package/template/lib/command-menu-config.ts +8 -1
  106. package/template/lib/command-menu-search-data.ts +11 -8
  107. package/template/lib/data-list-display-options.ts +1 -1
  108. package/template/lib/data-view-dashboard-placements-layout.ts +215 -0
  109. package/template/lib/date-filter.ts +1 -0
  110. package/template/lib/dedicated-search-recents.ts +76 -0
  111. package/template/lib/dedicated-search-url.ts +23 -0
  112. package/template/lib/discovery-hub.ts +15 -0
  113. package/template/lib/list-status-badges.ts +1 -21
  114. package/template/lib/mock/navigation.tsx +4 -2
  115. package/template/lib/mock/placements.ts +9 -9
  116. package/template/lib/mock/question-bank-folders.ts +7 -0
  117. package/template/lib/mock/question-bank-header-collaborators.ts +45 -5
  118. package/template/lib/mock/question-bank-inspector.ts +1 -2
  119. package/template/lib/mock/question-bank-kpi.ts +38 -26
  120. package/template/lib/mock/question-bank.ts +43 -16
  121. package/template/lib/question-bank-dedicated-search.ts +19 -0
  122. package/template/lib/question-bank-hub-search.ts +90 -0
  123. package/template/lib/question-bank-nav.ts +322 -6
  124. package/template/lib/question-bank-recent-searches.ts +22 -0
  125. package/template/package.json +0 -1
@@ -4,6 +4,7 @@ import * as React from "react"
4
4
  import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
5
5
 
6
6
  import { cn } from "../../lib/utils"
7
+ import { DROPDOWN_MENU_CONTENT_SURFACE_CLASS } from "../../lib/dropdown-menu-surface"
7
8
 
8
9
  function DropdownMenu({
9
10
  ...props
@@ -43,7 +44,11 @@ function DropdownMenuContent({
43
44
  data-slot="dropdown-menu-content"
44
45
  sideOffset={sideOffset}
45
46
  align={align}
46
- className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
47
+ className={cn(
48
+ "z-50 max-h-(--radix-dropdown-menu-content-available-height) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
49
+ DROPDOWN_MENU_CONTENT_SURFACE_CLASS,
50
+ className,
51
+ )}
47
52
  {...props}
48
53
  />
49
54
  </DropdownMenuPrimitive.Portal>
@@ -81,7 +86,7 @@ function DropdownMenuItem({
81
86
  data-variant={variant}
82
87
  asChild={asChild}
83
88
  className={cn(
84
- "group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([data-product-logo]):not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
89
+ "group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_i]:pointer-events-none [&_svg]:shrink-0 [&_i]:shrink-0 [&_svg:not([data-product-logo]):not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
85
90
  className
86
91
  )}
87
92
  {...props}
@@ -247,7 +252,7 @@ function DropdownMenuCheckboxItem({
247
252
  data-slot="dropdown-menu-checkbox-item"
248
253
  data-inset={inset}
249
254
  className={cn(
250
- "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:ps-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([data-product-logo]):not([class*='size-'])]:size-4",
255
+ "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:ps-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_i]:pointer-events-none [&_svg]:shrink-0 [&_i]:shrink-0 [&_svg:not([data-product-logo]):not([class*='size-'])]:size-4",
251
256
  className
252
257
  )}
253
258
  checked={checked}
@@ -290,7 +295,7 @@ function DropdownMenuRadioItem({
290
295
  data-slot="dropdown-menu-radio-item"
291
296
  data-inset={inset}
292
297
  className={cn(
293
- "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:ps-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([data-product-logo]):not([class*='size-'])]:size-4",
298
+ "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:ps-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_i]:pointer-events-none [&_svg]:shrink-0 [&_i]:shrink-0 [&_svg:not([data-product-logo]):not([class*='size-'])]:size-4",
294
299
  className
295
300
  )}
296
301
  {...props}
@@ -377,7 +382,7 @@ function DropdownMenuSubTrigger({
377
382
  suppressHydrationWarning
378
383
  data-inset={inset}
379
384
  className={cn(
380
- "flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([data-product-logo]):not([class*='size-'])]:size-4",
385
+ "flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_i]:pointer-events-none [&_svg]:shrink-0 [&_i]:shrink-0 [&_svg:not([data-product-logo]):not([class*='size-'])]:size-4",
381
386
  className
382
387
  )}
383
388
  {...props}
@@ -395,7 +400,11 @@ function DropdownMenuSubContent({
395
400
  return (
396
401
  <DropdownMenuPrimitive.SubContent
397
402
  data-slot="dropdown-menu-sub-content"
398
- className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
403
+ className={cn(
404
+ "z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
405
+ DROPDOWN_MENU_CONTENT_SURFACE_CLASS,
406
+ className,
407
+ )}
399
408
  {...props}
400
409
  />
401
410
  )
@@ -420,3 +429,5 @@ export {
420
429
  Shortcut,
421
430
  useShortcut,
422
431
  }
432
+
433
+ export { DROPDOWN_MENU_CONTENT_SURFACE_CLASS } from "../../lib/dropdown-menu-surface"
@@ -14,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
14
14
  data-slot="input-group"
15
15
  role="group"
16
16
  className={cn(
17
- "group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pe-1.5 has-[>[data-align=inline-start]]:[&>input]:ps-1.5",
17
+ "group/input-group relative flex h-8 w-full min-w-0 items-center rounded-md border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pe-1.5 has-[>[data-align=inline-start]]:[&>input]:ps-1.5",
18
18
  className
19
19
  )}
20
20
  {...props}
@@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
10
10
  type={type}
11
11
  data-slot="input"
12
12
  className={cn(
13
- "h-8 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/15 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
13
+ "h-8 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
14
14
  className
15
15
  )}
16
16
  {...props}
@@ -111,7 +111,7 @@ function SelectItem({
111
111
  <SelectPrimitive.Item
112
112
  data-slot="select-item"
113
113
  className={cn(
114
- "relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
114
+ "relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_i]:pointer-events-none [&_svg]:shrink-0 [&_i]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
115
115
  className
116
116
  )}
117
117
  {...props}
@@ -17,8 +17,8 @@ function Separator({
17
17
  decorative={decorative}
18
18
  orientation={orientation}
19
19
  className={cn(
20
- "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
21
- className
20
+ "shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch",
21
+ className,
22
22
  )}
23
23
  {...props}
24
24
  />
@@ -18,6 +18,8 @@ import {
18
18
 
19
19
  const SIDEBAR_COOKIE_NAME = "sidebar_state"
20
20
  const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
21
+ /** Matches `useIsMobile` / Tailwind `md:` — do not apply desktop cookie to mobile overlay. */
22
+ const SIDEBAR_COOKIE_VIEWPORT_MQ = "(max-width: 767px)"
21
23
  const SIDEBAR_WIDTH = "16rem"
22
24
  const SIDEBAR_WIDTH_ICON = "3rem"
23
25
  const SIDEBAR_KEYBOARD_SHORTCUT = "b"
@@ -43,6 +45,13 @@ function useSidebar() {
43
45
  return context
44
46
  }
45
47
 
48
+ function readSidebarStateCookie(): boolean | undefined {
49
+ if (typeof document === "undefined") return undefined
50
+ const m = document.cookie.match(new RegExp(`(?:^|; )${SIDEBAR_COOKIE_NAME}=(true|false)(?:;|$)`))
51
+ if (!m) return undefined
52
+ return m[1] === "true"
53
+ }
54
+
46
55
  function SidebarProvider({
47
56
  defaultOpen = true,
48
57
  open: openProp,
@@ -63,6 +72,18 @@ function SidebarProvider({
63
72
  // We use openProp and setOpenProp for control from outside the component.
64
73
  const [_open, _setOpen] = React.useState(defaultOpen)
65
74
  const open = openProp ?? _open
75
+
76
+ // `setOpen` already persists `sidebar_state` to a cookie on desktop; restore it on mount so
77
+ // full reloads and new tabs keep the rail expanded/collapsed. Skip when controlled or on mobile.
78
+ React.useLayoutEffect(() => {
79
+ if (openProp !== undefined) return
80
+ if (typeof window === "undefined") return
81
+ if (window.matchMedia(SIDEBAR_COOKIE_VIEWPORT_MQ).matches) return
82
+ const fromCookie = readSidebarStateCookie()
83
+ if (fromCookie === undefined) return
84
+ _setOpen(fromCookie)
85
+ }, [openProp])
86
+
66
87
  const setOpen = React.useCallback(
67
88
  (value: boolean | ((value: boolean) => boolean)) => {
68
89
  const openState = typeof value === "function" ? value(open) : value
@@ -530,8 +551,15 @@ const SidebarMenuButton = React.forwardRef<
530
551
  data-slot="sidebar-menu-button"
531
552
  data-sidebar="menu-button"
532
553
  data-size={size}
533
- data-active={isActive}
534
- className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
554
+ data-active={isActive || undefined}
555
+ className={cn(
556
+ sidebarMenuButtonVariants({ variant, size }),
557
+ className,
558
+ // `asChild` merges the child (e.g. Next `<Link className="w-full">`) onto this node —
559
+ // plain `w-full` can win over `group-data-[collapsible=icon]:w-8` in the stylesheet and
560
+ // squash the icon rail to a non-square hit target (WCAG 2.5.8).
561
+ "group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!min-w-8 group-data-[collapsible=icon]:!max-w-8 group-data-[collapsible=icon]:shrink-0",
562
+ )}
535
563
  {...props}
536
564
  />
537
565
  )
@@ -683,7 +711,7 @@ function SidebarMenuSubButton({
683
711
  data-slot="sidebar-menu-sub-button"
684
712
  data-sidebar="menu-sub-button"
685
713
  data-size={size}
686
- data-active={isActive}
714
+ data-active={isActive || undefined}
687
715
  className={cn(
688
716
  "flex h-7 min-w-0 cursor-pointer select-none -translate-x-px rtl:translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-background data-active:text-foreground data-active:shadow-sm data-active:ring-1 data-active:ring-sidebar-border data-active:hc:border data-active:hc:border-foreground data-active:forced-colors:border data-active:forced-colors:border-[Highlight] [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
689
717
  className
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
7
7
  <textarea
8
8
  data-slot="textarea"
9
9
  className={cn(
10
- "flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/15 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
10
+ "flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
11
11
  className
12
12
  )}
13
13
  {...props}
package/src/globals.css CHANGED
@@ -10,7 +10,6 @@
10
10
 
11
11
  @import "tailwindcss";
12
12
  @import "tw-animate-css";
13
- @import "shadcn/tailwind.css";
14
13
  /* Inter is loaded via next/font/google in layout.tsx — no @import needed here */
15
14
 
16
15
  /* RTL layout direction support */
package/src/index.ts CHANGED
@@ -51,5 +51,6 @@ export * from "./hooks/use-mobile"
51
51
  export * from "./hooks/use-mod-key-label"
52
52
 
53
53
  // Utilities
54
+ export * from "./lib/dropdown-menu-surface"
54
55
  export * from "./lib/utils"
55
56
  export * from "./lib/date-filter"
@@ -15,6 +15,15 @@ export function formatDateUS(raw: string | null | undefined): string {
15
15
  return `${m}/${day}/${y}`
16
16
  }
17
17
 
18
+ /** Format a `Date` with local calendar fields as MM/DD/YYYY (avoids UTC drift from `toISOString()`). */
19
+ export function formatDateFromDate(raw: Date | null | undefined): string {
20
+ if (!raw || Number.isNaN(raw.getTime())) return "—"
21
+ const m = String(raw.getMonth() + 1).padStart(2, "0")
22
+ const day = String(raw.getDate()).padStart(2, "0")
23
+ const y = raw.getFullYear()
24
+ return `${m}/${day}/${y}`
25
+ }
26
+
18
27
  /**
19
28
  * Format a Date (or ISO string) into "MM/DD/YYYY hh:mm AM/PM EST".
20
29
  * Time zone label is always appended as the literal string "EST" (display only).
@@ -45,11 +54,11 @@ export function parseRowDateToYmd(raw: string): string | null {
45
54
  return `${y}-${m}-${day}`
46
55
  }
47
56
 
48
- /** Format YYYY-MM-DD for compact filter chip label. */
57
+ /** Format YYYY-MM-DD for filter chip labels (MM/DD/YYYY). */
49
58
  export function formatYmdForDisplay(ymd: string): string {
50
- const d = new Date(`${ymd}T12:00:00`)
51
- if (Number.isNaN(d.getTime())) return ymd
52
- return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })
59
+ const d = ymdToLocalDate(ymd)
60
+ if (!d) return ymd
61
+ return formatDateFromDate(d)
53
62
  }
54
63
 
55
64
  /** Local noon to avoid timezone shifting the calendar day. */
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Default surface sizing for product dropdown menus (view settings, row ⋯, column menus, etc.).
3
+ *
4
+ * Uses **pure CSS** (`w-max` + `min-w-*` + `max-w-*`) so width follows labels and shortcuts
5
+ * without **ResizeObserver** or layout thrash.
6
+ *
7
+ * Override when you need a fixed rail, for example:
8
+ * - `className="w-20"` — page-size picker in `DataTablePaginated`
9
+ * - `className="w-(--radix-dropdown-menu-trigger-width) min-w-60"` — account / identity menus
10
+ * - `className="!w-max min-w-72 …"` — very wide school/program switcher
11
+ */
12
+ export const DROPDOWN_MENU_CONTENT_SURFACE_CLASS =
13
+ "min-w-52 w-max max-w-[min(24rem,calc(100vw-2rem))]" as const
@@ -21,6 +21,7 @@ description: >
21
21
  - **Stack:** Next.js 16 (App Router), React, TypeScript, Tailwind CSS, shadcn/ui primitives, Font Awesome icons
22
22
  - **App root:** `exxat-ds/app/(app)/` — route group that wraps all authenticated pages
23
23
  - **Single source of truth:** `exxat-ds/AGENTS.md` for full prose explanations; this skill is the actionable summary
24
+ - **Companion skills (narrow topics):** `exxat-fontawesome-icons`, `exxat-primary-nav-secondary-panel`, `exxat-centralized-list-dataset`, `exxat-list-page-view-shells`, `exxat-dedicated-search-surfaces`, `exxat-accessibility`, `exxat-board-cards`, `exxat-collaboration-access` — live under `.cursor/skills/`; vetted copies ship with **`@exxatdesignux/ui`** in `consumer-extras/cursor-skills/` after **`pnpm --filter @exxatdesignux/ui vendor:consumer-extras`**.
24
25
 
25
26
  ---
26
27
 
@@ -87,7 +88,7 @@ To add a primary nav item, append to `NAV_PRIMARY`:
87
88
  | Concern | Pattern |
88
89
  |--------|---------|
89
90
  | **Product (One / Prism)** | **`ExxatProductLogo`** (`components/exxat-product-logo.tsx`) for the header control and **`ProductSwitcher`** — **not** logo.dev rasters unless product explicitly changes that. |
90
- | **School/program menu width** | **`DropdownMenuContent`** ships with **`w-(--radix-dropdown-menu-trigger-width)`**, so the panel matches the **narrow sidebar trigger** and long names wrap too early. For **`TeamSwitcher`**, override with e.g. **`!w-max min-w-72 max-w-[min(100vw-2rem,28rem)]`**. |
91
+ | **School/program menu width** | **`DropdownMenuContent`** defaults to **intrinsic width** (**`min-w-52 w-max max-w-[min(24rem,calc(100vw-2rem))]`** via **`DROPDOWN_MENU_CONTENT_SURFACE_CLASS`** in **`@exxatdesignux/ui/lib/dropdown-menu-surface`**) pure CSS, no **`ResizeObserver`**. The **school / program** switcher still uses an explicit wider surface (**`!w-max min-w-72 max-w-[min(100vw-2rem,28rem)]`**) so dense rows stay readable. |
91
92
  | **School/program copy** | **Do not truncate** school or program names in the switcher; wrap (**`break-words`**, **`whitespace-normal`**, **`items-start`** on multi-line rows). The selected-school summary shows **school name + current program**. |
92
93
  | **Team switcher trigger** | **`SidebarMenuButton` `size="lg"`** uses **`h-12`** + **`overflow-hidden`**, which **clips** a second line (program). When the sidebar is **expanded** or **mobile**, add **`h-auto min-h-12`** and **`overflow-x-clip overflow-y-visible`**. On **icon rail**, hide label rows with **`group-data-[collapsible=icon]:hidden`** (tooltip still exposes the full string). Icon mode defaults **`size-8` + `p-2`** (~16px inner) **clips** school logos — override **`!size-9`**, **`!p-0`**, **`overflow-visible`**. Omit header **chevrons** next to logos if they look like stray chrome. |
93
94
  | **Motion / Animate UI** | [Animate UI](https://animate-ui.com/docs) — open **copy-first** animated components (Motion + Tailwind). This repo uses **`motion/react`** + **`lib/motion-ui.ts`** presets; pull more animations from their registry into `components/` when needed. |
@@ -178,6 +179,17 @@ Align with **`exxat-ds/AGENTS.md` §6.4**, **`docs/data-views-pattern.md`**, **`
178
179
  - `select` column: `defaultPin: "left"`, `lockPin: true`
179
180
  - `actions` column: `defaultPin: "right"`, `lockPin: true`
180
181
 
182
+ ### 5.1 Data table and view-toolbar menus
183
+
184
+ **`DropdownMenuContent`** (from **`@/components/ui/dropdown-menu`**, backed by **`@exxatdesignux/ui`**) applies a **default surface** so **view settings**, **Add view**, **row ⋯**, **column ⋯**, and **filter field** menus get **`min-w-52`**, grow with **`w-max`**, and cap at **`max-w-[min(24rem,calc(100vw-2rem))]`** — all **static Tailwind** (no **`ResizeObserver`** / layout measurement).
185
+
186
+ - **Override** only when the UX needs a fixed rail (e.g. **`className="w-20"`** on the pagination page-size menu, **`w-(--radix-dropdown-menu-trigger-width) min-w-60`** on **`NavUser`**, **`!w-max min-w-72 …`** on the school/program switcher).
187
+ - **Reuse** **`DROPDOWN_MENU_CONTENT_SURFACE_CLASS`** if you build a custom menu primitive that does not wrap **`DropdownMenuContent`**.
188
+
189
+ ### 5.2 KPI trends (`KeyMetrics`, `*-kpi.ts`)
190
+
191
+ **`MetricItem.trend`** must match the **signed change** (arrow direction = truth). **`trendPolarity`** (`higher_is_better` default, **`lower_is_better`**, **`informational`**) controls **tints** and **`aria-label`** — e.g. **low PBI / review flags** rising → `trend: "up"` + **`lower_is_better`** → unfavourable (red), not green. **Doc:** **`docs/kpi-trend-pattern.md`** · **Rule:** **`.cursor/rules/exxat-kpi-trends.mdc`** · **Skill:** **`.cursor/skills/exxat-kpi-trends/SKILL.md`**.
192
+
181
193
  **DataTable must wrap in `<div className="pb-6">`.**
182
194
 
183
195
  ---
@@ -206,7 +218,7 @@ Use `PageHeader` from `@/components/page-header` for the content-area header (be
206
218
  </Button>
207
219
  </DropdownMenuTrigger>
208
220
  </Tip>
209
- <DropdownMenuContent align="end" className="w-52">
221
+ <DropdownMenuContent align="end">
210
222
  <DropdownMenuItem onClick={onExport}>
211
223
  <i className="fa-light fa-arrow-down-to-line" aria-hidden="true" />
212
224
  Export
@@ -229,6 +241,12 @@ Use `PageHeader` from `@/components/page-header` for the content-area header (be
229
241
  - Subtitle: `"{count} items · Last updated now"` format
230
242
  - Title uses Ivy Presto (`font-heading` variable) — applied automatically by `PageHeader`
231
243
 
244
+ ### 6.1 Collaboration & access (shared hubs)
245
+
246
+ When a hub is **shared**, use **`PageHeader` `variant="collaboration"`**: **empty roster** → outline **Add collaborator**; **non-empty** → face rail (faces / **`+N`** open the invite sheet). **Invite people** also lives under the entity header **⋯ More** and opens **`InviteCollaboratorsDrawer`** via **`CollaborationAccessFlow`** when possible. Library access (Owner / Editor / Commenter / Viewer) comes from **`lib/collaborator-access.ts`**; directory tags (Faculty, Program coordinator, Director) use **`PageHeaderCollaborator.roles`**.
247
+
248
+ **Handbook:** `apps/web/AGENTS.md` §4.7 · **Doc:** `docs/collaboration-access-pattern.md` · **Skill:** `.cursor/skills/exxat-collaboration-access/SKILL.md` · **Reference:** Question bank header + client.
249
+
232
250
  ---
233
251
 
234
252
  ## 7. Navigation: Breadcrumbs vs Back Link
@@ -449,9 +467,9 @@ Full checklist in `references/accessibility.md`. Summary of the most-violated ru
449
467
 
450
468
  ### Icons that communicate information — always have a text alternative
451
469
 
452
- This rule covers **every icon that carries meaning**, not only icon-only buttons. FA glyphs, inline SVGs, trend arrows, status dots, chart-legend squares, calendar/clock/pin icons in cells — if the icon tells the user something, that something MUST be reachable by screen readers AND discoverable to sighted users who don't recognise the glyph. SC 1.1.1, 3.3.2, 2.4.6.
470
+ This rule covers **every icon that carries meaning**, not only icon-only buttons. FA glyphs, inline SVGs, avatar placeholders, trend arrows, status dots, chart-legend squares, calendar/clock/pin icons in cells — if the icon **tells the user something**, that something MUST be reachable by screen readers AND discoverable to sighted users who don't recognise the glyph. SC 1.1.1, 3.3.2, 2.4.6.
453
471
 
454
- **Case A — Decorative icon next to text that already names it** → icon is `aria-hidden`, no tooltip.
472
+ **Case A — Decorative icon next to text that already names it** → icon is `aria-hidden`, no `aria-label`, no tooltip. The text is the alt.
455
473
 
456
474
  ```tsx
457
475
  <span className="flex items-center gap-1.5">
@@ -460,7 +478,7 @@ This rule covers **every icon that carries meaning**, not only icon-only buttons
460
478
  </span>
461
479
  ```
462
480
 
463
- **Case B — Informational icon standing alone** (calendar = "date range", clock = "updated at", pin = "site", trend arrow, status dot, icon-only chart legend) → MUST pair `role="img"` + `aria-label` with a visible `Tooltip`. Wrapper MUST be keyboard-focusable (`tabIndex={0}`).
481
+ **Case B — Informational icon standing alone** (calendar = "date range", clock = "updated at", pin = "site", cap = "student", trend arrow, status dot, icon-only chart legend) → MUST pair `role="img"` + `aria-label` with a visible `Tooltip`. Wrapper MUST be keyboard-focusable (`tabIndex={0}`).
464
482
 
465
483
  ```tsx
466
484
  <Tooltip>
@@ -474,7 +492,7 @@ This rule covers **every icon that carries meaning**, not only icon-only buttons
474
492
  </Tooltip>
475
493
  ```
476
494
 
477
- **Case C — Interactive icon-only button / link** → MUST pair `aria-label` on the `<button>` with a wrapping `Tooltip`. Inner `<i>` / `<svg>` is `aria-hidden`. Target ≥ 24×24.
495
+ **Case C — Interactive icon-only button / link** (close `×`, chevron, overflow `⋯`, sort, filter-dismiss, copy, Ask Leo toggle, row actions) → MUST pair `aria-label` on the `<button>` with a wrapping `Tooltip`. Inner `<i>` / `<svg>` is `aria-hidden`. Target ≥ 24×24.
478
496
 
479
497
  ```tsx
480
498
  <Tooltip>
@@ -490,7 +508,7 @@ This rule covers **every icon that carries meaning**, not only icon-only buttons
490
508
  </Tooltip>
491
509
  ```
492
510
 
493
- **Decision tree:** adjacent text label? → A. Else interactive? → C. Else → B. When in doubt: add the accessible name + tooltip.
511
+ **Decision tree:** adjacent text label? → A. Else interactive? → C. Else → B. When in doubt: add the accessible name + tooltip. Narrow exception for all cases: a chevron inside a labelled composite (`Select`, `Combobox`) where the parent already carries the name.
494
512
 
495
513
  ### Touch targets
496
514
  - Minimum **24×24 CSS px** for all interactive controls
@@ -696,8 +714,8 @@ Copy and complete for every list/table/hub page:
696
714
  - [ ] All dates: `MM/DD/YYYY` / `MM/DD/YYYY hh:mm AM/PM EST`
697
715
  - [ ] All tooltips via `<Tip>` — no `title` attribute
698
716
  - [ ] All icons: `aria-hidden="true"`; Ask Leo: `fa-duotone fa-solid fa-star-christmas text-brand`
699
- - [ ] **Every icon that communicates info has a text alternative** — Case A adjacent label, Case B `role="img"` + `aria-label` + `Tooltip`, Case C `aria-label` + `Tooltip` on icon-only buttons; target ≥ 24×24 px. See §12 *Icons that communicate information*.
700
- - [ ] **`Kbd` inside a `Button` uses `variant="bare"`**; **`Kbd` inside `TooltipContent` uses the default tile** — see §11 Keyboard shortcuts
717
+ - [ ] **Every icon that communicates info has a text alternative** — Case A adjacent label (preferred), Case B `role="img"` + `aria-label` + `Tooltip` (calendar-for-date, status dot, trend arrow, icon-only legend), Case C `aria-label` + `Tooltip` on icon-only buttons; target ≥ 24×24 px. See §12 *Icons that communicate information*.
718
+ - [ ] **`Kbd` inside a `Button` uses `variant="bare"`** (glue chords into one bare kbd); **`Kbd` inside `TooltipContent` uses the default tile** — see §11 Keyboard shortcuts
701
719
  - [ ] `DialogTitle`/`SheetTitle`/`DrawerTitle` present on every overlay
702
720
  - [ ] `role="tablist"` contains only tab-role children
703
721
  - [ ] No new shadcn components, no hardcoded colors, no duplicate component abstractions
@@ -14,6 +14,7 @@ For **any app screen that shows a browsable, filterable grid of records** (lists
14
14
  3. **Filters:** Use the shared filter model (filter chips / `FilterFieldDef` and operators) consistent with existing list pages — not one-off filter UIs that bypass the table stack.
15
15
  4. **Table properties:** Include **Table properties** via `TablePropertiesDrawer` from `@/components/table-properties/drawer` (or the same toolbar + drawer pattern used on reference pages such as placements / data list). Users must be able to adjust columns, density, and related table settings from one place.
16
16
  5. **Active view:** On **`ListPageTemplate`** pages with **table / list / board / dashboard** tabs, **`TablePropertiesDrawer`** **MUST** receive **`currentView`** and **`onViewChange`** (see **`./AGENTS.md` §4.2** and **`.cursor/rules/exxat-table-properties-drawer.mdc`**) so Properties matches the selected view (not table-only copy on Board).
17
+ 6. **Dropdown menus:** **`DropdownMenuContent`** uses the shared **`@exxatdesignux/ui`** default (**intrinsic `w-max`**, **`min-w-52`**, capped **`max-w`**) for view settings, row ⋯, column menus, and filter pickers — **pure CSS**, no **`ResizeObserver`**. Override only for deliberate narrow/wide rails (e.g. pagination **`w-20`**, account trigger-width, school switcher **`!w-max min-w-72 …`**). See **`docs/data-views-pattern.md`** (“Dropdown menus”).
17
18
 
18
19
  **Reference implementation:** `components/data-list-table.tsx` (placements) shows how `DataTable`, filters, and `TablePropertiesDrawer` compose together.
19
20