@dashforge/tw 0.5.0-beta → 0.7.0-beta

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 (105) hide show
  1. package/CHANGELOG.md +526 -0
  2. package/THEME-AUDIT.md +175 -0
  3. package/dist/index.esm.js +2504 -51
  4. package/dist/src/components/Accordion/accordion.variants.d.ts.map +1 -1
  5. package/dist/src/components/Box/box.variants.d.ts.map +1 -1
  6. package/dist/src/components/DataGrid/DataGrid.d.ts +29 -0
  7. package/dist/src/components/DataGrid/DataGrid.d.ts.map +1 -0
  8. package/dist/src/components/DataGrid/dataGrid.types.d.ts +171 -0
  9. package/dist/src/components/DataGrid/dataGrid.types.d.ts.map +1 -0
  10. package/dist/src/components/DataGrid/dataGrid.variants.d.ts +216 -0
  11. package/dist/src/components/DataGrid/dataGrid.variants.d.ts.map +1 -0
  12. package/dist/src/components/Dialog/dialog.variants.d.ts.map +1 -1
  13. package/dist/src/components/Divider/divider.variants.d.ts.map +1 -1
  14. package/dist/src/components/Pagination/Pagination.d.ts.map +1 -1
  15. package/dist/src/components/Pagination/pagination.variants.d.ts.map +1 -1
  16. package/dist/src/components/Popover/popover.variants.d.ts.map +1 -1
  17. package/dist/src/components/Skeleton/skeleton.variants.d.ts.map +1 -1
  18. package/dist/src/components/Table/Table.d.ts +24 -0
  19. package/dist/src/components/Table/Table.d.ts.map +1 -0
  20. package/dist/src/components/Table/cells/RenderButton.d.ts +13 -0
  21. package/dist/src/components/Table/cells/RenderButton.d.ts.map +1 -0
  22. package/dist/src/components/Table/cells/RenderChip.d.ts +80 -0
  23. package/dist/src/components/Table/cells/RenderChip.d.ts.map +1 -0
  24. package/dist/src/components/Table/cells/RenderText.d.ts +21 -0
  25. package/dist/src/components/Table/cells/RenderText.d.ts.map +1 -0
  26. package/dist/src/components/Table/cells/RenderTwoLine.d.ts +20 -0
  27. package/dist/src/components/Table/cells/RenderTwoLine.d.ts.map +1 -0
  28. package/dist/src/components/Table/cells/RowActionsMenu.d.ts +36 -0
  29. package/dist/src/components/Table/cells/RowActionsMenu.d.ts.map +1 -0
  30. package/dist/src/components/Table/cells/index.d.ts +11 -0
  31. package/dist/src/components/Table/cells/index.d.ts.map +1 -0
  32. package/dist/src/components/Table/table.types.d.ts +312 -0
  33. package/dist/src/components/Table/table.types.d.ts.map +1 -0
  34. package/dist/src/components/Table/table.variants.d.ts +285 -0
  35. package/dist/src/components/Table/table.variants.d.ts.map +1 -0
  36. package/dist/src/components/Tabs/tabs.variants.d.ts.map +1 -1
  37. package/dist/src/components/Tooltip/tooltip.variants.d.ts.map +1 -1
  38. package/dist/src/components/Typography/typography.variants.d.ts.map +1 -1
  39. package/dist/src/components/_shared/data/getNestedValue.d.ts +20 -0
  40. package/dist/src/components/_shared/data/getNestedValue.d.ts.map +1 -0
  41. package/dist/src/components/_shared/data/useColumnAutoDetect.d.ts +45 -0
  42. package/dist/src/components/_shared/data/useColumnAutoDetect.d.ts.map +1 -0
  43. package/dist/src/components/_shared/data/useControllableState.d.ts +23 -0
  44. package/dist/src/components/_shared/data/useControllableState.d.ts.map +1 -0
  45. package/dist/src/components/_shared/data/useDebouncedValue.d.ts +10 -0
  46. package/dist/src/components/_shared/data/useDebouncedValue.d.ts.map +1 -0
  47. package/dist/src/components/_shared/data/useTableFilter.d.ts +13 -0
  48. package/dist/src/components/_shared/data/useTableFilter.d.ts.map +1 -0
  49. package/dist/src/components/_shared/data/useTableSearch.d.ts +23 -0
  50. package/dist/src/components/_shared/data/useTableSearch.d.ts.map +1 -0
  51. package/dist/src/components/_shared/data/useTableSelection.d.ts +25 -0
  52. package/dist/src/components/_shared/data/useTableSelection.d.ts.map +1 -0
  53. package/dist/src/components/_shared/data/useTableSort.d.ts +20 -0
  54. package/dist/src/components/_shared/data/useTableSort.d.ts.map +1 -0
  55. package/dist/src/components/_shared/data/useVirtualizer.d.ts +107 -0
  56. package/dist/src/components/_shared/data/useVirtualizer.d.ts.map +1 -0
  57. package/dist/src/index.d.ts +10 -1
  58. package/dist/src/index.d.ts.map +1 -1
  59. package/package.json +3 -3
  60. package/src/components/Accordion/accordion.variants.ts +5 -3
  61. package/src/components/Box/Box.test.tsx +32 -11
  62. package/src/components/Box/box.variants.ts +23 -4
  63. package/src/components/DataGrid/DataGrid.test.tsx +496 -0
  64. package/src/components/DataGrid/DataGrid.tsx +831 -0
  65. package/src/components/DataGrid/dataGrid.types.ts +189 -0
  66. package/src/components/DataGrid/dataGrid.variants.ts +144 -0
  67. package/src/components/Dialog/dialog.variants.ts +10 -6
  68. package/src/components/Divider/Divider.test.tsx +3 -1
  69. package/src/components/Divider/Divider.tsx +1 -1
  70. package/src/components/Divider/divider.variants.ts +2 -1
  71. package/src/components/Pagination/Pagination.tsx +3 -1
  72. package/src/components/Pagination/pagination.variants.ts +19 -13
  73. package/src/components/Popover/popover.variants.ts +10 -4
  74. package/src/components/Skeleton/skeleton.variants.ts +2 -1
  75. package/src/components/Table/Table.test.tsx +603 -0
  76. package/src/components/Table/Table.tsx +816 -0
  77. package/src/components/Table/cells/RenderButton.tsx +23 -0
  78. package/src/components/Table/cells/RenderChip.tsx +103 -0
  79. package/src/components/Table/cells/RenderText.tsx +37 -0
  80. package/src/components/Table/cells/RenderTwoLine.tsx +34 -0
  81. package/src/components/Table/cells/RowActionsMenu.tsx +147 -0
  82. package/src/components/Table/cells/index.ts +14 -0
  83. package/src/components/Table/table.types.ts +346 -0
  84. package/src/components/Table/table.variants.ts +207 -0
  85. package/src/components/Tabs/tabs.variants.ts +19 -8
  86. package/src/components/Tooltip/tooltip.variants.ts +12 -2
  87. package/src/components/Typography/Typography.test.tsx +3 -1
  88. package/src/components/Typography/typography.variants.ts +6 -2
  89. package/src/components/_shared/data/getNestedValue.test.ts +50 -0
  90. package/src/components/_shared/data/getNestedValue.ts +32 -0
  91. package/src/components/_shared/data/useColumnAutoDetect.test.ts +123 -0
  92. package/src/components/_shared/data/useColumnAutoDetect.ts +100 -0
  93. package/src/components/_shared/data/useControllableState.ts +48 -0
  94. package/src/components/_shared/data/useDebouncedValue.ts +24 -0
  95. package/src/components/_shared/data/useTableFilter.ts +37 -0
  96. package/src/components/_shared/data/useTableSearch.test.ts +115 -0
  97. package/src/components/_shared/data/useTableSearch.ts +66 -0
  98. package/src/components/_shared/data/useTableSelection.test.ts +100 -0
  99. package/src/components/_shared/data/useTableSelection.ts +100 -0
  100. package/src/components/_shared/data/useTableSort.test.ts +150 -0
  101. package/src/components/_shared/data/useTableSort.ts +141 -0
  102. package/src/components/_shared/data/useVirtualizer.test.ts +158 -0
  103. package/src/components/_shared/data/useVirtualizer.ts +199 -0
  104. package/src/components/_shared/themeIdentity.test.ts +113 -0
  105. package/src/index.ts +59 -1
package/dist/index.esm.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx, jsxs, Fragment as Fragment$1 } from 'react/jsx-runtime';
2
- import { useMemo, forwardRef, useContext, useId, useRef, useEffect, useCallback, useState, Fragment, createContext, Children, isValidElement, cloneElement } from 'react';
2
+ import { useMemo, forwardRef, useContext, useId, useRef, useEffect, useCallback, useState, Fragment, createContext, Children, isValidElement, cloneElement, useLayoutEffect } from 'react';
3
3
  import { Slot } from '@radix-ui/react-slot';
4
4
  import clsx from 'clsx';
5
5
  import { twMerge } from 'tailwind-merge';
@@ -5292,8 +5292,12 @@ const SnackbarContext = /*#__PURE__*/ createContext(null);
5292
5292
  warning: 'text-warning-700 dark:text-warning-400',
5293
5293
  danger: 'text-danger-700 dark:text-danger-400',
5294
5294
  info: 'text-info-700 dark:text-info-400',
5295
- neutral: 'text-neutral-900 dark:text-neutral-100',
5296
- muted: 'text-neutral-600 dark:text-neutral-400'
5295
+ // Neutral palette auto-inverts via the dashforgePreset() CSS-var
5296
+ // swap — no `dark:` variants. `text-neutral-900` resolves to
5297
+ // dark text in light mode and light text in dark mode; same
5298
+ // applies to `text-neutral-600` (muted).
5299
+ neutral: 'text-neutral-900',
5300
+ muted: 'text-neutral-600'
5297
5301
  },
5298
5302
  /*
5299
5303
  * `weight` overrides the variant's default weight. When unset, the
@@ -5494,7 +5498,13 @@ Typography.displayName = 'Typography';
5494
5498
  */ variant: {
5495
5499
  plain: '',
5496
5500
  outlined: 'border',
5497
- elevated: 'bg-white dark:bg-neutral-900',
5501
+ // `bg-white` is hardcoded (does NOT auto-invert via CSS vars),
5502
+ // so the `dark:` variant is required. Target `dark:bg-neutral-100`
5503
+ // resolves to `#171717` via the inversion → one elevation tier
5504
+ // above the page surface (`bg-neutral-50` = `#0a0a0a`) in dark
5505
+ // mode. `dark:bg-neutral-900` was wrong (resolved to near-white
5506
+ // in dark mode — overpowering elevation).
5507
+ elevated: 'bg-white dark:bg-neutral-100',
5498
5508
  soft: '',
5499
5509
  solid: ''
5500
5510
  },
@@ -5666,7 +5676,11 @@ Typography.displayName = 'Typography';
5666
5676
  {
5667
5677
  variant: 'outlined',
5668
5678
  color: 'neutral',
5669
- class: 'border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-900'
5679
+ // border-neutral-200 auto-inverts via CSS var swap (no `dark:`
5680
+ // needed). bg-white is hardcoded — `dark:bg-neutral-100` keeps
5681
+ // elevation tier above page surface in dark mode (NOT
5682
+ // `dark:bg-neutral-900` which renders near-white).
5683
+ class: 'border-neutral-200 bg-white dark:bg-neutral-100'
5670
5684
  },
5671
5685
  // ─── soft × color ─────────────────────────────────────────────────
5672
5686
  {
@@ -5702,7 +5716,9 @@ Typography.displayName = 'Typography';
5702
5716
  {
5703
5717
  variant: 'soft',
5704
5718
  color: 'neutral',
5705
- class: 'bg-neutral-100 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100'
5719
+ // Both neutral classes auto-invert via the dashforgePreset() CSS
5720
+ // var swap — no `dark:` variants needed.
5721
+ class: 'bg-neutral-100 text-neutral-900'
5706
5722
  },
5707
5723
  // ─── solid × color ────────────────────────────────────────────────
5708
5724
  {
@@ -5738,7 +5754,14 @@ Typography.displayName = 'Typography';
5738
5754
  {
5739
5755
  variant: 'solid',
5740
5756
  color: 'neutral',
5741
- class: 'bg-neutral-900 text-white dark:bg-neutral-100 dark:text-neutral-900'
5757
+ // Solid neutral is the high-emphasis "always-foreground accent"
5758
+ // variant. Both classes auto-invert: dark surface + light text
5759
+ // in light mode (`#171717` / `#fafafa`) flips to light surface +
5760
+ // dark text in dark mode (`#f5f5f5` / `#0a0a0a`). The
5761
+ // accent inverts WITH the page surface — consistent with the
5762
+ // identity rule. Previously used `text-white` (no auto-invert)
5763
+ // which forced double-inversion to keep readable.
5764
+ class: 'bg-neutral-900 text-neutral-50'
5742
5765
  }
5743
5766
  ],
5744
5767
  defaultVariants: {
@@ -6526,7 +6549,8 @@ Container.displayName = 'Container';
6526
6549
  dotted: 'border-dotted'
6527
6550
  },
6528
6551
  color: {
6529
- neutral: 'border-neutral-200 dark:border-neutral-800',
6552
+ // Neutral palette auto-inverts via CSS-var swap (no `dark:`).
6553
+ neutral: 'border-neutral-200',
6530
6554
  primary: 'border-primary-300 dark:border-primary-700',
6531
6555
  secondary: 'border-secondary-300 dark:border-secondary-700',
6532
6556
  success: 'border-success-300 dark:border-success-700',
@@ -6668,7 +6692,7 @@ Container.displayName = 'Container';
6668
6692
  className: cn(lineCommon, leftStubClass)
6669
6693
  }),
6670
6694
  jsx("span", {
6671
- className: cn('shrink-0 text-sm text-neutral-500 dark:text-neutral-400', labelPadding),
6695
+ className: cn('shrink-0 text-sm text-neutral-500', labelPadding),
6672
6696
  children: children
6673
6697
  }),
6674
6698
  jsx("span", {
@@ -6821,21 +6845,25 @@ VisuallyHidden.displayName = 'VisuallyHidden';
6821
6845
  ],
6822
6846
  content: [
6823
6847
  'fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2',
6824
- 'w-full bg-white dark:bg-neutral-900 rounded-lg shadow-xl',
6825
- 'border border-neutral-200 dark:border-neutral-800',
6848
+ // bg-white hardcoded (doesn't auto-invert) so `dark:` is needed.
6849
+ // Target `dark:bg-neutral-100` = #171717 in dark mode (one
6850
+ // elevation tier above page surface).
6851
+ 'w-full bg-white dark:bg-neutral-100 rounded-lg shadow-xl',
6852
+ // border-neutral-200 auto-inverts — no `dark:` needed.
6853
+ 'border border-neutral-200',
6826
6854
  'p-6 flex flex-col gap-4 max-h-[90vh]',
6827
6855
  'focus:outline-none',
6828
6856
  'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
6829
6857
  'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
6830
6858
  'motion-reduce:transition-none motion-reduce:duration-0'
6831
6859
  ],
6832
- title: 'text-lg font-semibold leading-tight text-neutral-900 dark:text-neutral-50',
6833
- description: 'text-sm text-neutral-600 dark:text-neutral-400',
6860
+ // All neutral text classes auto-invert via CSS-var swap.
6861
+ title: 'text-lg font-semibold leading-tight text-neutral-900',
6862
+ description: 'text-sm text-neutral-600',
6834
6863
  closeButton: [
6835
6864
  'absolute top-4 right-4 inline-flex items-center justify-center',
6836
6865
  'h-8 w-8 rounded-md text-neutral-500 hover:text-neutral-900',
6837
- 'dark:text-neutral-400 dark:hover:text-neutral-50',
6838
- 'hover:bg-neutral-100 dark:hover:bg-neutral-800',
6866
+ 'hover:bg-neutral-100',
6839
6867
  'focus:outline-none focus:ring-2 focus:ring-primary-500',
6840
6868
  'transition-colors'
6841
6869
  ],
@@ -6952,21 +6980,32 @@ const tabsVariants = tv({
6952
6980
  variants: {
6953
6981
  variant: {
6954
6982
  underline: {
6955
- list: 'border-b border-neutral-200 dark:border-neutral-800',
6983
+ // Neutral palette auto-inverts via CSS-var swap.
6984
+ list: 'border-b border-neutral-200',
6956
6985
  trigger: [
6957
- 'px-3 py-2 border-b-2 border-transparent text-neutral-600 dark:text-neutral-400',
6958
- 'hover:text-neutral-900 dark:hover:text-neutral-50',
6986
+ 'px-3 py-2 border-b-2 border-transparent text-neutral-600',
6987
+ 'hover:text-neutral-900',
6988
+ // Primary palette does NOT auto-invert — `dark:` shift is
6989
+ // an intentional tone refinement (kept).
6959
6990
  'data-[state=active]:border-primary-500 data-[state=active]:text-primary-700',
6960
6991
  'dark:data-[state=active]:text-primary-400'
6961
6992
  ]
6962
6993
  },
6963
6994
  pill: {
6964
- list: 'rounded-lg bg-neutral-100 dark:bg-neutral-800 p-1',
6995
+ // Pill list bg uses the elevation tier — auto-inverts.
6996
+ list: 'rounded-lg bg-neutral-100 p-1',
6965
6997
  trigger: [
6966
- 'px-3 py-1.5 rounded-md text-neutral-600 dark:text-neutral-400',
6967
- 'hover:text-neutral-900 dark:hover:text-neutral-50',
6968
- 'data-[state=active]:bg-white dark:data-[state=active]:bg-neutral-900',
6969
- 'data-[state=active]:text-neutral-900 dark:data-[state=active]:text-neutral-50',
6998
+ 'px-3 py-1.5 rounded-md text-neutral-600',
6999
+ 'hover:text-neutral-900',
7000
+ // Active pill is one tier above the list bg
7001
+ // (`bg-neutral-100`-inverted = #171717 in dark).
7002
+ // `bg-white` is hardcoded so a `dark:` is required —
7003
+ // target `dark:bg-neutral-200` (= #262626 in dark) sits
7004
+ // ONE TIER above the list bg, preserving the elevation
7005
+ // semantic from light mode (where `bg-white` sits above
7006
+ // `bg-neutral-100` = #f5f5f5).
7007
+ 'data-[state=active]:bg-white dark:data-[state=active]:bg-neutral-200',
7008
+ 'data-[state=active]:text-neutral-900',
6970
7009
  'data-[state=active]:shadow-sm'
6971
7010
  ]
6972
7011
  }
@@ -7042,14 +7081,24 @@ const tooltipVariants = tv({
7042
7081
  slots: {
7043
7082
  content: [
7044
7083
  'z-50 max-w-xs rounded-md px-3 py-1.5 text-xs font-medium',
7084
+ // Auto-invert via the dashforgePreset() CSS-var swap:
7085
+ // - Light mode: `bg-neutral-900` = #171717 (dark surface) +
7086
+ // `text-neutral-50` = #fafafa (light text) — DARK tooltip
7087
+ // on the light page (high contrast).
7088
+ // - Dark mode: `bg-neutral-900` flips to #f5f5f5 (light) +
7089
+ // `text-neutral-50` flips to #0a0a0a (dark text) — LIGHT
7090
+ // tooltip on the dark page (high contrast against page).
7091
+ // Adding `dark:bg-neutral-50 dark:text-neutral-900` (the old
7092
+ // pattern) would force the dark-mode tooltip back to a dark
7093
+ // surface — invisible against the dark page.
7045
7094
  'bg-neutral-900 text-neutral-50',
7046
- 'dark:bg-neutral-50 dark:text-neutral-900',
7047
7095
  'shadow-md',
7048
7096
  'data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95',
7049
7097
  'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
7050
7098
  'motion-reduce:transition-none motion-reduce:duration-0'
7051
7099
  ],
7052
- arrow: 'fill-neutral-900 dark:fill-neutral-50'
7100
+ // Arrow tracks the content bg — auto-inverts the same way.
7101
+ arrow: 'fill-neutral-900'
7053
7102
  }
7054
7103
  });
7055
7104
 
@@ -7114,15 +7163,21 @@ const tooltipVariants = tv({
7114
7163
  const popoverVariants = tv({
7115
7164
  slots: {
7116
7165
  content: [
7117
- 'z-50 rounded-md border border-neutral-200 dark:border-neutral-800',
7118
- 'bg-white dark:bg-neutral-900 p-4 shadow-lg',
7119
- 'text-sm text-neutral-900 dark:text-neutral-50',
7166
+ // Neutral border auto-inverts via CSS-var swap.
7167
+ 'z-50 rounded-md border border-neutral-200',
7168
+ // bg-white hardcoded (no auto-invert) so `dark:` is required.
7169
+ // Target `dark:bg-neutral-100` resolves to #171717 in dark mode
7170
+ // (one elevation tier above the page surface).
7171
+ 'bg-white dark:bg-neutral-100 p-4 shadow-lg',
7172
+ // Text auto-inverts.
7173
+ 'text-sm text-neutral-900',
7120
7174
  'focus:outline-none',
7121
7175
  'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
7122
7176
  'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
7123
7177
  'motion-reduce:transition-none motion-reduce:duration-0'
7124
7178
  ],
7125
- arrow: 'fill-white dark:fill-neutral-900'
7179
+ // Arrow follows content bg: `fill-white` in light + matching dark target.
7180
+ arrow: 'fill-white dark:fill-neutral-100'
7126
7181
  }
7127
7182
  });
7128
7183
 
@@ -7173,11 +7228,13 @@ const popoverVariants = tv({
7173
7228
  const accordionVariants = tv({
7174
7229
  slots: {
7175
7230
  root: 'w-full',
7176
- item: 'border-b border-neutral-200 dark:border-neutral-800',
7231
+ // Neutral palette auto-inverts via the dashforgePreset() CSS-var
7232
+ // swap — no `dark:` variant needed (would double-invert).
7233
+ item: 'border-b border-neutral-200',
7177
7234
  header: 'flex',
7178
7235
  trigger: [
7179
7236
  'flex flex-1 items-center justify-between py-3 text-left',
7180
- 'text-sm font-medium text-neutral-900 dark:text-neutral-50',
7237
+ 'text-sm font-medium text-neutral-900',
7181
7238
  'transition-all hover:underline',
7182
7239
  'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:rounded',
7183
7240
  '[&[data-state=open]>svg]:rotate-180',
@@ -7185,7 +7242,7 @@ const accordionVariants = tv({
7185
7242
  'motion-reduce:transition-none'
7186
7243
  ],
7187
7244
  content: [
7188
- 'overflow-hidden text-sm text-neutral-700 dark:text-neutral-300',
7245
+ 'overflow-hidden text-sm text-neutral-700',
7189
7246
  'data-[state=open]:animate-accordion-down',
7190
7247
  'data-[state=closed]:animate-accordion-up',
7191
7248
  'motion-reduce:animate-none'
@@ -7316,7 +7373,8 @@ const accordionVariants = tv({
7316
7373
  slots: {
7317
7374
  root: [
7318
7375
  'block',
7319
- 'bg-neutral-200 dark:bg-neutral-800',
7376
+ // Neutral palette auto-inverts via CSS-var swap (no `dark:`).
7377
+ 'bg-neutral-200',
7320
7378
  'motion-reduce:animate-none'
7321
7379
  ]
7322
7380
  },
@@ -7430,28 +7488,34 @@ const accordionVariants = tv({
7430
7488
  */ const paginationVariants = tv({
7431
7489
  slots: {
7432
7490
  root: 'flex flex-wrap items-center gap-3',
7433
- summary: 'text-neutral-600 dark:text-neutral-400',
7491
+ // All neutral classes auto-invert via the dashforgePreset() CSS-var
7492
+ // swap. `bg-neutral-50` provides the button surface (matches page
7493
+ // surface; the border gives the separation). `hover:bg-neutral-100`
7494
+ // is the one-tier elevation that works in both modes.
7495
+ summary: 'text-neutral-600',
7434
7496
  list: 'inline-flex items-center gap-1',
7435
7497
  pageButton: [
7436
7498
  'inline-flex items-center justify-center rounded-md',
7437
- 'border border-neutral-300 dark:border-neutral-700',
7438
- 'bg-white dark:bg-neutral-900',
7439
- 'text-neutral-700 dark:text-neutral-300',
7440
- 'hover:bg-neutral-50 dark:hover:bg-neutral-800',
7499
+ 'border border-neutral-300',
7500
+ 'bg-neutral-50',
7501
+ 'text-neutral-700',
7502
+ 'hover:bg-neutral-100',
7441
7503
  'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
7442
7504
  'transition-colors motion-reduce:transition-none',
7443
7505
  'disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none'
7444
7506
  ],
7445
7507
  activeButton: [
7508
+ // Primary palette does NOT auto-invert — `dark:` shift is a
7509
+ // design choice for tone refinement (kept).
7446
7510
  'bg-primary-500 text-white border-primary-500',
7447
7511
  'hover:bg-primary-600 dark:hover:bg-primary-400'
7448
7512
  ],
7449
7513
  navButton: [
7450
7514
  'inline-flex items-center justify-center rounded-md',
7451
- 'border border-neutral-300 dark:border-neutral-700',
7452
- 'bg-white dark:bg-neutral-900',
7453
- 'text-neutral-700 dark:text-neutral-300',
7454
- 'hover:bg-neutral-50 dark:hover:bg-neutral-800',
7515
+ 'border border-neutral-300',
7516
+ 'bg-neutral-50',
7517
+ 'text-neutral-700',
7518
+ 'hover:bg-neutral-100',
7455
7519
  'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
7456
7520
  'transition-colors motion-reduce:transition-none',
7457
7521
  'disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none'
@@ -7459,12 +7523,12 @@ const accordionVariants = tv({
7459
7523
  ellipsis: 'inline-flex items-center justify-center text-neutral-500 px-1',
7460
7524
  pageSizeSelector: [
7461
7525
  'inline-flex items-center gap-2',
7462
- 'text-neutral-700 dark:text-neutral-300'
7526
+ 'text-neutral-700'
7463
7527
  ],
7464
7528
  jumpInput: [
7465
- 'rounded-md border border-neutral-300 dark:border-neutral-700',
7466
- 'bg-white dark:bg-neutral-900',
7467
- 'text-neutral-900 dark:text-neutral-50',
7529
+ 'rounded-md border border-neutral-300',
7530
+ 'bg-neutral-50',
7531
+ 'text-neutral-900',
7468
7532
  'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
7469
7533
  'transition-colors motion-reduce:transition-none'
7470
7534
  ]
@@ -7601,7 +7665,7 @@ function range(start, end) {
7601
7665
  }, (_, i)=>start + i);
7602
7666
  }
7603
7667
 
7604
- const DEFAULT_LABELS = {
7668
+ const DEFAULT_LABELS$2 = {
7605
7669
  first: 'First',
7606
7670
  prev: 'Previous',
7607
7671
  next: 'Next',
@@ -7649,7 +7713,7 @@ const DEFAULT_PAGE_SIZE_OPTIONS = [
7649
7713
  */ function Pagination(props) {
7650
7714
  var _slotProps_root, _slotProps_summary, _slotProps_summary1, _slotProps_list, _slotProps_navButton, _slotProps_navButton1, _slotProps_navButton2, _slotProps_navButton3, _slotProps_pageSizeSelector, _slotProps_jumpInput;
7651
7715
  const { page, pageSize, totalCount, onPageChange, onPageSizeChange, pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, siblingCount = 1, boundaryCount = 1, showFirstLast = true, showJumpInput = true, disabled = false, labels: labelsProp, variant = 'default', size = 'md', sx, slotProps } = props;
7652
- const labels = _extends({}, DEFAULT_LABELS, labelsProp);
7716
+ const labels = _extends({}, DEFAULT_LABELS$2, labelsProp);
7653
7717
  const v = paginationVariants({
7654
7718
  variant,
7655
7719
  size
@@ -7807,7 +7871,9 @@ const DEFAULT_PAGE_SIZE_OPTIONS = [
7807
7871
  onChange: handlePageSize,
7808
7872
  disabled: disabled,
7809
7873
  "aria-label": labels.perPage,
7810
- className: cn('rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1', 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500'),
7874
+ className: cn(// Auto-invert via CSS-var swap — no `dark:` variants needed
7875
+ // on neutral palette.
7876
+ 'rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1', 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500'),
7811
7877
  children: pageSizeOptions.map((opt)=>jsx("option", {
7812
7878
  value: opt,
7813
7879
  children: opt
@@ -7844,6 +7910,2393 @@ const DEFAULT_PAGE_SIZE_OPTIONS = [
7844
7910
  });
7845
7911
  }
7846
7912
 
7913
+ /**
7914
+ * `<Table>` variant recipe.
7915
+ *
7916
+ * Slots:
7917
+ * - `root` — outer wrapper
7918
+ * - `toolbar` — search + filter chips row above table
7919
+ * - `search` — search input
7920
+ * - `scroll` — `<div class="overflow-auto">` wrapping the table
7921
+ * - `table` — `<table>` element
7922
+ * - `thead` — `<thead>` (sticky positioning here)
7923
+ * - `tbody` — `<tbody>`
7924
+ * - `headerRow` — `<tr>` inside `<thead>`
7925
+ * - `headerCell` — `<th>` cell
7926
+ * - `headerCellButton` — `<button>` inside sortable `<th>`
7927
+ * - `row` — `<tr>` inside `<tbody>` (data row)
7928
+ * - `cell` — `<td>` data cell
7929
+ * - `selectionCell` — first `<td>` when selection mode active
7930
+ * - `expandToggleCell` — second `<td>` when expandable active
7931
+ * - `expandedRow` — full-width `<tr>` rendered after an expanded row
7932
+ * - `rowActionsCell` — last `<td>` when `rowActions` provided
7933
+ * - `emptyState` — placeholder when rows is empty
7934
+ * - `loadingOverlay` — skeleton overlay when loading
7935
+ * - `bulkActionFooter` — sticky footer with bulk actions
7936
+ *
7937
+ * Variant axes:
7938
+ * - `variant` — visual row-division style:
7939
+ * - `plain` — minimal separators
7940
+ * - `lines` — Stripe-style thin horizontal dividers (default)
7941
+ * - `striped` — alternating row backgrounds (zebra)
7942
+ * - `bordered` — full cell borders
7943
+ * - `card` — each row rendered as a separate surface card
7944
+ * - `size` — `sm` / `md` (default) / `lg`
7945
+ * - `density` — `compact` (40px row) / `comfortable` (default 48px) / `spacious` (56px)
7946
+ *
7947
+ * Smart-default alignment (per column) is applied by the Table
7948
+ * component code, NOT here — it depends on the runtime-detected
7949
+ * value type, which the recipe doesn't see.
7950
+ *
7951
+ * **Dashforge identity rule** (memory: `feedback_dashforge_preset_is_identity`):
7952
+ * This recipe uses NO `dark:` variants on the neutral palette. The
7953
+ * `dashforgePreset()` CSS-variable swap auto-inverts neutral tokens
7954
+ * (`bg-neutral-50` is light surface in light mode, dark surface in
7955
+ * dark mode — same class, different physical color). Adding a
7956
+ * `dark:` variant on neutral palette = DOUBLE inversion = breaks
7957
+ * dark mode. The selected-row state uses the canonical LeftNav
7958
+ * `itemActive` pattern (`bg-primary-100 text-primary-900 font-medium`),
7959
+ * which works in both modes because the primary palette has no
7960
+ * inversion (both `bg` and `text` resolve to the same physical
7961
+ * colors regardless of mode → guaranteed contrast).
7962
+ */ const tableVariants = tv({
7963
+ slots: {
7964
+ root: 'w-full',
7965
+ toolbar: 'flex flex-wrap items-center gap-3 mb-3',
7966
+ search: [
7967
+ 'inline-flex items-center gap-2',
7968
+ 'w-full max-w-sm rounded-md',
7969
+ 'border border-neutral-300',
7970
+ // bg-neutral-50 auto-inverts via CSS var swap (light in light mode,
7971
+ // dark in dark mode). Same semantic as TextField wrapper.
7972
+ 'bg-neutral-50',
7973
+ 'focus-within:ring-2 focus-within:ring-primary-500',
7974
+ 'transition-colors motion-reduce:transition-none'
7975
+ ],
7976
+ scroll: 'relative w-full overflow-auto rounded-md',
7977
+ table: 'w-full caption-bottom text-left',
7978
+ // thead matches the page surface (auto-inverts). When sticky the
7979
+ // header sits opaque against the scroll content beneath it.
7980
+ thead: 'bg-neutral-50',
7981
+ tbody: '',
7982
+ headerRow: '',
7983
+ headerCell: [
7984
+ // Less prominent than the body — secondary-text intent
7985
+ // (auto-inverts: dark grey in light mode, light grey in dark).
7986
+ 'text-neutral-700',
7987
+ 'font-semibold whitespace-nowrap select-none',
7988
+ 'border-b border-neutral-200'
7989
+ ],
7990
+ headerCellButton: [
7991
+ 'group inline-flex items-center gap-1 select-none',
7992
+ // hover increases prominence (auto-inverts).
7993
+ 'hover:text-neutral-900',
7994
+ 'focus:outline-none focus-visible:underline',
7995
+ 'transition-colors motion-reduce:transition-none'
7996
+ ],
7997
+ row: [
7998
+ 'group/row transition-colors motion-reduce:transition-none',
7999
+ // bg-neutral-100 = "elevated above surface" in both modes via
8000
+ // auto-inversion. Slightly visible against the table bg.
8001
+ 'hover:bg-neutral-100',
8002
+ // Canonical LeftNav `itemActive` pattern — primary palette
8003
+ // doesn't invert, so dark-navy text on light-blue bg in BOTH
8004
+ // modes. Guaranteed contrast.
8005
+ 'data-[selected=true]:bg-primary-100',
8006
+ 'data-[selected=true]:text-primary-900',
8007
+ 'data-[selected=true]:font-medium'
8008
+ ],
8009
+ cell: [
8010
+ // Auto-inverts — dark text in light mode, light text in dark mode.
8011
+ 'text-neutral-900',
8012
+ 'border-b border-neutral-200',
8013
+ 'align-middle'
8014
+ ],
8015
+ selectionCell: 'w-10 text-center',
8016
+ expandToggleCell: 'w-10 text-center',
8017
+ // Expanded panel uses one tier of elevation above the page surface.
8018
+ expandedRow: 'bg-neutral-100',
8019
+ rowActionsCell: [
8020
+ 'w-12 text-right',
8021
+ // Row actions hidden until row is hovered or focus-within
8022
+ // (Pencil & Paper UX pattern: reveal-on-hover reduces visual density).
8023
+ 'opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100',
8024
+ 'transition-opacity motion-reduce:transition-none'
8025
+ ],
8026
+ emptyState: [
8027
+ 'py-12 text-center text-neutral-500'
8028
+ ],
8029
+ loadingOverlay: [
8030
+ 'pointer-events-none'
8031
+ ],
8032
+ bulkActionFooter: [
8033
+ 'sticky bottom-0 z-20',
8034
+ 'flex flex-wrap items-center justify-between gap-3',
8035
+ 'px-3 py-2 mt-0',
8036
+ 'border-t border-neutral-200',
8037
+ // Surface-elevated bg + shadow ladder.
8038
+ 'bg-neutral-50 shadow-md'
8039
+ ]
8040
+ },
8041
+ variants: {
8042
+ variant: {
8043
+ plain: {},
8044
+ lines: {
8045
+ },
8046
+ striped: {
8047
+ // bg-neutral-100/60 = subtle elevation tier (auto-inverts).
8048
+ row: 'odd:bg-neutral-100/60'
8049
+ },
8050
+ bordered: {
8051
+ table: 'border border-neutral-200',
8052
+ cell: 'border-r last:border-r-0 border-neutral-200',
8053
+ headerCell: 'border-r last:border-r-0 border-neutral-200'
8054
+ },
8055
+ card: {
8056
+ scroll: 'overflow-visible',
8057
+ table: 'border-separate border-spacing-y-2',
8058
+ row: [
8059
+ 'rounded-lg shadow-sm',
8060
+ // Each row becomes an elevated card above the surface.
8061
+ 'bg-neutral-50'
8062
+ ],
8063
+ cell: 'border-b-0 first:rounded-l-lg last:rounded-r-lg',
8064
+ headerCell: 'border-b-0'
8065
+ }
8066
+ },
8067
+ size: {
8068
+ sm: {
8069
+ headerCell: 'text-xs',
8070
+ cell: 'text-xs',
8071
+ search: 'text-xs'
8072
+ },
8073
+ md: {
8074
+ headerCell: 'text-sm',
8075
+ cell: 'text-sm',
8076
+ search: 'text-sm'
8077
+ },
8078
+ lg: {
8079
+ headerCell: 'text-base',
8080
+ cell: 'text-base',
8081
+ search: 'text-base'
8082
+ }
8083
+ },
8084
+ density: {
8085
+ // Pencil & Paper UX research: 40px / 48px / 56px row height.
8086
+ compact: {
8087
+ headerCell: 'px-2 py-1',
8088
+ cell: 'px-2 py-1'
8089
+ },
8090
+ comfortable: {
8091
+ headerCell: 'px-3 py-2',
8092
+ cell: 'px-3 py-2'
8093
+ },
8094
+ spacious: {
8095
+ headerCell: 'px-4 py-3',
8096
+ cell: 'px-4 py-3'
8097
+ }
8098
+ },
8099
+ stickyHeader: {
8100
+ true: {
8101
+ thead: 'sticky top-0 z-10'
8102
+ },
8103
+ false: {}
8104
+ }
8105
+ },
8106
+ defaultVariants: {
8107
+ variant: 'lines',
8108
+ size: 'md',
8109
+ density: 'comfortable',
8110
+ stickyHeader: true
8111
+ }
8112
+ });
8113
+
8114
+ /**
8115
+ * Generic "controllable" state hook — switches between controlled
8116
+ * (via parent-supplied value + setter) and uncontrolled (internal
8117
+ * `useState`).
8118
+ *
8119
+ * - If `controlledValue !== undefined`, the hook is in **controlled**
8120
+ * mode: the internal state is ignored; the returned setter calls
8121
+ * `onControlledChange` only.
8122
+ * - Otherwise it's in **uncontrolled** mode: internal `useState`,
8123
+ * defaulting to `defaultValue`. The optional `onControlledChange`
8124
+ * is still fired on every change so the parent can react.
8125
+ *
8126
+ * The control mode is determined per-render. Mixing modes (passing
8127
+ * `controlledValue` undefined sometimes) is a consumer bug — we don't
8128
+ * try to "remember" the last mode (React's rules-of-hooks make that
8129
+ * unsafe).
8130
+ */ function useControllableState(args) {
8131
+ const { controlledValue, defaultValue, onChange } = args;
8132
+ const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
8133
+ const isControlled = controlledValue !== undefined;
8134
+ const value = isControlled ? controlledValue : uncontrolledValue;
8135
+ // Stable ref to `onChange` so the setter doesn't change identity
8136
+ // every render (consumer can pass an inline lambda safely).
8137
+ const onChangeRef = useRef(onChange);
8138
+ onChangeRef.current = onChange;
8139
+ const setValue = useCallback((next)=>{
8140
+ const resolved = typeof next === 'function' ? next(value) : next;
8141
+ if (!isControlled) {
8142
+ setUncontrolledValue(resolved);
8143
+ }
8144
+ onChangeRef.current == null ? void 0 : onChangeRef.current.call(onChangeRef, resolved);
8145
+ }, [
8146
+ isControlled,
8147
+ value
8148
+ ]);
8149
+ return [
8150
+ value,
8151
+ setValue
8152
+ ];
8153
+ }
8154
+
8155
+ /**
8156
+ * Returns a debounced version of `value` — only updates after `delayMs`
8157
+ * of no further changes. Used by the search input so we don't re-filter
8158
+ * the entire row set on every keystroke.
8159
+ *
8160
+ * `delayMs <= 0` short-circuits to the input value (no debounce —
8161
+ * useful for tests).
8162
+ */ function useDebouncedValue(value, delayMs) {
8163
+ const [debounced, setDebounced] = useState(value);
8164
+ useEffect(()=>{
8165
+ if (delayMs <= 0) {
8166
+ setDebounced(value);
8167
+ return;
8168
+ }
8169
+ const timer = setTimeout(()=>setDebounced(value), delayMs);
8170
+ return ()=>clearTimeout(timer);
8171
+ }, [
8172
+ value,
8173
+ delayMs
8174
+ ]);
8175
+ return debounced;
8176
+ }
8177
+
8178
+ /**
8179
+ * Walk a dotted path through an object and return the value, or
8180
+ * `undefined` if any segment is missing / nullish.
8181
+ *
8182
+ * Bug fix from the MUI-side Table (Sprint 4.1 motivation): the
8183
+ * existing wrapper typed `field: NestedKeyOf<T>` but performed a
8184
+ * flat `row[col.field]` lookup at runtime — nested keys silently
8185
+ * returned `undefined`. This helper makes the runtime match the
8186
+ * type contract.
8187
+ *
8188
+ * @example
8189
+ * ```ts
8190
+ * getNestedValue({ user: { name: 'Jane' } }, 'user.name') // 'Jane'
8191
+ * getNestedValue({ user: null }, 'user.name') // undefined
8192
+ * getNestedValue({}, 'a.b.c') // undefined
8193
+ * getNestedValue({ a: { b: 0 } }, 'a.b') // 0 (NOT undefined)
8194
+ * ```
8195
+ */ function getNestedValue(row, path) {
8196
+ if (!path) return undefined;
8197
+ const segments = path.split('.');
8198
+ let current = row;
8199
+ for (const segment of segments){
8200
+ if (current == null) return undefined;
8201
+ if (typeof current !== 'object') return undefined;
8202
+ current = current[segment];
8203
+ }
8204
+ return current;
8205
+ }
8206
+
8207
+ /**
8208
+ * Stringify any value into a search-comparable form. Handles all
8209
+ * the edge cases that bit the MUI-side Table:
8210
+ *
8211
+ * - `null` / `undefined` → `''`
8212
+ * - `string` → as-is
8213
+ * - `number` / `bigint` → `String(value)`
8214
+ * - `boolean` → `'true'` / `'false'`
8215
+ * - `Date` → ISO 8601
8216
+ * - `Array` / object → `JSON.stringify` (best effort)
8217
+ */ function stringifyForSearch(value) {
8218
+ if (value == null) return '';
8219
+ if (typeof value === 'string') return value;
8220
+ if (typeof value === 'number' || typeof value === 'bigint') return String(value);
8221
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
8222
+ if (value instanceof Date) return value.toISOString();
8223
+ if (Array.isArray(value)) {
8224
+ try {
8225
+ return value.map(stringifyForSearch).join(' ');
8226
+ } catch (unused) {
8227
+ return '';
8228
+ }
8229
+ }
8230
+ if (typeof value === 'object') {
8231
+ try {
8232
+ return JSON.stringify(value);
8233
+ } catch (unused) {
8234
+ return '';
8235
+ }
8236
+ }
8237
+ return String(value);
8238
+ }
8239
+ /**
8240
+ * Filters `rows` by `query` against the columns flagged
8241
+ * `searchable: true`. Empty / whitespace-only query is a no-op
8242
+ * (returns the original rows).
8243
+ *
8244
+ * Case-insensitive substring match. The caller is responsible for
8245
+ * debouncing the input (use `useDebouncedValue`).
8246
+ */ function useTableSearch(rows, cols, query) {
8247
+ return useMemo(()=>{
8248
+ const trimmed = query.trim();
8249
+ if (!trimmed) return rows;
8250
+ const needle = trimmed.toLowerCase();
8251
+ const searchableCols = cols.filter((c)=>c.searchable);
8252
+ if (searchableCols.length === 0) return rows;
8253
+ return rows.filter((row)=>searchableCols.some((col)=>stringifyForSearch(getNestedValue(row, col.field)).toLowerCase().includes(needle)));
8254
+ }, [
8255
+ rows,
8256
+ cols,
8257
+ query
8258
+ ]);
8259
+ }
8260
+
8261
+ /**
8262
+ * Filters `rows` by the per-column filter model. v1 only supports
8263
+ * the `contains` operator (text substring) — additional ops
8264
+ * (`equals`, `in`, `gte`, `lte`, `between`, `before`, `after`)
8265
+ * land in v1-bis once the filter UI surface for each shape is
8266
+ * fleshed out.
8267
+ *
8268
+ * AND semantics across columns: a row must match EVERY active
8269
+ * filter. Within a single filter, the op decides the predicate.
8270
+ */ function useTableFilter(rows, filterModel) {
8271
+ return useMemo(()=>{
8272
+ if (!filterModel || filterModel.length === 0) return rows;
8273
+ return rows.filter((row)=>filterModel.every((filter)=>{
8274
+ const value = getNestedValue(row, filter.field);
8275
+ const stringValue = stringifyForSearch(value).toLowerCase();
8276
+ const needle = filter.value.trim().toLowerCase();
8277
+ if (!needle) return true; // empty filter == no-op
8278
+ switch(filter.op){
8279
+ case 'contains':
8280
+ return stringValue.includes(needle);
8281
+ default:
8282
+ return true;
8283
+ }
8284
+ }));
8285
+ }, [
8286
+ rows,
8287
+ filterModel
8288
+ ]);
8289
+ }
8290
+
8291
+ /**
8292
+ * Default comparator. Handles the common primitive types in a
8293
+ * sensible way:
8294
+ *
8295
+ * - `null` / `undefined` always sort LAST (stable across asc/desc)
8296
+ * - `number` / `bigint` → numeric comparison
8297
+ * - `string` → locale-aware compare via `Intl.Collator`
8298
+ * - `Date` / ISO-string-date → numeric compare on epoch ms
8299
+ * - `boolean` → `false < true`
8300
+ * - mixed types fall back to string compare
8301
+ *
8302
+ * The locale collator is created once per call (cheap; cached
8303
+ * upstream via memoization).
8304
+ */ function defaultCompare(a, b) {
8305
+ // Numbers
8306
+ if (typeof a === 'number' && typeof b === 'number') {
8307
+ return a - b;
8308
+ }
8309
+ if (typeof a === 'bigint' && typeof b === 'bigint') {
8310
+ return a < b ? -1 : a > b ? 1 : 0;
8311
+ }
8312
+ // Dates
8313
+ if (a instanceof Date && b instanceof Date) {
8314
+ return a.getTime() - b.getTime();
8315
+ }
8316
+ // Booleans
8317
+ if (typeof a === 'boolean' && typeof b === 'boolean') {
8318
+ return a === b ? 0 : a ? 1 : -1;
8319
+ }
8320
+ // Strings (incl. ISO date strings — natural lexical ordering matches chronology)
8321
+ if (typeof a === 'string' && typeof b === 'string') {
8322
+ return a.localeCompare(b, undefined, {
8323
+ numeric: true,
8324
+ sensitivity: 'base'
8325
+ });
8326
+ }
8327
+ // Fallback: stringify and compare
8328
+ return String(a).localeCompare(String(b), undefined, {
8329
+ numeric: true,
8330
+ sensitivity: 'base'
8331
+ });
8332
+ }
8333
+ /**
8334
+ * Applies the `sortModel` to `rows`. Returns a NEW sorted array
8335
+ * (does not mutate input). Stable sort guaranteed by the underlying
8336
+ * `Array.prototype.sort` in modern engines.
8337
+ *
8338
+ * Multi-column sort: items earlier in `sortModel` have higher
8339
+ * precedence. Equal values fall through to the next sort item; if
8340
+ * all sort items tie, the original order is preserved (stable).
8341
+ */ function useTableSort(rows, cols, sortModel) {
8342
+ return useMemo(()=>{
8343
+ if (!sortModel || sortModel.length === 0) return rows;
8344
+ const sortConfigs = sortModel.map((item)=>{
8345
+ const col = cols.find((c)=>c.field === item.field);
8346
+ if (!col || !col.sortable) return null;
8347
+ const customCompare = typeof col.sortable === 'function' ? col.sortable : undefined;
8348
+ return {
8349
+ item,
8350
+ col,
8351
+ customCompare
8352
+ };
8353
+ }).filter((x)=>x != null);
8354
+ if (sortConfigs.length === 0) return rows;
8355
+ // Decorate-sort-undecorate for stability across engines.
8356
+ //
8357
+ // Per sort item: null/undefined values always sort LAST, regardless
8358
+ // of direction. This invariant lives outside the comparator so it's
8359
+ // not inverted by `-cmp` when direction is `desc`. (Without this
8360
+ // separation, `desc` flipped null-handling and placed nulls FIRST,
8361
+ // which broke user expectations — fixed in the v1 implementation.)
8362
+ return [
8363
+ ...rows
8364
+ ].sort((a, b)=>{
8365
+ for (const { item, customCompare } of sortConfigs){
8366
+ const aVal = customCompare ? a : getNestedValue(a, item.field);
8367
+ const bVal = customCompare ? b : getNestedValue(b, item.field);
8368
+ // Null-last (direction-independent)
8369
+ const aNull = aVal == null;
8370
+ const bNull = bVal == null;
8371
+ if (aNull && bNull) continue;
8372
+ if (aNull) return 1;
8373
+ if (bNull) return -1;
8374
+ const cmp = customCompare ? customCompare(a, b) : defaultCompare(aVal, bVal);
8375
+ if (cmp !== 0) {
8376
+ return item.direction === 'desc' ? -cmp : cmp;
8377
+ }
8378
+ }
8379
+ return 0;
8380
+ });
8381
+ }, [
8382
+ rows,
8383
+ cols,
8384
+ sortModel
8385
+ ]);
8386
+ }
8387
+ /**
8388
+ * Three-state sort cycler: `none` → `asc` → `desc` → `none`.
8389
+ *
8390
+ * Used by the header click handler. Returns the next sort model
8391
+ * to apply, given the current model + the clicked field + whether
8392
+ * the user is multi-sorting (shift key).
8393
+ */ function cycleSortFor(current, field, multi) {
8394
+ const existing = current.find((s)=>s.field === field);
8395
+ const nextDirection = !existing ? 'asc' : existing.direction === 'asc' ? 'desc' : null;
8396
+ if (multi) {
8397
+ // Multi-sort: keep other entries, update / add / remove this one.
8398
+ const rest = current.filter((s)=>s.field !== field);
8399
+ if (nextDirection == null) return rest;
8400
+ return [
8401
+ ...rest,
8402
+ {
8403
+ field,
8404
+ direction: nextDirection
8405
+ }
8406
+ ];
8407
+ }
8408
+ // Single-sort: replace the whole model.
8409
+ if (nextDirection == null) return [];
8410
+ return [
8411
+ {
8412
+ field,
8413
+ direction: nextDirection
8414
+ }
8415
+ ];
8416
+ }
8417
+
8418
+ /**
8419
+ * Selection logic helper. Given the controllable selection state +
8420
+ * the setter (from `useControllableState`), exposes ergonomic
8421
+ * helpers the Table calls from its render path.
8422
+ *
8423
+ * - `single` mode: at most one row selected. Toggling another
8424
+ * replaces the previous.
8425
+ * - `multiple` mode: any subset selectable; toggling adds/removes.
8426
+ * - `none` mode: every toggle is a no-op (the Table shouldn't
8427
+ * render the checkbox cells anyway, but this is defensive).
8428
+ */ function useTableSelection(mode, selectedRowIds, setSelectedRowIds) {
8429
+ const selectedSet = useMemo(()=>new Set(selectedRowIds), [
8430
+ selectedRowIds
8431
+ ]);
8432
+ const isSelected = useCallback((rowId)=>selectedSet.has(rowId), [
8433
+ selectedSet
8434
+ ]);
8435
+ const toggleRow = useCallback((rowId)=>{
8436
+ if (mode === 'none') return;
8437
+ if (mode === 'single') {
8438
+ setSelectedRowIds(selectedSet.has(rowId) ? [] : [
8439
+ rowId
8440
+ ]);
8441
+ return;
8442
+ }
8443
+ // multiple
8444
+ setSelectedRowIds((prev)=>{
8445
+ const next = new Set(prev);
8446
+ if (next.has(rowId)) next.delete(rowId);
8447
+ else next.add(rowId);
8448
+ return [
8449
+ ...next
8450
+ ];
8451
+ });
8452
+ }, [
8453
+ mode,
8454
+ selectedSet,
8455
+ setSelectedRowIds
8456
+ ]);
8457
+ const toggleAll = useCallback((allRowIds)=>{
8458
+ if (mode !== 'multiple') return;
8459
+ const allSelected = allRowIds.every((id)=>selectedSet.has(id));
8460
+ setSelectedRowIds(allSelected ? [] : [
8461
+ ...allRowIds
8462
+ ]);
8463
+ }, [
8464
+ mode,
8465
+ selectedSet,
8466
+ setSelectedRowIds
8467
+ ]);
8468
+ const clearSelection = useCallback(()=>{
8469
+ setSelectedRowIds([]);
8470
+ }, [
8471
+ setSelectedRowIds
8472
+ ]);
8473
+ const isAllSelected = useCallback((allRowIds)=>{
8474
+ if (allRowIds.length === 0) return false;
8475
+ return allRowIds.every((id)=>selectedSet.has(id));
8476
+ }, [
8477
+ selectedSet
8478
+ ]);
8479
+ const isIndeterminate = useCallback((allRowIds)=>{
8480
+ if (allRowIds.length === 0) return false;
8481
+ const some = allRowIds.some((id)=>selectedSet.has(id));
8482
+ const all = allRowIds.every((id)=>selectedSet.has(id));
8483
+ return some && !all;
8484
+ }, [
8485
+ selectedSet
8486
+ ]);
8487
+ return {
8488
+ selectedRowIds,
8489
+ isSelected,
8490
+ toggleRow,
8491
+ toggleAll,
8492
+ clearSelection,
8493
+ isAllSelected,
8494
+ isIndeterminate
8495
+ };
8496
+ }
8497
+
8498
+ /**
8499
+ * Auto-detects the primitive type of each column by scanning the
8500
+ * first non-null value across the supplied rows. Drives smart
8501
+ * defaults for `align` and `monospace` on the cell render path.
8502
+ *
8503
+ * Scans only the first N rows (default 50) for performance — if the
8504
+ * dataset's first 50 rows are unrepresentative, the consumer can
8505
+ * override per-column via explicit `align` / `monospace` props.
8506
+ *
8507
+ * Returns `Map<columnField, inferredType>`.
8508
+ */ function useColumnAutoDetect(rows, cols, sampleSize = 50) {
8509
+ return useMemo(()=>{
8510
+ const result = new Map();
8511
+ const sample = rows.slice(0, sampleSize);
8512
+ for (const col of cols){
8513
+ const field = col.field;
8514
+ let inferred = 'unknown';
8515
+ for (const row of sample){
8516
+ const value = getNestedValue(row, field);
8517
+ if (value == null) continue;
8518
+ inferred = inferType(value);
8519
+ break; // first non-null wins
8520
+ }
8521
+ result.set(field, inferred);
8522
+ }
8523
+ return result;
8524
+ }, [
8525
+ rows,
8526
+ cols,
8527
+ sampleSize
8528
+ ]);
8529
+ }
8530
+ function inferType(value) {
8531
+ if (typeof value === 'number') return 'number';
8532
+ if (typeof value === 'boolean') return 'boolean';
8533
+ if (value instanceof Date) return 'date';
8534
+ if (typeof value === 'string') {
8535
+ // Heuristic: ISO date string (YYYY-MM-DD[T...]) → 'date'
8536
+ if (/^\d{4}-\d{2}-\d{2}/.test(value)) return 'date';
8537
+ return 'string';
8538
+ }
8539
+ return 'unknown';
8540
+ }
8541
+ /**
8542
+ * Resolves the effective `align` for a column, honoring explicit
8543
+ * `col.align` first and falling back to the inferred type.
8544
+ */ function resolveAlign(col, inferred) {
8545
+ if (col.align) return col.align;
8546
+ switch(inferred){
8547
+ case 'number':
8548
+ return 'right';
8549
+ case 'boolean':
8550
+ return 'center';
8551
+ default:
8552
+ return 'left';
8553
+ }
8554
+ }
8555
+ /**
8556
+ * Resolves the effective `tabularNums` flag for a column.
8557
+ *
8558
+ * `tabular-nums` is a `font-variant-numeric` rule that makes every
8559
+ * digit occupy the same width — it does NOT change the font family.
8560
+ * Auto-applied for `number` and `date` columns so currency values
8561
+ * and ISO dates align cleanly. Explicit `col.tabularNums` wins.
8562
+ */ function resolveTabularNums(col, inferred) {
8563
+ if (col.tabularNums !== undefined) return col.tabularNums;
8564
+ return inferred === 'number' || inferred === 'date';
8565
+ }
8566
+ /**
8567
+ * Resolves the effective `monospace` flag for a column.
8568
+ *
8569
+ * `font-mono` changes the font FAMILY to a monospace stack, which
8570
+ * would override the consumer's theme `font-sans`. The library
8571
+ * never auto-applies it — only an explicit `col.monospace: true`
8572
+ * opts in. Consumers wanting a custom mono stack configure it
8573
+ * through Tailwind's `theme.extend.fontFamily.mono` in their own
8574
+ * preset (the dashforgePreset does not own the `fontFamily` axis).
8575
+ */ function resolveMonospace(col) {
8576
+ return col.monospace === true;
8577
+ }
8578
+
8579
+ const DEFAULT_LABELS$1 = {
8580
+ searchPlaceholder: 'Search...',
8581
+ noData: 'No data',
8582
+ loading: 'Loading…',
8583
+ ariaSortNone: 'Not sorted. Click to sort ascending.',
8584
+ ariaSortAscending: 'Sorted ascending. Click to sort descending.',
8585
+ ariaSortDescending: 'Sorted descending. Click to clear sort.',
8586
+ ariaSelectRow: 'Select row',
8587
+ ariaSelectAllRows: 'Select all rows',
8588
+ ariaExpandRow: 'Expand row',
8589
+ ariaCollapseRow: 'Collapse row',
8590
+ selectedCount: '{count} selected',
8591
+ clearSelection: 'Clear selection',
8592
+ filterColumn: 'Filter',
8593
+ filterApply: 'Apply',
8594
+ filterClear: 'Clear',
8595
+ density: 'Density',
8596
+ densityCompact: 'Compact',
8597
+ densityComfortable: 'Comfortable',
8598
+ densitySpacious: 'Spacious'
8599
+ };
8600
+ const DEFAULT_GET_ROW_ID = (_row, index)=>String(index);
8601
+ /**
8602
+ * Dashforge TW `<Table>` — declarative-first, market-grounded table.
8603
+ *
8604
+ * Design references:
8605
+ * - Visual: Stripe (clean line dividers, hover row actions, sticky header)
8606
+ * - Density: Pencil & Paper UX research (compact / comfortable / spacious)
8607
+ * - A11Y: W3C WAI Table tutorial (semantic `<table>`, scope, aria-sort)
8608
+ * - Column model: extended from Dashforge MUI Table existing API
8609
+ *
8610
+ * Smart defaults: column types auto-detected → numbers right-aligned
8611
+ * + tabular-nums, dates monospace, booleans center-aligned. Override
8612
+ * via explicit `align` / `monospace` per column.
8613
+ *
8614
+ * State (sort / search / filter / selection / expansion) is internal
8615
+ * by default; pass the controlled prop + matching change handler to
8616
+ * lift state up — same pattern as `<TextField>` value.
8617
+ *
8618
+ * i18n: pass `header: t('users.name')` for column headers; pass
8619
+ * `labels={{ searchPlaceholder: t('...'), ... }}` for internal default
8620
+ * strings.
8621
+ */ function Table(props) {
8622
+ var _slotProps_root, _slotProps_toolbar, _slotProps_search, _slotProps_scroll, _slotProps_table, _slotProps_thead, _slotProps_headerRow, _slotProps_headerCell, _slotProps_selectionCell, _slotProps_headerCell1, _slotProps_expandToggleCell, _slotProps_headerCell2, _slotProps_tbody, _slotProps_row, _slotProps_cell, _slotProps_emptyState, _slotProps_bulkActionFooter;
8623
+ const { rows, cols, getRowId = DEFAULT_GET_ROW_ID, // State props (controllable)
8624
+ sortModel: sortModelProp, onSortChange, defaultSortModel, enableSearch = false, searchQuery: searchQueryProp, onSearchQueryChange, searchPlaceholder, searchDebounceMs = 200, filterModel: filterModelProp, onFilterChange, rowSelection = 'none', selectedRowIds: selectedRowIdsProp, onSelectionChange, bulkActions, expandable, rowActions, loading = false, loadingRowCount = 5, emptyState, stickyHeader = true, caption, showCaption = false, access: tableAccess, labels: labelsProp, variant, size, density, sx, slotProps } = props;
8625
+ const labels = _extends({}, DEFAULT_LABELS$1, labelsProp);
8626
+ const tableAccessState = useAccessState(tableAccess);
8627
+ // ───── Controllable state machinery ─────
8628
+ const [sortModel, setSortModel] = useControllableState({
8629
+ controlledValue: sortModelProp,
8630
+ defaultValue: defaultSortModel != null ? defaultSortModel : [],
8631
+ onChange: onSortChange
8632
+ });
8633
+ const [searchQuery, setSearchQuery] = useControllableState({
8634
+ controlledValue: searchQueryProp,
8635
+ defaultValue: '',
8636
+ onChange: onSearchQueryChange
8637
+ });
8638
+ const filterModel = filterModelProp != null ? filterModelProp : [];
8639
+ const [selectedRowIds, setSelectedRowIds] = useControllableState({
8640
+ controlledValue: selectedRowIdsProp,
8641
+ defaultValue: [],
8642
+ onChange: onSelectionChange
8643
+ });
8644
+ const [expandedRowIds, setExpandedRowIds] = useControllableState({
8645
+ controlledValue: expandable == null ? void 0 : expandable.expandedRowIds,
8646
+ defaultValue: [],
8647
+ onChange: expandable == null ? void 0 : expandable.onExpandChange
8648
+ });
8649
+ // ───── RBAC: filter out hidden columns ─────
8650
+ const visibleCols = useMemo(()=>cols.filter((col)=>!col.access || col.access.onUnauthorized !== 'hide'), [
8651
+ cols
8652
+ ]);
8653
+ // ───── Smart-default column types ─────
8654
+ const columnTypes = useColumnAutoDetect(rows, visibleCols);
8655
+ // ───── Pipeline: search → filter → sort ─────
8656
+ const debouncedSearchQuery = useDebouncedValue(searchQuery, searchDebounceMs);
8657
+ const searchedRows = useTableSearch(rows, visibleCols, debouncedSearchQuery);
8658
+ const filteredRows = useTableFilter(searchedRows, filterModel);
8659
+ const sortedRows = useTableSort(filteredRows, visibleCols, sortModel);
8660
+ // ───── Selection helpers ─────
8661
+ const visibleRowIds = useMemo(()=>sortedRows.map((row, idx)=>getRowId(row, idx)), [
8662
+ sortedRows,
8663
+ getRowId
8664
+ ]);
8665
+ const selection = useTableSelection(rowSelection, selectedRowIds, setSelectedRowIds);
8666
+ const selectedRows = useMemo(()=>sortedRows.filter((row, idx)=>selection.isSelected(getRowId(row, idx))), [
8667
+ sortedRows,
8668
+ selection,
8669
+ getRowId
8670
+ ]);
8671
+ // ───── Variant recipe ─────
8672
+ const v = tableVariants({
8673
+ variant,
8674
+ size,
8675
+ density,
8676
+ stickyHeader
8677
+ });
8678
+ // ───── Render ─────
8679
+ if (!tableAccessState.visible) return null;
8680
+ const isTableInteractive = !tableAccessState.disabled;
8681
+ const showSelectionColumn = rowSelection !== 'none';
8682
+ const showExpandColumn = expandable != null;
8683
+ const showRowActionsColumn = rowActions != null;
8684
+ const totalColumnCount = visibleCols.length + (showSelectionColumn ? 1 : 0) + (showExpandColumn ? 1 : 0) + (showRowActionsColumn ? 1 : 0);
8685
+ return jsxs("div", {
8686
+ className: cn(v.root(), sx, slotProps == null ? void 0 : (_slotProps_root = slotProps.root) == null ? void 0 : _slotProps_root.className),
8687
+ "data-disabled": !isTableInteractive ? 'true' : undefined,
8688
+ children: [
8689
+ enableSearch && jsx("div", {
8690
+ className: cn(v.toolbar(), slotProps == null ? void 0 : (_slotProps_toolbar = slotProps.toolbar) == null ? void 0 : _slotProps_toolbar.className),
8691
+ children: jsxs("label", {
8692
+ className: cn(v.search(), slotProps == null ? void 0 : (_slotProps_search = slotProps.search) == null ? void 0 : _slotProps_search.className),
8693
+ children: [
8694
+ jsx("span", {
8695
+ className: "sr-only",
8696
+ children: labels.searchPlaceholder
8697
+ }),
8698
+ jsx(SearchIcon$1, {
8699
+ className: "ml-2 shrink-0 text-neutral-500"
8700
+ }),
8701
+ jsx("input", {
8702
+ type: "text",
8703
+ value: searchQuery,
8704
+ onChange: (e)=>setSearchQuery(e.target.value),
8705
+ placeholder: searchPlaceholder != null ? searchPlaceholder : labels.searchPlaceholder,
8706
+ disabled: !isTableInteractive,
8707
+ className: cn('flex-1 bg-transparent border-0 outline-none', 'px-2 py-1.5', // placeholder color auto-inverts via the CSS-var preset;
8708
+ // no `dark:` variant needed on neutral palette.
8709
+ 'placeholder:text-neutral-400', 'disabled:opacity-50 disabled:cursor-not-allowed')
8710
+ })
8711
+ ]
8712
+ })
8713
+ }),
8714
+ jsx("div", {
8715
+ className: cn(v.scroll(), slotProps == null ? void 0 : (_slotProps_scroll = slotProps.scroll) == null ? void 0 : _slotProps_scroll.className),
8716
+ children: jsxs("table", {
8717
+ className: cn(v.table(), slotProps == null ? void 0 : (_slotProps_table = slotProps.table) == null ? void 0 : _slotProps_table.className),
8718
+ children: [
8719
+ caption != null && jsx("caption", {
8720
+ className: cn(!showCaption && 'sr-only', 'py-2 text-sm text-neutral-500'),
8721
+ children: caption
8722
+ }),
8723
+ jsx("thead", {
8724
+ className: cn(v.thead(), slotProps == null ? void 0 : (_slotProps_thead = slotProps.thead) == null ? void 0 : _slotProps_thead.className),
8725
+ children: jsxs("tr", {
8726
+ className: cn(v.headerRow(), slotProps == null ? void 0 : (_slotProps_headerRow = slotProps.headerRow) == null ? void 0 : _slotProps_headerRow.className),
8727
+ children: [
8728
+ showSelectionColumn && jsx("th", {
8729
+ scope: "col",
8730
+ className: cn(v.headerCell(), v.selectionCell(), slotProps == null ? void 0 : (_slotProps_headerCell = slotProps.headerCell) == null ? void 0 : _slotProps_headerCell.className, slotProps == null ? void 0 : (_slotProps_selectionCell = slotProps.selectionCell) == null ? void 0 : _slotProps_selectionCell.className),
8731
+ children: rowSelection === 'multiple' && jsx("input", {
8732
+ type: "checkbox",
8733
+ "aria-label": labels.ariaSelectAllRows,
8734
+ checked: selection.isAllSelected(visibleRowIds),
8735
+ ref: (el)=>{
8736
+ if (el) el.indeterminate = selection.isIndeterminate(visibleRowIds);
8737
+ },
8738
+ onChange: ()=>selection.toggleAll(visibleRowIds),
8739
+ disabled: !isTableInteractive,
8740
+ className: "cursor-pointer"
8741
+ })
8742
+ }),
8743
+ showExpandColumn && jsx("th", {
8744
+ scope: "col",
8745
+ "aria-hidden": "true",
8746
+ className: cn(v.headerCell(), v.expandToggleCell(), slotProps == null ? void 0 : (_slotProps_headerCell1 = slotProps.headerCell) == null ? void 0 : _slotProps_headerCell1.className, slotProps == null ? void 0 : (_slotProps_expandToggleCell = slotProps.expandToggleCell) == null ? void 0 : _slotProps_expandToggleCell.className)
8747
+ }),
8748
+ visibleCols.map((col)=>{
8749
+ var _slotProps_headerCell;
8750
+ return jsx(HeaderCell$1, {
8751
+ col: col,
8752
+ sortModel: sortModel,
8753
+ onSortClick: (e)=>handleSortClick$1(col, e, sortModel, setSortModel),
8754
+ inferredType: columnTypes.get(col.field),
8755
+ labels: labels,
8756
+ className: cn(v.headerCell(), slotProps == null ? void 0 : (_slotProps_headerCell = slotProps.headerCell) == null ? void 0 : _slotProps_headerCell.className),
8757
+ buttonClassName: v.headerCellButton(),
8758
+ disabled: !isTableInteractive
8759
+ }, col.field);
8760
+ }),
8761
+ showRowActionsColumn && jsx("th", {
8762
+ scope: "col",
8763
+ "aria-hidden": "true",
8764
+ className: cn(v.headerCell(), 'w-12', slotProps == null ? void 0 : (_slotProps_headerCell2 = slotProps.headerCell) == null ? void 0 : _slotProps_headerCell2.className)
8765
+ })
8766
+ ]
8767
+ })
8768
+ }),
8769
+ jsx("tbody", {
8770
+ className: cn(v.tbody(), slotProps == null ? void 0 : (_slotProps_tbody = slotProps.tbody) == null ? void 0 : _slotProps_tbody.className),
8771
+ children: loading ? renderLoadingRows$1({
8772
+ count: loadingRowCount,
8773
+ visibleCols,
8774
+ showSelectionColumn,
8775
+ showExpandColumn,
8776
+ showRowActionsColumn,
8777
+ rowClass: cn(v.row(), slotProps == null ? void 0 : (_slotProps_row = slotProps.row) == null ? void 0 : _slotProps_row.className),
8778
+ cellClass: cn(v.cell(), slotProps == null ? void 0 : (_slotProps_cell = slotProps.cell) == null ? void 0 : _slotProps_cell.className)
8779
+ }) : sortedRows.length === 0 ? renderEmptyState$1({
8780
+ totalColumnCount,
8781
+ emptyState,
8782
+ fallback: labels.noData,
8783
+ cellClass: cn(v.cell(), v.emptyState(), slotProps == null ? void 0 : (_slotProps_emptyState = slotProps.emptyState) == null ? void 0 : _slotProps_emptyState.className)
8784
+ }) : sortedRows.map((row, idx)=>{
8785
+ var _slotProps_row, _slotProps_cell, _slotProps_cell1, _slotProps_selectionCell, _slotProps_cell2, _slotProps_expandToggleCell, _slotProps_cell3, _slotProps_rowActionsCell, _slotProps_expandedRow;
8786
+ const rowId = getRowId(row, idx);
8787
+ const isSelected = selection.isSelected(rowId);
8788
+ const isExpanded = expandedRowIds.includes(rowId);
8789
+ return jsx(Row, {
8790
+ row: row,
8791
+ rowId: rowId,
8792
+ rowIndex: idx,
8793
+ isSelected: isSelected,
8794
+ isExpanded: isExpanded,
8795
+ visibleCols: visibleCols,
8796
+ columnTypes: columnTypes,
8797
+ rowSelection: rowSelection,
8798
+ showSelectionColumn: showSelectionColumn,
8799
+ showExpandColumn: showExpandColumn,
8800
+ showRowActionsColumn: showRowActionsColumn,
8801
+ expandable: expandable,
8802
+ rowActions: rowActions,
8803
+ labels: labels,
8804
+ toggleRow: selection.toggleRow,
8805
+ toggleExpand: (id)=>setExpandedRowIds((prev)=>prev.includes(id) ? prev.filter((p)=>p !== id) : [
8806
+ ...prev,
8807
+ id
8808
+ ]),
8809
+ isInteractive: isTableInteractive,
8810
+ rowClass: cn(v.row(), slotProps == null ? void 0 : (_slotProps_row = slotProps.row) == null ? void 0 : _slotProps_row.className),
8811
+ cellClass: cn(v.cell(), slotProps == null ? void 0 : (_slotProps_cell = slotProps.cell) == null ? void 0 : _slotProps_cell.className),
8812
+ selectionCellClass: cn(v.cell(), v.selectionCell(), slotProps == null ? void 0 : (_slotProps_cell1 = slotProps.cell) == null ? void 0 : _slotProps_cell1.className, slotProps == null ? void 0 : (_slotProps_selectionCell = slotProps.selectionCell) == null ? void 0 : _slotProps_selectionCell.className),
8813
+ expandToggleCellClass: cn(v.cell(), v.expandToggleCell(), slotProps == null ? void 0 : (_slotProps_cell2 = slotProps.cell) == null ? void 0 : _slotProps_cell2.className, slotProps == null ? void 0 : (_slotProps_expandToggleCell = slotProps.expandToggleCell) == null ? void 0 : _slotProps_expandToggleCell.className),
8814
+ rowActionsCellClass: cn(v.cell(), v.rowActionsCell(), slotProps == null ? void 0 : (_slotProps_cell3 = slotProps.cell) == null ? void 0 : _slotProps_cell3.className, slotProps == null ? void 0 : (_slotProps_rowActionsCell = slotProps.rowActionsCell) == null ? void 0 : _slotProps_rowActionsCell.className),
8815
+ expandedRowClass: cn(v.expandedRow(), slotProps == null ? void 0 : (_slotProps_expandedRow = slotProps.expandedRow) == null ? void 0 : _slotProps_expandedRow.className),
8816
+ totalColumnCount: totalColumnCount
8817
+ }, rowId);
8818
+ })
8819
+ })
8820
+ ]
8821
+ })
8822
+ }),
8823
+ bulkActions && selectedRows.length > 0 && jsxs("div", {
8824
+ className: cn(v.bulkActionFooter(), slotProps == null ? void 0 : (_slotProps_bulkActionFooter = slotProps.bulkActionFooter) == null ? void 0 : _slotProps_bulkActionFooter.className),
8825
+ role: "region",
8826
+ "aria-label": labels.selectedCount.replace('{count}', String(selectedRows.length)),
8827
+ children: [
8828
+ jsx("span", {
8829
+ className: "text-sm text-neutral-700",
8830
+ children: labels.selectedCount.replace('{count}', String(selectedRows.length))
8831
+ }),
8832
+ jsxs("div", {
8833
+ className: "flex items-center gap-2",
8834
+ children: [
8835
+ bulkActions(selectedRows),
8836
+ jsx("button", {
8837
+ type: "button",
8838
+ onClick: selection.clearSelection,
8839
+ className: cn(// text-neutral-500 → text-neutral-900 on hover; both auto-invert.
8840
+ 'text-sm text-neutral-500 hover:text-neutral-900', 'underline-offset-2 hover:underline', 'focus:outline-none focus-visible:underline'),
8841
+ children: labels.clearSelection
8842
+ })
8843
+ ]
8844
+ })
8845
+ ]
8846
+ })
8847
+ ]
8848
+ });
8849
+ }
8850
+ // ───── Header cell (with sort button) ─────
8851
+ function HeaderCell$1(props) {
8852
+ const { col, sortModel, onSortClick, inferredType, labels, className, buttonClassName, disabled } = props;
8853
+ const align = resolveAlign(col, inferredType);
8854
+ const alignClass = align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left';
8855
+ const sortEntry = sortModel.find((s)=>s.field === col.field);
8856
+ const ariaSort = (sortEntry == null ? void 0 : sortEntry.direction) === 'asc' ? 'ascending' : (sortEntry == null ? void 0 : sortEntry.direction) === 'desc' ? 'descending' : 'none';
8857
+ const headerContent = typeof col.header === 'function' ? col.header() : col.header;
8858
+ const cellStyle = {
8859
+ width: col.width != null ? `${col.width}px` : undefined,
8860
+ minWidth: col.minWidth != null ? `${col.minWidth}px` : undefined,
8861
+ flex: col.flex
8862
+ };
8863
+ if (col.sortable) {
8864
+ return jsx("th", {
8865
+ scope: "col",
8866
+ "aria-sort": ariaSort,
8867
+ className: cn(className, alignClass),
8868
+ style: cellStyle,
8869
+ children: jsxs("button", {
8870
+ type: "button",
8871
+ onClick: onSortClick,
8872
+ disabled: disabled,
8873
+ "aria-label": ariaSort === 'ascending' ? labels.ariaSortAscending : ariaSort === 'descending' ? labels.ariaSortDescending : labels.ariaSortNone,
8874
+ className: cn(buttonClassName, align === 'right' && 'flex-row-reverse', 'disabled:cursor-not-allowed disabled:opacity-50'),
8875
+ children: [
8876
+ jsx("span", {
8877
+ children: headerContent
8878
+ }),
8879
+ jsx(SortIcon$1, {
8880
+ direction: sortEntry == null ? void 0 : sortEntry.direction
8881
+ })
8882
+ ]
8883
+ })
8884
+ });
8885
+ }
8886
+ return jsx("th", {
8887
+ scope: "col",
8888
+ className: cn(className, alignClass),
8889
+ style: cellStyle,
8890
+ children: headerContent
8891
+ });
8892
+ }
8893
+ // ───── Data row ─────
8894
+ function Row(props) {
8895
+ const { row, rowId, rowIndex, isSelected, isExpanded, visibleCols, columnTypes, rowSelection, showSelectionColumn, showExpandColumn, showRowActionsColumn, expandable, rowActions, labels, toggleRow, toggleExpand, isInteractive, rowClass, cellClass, selectionCellClass, expandToggleCellClass, rowActionsCellClass, expandedRowClass, totalColumnCount } = props;
8896
+ return jsxs(Fragment$1, {
8897
+ children: [
8898
+ jsxs("tr", {
8899
+ "data-selected": isSelected ? 'true' : 'false',
8900
+ "aria-selected": rowSelection !== 'none' ? isSelected : undefined,
8901
+ className: rowClass,
8902
+ children: [
8903
+ showSelectionColumn && jsx("td", {
8904
+ className: selectionCellClass,
8905
+ children: jsx("input", {
8906
+ type: "checkbox",
8907
+ "aria-label": labels.ariaSelectRow,
8908
+ checked: isSelected,
8909
+ onChange: ()=>toggleRow(rowId),
8910
+ disabled: !isInteractive,
8911
+ className: "cursor-pointer"
8912
+ })
8913
+ }),
8914
+ showExpandColumn && jsx("td", {
8915
+ className: expandToggleCellClass,
8916
+ children: jsx("button", {
8917
+ type: "button",
8918
+ onClick: ()=>toggleExpand(rowId),
8919
+ "aria-label": isExpanded ? labels.ariaCollapseRow : labels.ariaExpandRow,
8920
+ "aria-expanded": isExpanded,
8921
+ disabled: !isInteractive,
8922
+ className: cn('inline-flex items-center justify-center h-6 w-6 rounded', // text-neutral-500 + hover bump; both auto-invert via CSS-var preset.
8923
+ 'text-neutral-500 hover:text-neutral-900', 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500', 'transition-transform motion-reduce:transition-none', isExpanded && 'rotate-90'),
8924
+ children: jsx(ChevronRightIcon, {})
8925
+ })
8926
+ }),
8927
+ visibleCols.map((col)=>jsx(DataCell$1, {
8928
+ col: col,
8929
+ row: row,
8930
+ rowIndex: rowIndex,
8931
+ isSelected: isSelected,
8932
+ isExpanded: isExpanded,
8933
+ inferredType: columnTypes.get(col.field),
8934
+ cellClass: cellClass
8935
+ }, col.field)),
8936
+ showRowActionsColumn && rowActions && jsx("td", {
8937
+ className: rowActionsCellClass,
8938
+ children: rowActions(row)
8939
+ })
8940
+ ]
8941
+ }),
8942
+ isExpanded && expandable && jsx("tr", {
8943
+ className: expandedRowClass,
8944
+ children: jsx("td", {
8945
+ colSpan: totalColumnCount,
8946
+ className: "p-3",
8947
+ children: expandable.render(row)
8948
+ })
8949
+ })
8950
+ ]
8951
+ });
8952
+ }
8953
+ // ───── Data cell ─────
8954
+ function DataCell$1(props) {
8955
+ const { col, row, rowIndex, isSelected, isExpanded, inferredType, cellClass } = props;
8956
+ const value = getNestedValue(row, col.field);
8957
+ const align = resolveAlign(col, inferredType);
8958
+ // tabularNums = digit alignment via font-variant-numeric — does NOT
8959
+ // change the font family. Auto-true for number / date columns.
8960
+ const tabularNums = resolveTabularNums(col, inferredType);
8961
+ // monospace = font-family override (font-mono). Library never
8962
+ // auto-picks a font family — only explicit consumer opt-in. The
8963
+ // consumer's theme font-sans is preserved by default.
8964
+ const monospace = resolveMonospace(col);
8965
+ const alignClass = align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left';
8966
+ // Per-column RBAC state for the cellRenderer context
8967
+ const columnAccessState = col.access ? {
8968
+ hidden: col.access.onUnauthorized === 'hide',
8969
+ disabled: col.access.onUnauthorized === 'disable',
8970
+ readonly: col.access.onUnauthorized === 'readonly'
8971
+ } : null;
8972
+ const content = col.cellRenderer ? col.cellRenderer({
8973
+ row,
8974
+ value,
8975
+ rowIndex,
8976
+ isSelected,
8977
+ isExpanded,
8978
+ access: columnAccessState
8979
+ }) : renderDefaultCell$1(value);
8980
+ return jsx("td", {
8981
+ className: cn(cellClass, alignClass, tabularNums && 'tabular-nums', monospace && 'font-mono'),
8982
+ style: {
8983
+ width: col.width != null ? `${col.width}px` : undefined,
8984
+ minWidth: col.minWidth != null ? `${col.minWidth}px` : undefined
8985
+ },
8986
+ children: content
8987
+ });
8988
+ }
8989
+ // ───── Helpers ─────
8990
+ function renderDefaultCell$1(value) {
8991
+ if (value == null) return null;
8992
+ if (typeof value === 'boolean') return value ? '✓' : '✗';
8993
+ if (value instanceof Date) return value.toISOString().slice(0, 10);
8994
+ if (typeof value === 'object') return JSON.stringify(value);
8995
+ return value;
8996
+ }
8997
+ function handleSortClick$1(col, event, current, setSortModel) {
8998
+ if (!col.sortable) return;
8999
+ const multi = event.shiftKey;
9000
+ setSortModel((prev)=>cycleSortFor(prev, col.field, multi));
9001
+ }
9002
+ function renderLoadingRows$1(args) {
9003
+ const { count, visibleCols, showSelectionColumn, showExpandColumn, showRowActionsColumn, rowClass, cellClass } = args;
9004
+ return Array.from({
9005
+ length: count
9006
+ }).map((_, i)=>jsxs("tr", {
9007
+ className: rowClass,
9008
+ "aria-busy": "true",
9009
+ children: [
9010
+ showSelectionColumn && jsx("td", {
9011
+ className: cellClass,
9012
+ children: jsx(Skeleton, {
9013
+ variant: "rectangle",
9014
+ width: "16px",
9015
+ height: "16px"
9016
+ })
9017
+ }),
9018
+ showExpandColumn && jsx("td", {
9019
+ className: cellClass,
9020
+ children: jsx(Skeleton, {
9021
+ variant: "rectangle",
9022
+ width: "16px",
9023
+ height: "16px"
9024
+ })
9025
+ }),
9026
+ visibleCols.map((col)=>jsx("td", {
9027
+ className: cellClass,
9028
+ children: jsx(Skeleton, {
9029
+ variant: "text",
9030
+ width: "80%"
9031
+ })
9032
+ }, col.field)),
9033
+ showRowActionsColumn && jsx("td", {
9034
+ className: cellClass,
9035
+ children: jsx(Skeleton, {
9036
+ variant: "rectangle",
9037
+ width: "24px",
9038
+ height: "24px"
9039
+ })
9040
+ })
9041
+ ]
9042
+ }, `loading-${i}`));
9043
+ }
9044
+ function renderEmptyState$1(args) {
9045
+ var _args_emptyState;
9046
+ return jsx("tr", {
9047
+ children: jsx("td", {
9048
+ colSpan: args.totalColumnCount,
9049
+ className: args.cellClass,
9050
+ children: (_args_emptyState = args.emptyState) != null ? _args_emptyState : args.fallback
9051
+ })
9052
+ });
9053
+ }
9054
+ // ───── Icons ─────
9055
+ function SortIcon$1(props) {
9056
+ return jsx("svg", {
9057
+ width: "0.75em",
9058
+ height: "0.75em",
9059
+ viewBox: "0 0 16 16",
9060
+ fill: "currentColor",
9061
+ "aria-hidden": "true",
9062
+ className: "shrink-0 opacity-60 group-hover:opacity-100",
9063
+ children: props.direction === 'asc' ? jsx("path", {
9064
+ d: "M8 4l4 6H4z"
9065
+ }) : props.direction === 'desc' ? jsx("path", {
9066
+ d: "M8 12L4 6h8z"
9067
+ }) : jsxs(Fragment$1, {
9068
+ children: [
9069
+ jsx("path", {
9070
+ d: "M8 3l3 4H5z",
9071
+ opacity: "0.5"
9072
+ }),
9073
+ jsx("path", {
9074
+ d: "M8 13l-3-4h6z",
9075
+ opacity: "0.5"
9076
+ })
9077
+ ]
9078
+ })
9079
+ });
9080
+ }
9081
+ function ChevronRightIcon() {
9082
+ return jsx("svg", {
9083
+ width: "0.875em",
9084
+ height: "0.875em",
9085
+ viewBox: "0 0 16 16",
9086
+ fill: "none",
9087
+ stroke: "currentColor",
9088
+ strokeWidth: "1.5",
9089
+ strokeLinecap: "round",
9090
+ strokeLinejoin: "round",
9091
+ "aria-hidden": "true",
9092
+ children: jsx("path", {
9093
+ d: "M6 4l4 4-4 4"
9094
+ })
9095
+ });
9096
+ }
9097
+ function SearchIcon$1(props) {
9098
+ return jsxs("svg", {
9099
+ width: "0.875em",
9100
+ height: "0.875em",
9101
+ viewBox: "0 0 16 16",
9102
+ fill: "none",
9103
+ stroke: "currentColor",
9104
+ strokeWidth: "1.5",
9105
+ strokeLinecap: "round",
9106
+ strokeLinejoin: "round",
9107
+ "aria-hidden": "true",
9108
+ className: props.className,
9109
+ children: [
9110
+ jsx("circle", {
9111
+ cx: "7",
9112
+ cy: "7",
9113
+ r: "4.5"
9114
+ }),
9115
+ jsx("path", {
9116
+ d: "M11 11l3 3"
9117
+ })
9118
+ ]
9119
+ });
9120
+ }
9121
+
9122
+ /**
9123
+ * Plain text cell renderer. Wraps the value in a `<span>` and
9124
+ * optionally truncates on overflow. Use this for the most common
9125
+ * case: render a single string value.
9126
+ *
9127
+ * The Table component does NOT use this by default for plain
9128
+ * string columns — it just renders `{value}` directly. Use
9129
+ * `RenderText` when you need the truncate / muted styling.
9130
+ */ function RenderText(props) {
9131
+ const { value, truncate, muted, className } = props;
9132
+ return jsx("span", {
9133
+ className: cn(truncate && 'block max-w-full truncate', // Neutral palette auto-inverts via dashforgePreset() CSS-var swap.
9134
+ muted && 'text-neutral-500', className),
9135
+ children: value
9136
+ });
9137
+ }
9138
+
9139
+ /**
9140
+ * Two-line cell renderer: a bold primary line stacked above a muted
9141
+ * secondary line. Useful for `Name + email`, `Title + ID`, etc.
9142
+ *
9143
+ * Replaces the MUI-side `RenderTwoLineCell` without the magic-number
9144
+ * vertical-align fudges (`mt: 0.8 / 0.2`) — flexbox handles the
9145
+ * vertical layout cleanly.
9146
+ *
9147
+ * Dashforge identity rule: text colors auto-invert via the
9148
+ * `dashforgePreset()` CSS-variable swap. No `dark:` variants needed
9149
+ * on neutral palette.
9150
+ */ function RenderTwoLine(props) {
9151
+ const { primary, secondary, className } = props;
9152
+ return jsxs("div", {
9153
+ className: cn('flex flex-col leading-tight', className),
9154
+ children: [
9155
+ jsx("span", {
9156
+ className: "font-medium text-neutral-900",
9157
+ children: primary
9158
+ }),
9159
+ jsx("span", {
9160
+ className: "text-xs text-neutral-500 mt-0.5",
9161
+ children: secondary
9162
+ })
9163
+ ]
9164
+ });
9165
+ }
9166
+
9167
+ /**
9168
+ * Internal mini-chip used by the cell renderer library. Not exported
9169
+ * from `@dashforge/tw` as a standalone component (per Sprint 4.1
9170
+ * scope decision — a standalone `<Chip>` may ship in Sprint 5 if
9171
+ * demand emerges).
9172
+ *
9173
+ * Variants:
9174
+ * - `color`: 7 intents (neutral / primary / success / warning /
9175
+ * danger / info / secondary)
9176
+ * - `variant`: `soft` (default) / `solid` / `outline`
9177
+ * - `size`: `sm` / `md`
9178
+ */ const chipVariants = tv({
9179
+ base: [
9180
+ 'inline-flex items-center gap-1 rounded-full font-medium',
9181
+ 'whitespace-nowrap'
9182
+ ],
9183
+ variants: {
9184
+ color: {
9185
+ neutral: '',
9186
+ primary: '',
9187
+ success: '',
9188
+ warning: '',
9189
+ danger: '',
9190
+ info: '',
9191
+ secondary: ''
9192
+ },
9193
+ variant: {
9194
+ soft: '',
9195
+ solid: '',
9196
+ outline: ''
9197
+ },
9198
+ size: {
9199
+ sm: 'h-5 px-2 text-xs',
9200
+ md: 'h-6 px-2.5 text-xs'
9201
+ }
9202
+ },
9203
+ compoundVariants: [
9204
+ // soft = subtle bg + dark text.
9205
+ // Neutral row uses NO `dark:` variants — the dashforgePreset()
9206
+ // CSS-var swap auto-inverts the neutral palette already.
9207
+ // Color rows below KEEP `dark:` variants because color palettes
9208
+ // don't auto-invert: the `dark:` shift is an intentional design
9209
+ // choice (darker bg + lighter text in dark mode for visual cohesion).
9210
+ {
9211
+ variant: 'soft',
9212
+ color: 'neutral',
9213
+ class: 'bg-neutral-100 text-neutral-700'
9214
+ },
9215
+ {
9216
+ variant: 'soft',
9217
+ color: 'primary',
9218
+ class: 'bg-primary-100 dark:bg-primary-950 text-primary-700 dark:text-primary-300'
9219
+ },
9220
+ {
9221
+ variant: 'soft',
9222
+ color: 'success',
9223
+ class: 'bg-success-100 dark:bg-success-950 text-success-700 dark:text-success-300'
9224
+ },
9225
+ {
9226
+ variant: 'soft',
9227
+ color: 'warning',
9228
+ class: 'bg-warning-100 dark:bg-warning-950 text-warning-700 dark:text-warning-300'
9229
+ },
9230
+ {
9231
+ variant: 'soft',
9232
+ color: 'danger',
9233
+ class: 'bg-danger-100 dark:bg-danger-950 text-danger-700 dark:text-danger-300'
9234
+ },
9235
+ {
9236
+ variant: 'soft',
9237
+ color: 'info',
9238
+ class: 'bg-info-100 dark:bg-info-950 text-info-700 dark:text-info-300'
9239
+ },
9240
+ {
9241
+ variant: 'soft',
9242
+ color: 'secondary',
9243
+ class: 'bg-secondary-100 dark:bg-secondary-950 text-secondary-700 dark:text-secondary-300'
9244
+ },
9245
+ // solid = full bg + light text
9246
+ // solid neutral: bg + text both anchored to neutral palette
9247
+ // (auto-inverts). `text-white` would not invert and would break
9248
+ // contrast in light mode against a `bg-neutral-700` dark bg, so
9249
+ // we stick to `text-neutral-50` (light text in light mode, dark
9250
+ // in dark — wait, that's wrong too). Actually `text-neutral-50`
9251
+ // auto-inverts: light text in light mode (against dark bg) is
9252
+ // what we want; in dark mode `bg-neutral-700` becomes light AND
9253
+ // `text-neutral-50` becomes dark — contrast preserved.
9254
+ {
9255
+ variant: 'solid',
9256
+ color: 'neutral',
9257
+ class: 'bg-neutral-700 text-neutral-50'
9258
+ },
9259
+ {
9260
+ variant: 'solid',
9261
+ color: 'primary',
9262
+ class: 'bg-primary-500 text-white'
9263
+ },
9264
+ {
9265
+ variant: 'solid',
9266
+ color: 'success',
9267
+ class: 'bg-success-500 text-white'
9268
+ },
9269
+ {
9270
+ variant: 'solid',
9271
+ color: 'warning',
9272
+ class: 'bg-warning-500 text-white'
9273
+ },
9274
+ {
9275
+ variant: 'solid',
9276
+ color: 'danger',
9277
+ class: 'bg-danger-500 text-white'
9278
+ },
9279
+ {
9280
+ variant: 'solid',
9281
+ color: 'info',
9282
+ class: 'bg-info-500 text-white'
9283
+ },
9284
+ {
9285
+ variant: 'solid',
9286
+ color: 'secondary',
9287
+ class: 'bg-secondary-500 text-white'
9288
+ },
9289
+ // outline = border + colored text
9290
+ // outline neutral: both border + text auto-invert via CSS-var swap.
9291
+ {
9292
+ variant: 'outline',
9293
+ color: 'neutral',
9294
+ class: 'border border-neutral-300 text-neutral-700'
9295
+ },
9296
+ {
9297
+ variant: 'outline',
9298
+ color: 'primary',
9299
+ class: 'border border-primary-500 text-primary-700 dark:text-primary-300'
9300
+ },
9301
+ {
9302
+ variant: 'outline',
9303
+ color: 'success',
9304
+ class: 'border border-success-500 text-success-700 dark:text-success-300'
9305
+ },
9306
+ {
9307
+ variant: 'outline',
9308
+ color: 'warning',
9309
+ class: 'border border-warning-500 text-warning-700 dark:text-warning-300'
9310
+ },
9311
+ {
9312
+ variant: 'outline',
9313
+ color: 'danger',
9314
+ class: 'border border-danger-500 text-danger-700 dark:text-danger-300'
9315
+ },
9316
+ {
9317
+ variant: 'outline',
9318
+ color: 'info',
9319
+ class: 'border border-info-500 text-info-700 dark:text-info-300'
9320
+ },
9321
+ {
9322
+ variant: 'outline',
9323
+ color: 'secondary',
9324
+ class: 'border border-secondary-500 text-secondary-700 dark:text-secondary-300'
9325
+ }
9326
+ ],
9327
+ defaultVariants: {
9328
+ color: 'neutral',
9329
+ variant: 'soft',
9330
+ size: 'sm'
9331
+ }
9332
+ });
9333
+ function RenderChip(props) {
9334
+ const { children, color, variant, size, className } = props;
9335
+ return jsx("span", {
9336
+ className: cn(chipVariants({
9337
+ color,
9338
+ variant,
9339
+ size
9340
+ }), className),
9341
+ children: children
9342
+ });
9343
+ }
9344
+
9345
+ /**
9346
+ * Inline button cell renderer. Thin wrapper over `<Button>` with
9347
+ * the size narrowed to `sm` by default (table density expects compact
9348
+ * controls).
9349
+ */ function RenderButton(props) {
9350
+ const { label, onClick, variant = 'ghost', size = 'sm' } = props, rest = _object_without_properties_loose(props, [
9351
+ "label",
9352
+ "onClick",
9353
+ "variant",
9354
+ "size"
9355
+ ]);
9356
+ return jsx(Button, _extends({
9357
+ variant: variant,
9358
+ size: size,
9359
+ onClick: onClick
9360
+ }, rest, {
9361
+ children: label
9362
+ }));
9363
+ }
9364
+
9365
+ /**
9366
+ * 3-dot row actions menu. Renders a trigger button that opens a
9367
+ * Popover (Sprint 3 component) with a list of action menu items.
9368
+ *
9369
+ * Replaces the MUI-side `RowActions.tsx` (IconButton + Menu) with
9370
+ * the TW equivalents. Per-action RBAC honored: hidden actions are
9371
+ * filtered out, disabled actions render disabled.
9372
+ */ function RowActionsMenu(props) {
9373
+ const { row, actions, trigger, ariaLabel = 'Row actions' } = props;
9374
+ const [open, setOpen] = useState(false);
9375
+ // Filter actions by RBAC (hidden actions removed entirely)
9376
+ const visibleActions = actions.filter((action)=>{
9377
+ if (!action.access) return true;
9378
+ return action.access.onUnauthorized !== 'hide';
9379
+ });
9380
+ if (visibleActions.length === 0) return null;
9381
+ return jsx(Popover, {
9382
+ open: open,
9383
+ onOpenChange: setOpen,
9384
+ side: "bottom",
9385
+ align: "end",
9386
+ content: jsx("ul", {
9387
+ className: "flex flex-col gap-0.5 min-w-[140px]",
9388
+ role: "menu",
9389
+ children: visibleActions.map((action, idx)=>jsx(RowActionMenuItem, {
9390
+ action: action,
9391
+ row: row,
9392
+ onClose: ()=>setOpen(false)
9393
+ }, idx))
9394
+ }),
9395
+ children: trigger != null ? trigger : jsx("button", {
9396
+ type: "button",
9397
+ "aria-label": ariaLabel,
9398
+ "aria-haspopup": "menu",
9399
+ "aria-expanded": open,
9400
+ className: cn('inline-flex items-center justify-center h-8 w-8 rounded-md', // Neutral palette auto-inverts via the dashforgePreset()
9401
+ // CSS-var swap — no `dark:` variant needed.
9402
+ 'text-neutral-500 hover:text-neutral-900', 'hover:bg-neutral-100', 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500', 'transition-colors motion-reduce:transition-none'),
9403
+ children: jsxs("svg", {
9404
+ width: "1em",
9405
+ height: "1em",
9406
+ viewBox: "0 0 16 16",
9407
+ fill: "currentColor",
9408
+ "aria-hidden": "true",
9409
+ children: [
9410
+ jsx("circle", {
9411
+ cx: "8",
9412
+ cy: "3",
9413
+ r: "1.4"
9414
+ }),
9415
+ jsx("circle", {
9416
+ cx: "8",
9417
+ cy: "8",
9418
+ r: "1.4"
9419
+ }),
9420
+ jsx("circle", {
9421
+ cx: "8",
9422
+ cy: "13",
9423
+ r: "1.4"
9424
+ })
9425
+ ]
9426
+ })
9427
+ })
9428
+ });
9429
+ }
9430
+ function RowActionMenuItem(props) {
9431
+ const { action, row, onClose } = props;
9432
+ const accessState = useAccessState(action.access);
9433
+ const isDisabled = action.disabled || accessState.disabled || accessState.readonly;
9434
+ return jsx("li", {
9435
+ role: "none",
9436
+ children: jsxs("button", {
9437
+ type: "button",
9438
+ role: "menuitem",
9439
+ disabled: isDisabled,
9440
+ onClick: ()=>{
9441
+ onClose();
9442
+ action.onClick(row);
9443
+ },
9444
+ className: cn('flex items-center gap-2 w-full text-left', 'px-3 py-1.5 rounded-md text-sm', // Neutral palette auto-inverts; danger/warning are color
9445
+ // palettes (no auto-invert) so they keep their `dark:` shift
9446
+ // intentionally — design choice, not a bug.
9447
+ 'hover:bg-neutral-100', 'focus:outline-none focus-visible:bg-neutral-100', 'transition-colors motion-reduce:transition-none', 'disabled:opacity-50 disabled:pointer-events-none', action.color === 'danger' && 'text-danger-700 dark:text-danger-300', action.color === 'warning' && 'text-warning-700 dark:text-warning-300'),
9448
+ children: [
9449
+ action.icon && jsx("span", {
9450
+ className: "inline-flex shrink-0",
9451
+ "aria-hidden": "true",
9452
+ children: action.icon
9453
+ }),
9454
+ jsx("span", {
9455
+ children: action.label
9456
+ })
9457
+ ]
9458
+ })
9459
+ });
9460
+ }
9461
+
9462
+ /**
9463
+ * `<DataGrid>` variant recipe.
9464
+ *
9465
+ * Sibling of `tableVariants` — shares the same visual axes (variant /
9466
+ * size / density) and the same identity-rule patterns:
9467
+ * - No `dark:` variants on neutral palette (auto-invert via the
9468
+ * dashforgePreset() CSS var swap).
9469
+ * - Selected row uses LeftNav `itemActive` pattern
9470
+ * (`bg-primary-100 text-primary-900`) — guaranteed contrast.
9471
+ * - Hover uses one-tier elevation (`bg-neutral-100`).
9472
+ *
9473
+ * DataGrid-specific slot additions vs Table:
9474
+ * - `stickyLeftCell` — column that uses `cols[i].sticky === 'left'`.
9475
+ * Z-index ordering: `thead` (z-10) < `stickyLeftCell` (z-11) <
9476
+ * `header × stickyLeft top-left corner cell` (z-20).
9477
+ * - `paginationFooter` — wraps the optional `<Pagination>`.
9478
+ */ const dataGridVariants = tv({
9479
+ slots: {
9480
+ root: 'w-full',
9481
+ toolbar: 'flex flex-wrap items-center gap-3 mb-3',
9482
+ search: [
9483
+ 'inline-flex items-center gap-2',
9484
+ 'w-full max-w-sm rounded-md',
9485
+ 'border border-neutral-300',
9486
+ 'bg-neutral-50',
9487
+ 'focus-within:ring-2 focus-within:ring-primary-500',
9488
+ 'transition-colors motion-reduce:transition-none'
9489
+ ],
9490
+ // The scroll container needs an explicit bounded height for
9491
+ // virtualization to compute the window. The consumer passes
9492
+ // `height` prop (inline style) or sets it via `sx` / parent
9493
+ // layout — DataGrid does NOT default a height (would be wrong
9494
+ // for most layouts).
9495
+ scroll: 'relative w-full overflow-auto rounded-md',
9496
+ table: 'w-full caption-bottom text-left',
9497
+ thead: 'bg-neutral-50 sticky top-0 z-10',
9498
+ tbody: '',
9499
+ headerRow: '',
9500
+ headerCell: [
9501
+ 'text-neutral-700',
9502
+ 'font-semibold whitespace-nowrap select-none',
9503
+ 'border-b border-neutral-200',
9504
+ 'bg-neutral-50'
9505
+ ],
9506
+ headerCellButton: [
9507
+ 'group inline-flex items-center gap-1 select-none',
9508
+ 'hover:text-neutral-900',
9509
+ 'focus:outline-none focus-visible:underline',
9510
+ 'transition-colors motion-reduce:transition-none'
9511
+ ],
9512
+ row: [
9513
+ 'group/row transition-colors motion-reduce:transition-none',
9514
+ 'hover:bg-neutral-100',
9515
+ 'data-[selected=true]:bg-primary-100',
9516
+ 'data-[selected=true]:text-primary-900',
9517
+ 'data-[selected=true]:font-medium'
9518
+ ],
9519
+ cell: [
9520
+ 'text-neutral-900',
9521
+ 'border-b border-neutral-200',
9522
+ 'align-middle'
9523
+ ],
9524
+ selectionCell: 'w-10 text-center',
9525
+ rowActionsCell: [
9526
+ 'w-12 text-right',
9527
+ 'opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100',
9528
+ 'transition-opacity motion-reduce:transition-none'
9529
+ ],
9530
+ emptyState: 'py-12 text-center text-neutral-500',
9531
+ loadingOverlay: 'pointer-events-none',
9532
+ bulkActionFooter: [
9533
+ 'sticky bottom-0 z-20',
9534
+ 'flex flex-wrap items-center justify-between gap-3',
9535
+ 'px-3 py-2',
9536
+ 'border-t border-neutral-200',
9537
+ 'bg-neutral-50 shadow-md'
9538
+ ],
9539
+ // Sticky left column — uses `position: sticky; left: 0` + z-index
9540
+ // so the column stays pinned during horizontal scroll. The
9541
+ // `bg-neutral-50` ensures the column doesn't bleed when content
9542
+ // scrolls underneath.
9543
+ stickyLeftCell: 'sticky left-0 z-[1] bg-neutral-50',
9544
+ // Sticky left × header intersection (top-left corner): higher
9545
+ // z-index than both header (`z-10`) and sticky col (`z-[1]`).
9546
+ stickyLeftHeaderCell: 'sticky left-0 z-20 bg-neutral-50',
9547
+ paginationFooter: 'flex justify-end mt-3'
9548
+ },
9549
+ variants: {
9550
+ variant: {
9551
+ plain: {},
9552
+ lines: {},
9553
+ striped: {
9554
+ row: 'odd:bg-neutral-100/60'
9555
+ },
9556
+ bordered: {
9557
+ table: 'border border-neutral-200',
9558
+ cell: 'border-r last:border-r-0 border-neutral-200',
9559
+ headerCell: 'border-r last:border-r-0 border-neutral-200'
9560
+ }
9561
+ },
9562
+ size: {
9563
+ sm: {
9564
+ headerCell: 'text-xs',
9565
+ cell: 'text-xs',
9566
+ search: 'text-xs'
9567
+ },
9568
+ md: {
9569
+ headerCell: 'text-sm',
9570
+ cell: 'text-sm',
9571
+ search: 'text-sm'
9572
+ },
9573
+ lg: {
9574
+ headerCell: 'text-base',
9575
+ cell: 'text-base',
9576
+ search: 'text-base'
9577
+ }
9578
+ },
9579
+ density: {
9580
+ compact: {
9581
+ headerCell: 'px-2 py-1',
9582
+ cell: 'px-2 py-1'
9583
+ },
9584
+ comfortable: {
9585
+ headerCell: 'px-3 py-2',
9586
+ cell: 'px-3 py-2'
9587
+ },
9588
+ spacious: {
9589
+ headerCell: 'px-4 py-3',
9590
+ cell: 'px-4 py-3'
9591
+ }
9592
+ }
9593
+ },
9594
+ defaultVariants: {
9595
+ variant: 'lines',
9596
+ size: 'md',
9597
+ density: 'comfortable'
9598
+ }
9599
+ });
9600
+
9601
+ /**
9602
+ * Compute the visible window of rows + the spacer heights given the
9603
+ * current scroll position. Pure function — exported for testability.
9604
+ *
9605
+ * @example
9606
+ * ```ts
9607
+ * computeWindow({ totalCount: 1000, rowHeight: 48, viewportHeight: 600,
9608
+ * scrollTop: 0, overscan: 5 });
9609
+ * // → { startIndex: 0, endIndex: 17, paddingTop: 0, paddingBottom: 47136 }
9610
+ * ```
9611
+ */ function computeWindow(args) {
9612
+ const { totalCount, rowHeight, viewportHeight, scrollTop, overscan = 5 } = args;
9613
+ if (totalCount <= 0 || rowHeight <= 0 || viewportHeight <= 0) {
9614
+ return {
9615
+ startIndex: 0,
9616
+ endIndex: -1,
9617
+ paddingTop: 0,
9618
+ paddingBottom: 0
9619
+ };
9620
+ }
9621
+ // Raw visible range from scrollTop alone.
9622
+ const rawStart = Math.floor(scrollTop / rowHeight);
9623
+ const rawEnd = Math.ceil((scrollTop + viewportHeight) / rowHeight);
9624
+ // Apply overscan + clamp to [0, totalCount - 1].
9625
+ const startIndex = Math.max(0, rawStart - overscan);
9626
+ const endIndex = Math.min(totalCount - 1, rawEnd + overscan);
9627
+ const paddingTop = startIndex * rowHeight;
9628
+ const paddingBottom = Math.max(0, (totalCount - 1 - endIndex) * rowHeight);
9629
+ return {
9630
+ startIndex,
9631
+ endIndex,
9632
+ paddingTop,
9633
+ paddingBottom
9634
+ };
9635
+ }
9636
+ /**
9637
+ * `useVirtualizer` — viewport-based row windowing for `<DataGrid>`.
9638
+ *
9639
+ * Hand-rolled (no `@tanstack/react-virtual` dep — constraint memorized
9640
+ * in `feedback_dashforge_preset_is_identity` companion: "no new
9641
+ * external runtime deps"). Uses scroll event + `requestAnimationFrame`
9642
+ * debounce + `ResizeObserver` on the container. Fixed row height
9643
+ * only in v1 — variable-height with measurement cache deferred to
9644
+ * v1-bis.
9645
+ *
9646
+ * Returns:
9647
+ * - `scrollRef`: attach to the scroll container `<div>`.
9648
+ * - `window`: which row indices to mount + top/bottom spacer heights
9649
+ * to preserve scroll-area size.
9650
+ * - `viewportHeight`: current container height (re-measured on
9651
+ * `ResizeObserver` ticks).
9652
+ *
9653
+ * The DataGrid renders `rows.slice(window.startIndex, window.endIndex + 1)`
9654
+ * between two spacer `<tr style={{ height: paddingTop }} />` elements
9655
+ * (above and below). This preserves `<table>` semantics — screen
9656
+ * readers see a continuous structure with `aria-hidden` spacers.
9657
+ *
9658
+ * @example
9659
+ * ```tsx
9660
+ * const { scrollRef, window } = useVirtualizer({
9661
+ * totalCount: rows.length,
9662
+ * rowHeight: 48,
9663
+ * overscan: 5,
9664
+ * });
9665
+ *
9666
+ * return (
9667
+ * <div ref={scrollRef} style={{ height: 600, overflow: 'auto' }}>
9668
+ * <table>
9669
+ * <tbody>
9670
+ * <tr aria-hidden="true" style={{ height: window.paddingTop }} />
9671
+ * {rows.slice(window.startIndex, window.endIndex + 1).map(...)}
9672
+ * <tr aria-hidden="true" style={{ height: window.paddingBottom }} />
9673
+ * </tbody>
9674
+ * </table>
9675
+ * </div>
9676
+ * );
9677
+ * ```
9678
+ */ function useVirtualizer(args) {
9679
+ const { totalCount, rowHeight, overscan = 5, initialViewportHeight = 400 } = args;
9680
+ const scrollRef = useRef(null);
9681
+ const [scrollTop, setScrollTop] = useState(0);
9682
+ const [viewportHeight, setViewportHeight] = useState(initialViewportHeight);
9683
+ // Debounce scroll handler via requestAnimationFrame so we never
9684
+ // re-render more than once per paint frame even when the scroll
9685
+ // event fires rapidly during fast scrolling.
9686
+ const rafIdRef = useRef(null);
9687
+ const handleScroll = useCallback(()=>{
9688
+ if (rafIdRef.current != null) return;
9689
+ rafIdRef.current = requestAnimationFrame(()=>{
9690
+ rafIdRef.current = null;
9691
+ const el = scrollRef.current;
9692
+ if (!el) return;
9693
+ setScrollTop(el.scrollTop);
9694
+ });
9695
+ }, []);
9696
+ // Attach scroll listener.
9697
+ useEffect(()=>{
9698
+ const el = scrollRef.current;
9699
+ if (!el) return;
9700
+ el.addEventListener('scroll', handleScroll, {
9701
+ passive: true
9702
+ });
9703
+ return ()=>{
9704
+ el.removeEventListener('scroll', handleScroll);
9705
+ if (rafIdRef.current != null) {
9706
+ cancelAnimationFrame(rafIdRef.current);
9707
+ rafIdRef.current = null;
9708
+ }
9709
+ };
9710
+ }, [
9711
+ handleScroll
9712
+ ]);
9713
+ // Track container viewport height via ResizeObserver — re-virtualize
9714
+ // when the container size changes (window resize, sidebar collapse,
9715
+ // etc.). Use `useLayoutEffect` so the first measurement happens
9716
+ // before paint to avoid a flash of stale window.
9717
+ //
9718
+ // **Defensive**: only accept measurements > 0. jsdom (test env)
9719
+ // returns `clientHeight === 0` for unmounted containers, which would
9720
+ // collapse the window to empty. The hook keeps the
9721
+ // `initialViewportHeight` fallback in that case.
9722
+ useLayoutEffect(()=>{
9723
+ const el = scrollRef.current;
9724
+ if (!el) return;
9725
+ if (el.clientHeight > 0) {
9726
+ setViewportHeight(el.clientHeight);
9727
+ }
9728
+ if (typeof ResizeObserver === 'undefined') return;
9729
+ const observer = new ResizeObserver((entries)=>{
9730
+ const entry = entries[0];
9731
+ if (!entry || entry.contentRect.height <= 0) return;
9732
+ setViewportHeight(entry.contentRect.height);
9733
+ });
9734
+ observer.observe(el);
9735
+ return ()=>observer.disconnect();
9736
+ }, []);
9737
+ const window = computeWindow({
9738
+ totalCount,
9739
+ rowHeight,
9740
+ viewportHeight,
9741
+ scrollTop,
9742
+ overscan
9743
+ });
9744
+ return {
9745
+ scrollRef,
9746
+ window,
9747
+ viewportHeight
9748
+ };
9749
+ }
9750
+
9751
+ const DEFAULT_LABELS = {
9752
+ searchPlaceholder: 'Search...',
9753
+ noData: 'No data',
9754
+ loading: 'Loading…',
9755
+ ariaSortNone: 'Not sorted. Click to sort ascending.',
9756
+ ariaSortAscending: 'Sorted ascending. Click to sort descending.',
9757
+ ariaSortDescending: 'Sorted descending. Click to clear sort.',
9758
+ ariaSelectRow: 'Select row',
9759
+ ariaSelectAllRows: 'Select all rows',
9760
+ ariaExpandRow: 'Expand row',
9761
+ ariaCollapseRow: 'Collapse row',
9762
+ selectedCount: '{count} selected',
9763
+ clearSelection: 'Clear selection',
9764
+ filterColumn: 'Filter',
9765
+ filterApply: 'Apply',
9766
+ filterClear: 'Clear',
9767
+ density: 'Density',
9768
+ densityCompact: 'Compact',
9769
+ densityComfortable: 'Comfortable',
9770
+ densitySpacious: 'Spacious'
9771
+ };
9772
+ /**
9773
+ * Dashforge TW `<DataGrid>` — virtualized data table.
9774
+ *
9775
+ * Companion to `<Table>` for large data sets (~500+ rows up to
9776
+ * millions via virtualization). Same column model, sort/search/
9777
+ * filter/selection logic (shared via `_shared/data/` helpers) —
9778
+ * differs in render path: only the visible window of rows is mounted
9779
+ * in DOM, with spacer `<tr>` above and below to preserve scrollbar
9780
+ * size.
9781
+ *
9782
+ * Virtualization: hand-rolled (`useVirtualizer`) using
9783
+ * `IntersectionObserver` + scroll-event + `requestAnimationFrame`
9784
+ * debounce + `ResizeObserver`. Zero new runtime deps.
9785
+ *
9786
+ * Server-side mode (4 independent opt-in flags):
9787
+ * - `serverSideSort` — emits `onSortChange` but does NOT sort locally
9788
+ * - `serverSideFilter` — emits `onFilterChange` but does NOT filter
9789
+ * - `serverSideSearch` — emits `onSearchQueryChange` (debounced)
9790
+ * - `serverSidePagination` — `totalCount` prop sizes the virtual
9791
+ * scrollbar; `rows` is the page slice from the server
9792
+ *
9793
+ * Selection at scale: `selectAllScope`:
9794
+ * - `'allLoaded'` (default) — header checkbox toggles all rows in
9795
+ * `rows` prop
9796
+ * - `'visible'` — toggles only the current viewport window
9797
+ */ function DataGrid(props) {
9798
+ var _slotProps_root, _slotProps_toolbar, _slotProps_search, _slotProps_scroll, _slotProps_table, _slotProps_thead, _slotProps_headerRow, _slotProps_headerCell, _slotProps_selectionCell, _slotProps_headerCell1, _slotProps_tbody, _slotProps_row, _slotProps_cell, _slotProps_emptyState, _slotProps_row1, _slotProps_cell1, _slotProps_cell2, _slotProps_selectionCell1, _slotProps_cell3, _slotProps_rowActionsCell, _slotProps_bulkActionFooter, _slotProps_pagination;
9799
+ const { rows, cols, getRowId, totalCount: totalCountProp, rowHeight, overscan = 5, height, sortModel: sortModelProp, onSortChange, defaultSortModel, serverSideSort = false, enableSearch = false, searchQuery: searchQueryProp, onSearchQueryChange, searchPlaceholder, searchDebounceMs = 200, serverSideSearch = false, filterModel: filterModelProp, onFilterChange, serverSideFilter = false, rowSelection = 'none', selectedRowIds: selectedRowIdsProp, onSelectionChange, bulkActions, selectAllScope = 'allLoaded', pagination, serverSidePagination = false, rowActions, loading = false, loadingRowCount = 8, emptyState, stickyHeader = true, caption, showCaption = false, access: gridAccess, labels: labelsProp, variant, size, density, sx, slotProps } = props;
9800
+ const labels = _extends({}, DEFAULT_LABELS, labelsProp);
9801
+ const gridAccessState = useAccessState(gridAccess);
9802
+ // ───── Controllable state ─────
9803
+ const [sortModel, setSortModel] = useControllableState({
9804
+ controlledValue: sortModelProp,
9805
+ defaultValue: defaultSortModel != null ? defaultSortModel : [],
9806
+ onChange: onSortChange
9807
+ });
9808
+ const [searchQuery, setSearchQuery] = useControllableState({
9809
+ controlledValue: searchQueryProp,
9810
+ defaultValue: '',
9811
+ onChange: onSearchQueryChange
9812
+ });
9813
+ const filterModel = filterModelProp != null ? filterModelProp : [];
9814
+ const [selectedRowIds, setSelectedRowIds] = useControllableState({
9815
+ controlledValue: selectedRowIdsProp,
9816
+ defaultValue: [],
9817
+ onChange: onSelectionChange
9818
+ });
9819
+ // ───── RBAC: filter columns ─────
9820
+ const visibleCols = useMemo(()=>cols.filter((col)=>!col.access || col.access.onUnauthorized !== 'hide'), [
9821
+ cols
9822
+ ]);
9823
+ // ───── Smart-default column types ─────
9824
+ const columnTypes = useColumnAutoDetect(rows, visibleCols);
9825
+ // ───── Pipeline: search → filter → sort (skip when server-side) ─────
9826
+ //
9827
+ // Each pipeline hook is ALWAYS called (React hooks rules). The
9828
+ // server-side flags decide whether to use the local-computed result
9829
+ // or pass the upstream rows through unchanged. The hooks are
9830
+ // `useMemo`-based internally so they're cheap when their inputs
9831
+ // are unchanged.
9832
+ const debouncedSearchQuery = useDebouncedValue(searchQuery, searchDebounceMs);
9833
+ const localSearched = useTableSearch(rows, visibleCols, debouncedSearchQuery);
9834
+ const searchedRows = serverSideSearch ? rows : localSearched;
9835
+ const localFiltered = useTableFilter(searchedRows, filterModel);
9836
+ const filteredRows = serverSideFilter ? searchedRows : localFiltered;
9837
+ const localSorted = useTableSort(filteredRows, visibleCols, sortModel);
9838
+ const sortedRows = serverSideSort ? filteredRows : localSorted;
9839
+ // ───── Virtualization ─────
9840
+ // Effective row count for the virtual scrollbar:
9841
+ // - Client-side: actual sorted rows count
9842
+ // - Server-side pagination: caller-provided totalCount
9843
+ const effectiveTotalCount = serverSidePagination ? totalCountProp != null ? totalCountProp : sortedRows.length : sortedRows.length;
9844
+ const initialViewportHeight = height ? parseInt(height, 10) || 400 : 400;
9845
+ const { scrollRef, window: vWindow } = useVirtualizer({
9846
+ totalCount: effectiveTotalCount,
9847
+ rowHeight,
9848
+ overscan,
9849
+ initialViewportHeight
9850
+ });
9851
+ // The rows actually mounted in DOM.
9852
+ const renderedRows = useMemo(()=>{
9853
+ if (serverSidePagination) {
9854
+ // In server-side pagination, `rows` IS the page slice; we
9855
+ // render all of it (virtualization within the page is still
9856
+ // useful for very large page sizes).
9857
+ return rows.slice(vWindow.startIndex, vWindow.endIndex + 1);
9858
+ }
9859
+ return sortedRows.slice(vWindow.startIndex, vWindow.endIndex + 1);
9860
+ }, [
9861
+ serverSidePagination,
9862
+ rows,
9863
+ sortedRows,
9864
+ vWindow.startIndex,
9865
+ vWindow.endIndex
9866
+ ]);
9867
+ // ───── Selection helpers ─────
9868
+ // For `selectAllScope='visible'`, the select-all scope is the
9869
+ // current window. For `'allLoaded'`, it's the whole sorted set.
9870
+ const selectAllPool = useMemo(()=>{
9871
+ if (selectAllScope === 'visible') {
9872
+ return renderedRows.map((row, idx)=>getRowId(row, vWindow.startIndex + idx));
9873
+ }
9874
+ return sortedRows.map((row, idx)=>getRowId(row, idx));
9875
+ }, [
9876
+ selectAllScope,
9877
+ renderedRows,
9878
+ sortedRows,
9879
+ getRowId,
9880
+ vWindow.startIndex
9881
+ ]);
9882
+ const selection = useTableSelection(rowSelection, selectedRowIds, setSelectedRowIds);
9883
+ const selectedRows = useMemo(()=>sortedRows.filter((row, idx)=>selection.isSelected(getRowId(row, idx))), [
9884
+ sortedRows,
9885
+ selection,
9886
+ getRowId
9887
+ ]);
9888
+ // ───── Variant recipe ─────
9889
+ const v = dataGridVariants({
9890
+ variant,
9891
+ size,
9892
+ density
9893
+ });
9894
+ // ───── Render ─────
9895
+ if (!gridAccessState.visible) return null;
9896
+ const isInteractive = !gridAccessState.disabled;
9897
+ const showSelectionColumn = rowSelection !== 'none';
9898
+ const showRowActionsColumn = rowActions != null;
9899
+ const totalColumnCount = visibleCols.length + (showSelectionColumn ? 1 : 0) + (showRowActionsColumn ? 1 : 0);
9900
+ return jsxs("div", {
9901
+ className: cn(v.root(), sx, slotProps == null ? void 0 : (_slotProps_root = slotProps.root) == null ? void 0 : _slotProps_root.className),
9902
+ "data-disabled": !isInteractive ? 'true' : undefined,
9903
+ children: [
9904
+ enableSearch && jsx("div", {
9905
+ className: cn(v.toolbar(), slotProps == null ? void 0 : (_slotProps_toolbar = slotProps.toolbar) == null ? void 0 : _slotProps_toolbar.className),
9906
+ children: jsxs("label", {
9907
+ className: cn(v.search(), slotProps == null ? void 0 : (_slotProps_search = slotProps.search) == null ? void 0 : _slotProps_search.className),
9908
+ children: [
9909
+ jsx("span", {
9910
+ className: "sr-only",
9911
+ children: labels.searchPlaceholder
9912
+ }),
9913
+ jsx(SearchIcon, {
9914
+ className: "ml-2 shrink-0 text-neutral-500"
9915
+ }),
9916
+ jsx("input", {
9917
+ type: "text",
9918
+ value: searchQuery,
9919
+ onChange: (e)=>setSearchQuery(e.target.value),
9920
+ placeholder: searchPlaceholder != null ? searchPlaceholder : labels.searchPlaceholder,
9921
+ disabled: !isInteractive,
9922
+ className: cn('flex-1 bg-transparent border-0 outline-none', 'px-2 py-1.5', 'placeholder:text-neutral-400', 'disabled:opacity-50 disabled:cursor-not-allowed')
9923
+ })
9924
+ ]
9925
+ })
9926
+ }),
9927
+ jsx("div", {
9928
+ ref: scrollRef,
9929
+ className: cn(v.scroll(), slotProps == null ? void 0 : (_slotProps_scroll = slotProps.scroll) == null ? void 0 : _slotProps_scroll.className),
9930
+ style: height ? {
9931
+ height,
9932
+ maxHeight: height
9933
+ } : undefined,
9934
+ children: jsxs("table", {
9935
+ className: cn(v.table(), slotProps == null ? void 0 : (_slotProps_table = slotProps.table) == null ? void 0 : _slotProps_table.className),
9936
+ children: [
9937
+ caption != null && jsx("caption", {
9938
+ className: cn(!showCaption && 'sr-only', 'py-2 text-sm text-neutral-500'),
9939
+ children: caption
9940
+ }),
9941
+ jsx("thead", {
9942
+ className: cn(stickyHeader ? v.thead() : 'bg-neutral-50', slotProps == null ? void 0 : (_slotProps_thead = slotProps.thead) == null ? void 0 : _slotProps_thead.className),
9943
+ children: jsxs("tr", {
9944
+ className: cn(v.headerRow(), slotProps == null ? void 0 : (_slotProps_headerRow = slotProps.headerRow) == null ? void 0 : _slotProps_headerRow.className),
9945
+ children: [
9946
+ showSelectionColumn && jsx("th", {
9947
+ scope: "col",
9948
+ className: cn(v.headerCell(), v.selectionCell(), v.stickyLeftHeaderCell(), slotProps == null ? void 0 : (_slotProps_headerCell = slotProps.headerCell) == null ? void 0 : _slotProps_headerCell.className, slotProps == null ? void 0 : (_slotProps_selectionCell = slotProps.selectionCell) == null ? void 0 : _slotProps_selectionCell.className),
9949
+ children: rowSelection === 'multiple' && jsx("input", {
9950
+ type: "checkbox",
9951
+ "aria-label": labels.ariaSelectAllRows,
9952
+ checked: selection.isAllSelected(selectAllPool),
9953
+ ref: (el)=>{
9954
+ if (el) el.indeterminate = selection.isIndeterminate(selectAllPool);
9955
+ },
9956
+ onChange: ()=>selection.toggleAll(selectAllPool),
9957
+ disabled: !isInteractive,
9958
+ className: "cursor-pointer"
9959
+ })
9960
+ }),
9961
+ visibleCols.map((col)=>{
9962
+ var _slotProps_headerCell;
9963
+ return jsx(HeaderCell, {
9964
+ col: col,
9965
+ sortModel: sortModel,
9966
+ onSortClick: (e)=>handleSortClick(col, e, sortModel, setSortModel),
9967
+ inferredType: columnTypes.get(col.field),
9968
+ labels: labels,
9969
+ className: cn(v.headerCell(), col.sticky === 'left' && v.stickyLeftHeaderCell(), slotProps == null ? void 0 : (_slotProps_headerCell = slotProps.headerCell) == null ? void 0 : _slotProps_headerCell.className),
9970
+ buttonClassName: v.headerCellButton(),
9971
+ disabled: !isInteractive
9972
+ }, col.field);
9973
+ }),
9974
+ showRowActionsColumn && jsx("th", {
9975
+ scope: "col",
9976
+ "aria-hidden": "true",
9977
+ className: cn(v.headerCell(), 'w-12', slotProps == null ? void 0 : (_slotProps_headerCell1 = slotProps.headerCell) == null ? void 0 : _slotProps_headerCell1.className)
9978
+ })
9979
+ ]
9980
+ })
9981
+ }),
9982
+ jsx("tbody", {
9983
+ className: cn(v.tbody(), slotProps == null ? void 0 : (_slotProps_tbody = slotProps.tbody) == null ? void 0 : _slotProps_tbody.className),
9984
+ children: loading ? renderLoadingRows({
9985
+ count: loadingRowCount,
9986
+ visibleCols,
9987
+ showSelectionColumn,
9988
+ showRowActionsColumn,
9989
+ rowHeight,
9990
+ rowClass: cn(v.row(), slotProps == null ? void 0 : (_slotProps_row = slotProps.row) == null ? void 0 : _slotProps_row.className),
9991
+ cellClass: cn(v.cell(), slotProps == null ? void 0 : (_slotProps_cell = slotProps.cell) == null ? void 0 : _slotProps_cell.className)
9992
+ }) : sortedRows.length === 0 ? renderEmptyState({
9993
+ totalColumnCount,
9994
+ emptyState,
9995
+ fallback: labels.noData,
9996
+ cellClass: cn(v.cell(), v.emptyState(), slotProps == null ? void 0 : (_slotProps_emptyState = slotProps.emptyState) == null ? void 0 : _slotProps_emptyState.className)
9997
+ }) : renderVirtualizedBody({
9998
+ vWindow,
9999
+ renderedRows,
10000
+ visibleCols,
10001
+ columnTypes,
10002
+ selection,
10003
+ rowSelection,
10004
+ showSelectionColumn,
10005
+ showRowActionsColumn,
10006
+ rowActions,
10007
+ getRowId,
10008
+ isInteractive,
10009
+ labels,
10010
+ classes: {
10011
+ row: cn(v.row(), slotProps == null ? void 0 : (_slotProps_row1 = slotProps.row) == null ? void 0 : _slotProps_row1.className),
10012
+ cell: cn(v.cell(), slotProps == null ? void 0 : (_slotProps_cell1 = slotProps.cell) == null ? void 0 : _slotProps_cell1.className),
10013
+ selectionCell: cn(v.cell(), v.selectionCell(), v.stickyLeftCell(), slotProps == null ? void 0 : (_slotProps_cell2 = slotProps.cell) == null ? void 0 : _slotProps_cell2.className, slotProps == null ? void 0 : (_slotProps_selectionCell1 = slotProps.selectionCell) == null ? void 0 : _slotProps_selectionCell1.className),
10014
+ stickyLeftCell: v.stickyLeftCell(),
10015
+ rowActionsCell: cn(v.cell(), v.rowActionsCell(), slotProps == null ? void 0 : (_slotProps_cell3 = slotProps.cell) == null ? void 0 : _slotProps_cell3.className, slotProps == null ? void 0 : (_slotProps_rowActionsCell = slotProps.rowActionsCell) == null ? void 0 : _slotProps_rowActionsCell.className)
10016
+ }
10017
+ })
10018
+ })
10019
+ ]
10020
+ })
10021
+ }),
10022
+ bulkActions && selectedRows.length > 0 && jsxs("div", {
10023
+ className: cn(v.bulkActionFooter(), slotProps == null ? void 0 : (_slotProps_bulkActionFooter = slotProps.bulkActionFooter) == null ? void 0 : _slotProps_bulkActionFooter.className),
10024
+ role: "region",
10025
+ "aria-label": labels.selectedCount.replace('{count}', String(selectedRows.length)),
10026
+ children: [
10027
+ jsx("span", {
10028
+ className: "text-sm text-neutral-700",
10029
+ children: labels.selectedCount.replace('{count}', String(selectedRows.length))
10030
+ }),
10031
+ jsxs("div", {
10032
+ className: "flex items-center gap-2",
10033
+ children: [
10034
+ bulkActions(selectedRows),
10035
+ jsx("button", {
10036
+ type: "button",
10037
+ onClick: selection.clearSelection,
10038
+ className: cn('text-sm text-neutral-500 hover:text-neutral-900', 'underline-offset-2 hover:underline', 'focus:outline-none focus-visible:underline'),
10039
+ children: labels.clearSelection
10040
+ })
10041
+ ]
10042
+ })
10043
+ ]
10044
+ }),
10045
+ pagination && jsx("div", {
10046
+ className: cn(v.paginationFooter(), slotProps == null ? void 0 : (_slotProps_pagination = slotProps.pagination) == null ? void 0 : _slotProps_pagination.className),
10047
+ children: jsx(Pagination, {
10048
+ page: pagination.page,
10049
+ pageSize: pagination.pageSize,
10050
+ totalCount: totalCountProp != null ? totalCountProp : sortedRows.length,
10051
+ onPageChange: pagination.onPageChange,
10052
+ onPageSizeChange: pagination.onPageSizeChange,
10053
+ pageSizeOptions: pagination.pageSizeOptions
10054
+ })
10055
+ })
10056
+ ]
10057
+ });
10058
+ }
10059
+ // ───── Header cell ─────
10060
+ function HeaderCell(props) {
10061
+ const { col, sortModel, onSortClick, inferredType, labels, className, buttonClassName, disabled } = props;
10062
+ const align = resolveAlign(col, inferredType);
10063
+ const alignClass = align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left';
10064
+ const sortEntry = sortModel.find((s)=>s.field === col.field);
10065
+ const ariaSort = (sortEntry == null ? void 0 : sortEntry.direction) === 'asc' ? 'ascending' : (sortEntry == null ? void 0 : sortEntry.direction) === 'desc' ? 'descending' : 'none';
10066
+ const headerContent = typeof col.header === 'function' ? col.header() : col.header;
10067
+ const cellStyle = {
10068
+ width: col.width != null ? `${col.width}px` : undefined,
10069
+ minWidth: col.minWidth != null ? `${col.minWidth}px` : undefined,
10070
+ flex: col.flex
10071
+ };
10072
+ if (col.sortable) {
10073
+ return jsx("th", {
10074
+ scope: "col",
10075
+ "aria-sort": ariaSort,
10076
+ className: cn(className, alignClass),
10077
+ style: cellStyle,
10078
+ children: jsxs("button", {
10079
+ type: "button",
10080
+ onClick: onSortClick,
10081
+ disabled: disabled,
10082
+ "aria-label": ariaSort === 'ascending' ? labels.ariaSortAscending : ariaSort === 'descending' ? labels.ariaSortDescending : labels.ariaSortNone,
10083
+ className: cn(buttonClassName, align === 'right' && 'flex-row-reverse', 'disabled:cursor-not-allowed disabled:opacity-50'),
10084
+ children: [
10085
+ jsx("span", {
10086
+ children: headerContent
10087
+ }),
10088
+ jsx(SortIcon, {
10089
+ direction: sortEntry == null ? void 0 : sortEntry.direction
10090
+ })
10091
+ ]
10092
+ })
10093
+ });
10094
+ }
10095
+ return jsx("th", {
10096
+ scope: "col",
10097
+ className: cn(className, alignClass),
10098
+ style: cellStyle,
10099
+ children: headerContent
10100
+ });
10101
+ }
10102
+ // ───── Virtualized body ─────
10103
+ function renderVirtualizedBody(args) {
10104
+ const { vWindow, renderedRows, visibleCols, columnTypes, selection, rowSelection, showSelectionColumn, showRowActionsColumn, rowActions, getRowId, isInteractive, labels, classes } = args;
10105
+ return jsxs(Fragment$1, {
10106
+ children: [
10107
+ vWindow.paddingTop > 0 && jsx("tr", {
10108
+ "aria-hidden": "true",
10109
+ style: {
10110
+ height: vWindow.paddingTop
10111
+ }
10112
+ }),
10113
+ renderedRows.map((row, localIdx)=>{
10114
+ const absoluteIdx = vWindow.startIndex + localIdx;
10115
+ const rowId = getRowId(row, absoluteIdx);
10116
+ const isSelected = selection.isSelected(rowId);
10117
+ return jsxs("tr", {
10118
+ "data-selected": isSelected ? 'true' : 'false',
10119
+ "aria-selected": rowSelection !== 'none' ? isSelected : undefined,
10120
+ className: classes.row,
10121
+ children: [
10122
+ showSelectionColumn && jsx("td", {
10123
+ className: classes.selectionCell,
10124
+ children: jsx("input", {
10125
+ type: "checkbox",
10126
+ "aria-label": labels.ariaSelectRow,
10127
+ checked: isSelected,
10128
+ onChange: ()=>selection.toggleRow(rowId),
10129
+ disabled: !isInteractive,
10130
+ className: "cursor-pointer"
10131
+ })
10132
+ }),
10133
+ visibleCols.map((col)=>jsx(DataCell, {
10134
+ col: col,
10135
+ row: row,
10136
+ rowIndex: absoluteIdx,
10137
+ isSelected: isSelected,
10138
+ inferredType: columnTypes.get(col.field),
10139
+ cellClass: classes.cell,
10140
+ stickyLeftClass: classes.stickyLeftCell
10141
+ }, col.field)),
10142
+ showRowActionsColumn && rowActions && jsx("td", {
10143
+ className: classes.rowActionsCell,
10144
+ children: rowActions(row)
10145
+ })
10146
+ ]
10147
+ }, rowId);
10148
+ }),
10149
+ vWindow.paddingBottom > 0 && jsx("tr", {
10150
+ "aria-hidden": "true",
10151
+ style: {
10152
+ height: vWindow.paddingBottom
10153
+ }
10154
+ })
10155
+ ]
10156
+ });
10157
+ }
10158
+ // ───── Data cell ─────
10159
+ function DataCell(props) {
10160
+ const { col, row, rowIndex, isSelected, inferredType, cellClass, stickyLeftClass } = props;
10161
+ const value = getNestedValue(row, col.field);
10162
+ const align = resolveAlign(col, inferredType);
10163
+ const tabularNums = resolveTabularNums(col, inferredType);
10164
+ const monospace = resolveMonospace(col);
10165
+ const alignClass = align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left';
10166
+ const columnAccessState = col.access ? {
10167
+ hidden: col.access.onUnauthorized === 'hide',
10168
+ disabled: col.access.onUnauthorized === 'disable',
10169
+ readonly: col.access.onUnauthorized === 'readonly'
10170
+ } : null;
10171
+ const content = col.cellRenderer ? col.cellRenderer({
10172
+ row,
10173
+ value,
10174
+ rowIndex,
10175
+ isSelected,
10176
+ isExpanded: false,
10177
+ access: columnAccessState
10178
+ }) : renderDefaultCell(value);
10179
+ return jsx("td", {
10180
+ className: cn(cellClass, alignClass, tabularNums && 'tabular-nums', monospace && 'font-mono', col.sticky === 'left' && stickyLeftClass),
10181
+ style: {
10182
+ width: col.width != null ? `${col.width}px` : undefined,
10183
+ minWidth: col.minWidth != null ? `${col.minWidth}px` : undefined
10184
+ },
10185
+ children: content
10186
+ });
10187
+ }
10188
+ // ───── Helpers ─────
10189
+ function renderDefaultCell(value) {
10190
+ if (value == null) return null;
10191
+ if (typeof value === 'boolean') return value ? '✓' : '✗';
10192
+ if (value instanceof Date) return value.toISOString().slice(0, 10);
10193
+ if (typeof value === 'object') return JSON.stringify(value);
10194
+ return value;
10195
+ }
10196
+ function handleSortClick(col, event, current, setSortModel) {
10197
+ if (!col.sortable) return;
10198
+ const multi = event.shiftKey;
10199
+ setSortModel((prev)=>cycleSortFor(prev, col.field, multi));
10200
+ }
10201
+ function renderLoadingRows(args) {
10202
+ const { count, visibleCols, showSelectionColumn, showRowActionsColumn, rowHeight, rowClass, cellClass } = args;
10203
+ return Array.from({
10204
+ length: count
10205
+ }).map((_, i)=>jsxs("tr", {
10206
+ className: rowClass,
10207
+ "aria-busy": "true",
10208
+ style: {
10209
+ height: rowHeight
10210
+ },
10211
+ children: [
10212
+ showSelectionColumn && jsx("td", {
10213
+ className: cellClass,
10214
+ children: jsx(Skeleton, {
10215
+ variant: "rectangle",
10216
+ width: "16px",
10217
+ height: "16px"
10218
+ })
10219
+ }),
10220
+ visibleCols.map((col)=>jsx("td", {
10221
+ className: cellClass,
10222
+ children: jsx(Skeleton, {
10223
+ variant: "text",
10224
+ width: "80%"
10225
+ })
10226
+ }, col.field)),
10227
+ showRowActionsColumn && jsx("td", {
10228
+ className: cellClass,
10229
+ children: jsx(Skeleton, {
10230
+ variant: "rectangle",
10231
+ width: "24px",
10232
+ height: "24px"
10233
+ })
10234
+ })
10235
+ ]
10236
+ }, `loading-${i}`));
10237
+ }
10238
+ function renderEmptyState(args) {
10239
+ var _args_emptyState;
10240
+ return jsx("tr", {
10241
+ children: jsx("td", {
10242
+ colSpan: args.totalColumnCount,
10243
+ className: args.cellClass,
10244
+ children: (_args_emptyState = args.emptyState) != null ? _args_emptyState : args.fallback
10245
+ })
10246
+ });
10247
+ }
10248
+ // ───── Icons ─────
10249
+ function SortIcon(props) {
10250
+ return jsx("svg", {
10251
+ width: "0.75em",
10252
+ height: "0.75em",
10253
+ viewBox: "0 0 16 16",
10254
+ fill: "currentColor",
10255
+ "aria-hidden": "true",
10256
+ className: "shrink-0 opacity-60 group-hover:opacity-100",
10257
+ children: props.direction === 'asc' ? jsx("path", {
10258
+ d: "M8 4l4 6H4z"
10259
+ }) : props.direction === 'desc' ? jsx("path", {
10260
+ d: "M8 12L4 6h8z"
10261
+ }) : jsxs(Fragment$1, {
10262
+ children: [
10263
+ jsx("path", {
10264
+ d: "M8 3l3 4H5z",
10265
+ opacity: "0.5"
10266
+ }),
10267
+ jsx("path", {
10268
+ d: "M8 13l-3-4h6z",
10269
+ opacity: "0.5"
10270
+ })
10271
+ ]
10272
+ })
10273
+ });
10274
+ }
10275
+ function SearchIcon(props) {
10276
+ return jsxs("svg", {
10277
+ width: "0.875em",
10278
+ height: "0.875em",
10279
+ viewBox: "0 0 16 16",
10280
+ fill: "none",
10281
+ stroke: "currentColor",
10282
+ strokeWidth: "1.5",
10283
+ strokeLinecap: "round",
10284
+ strokeLinejoin: "round",
10285
+ "aria-hidden": "true",
10286
+ className: props.className,
10287
+ children: [
10288
+ jsx("circle", {
10289
+ cx: "7",
10290
+ cy: "7",
10291
+ r: "4.5"
10292
+ }),
10293
+ jsx("path", {
10294
+ d: "M11 11l3 3"
10295
+ })
10296
+ ]
10297
+ });
10298
+ }
10299
+
7847
10300
  /**
7848
10301
  * @dashforge/tw
7849
10302
  *
@@ -7871,6 +10324,6 @@ const DEFAULT_PAGE_SIZE_OPTIONS = [
7871
10324
  */ // ───── Components ─────
7872
10325
  /**
7873
10326
  * Package version (synced with `package.json` at publish time).
7874
- */ const VERSION = '0.5.0-beta';
10327
+ */ const VERSION = '0.7.0-beta';
7875
10328
 
7876
- export { Accordion, AppShell, AspectRatio, Autocomplete, Box, Breadcrumbs, Button, Checkbox, ConfirmDialogProvider, Container, DateTimePicker, Dialog, Divider, Grid, LeftNav, NumberField, OTPField, Pagination, Popover, RadioGroup, Skeleton, SnackbarProvider, Stack, Switch, Tabs, TextField, Textarea, Tooltip, TopBar, Typography, VERSION, VisuallyHidden, accordionVariants, appShellVariants, autocompleteVariants, boxVariants, breadcrumbsVariants, buttonVariants, checkboxVariants, cn, confirmDialogVariants, containerVariants, dateTimePickerVariants, dialogVariants, dividerLineVariants, dividerVariants, gridVariants, isoToInputValue, leftNavVariants, numberFieldVariants, otpFieldVariants, paginationVariants, popoverVariants, radioGroupVariants, skeletonVariants, snackbarVariants, stackVariants, switchVariants, tabsVariants, textFieldVariants, textareaVariants, tooltipVariants, topBarVariants, typographyVariants, useAccessState, useConfirm, useSnackbar };
10329
+ export { Accordion, AppShell, AspectRatio, Autocomplete, Box, Breadcrumbs, Button, Checkbox, ConfirmDialogProvider, Container, DataGrid, DateTimePicker, Dialog, Divider, Grid, LeftNav, NumberField, OTPField, Pagination, Popover, RadioGroup, RenderButton, RenderChip, RenderText, RenderTwoLine, RowActionsMenu, Skeleton, SnackbarProvider, Stack, Switch, Table, Tabs, TextField, Textarea, Tooltip, TopBar, Typography, VERSION, VisuallyHidden, accordionVariants, appShellVariants, autocompleteVariants, boxVariants, breadcrumbsVariants, buttonVariants, checkboxVariants, cn, confirmDialogVariants, containerVariants, dataGridVariants, dateTimePickerVariants, dialogVariants, dividerLineVariants, dividerVariants, getNestedValue, gridVariants, isoToInputValue, leftNavVariants, numberFieldVariants, otpFieldVariants, paginationVariants, popoverVariants, radioGroupVariants, skeletonVariants, snackbarVariants, stackVariants, switchVariants, tableVariants, tabsVariants, textFieldVariants, textareaVariants, tooltipVariants, topBarVariants, typographyVariants, useAccessState, useConfirm, useSnackbar };