@dashforge/tw 0.4.0-beta → 0.6.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.
- package/CHANGELOG.md +391 -0
- package/dist/index.esm.js +2113 -2
- package/dist/src/components/Pagination/Pagination.d.ts +32 -0
- package/dist/src/components/Pagination/Pagination.d.ts.map +1 -0
- package/dist/src/components/Pagination/pagination.helpers.d.ts +23 -0
- package/dist/src/components/Pagination/pagination.helpers.d.ts.map +1 -0
- package/dist/src/components/Pagination/pagination.types.d.ts +120 -0
- package/dist/src/components/Pagination/pagination.types.d.ts.map +1 -0
- package/dist/src/components/Pagination/pagination.variants.d.ts +150 -0
- package/dist/src/components/Pagination/pagination.variants.d.ts.map +1 -0
- package/dist/src/components/Skeleton/Skeleton.d.ts +23 -0
- package/dist/src/components/Skeleton/Skeleton.d.ts.map +1 -0
- package/dist/src/components/Skeleton/skeleton.types.d.ts +56 -0
- package/dist/src/components/Skeleton/skeleton.types.d.ts.map +1 -0
- package/dist/src/components/Skeleton/skeleton.variants.d.ts +110 -0
- package/dist/src/components/Skeleton/skeleton.variants.d.ts.map +1 -0
- package/dist/src/components/Table/Table.d.ts +24 -0
- package/dist/src/components/Table/Table.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/getNestedValue.d.ts +20 -0
- package/dist/src/components/Table/_internal/getNestedValue.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useColumnAutoDetect.d.ts +45 -0
- package/dist/src/components/Table/_internal/useColumnAutoDetect.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useControllableState.d.ts +23 -0
- package/dist/src/components/Table/_internal/useControllableState.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useDebouncedValue.d.ts +10 -0
- package/dist/src/components/Table/_internal/useDebouncedValue.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useTableFilter.d.ts +13 -0
- package/dist/src/components/Table/_internal/useTableFilter.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useTableSearch.d.ts +23 -0
- package/dist/src/components/Table/_internal/useTableSearch.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useTableSelection.d.ts +25 -0
- package/dist/src/components/Table/_internal/useTableSelection.d.ts.map +1 -0
- package/dist/src/components/Table/_internal/useTableSort.d.ts +20 -0
- package/dist/src/components/Table/_internal/useTableSort.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RenderButton.d.ts +13 -0
- package/dist/src/components/Table/cells/RenderButton.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RenderChip.d.ts +80 -0
- package/dist/src/components/Table/cells/RenderChip.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RenderText.d.ts +21 -0
- package/dist/src/components/Table/cells/RenderText.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RenderTwoLine.d.ts +20 -0
- package/dist/src/components/Table/cells/RenderTwoLine.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RowActionsMenu.d.ts +36 -0
- package/dist/src/components/Table/cells/RowActionsMenu.d.ts.map +1 -0
- package/dist/src/components/Table/cells/index.d.ts +11 -0
- package/dist/src/components/Table/cells/index.d.ts.map +1 -0
- package/dist/src/components/Table/table.types.d.ts +304 -0
- package/dist/src/components/Table/table.types.d.ts.map +1 -0
- package/dist/src/components/Table/table.variants.d.ts +285 -0
- package/dist/src/components/Table/table.variants.d.ts.map +1 -0
- package/dist/src/index.d.ts +13 -1
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/Pagination/Pagination.test.tsx +314 -0
- package/src/components/Pagination/Pagination.tsx +275 -0
- package/src/components/Pagination/pagination.helpers.ts +102 -0
- package/src/components/Pagination/pagination.types.ts +117 -0
- package/src/components/Pagination/pagination.variants.ts +111 -0
- package/src/components/Skeleton/Skeleton.test.tsx +127 -0
- package/src/components/Skeleton/Skeleton.tsx +49 -0
- package/src/components/Skeleton/skeleton.types.ts +59 -0
- package/src/components/Skeleton/skeleton.variants.ts +86 -0
- package/src/components/Table/Table.test.tsx +603 -0
- package/src/components/Table/Table.tsx +816 -0
- package/src/components/Table/_internal/getNestedValue.test.ts +50 -0
- package/src/components/Table/_internal/getNestedValue.ts +32 -0
- package/src/components/Table/_internal/themeIdentity.test.ts +84 -0
- package/src/components/Table/_internal/useColumnAutoDetect.test.ts +123 -0
- package/src/components/Table/_internal/useColumnAutoDetect.ts +100 -0
- package/src/components/Table/_internal/useControllableState.ts +48 -0
- package/src/components/Table/_internal/useDebouncedValue.ts +24 -0
- package/src/components/Table/_internal/useTableFilter.ts +37 -0
- package/src/components/Table/_internal/useTableSearch.test.ts +115 -0
- package/src/components/Table/_internal/useTableSearch.ts +66 -0
- package/src/components/Table/_internal/useTableSelection.test.ts +100 -0
- package/src/components/Table/_internal/useTableSelection.ts +100 -0
- package/src/components/Table/_internal/useTableSort.test.ts +150 -0
- package/src/components/Table/_internal/useTableSort.ts +141 -0
- package/src/components/Table/cells/RenderButton.tsx +23 -0
- package/src/components/Table/cells/RenderChip.tsx +103 -0
- package/src/components/Table/cells/RenderText.tsx +37 -0
- package/src/components/Table/cells/RenderTwoLine.tsx +34 -0
- package/src/components/Table/cells/RowActionsMenu.tsx +147 -0
- package/src/components/Table/cells/index.ts +14 -0
- package/src/components/Table/table.types.ts +337 -0
- package/src/components/Table/table.variants.ts +207 -0
- package/src/index.ts +60 -1
package/dist/index.esm.js
CHANGED
|
@@ -7282,6 +7282,2117 @@ const accordionVariants = tv({
|
|
|
7282
7282
|
});
|
|
7283
7283
|
}
|
|
7284
7284
|
|
|
7285
|
+
/**
|
|
7286
|
+
* `<Skeleton>` variant recipe.
|
|
7287
|
+
*
|
|
7288
|
+
* Slots:
|
|
7289
|
+
* - `root` — single element (atomic component)
|
|
7290
|
+
*
|
|
7291
|
+
* Variant axes:
|
|
7292
|
+
* - `variant` (`text` | `rectangle` | `circle`) — shape / radius
|
|
7293
|
+
* - `animation` (`pulse` | `wave` | `none`) — how the placeholder
|
|
7294
|
+
* "breathes" while data loads. Always gated on
|
|
7295
|
+
* `prefers-reduced-motion` (no exception — animations are
|
|
7296
|
+
* purely cosmetic).
|
|
7297
|
+
*
|
|
7298
|
+
* Implementation notes:
|
|
7299
|
+
*
|
|
7300
|
+
* - `pulse` uses Tailwind's built-in `animate-pulse` (opacity
|
|
7301
|
+
* 1.0 ↔ 0.5 over 2s, ease-in-out infinite).
|
|
7302
|
+
* - `wave` overlays a moving gradient. Implemented with a
|
|
7303
|
+
* `relative` container + an `::after` pseudo-element via a
|
|
7304
|
+
* Tailwind arbitrary-value class. The gradient slides
|
|
7305
|
+
* horizontally over the surface, mimicking the MUI Skeleton
|
|
7306
|
+
* "wave" look.
|
|
7307
|
+
* - Color uses neutral-200 (light) and neutral-800 (dark) for
|
|
7308
|
+
* the base, picked up automatically by the dashforge preset's
|
|
7309
|
+
* CSS-variable layer.
|
|
7310
|
+
*
|
|
7311
|
+
* Accessibility: the root has `aria-hidden="true"` (set on the JSX
|
|
7312
|
+
* side, not here). Screen readers skip the skeleton entirely;
|
|
7313
|
+
* the surrounding component is responsible for announcing
|
|
7314
|
+
* loading state via `aria-busy` or `aria-live` if needed.
|
|
7315
|
+
*/ const skeletonVariants = tv({
|
|
7316
|
+
slots: {
|
|
7317
|
+
root: [
|
|
7318
|
+
'block',
|
|
7319
|
+
'bg-neutral-200 dark:bg-neutral-800',
|
|
7320
|
+
'motion-reduce:animate-none'
|
|
7321
|
+
]
|
|
7322
|
+
},
|
|
7323
|
+
variants: {
|
|
7324
|
+
variant: {
|
|
7325
|
+
text: {
|
|
7326
|
+
root: 'rounded h-[1em] w-full'
|
|
7327
|
+
},
|
|
7328
|
+
rectangle: {
|
|
7329
|
+
root: 'rounded-md w-full h-[100px]'
|
|
7330
|
+
},
|
|
7331
|
+
circle: {
|
|
7332
|
+
root: 'rounded-full w-10 h-10'
|
|
7333
|
+
}
|
|
7334
|
+
},
|
|
7335
|
+
animation: {
|
|
7336
|
+
pulse: {
|
|
7337
|
+
root: 'animate-pulse'
|
|
7338
|
+
},
|
|
7339
|
+
wave: {
|
|
7340
|
+
root: [
|
|
7341
|
+
'relative overflow-hidden',
|
|
7342
|
+
// The wave is rendered via an arbitrary-value ::after
|
|
7343
|
+
// pseudo-element with a moving linear gradient. Tailwind
|
|
7344
|
+
// doesn't ship a `wave` keyframe; we use the built-in
|
|
7345
|
+
// `animate-pulse` as a fallback if a consumer needs to
|
|
7346
|
+
// override. The gradient stop direction works in both
|
|
7347
|
+
// light and dark mode (white shimmer in light, lighter
|
|
7348
|
+
// overlay in dark).
|
|
7349
|
+
'after:absolute after:inset-0',
|
|
7350
|
+
'after:bg-gradient-to-r after:from-transparent after:via-white/40 after:to-transparent',
|
|
7351
|
+
'dark:after:via-white/10',
|
|
7352
|
+
'after:animate-pulse',
|
|
7353
|
+
'after:motion-reduce:animate-none'
|
|
7354
|
+
]
|
|
7355
|
+
},
|
|
7356
|
+
none: {
|
|
7357
|
+
root: ''
|
|
7358
|
+
}
|
|
7359
|
+
}
|
|
7360
|
+
},
|
|
7361
|
+
defaultVariants: {
|
|
7362
|
+
variant: 'text',
|
|
7363
|
+
animation: 'pulse'
|
|
7364
|
+
}
|
|
7365
|
+
});
|
|
7366
|
+
|
|
7367
|
+
/**
|
|
7368
|
+
* Dashforge TW `<Skeleton>` — loading placeholder primitive.
|
|
7369
|
+
*
|
|
7370
|
+
* Renders as a non-interactive `<span aria-hidden="true">` so screen
|
|
7371
|
+
* readers skip it. The surrounding component is responsible for
|
|
7372
|
+
* announcing loading state via `aria-busy` or `aria-live` if needed.
|
|
7373
|
+
*
|
|
7374
|
+
* Variants:
|
|
7375
|
+
* - `variant` — `text` (default, line shape) / `rectangle` / `circle`
|
|
7376
|
+
* - `animation` — `pulse` (default) / `wave` / `none`
|
|
7377
|
+
*
|
|
7378
|
+
* Both animations are gated by `prefers-reduced-motion` (WCAG 2.3.3).
|
|
7379
|
+
*
|
|
7380
|
+
* @example
|
|
7381
|
+
* ```tsx
|
|
7382
|
+
* <Skeleton variant="text" width="200px" />
|
|
7383
|
+
* <Skeleton variant="rectangle" width="100%" height="120px" />
|
|
7384
|
+
* <Skeleton variant="circle" width="40px" />
|
|
7385
|
+
* ```
|
|
7386
|
+
*/ function Skeleton(props) {
|
|
7387
|
+
var _slotProps_root;
|
|
7388
|
+
const { variant = 'text', animation = 'pulse', width, height, sx, slotProps } = props;
|
|
7389
|
+
const v = skeletonVariants({
|
|
7390
|
+
variant,
|
|
7391
|
+
animation
|
|
7392
|
+
});
|
|
7393
|
+
// Width / height come through as inline style — Tailwind doesn't
|
|
7394
|
+
// know about arbitrary user-supplied lengths. We default `circle`'s
|
|
7395
|
+
// height to `width` so consumers don't have to repeat themselves.
|
|
7396
|
+
const style = {};
|
|
7397
|
+
if (width != null) style.width = width;
|
|
7398
|
+
if (height != null) {
|
|
7399
|
+
style.height = height;
|
|
7400
|
+
} else if (variant === 'circle' && width != null) {
|
|
7401
|
+
style.height = width;
|
|
7402
|
+
}
|
|
7403
|
+
return jsx("span", {
|
|
7404
|
+
"aria-hidden": "true",
|
|
7405
|
+
role: "presentation",
|
|
7406
|
+
className: cn(v.root(), sx, slotProps == null ? void 0 : (_slotProps_root = slotProps.root) == null ? void 0 : _slotProps_root.className),
|
|
7407
|
+
style: style
|
|
7408
|
+
});
|
|
7409
|
+
}
|
|
7410
|
+
|
|
7411
|
+
/**
|
|
7412
|
+
* `<Pagination>` variant recipe.
|
|
7413
|
+
*
|
|
7414
|
+
* Slots:
|
|
7415
|
+
* - `root` — outer `<nav>` container
|
|
7416
|
+
* - `summary` — "Showing X-Y of Z" text
|
|
7417
|
+
* - `list` — `<ul>` of page buttons
|
|
7418
|
+
* - `pageButton` — individual page number button
|
|
7419
|
+
* - `activeButton` — modifier applied to the current page
|
|
7420
|
+
* - `navButton` — first/prev/next/last buttons
|
|
7421
|
+
* - `ellipsis` — `…` rendered when range gap
|
|
7422
|
+
* - `pageSizeSelector` — native `<select>` for page size
|
|
7423
|
+
* - `jumpInput` — direct page jump `<input>`
|
|
7424
|
+
*
|
|
7425
|
+
* Variant axes:
|
|
7426
|
+
* - `variant` — `default` (full kit) | `compact` (number buttons +
|
|
7427
|
+
* prev/next only) | `minimal` ("X / Y pages" + prev/next only).
|
|
7428
|
+
* - `size` — `sm` | `md` (default) | `lg`. Drives padding + text
|
|
7429
|
+
* size on the buttons.
|
|
7430
|
+
*/ const paginationVariants = tv({
|
|
7431
|
+
slots: {
|
|
7432
|
+
root: 'flex flex-wrap items-center gap-3',
|
|
7433
|
+
summary: 'text-neutral-600 dark:text-neutral-400',
|
|
7434
|
+
list: 'inline-flex items-center gap-1',
|
|
7435
|
+
pageButton: [
|
|
7436
|
+
'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',
|
|
7441
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
|
7442
|
+
'transition-colors motion-reduce:transition-none',
|
|
7443
|
+
'disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none'
|
|
7444
|
+
],
|
|
7445
|
+
activeButton: [
|
|
7446
|
+
'bg-primary-500 text-white border-primary-500',
|
|
7447
|
+
'hover:bg-primary-600 dark:hover:bg-primary-400'
|
|
7448
|
+
],
|
|
7449
|
+
navButton: [
|
|
7450
|
+
'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',
|
|
7455
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
|
7456
|
+
'transition-colors motion-reduce:transition-none',
|
|
7457
|
+
'disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none'
|
|
7458
|
+
],
|
|
7459
|
+
ellipsis: 'inline-flex items-center justify-center text-neutral-500 px-1',
|
|
7460
|
+
pageSizeSelector: [
|
|
7461
|
+
'inline-flex items-center gap-2',
|
|
7462
|
+
'text-neutral-700 dark:text-neutral-300'
|
|
7463
|
+
],
|
|
7464
|
+
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',
|
|
7468
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
|
7469
|
+
'transition-colors motion-reduce:transition-none'
|
|
7470
|
+
]
|
|
7471
|
+
},
|
|
7472
|
+
variants: {
|
|
7473
|
+
// Note: variant-driven visibility is enforced via conditional JSX
|
|
7474
|
+
// rendering (in `Pagination.tsx`), NOT via `hidden` Tailwind class.
|
|
7475
|
+
// Reason: `display: none` keeps the elements in the DOM (and in the
|
|
7476
|
+
// a11y tree depending on how it's applied), which leaks into
|
|
7477
|
+
// testing-library `queryByText` and feels semantically wrong for
|
|
7478
|
+
// "this UI piece doesn't exist in this variant". So compact and
|
|
7479
|
+
// minimal slots stay empty here.
|
|
7480
|
+
variant: {
|
|
7481
|
+
default: {},
|
|
7482
|
+
compact: {},
|
|
7483
|
+
minimal: {}
|
|
7484
|
+
},
|
|
7485
|
+
size: {
|
|
7486
|
+
sm: {
|
|
7487
|
+
summary: 'text-xs',
|
|
7488
|
+
pageButton: 'h-7 min-w-7 px-2 text-xs',
|
|
7489
|
+
navButton: 'h-7 min-w-7 px-2 text-xs',
|
|
7490
|
+
ellipsis: 'h-7 min-w-5 text-xs',
|
|
7491
|
+
pageSizeSelector: 'text-xs',
|
|
7492
|
+
jumpInput: 'h-7 px-2 text-xs w-14'
|
|
7493
|
+
},
|
|
7494
|
+
md: {
|
|
7495
|
+
summary: 'text-sm',
|
|
7496
|
+
pageButton: 'h-9 min-w-9 px-3 text-sm',
|
|
7497
|
+
navButton: 'h-9 min-w-9 px-3 text-sm',
|
|
7498
|
+
ellipsis: 'h-9 min-w-6 text-sm',
|
|
7499
|
+
pageSizeSelector: 'text-sm',
|
|
7500
|
+
jumpInput: 'h-9 px-2 text-sm w-16'
|
|
7501
|
+
},
|
|
7502
|
+
lg: {
|
|
7503
|
+
summary: 'text-base',
|
|
7504
|
+
pageButton: 'h-10 min-w-10 px-4 text-base',
|
|
7505
|
+
navButton: 'h-10 min-w-10 px-4 text-base',
|
|
7506
|
+
ellipsis: 'h-10 min-w-7 text-base',
|
|
7507
|
+
pageSizeSelector: 'text-base',
|
|
7508
|
+
jumpInput: 'h-10 px-3 text-base w-20'
|
|
7509
|
+
}
|
|
7510
|
+
}
|
|
7511
|
+
},
|
|
7512
|
+
defaultVariants: {
|
|
7513
|
+
variant: 'default',
|
|
7514
|
+
size: 'md'
|
|
7515
|
+
}
|
|
7516
|
+
});
|
|
7517
|
+
|
|
7518
|
+
/**
|
|
7519
|
+
* Compute the array of page indices (or `'ellipsis'` sentinels) to
|
|
7520
|
+
* render for a `<Pagination>`.
|
|
7521
|
+
*
|
|
7522
|
+
* Algorithm (standard "siblings + boundaries + ellipsis fill"):
|
|
7523
|
+
* 1. Always show `boundaryCount` pages at the start and `boundaryCount`
|
|
7524
|
+
* at the end.
|
|
7525
|
+
* 2. Show `siblingCount` pages on each side of the current page.
|
|
7526
|
+
* 3. Fill gaps wider than 1 with `'ellipsis'`.
|
|
7527
|
+
* 4. If a gap is exactly 1 page wide, include that page directly
|
|
7528
|
+
* (no ellipsis for a single hidden number).
|
|
7529
|
+
*
|
|
7530
|
+
* Output is 1-indexed page numbers OR the literal string `'ellipsis'`.
|
|
7531
|
+
*
|
|
7532
|
+
* @example
|
|
7533
|
+
* ```ts
|
|
7534
|
+
* range(1, 10, 1, 1) // [1, 'ellipsis', 5, 'ellipsis', 10] when current=5
|
|
7535
|
+
* range(1, 5, 1, 1) // [1, 2, 3, 4, 5]
|
|
7536
|
+
* range(1, 100, 1, 1) // edges varying with current
|
|
7537
|
+
* ```
|
|
7538
|
+
*/ function computePageRange(currentPage, totalPages, siblingCount, boundaryCount) {
|
|
7539
|
+
if (totalPages <= 0) return [];
|
|
7540
|
+
if (totalPages === 1) return [
|
|
7541
|
+
1
|
|
7542
|
+
];
|
|
7543
|
+
// Budget: if `totalPages` fits within boundaries + siblings + current
|
|
7544
|
+
// (plus the 2 hypothetical ellipsis slots), don't bother with ellipses
|
|
7545
|
+
// at all — list every page. Avoids the "[1, 2, …, 5]" footgun where
|
|
7546
|
+
// the user expected to see 3 and 4 explicitly.
|
|
7547
|
+
const minPagesForEllipsis = 2 * boundaryCount + 2 * siblingCount + 3;
|
|
7548
|
+
if (totalPages <= minPagesForEllipsis) {
|
|
7549
|
+
return range(1, totalPages);
|
|
7550
|
+
}
|
|
7551
|
+
const startBoundary = range(1, Math.min(boundaryCount, totalPages));
|
|
7552
|
+
const endBoundary = range(Math.max(totalPages - boundaryCount + 1, boundaryCount + 1), totalPages);
|
|
7553
|
+
const siblingsStart = Math.max(currentPage - siblingCount, boundaryCount + 2);
|
|
7554
|
+
const siblingsEnd = Math.min(currentPage + siblingCount, totalPages - boundaryCount - 1);
|
|
7555
|
+
const siblings = range(siblingsStart, siblingsEnd);
|
|
7556
|
+
const showStartEllipsis = siblingsStart > boundaryCount + 2;
|
|
7557
|
+
const showEndEllipsis = siblingsEnd < totalPages - boundaryCount - 1;
|
|
7558
|
+
const out = [
|
|
7559
|
+
...startBoundary
|
|
7560
|
+
];
|
|
7561
|
+
if (showStartEllipsis) {
|
|
7562
|
+
out.push('ellipsis');
|
|
7563
|
+
} else if (siblingsStart === boundaryCount + 2) {
|
|
7564
|
+
// Single hidden page → fill it directly instead of an ellipsis.
|
|
7565
|
+
out.push(boundaryCount + 1);
|
|
7566
|
+
}
|
|
7567
|
+
out.push(...siblings);
|
|
7568
|
+
if (showEndEllipsis) {
|
|
7569
|
+
out.push('ellipsis');
|
|
7570
|
+
} else if (siblingsEnd === totalPages - boundaryCount - 1) {
|
|
7571
|
+
out.push(totalPages - boundaryCount);
|
|
7572
|
+
}
|
|
7573
|
+
out.push(...endBoundary);
|
|
7574
|
+
// Dedupe while preserving order (overlapping ranges produce duplicates
|
|
7575
|
+
// when totalPages is small). Two distinct rules:
|
|
7576
|
+
// 1. Number entries: keep only the first occurrence.
|
|
7577
|
+
// 2. Ellipsis entries: collapse adjacent ellipses to one.
|
|
7578
|
+
const seen = new Set();
|
|
7579
|
+
const deduped = [];
|
|
7580
|
+
let prevWasEllipsis = false;
|
|
7581
|
+
for (const entry of out){
|
|
7582
|
+
if (entry === 'ellipsis') {
|
|
7583
|
+
if (!prevWasEllipsis) {
|
|
7584
|
+
deduped.push('ellipsis');
|
|
7585
|
+
prevWasEllipsis = true;
|
|
7586
|
+
}
|
|
7587
|
+
continue;
|
|
7588
|
+
}
|
|
7589
|
+
if (!seen.has(entry)) {
|
|
7590
|
+
seen.add(entry);
|
|
7591
|
+
deduped.push(entry);
|
|
7592
|
+
prevWasEllipsis = false;
|
|
7593
|
+
}
|
|
7594
|
+
}
|
|
7595
|
+
return deduped;
|
|
7596
|
+
}
|
|
7597
|
+
function range(start, end) {
|
|
7598
|
+
if (end < start) return [];
|
|
7599
|
+
return Array.from({
|
|
7600
|
+
length: end - start + 1
|
|
7601
|
+
}, (_, i)=>start + i);
|
|
7602
|
+
}
|
|
7603
|
+
|
|
7604
|
+
const DEFAULT_LABELS$1 = {
|
|
7605
|
+
first: 'First',
|
|
7606
|
+
prev: 'Previous',
|
|
7607
|
+
next: 'Next',
|
|
7608
|
+
last: 'Last',
|
|
7609
|
+
page: 'Page',
|
|
7610
|
+
of: 'of',
|
|
7611
|
+
showing: 'Showing',
|
|
7612
|
+
perPage: 'per page',
|
|
7613
|
+
goToPage: 'Go to page'
|
|
7614
|
+
};
|
|
7615
|
+
const DEFAULT_PAGE_SIZE_OPTIONS = [
|
|
7616
|
+
10,
|
|
7617
|
+
20,
|
|
7618
|
+
50,
|
|
7619
|
+
100
|
|
7620
|
+
];
|
|
7621
|
+
/**
|
|
7622
|
+
* Dashforge TW `<Pagination>` — controlled pagination primitive.
|
|
7623
|
+
*
|
|
7624
|
+
* Three variants:
|
|
7625
|
+
* - `default` — page-size selector + summary + page numbers + nav buttons + jump input
|
|
7626
|
+
* - `compact` — page numbers + nav buttons only (no summary, no selector, no jump)
|
|
7627
|
+
* - `minimal` — "Page X of Y" + prev/next only
|
|
7628
|
+
*
|
|
7629
|
+
* Always controlled (no internal state besides the jump-input draft).
|
|
7630
|
+
*
|
|
7631
|
+
* A11Y:
|
|
7632
|
+
* - `<nav aria-label="Pagination">` root
|
|
7633
|
+
* - `aria-current="page"` on the active page button
|
|
7634
|
+
* - `aria-label` on every interactive element
|
|
7635
|
+
*
|
|
7636
|
+
* @example
|
|
7637
|
+
* ```tsx
|
|
7638
|
+
* const [page, setPage] = useState(1);
|
|
7639
|
+
* const [pageSize, setPageSize] = useState(20);
|
|
7640
|
+
*
|
|
7641
|
+
* <Pagination
|
|
7642
|
+
* page={page}
|
|
7643
|
+
* pageSize={pageSize}
|
|
7644
|
+
* totalCount={1437}
|
|
7645
|
+
* onPageChange={setPage}
|
|
7646
|
+
* onPageSizeChange={setPageSize}
|
|
7647
|
+
* />
|
|
7648
|
+
* ```
|
|
7649
|
+
*/ function Pagination(props) {
|
|
7650
|
+
var _slotProps_root, _slotProps_summary, _slotProps_summary1, _slotProps_list, _slotProps_navButton, _slotProps_navButton1, _slotProps_navButton2, _slotProps_navButton3, _slotProps_pageSizeSelector, _slotProps_jumpInput;
|
|
7651
|
+
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$1, labelsProp);
|
|
7653
|
+
const v = paginationVariants({
|
|
7654
|
+
variant,
|
|
7655
|
+
size
|
|
7656
|
+
});
|
|
7657
|
+
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
|
7658
|
+
const safePage = Math.min(Math.max(1, page), totalPages);
|
|
7659
|
+
const pages = computePageRange(safePage, totalPages, siblingCount, boundaryCount);
|
|
7660
|
+
// Summary range
|
|
7661
|
+
const firstItem = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1;
|
|
7662
|
+
const lastItem = Math.min(safePage * pageSize, totalCount);
|
|
7663
|
+
// Jump input local state (commit on Enter or blur)
|
|
7664
|
+
const [jumpDraft, setJumpDraft] = useState('');
|
|
7665
|
+
const goTo = useCallback((nextPage)=>{
|
|
7666
|
+
if (disabled) return;
|
|
7667
|
+
const clamped = Math.min(Math.max(1, nextPage), totalPages);
|
|
7668
|
+
if (clamped !== safePage) onPageChange(clamped);
|
|
7669
|
+
}, [
|
|
7670
|
+
disabled,
|
|
7671
|
+
totalPages,
|
|
7672
|
+
safePage,
|
|
7673
|
+
onPageChange
|
|
7674
|
+
]);
|
|
7675
|
+
const commitJump = useCallback(()=>{
|
|
7676
|
+
const parsed = Number(jumpDraft);
|
|
7677
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
7678
|
+
setJumpDraft('');
|
|
7679
|
+
return;
|
|
7680
|
+
}
|
|
7681
|
+
goTo(Math.floor(parsed));
|
|
7682
|
+
setJumpDraft('');
|
|
7683
|
+
}, [
|
|
7684
|
+
jumpDraft,
|
|
7685
|
+
goTo
|
|
7686
|
+
]);
|
|
7687
|
+
const onJumpKeyDown = useCallback((e)=>{
|
|
7688
|
+
if (e.key === 'Enter') {
|
|
7689
|
+
e.preventDefault();
|
|
7690
|
+
commitJump();
|
|
7691
|
+
}
|
|
7692
|
+
}, [
|
|
7693
|
+
commitJump
|
|
7694
|
+
]);
|
|
7695
|
+
const handlePageSize = useCallback((e)=>{
|
|
7696
|
+
if (!onPageSizeChange) return;
|
|
7697
|
+
const next = Number(e.target.value);
|
|
7698
|
+
if (Number.isFinite(next) && next > 0) onPageSizeChange(next);
|
|
7699
|
+
}, [
|
|
7700
|
+
onPageSizeChange
|
|
7701
|
+
]);
|
|
7702
|
+
const isMinimal = variant === 'minimal';
|
|
7703
|
+
return jsxs("nav", {
|
|
7704
|
+
"aria-label": "Pagination",
|
|
7705
|
+
className: cn(v.root(), sx, slotProps == null ? void 0 : (_slotProps_root = slotProps.root) == null ? void 0 : _slotProps_root.className),
|
|
7706
|
+
children: [
|
|
7707
|
+
variant === 'default' && jsxs("span", {
|
|
7708
|
+
className: cn(v.summary(), slotProps == null ? void 0 : (_slotProps_summary = slotProps.summary) == null ? void 0 : _slotProps_summary.className),
|
|
7709
|
+
children: [
|
|
7710
|
+
labels.showing,
|
|
7711
|
+
" ",
|
|
7712
|
+
firstItem,
|
|
7713
|
+
"-",
|
|
7714
|
+
lastItem,
|
|
7715
|
+
" ",
|
|
7716
|
+
labels.of,
|
|
7717
|
+
" ",
|
|
7718
|
+
totalCount
|
|
7719
|
+
]
|
|
7720
|
+
}),
|
|
7721
|
+
isMinimal && jsxs("span", {
|
|
7722
|
+
className: cn(v.summary(), 'text-sm', slotProps == null ? void 0 : (_slotProps_summary1 = slotProps.summary) == null ? void 0 : _slotProps_summary1.className),
|
|
7723
|
+
children: [
|
|
7724
|
+
labels.page,
|
|
7725
|
+
" ",
|
|
7726
|
+
safePage,
|
|
7727
|
+
" ",
|
|
7728
|
+
labels.of,
|
|
7729
|
+
" ",
|
|
7730
|
+
totalPages
|
|
7731
|
+
]
|
|
7732
|
+
}),
|
|
7733
|
+
jsxs("ul", {
|
|
7734
|
+
className: cn(v.list(), slotProps == null ? void 0 : (_slotProps_list = slotProps.list) == null ? void 0 : _slotProps_list.className),
|
|
7735
|
+
children: [
|
|
7736
|
+
showFirstLast && !isMinimal && jsx("li", {
|
|
7737
|
+
children: jsx("button", {
|
|
7738
|
+
type: "button",
|
|
7739
|
+
"aria-label": labels.first,
|
|
7740
|
+
disabled: disabled || safePage === 1,
|
|
7741
|
+
onClick: ()=>goTo(1),
|
|
7742
|
+
className: cn(v.navButton(), slotProps == null ? void 0 : (_slotProps_navButton = slotProps.navButton) == null ? void 0 : _slotProps_navButton.className),
|
|
7743
|
+
children: "\u00AB"
|
|
7744
|
+
})
|
|
7745
|
+
}),
|
|
7746
|
+
jsx("li", {
|
|
7747
|
+
children: jsx("button", {
|
|
7748
|
+
type: "button",
|
|
7749
|
+
"aria-label": labels.prev,
|
|
7750
|
+
disabled: disabled || safePage === 1,
|
|
7751
|
+
onClick: ()=>goTo(safePage - 1),
|
|
7752
|
+
className: cn(v.navButton(), slotProps == null ? void 0 : (_slotProps_navButton1 = slotProps.navButton) == null ? void 0 : _slotProps_navButton1.className),
|
|
7753
|
+
children: "\u2039"
|
|
7754
|
+
})
|
|
7755
|
+
}),
|
|
7756
|
+
!isMinimal && pages.map((entry, idx)=>{
|
|
7757
|
+
var _slotProps_ellipsis, _slotProps_pageButton, _slotProps_activeButton;
|
|
7758
|
+
return entry === 'ellipsis' ? jsx("li", {
|
|
7759
|
+
children: jsx("span", {
|
|
7760
|
+
"aria-hidden": "true",
|
|
7761
|
+
className: cn(v.ellipsis(), slotProps == null ? void 0 : (_slotProps_ellipsis = slotProps.ellipsis) == null ? void 0 : _slotProps_ellipsis.className),
|
|
7762
|
+
children: "\u2026"
|
|
7763
|
+
})
|
|
7764
|
+
}, `e-${idx}`) : jsx("li", {
|
|
7765
|
+
children: jsx("button", {
|
|
7766
|
+
type: "button",
|
|
7767
|
+
"aria-label": `${labels.page} ${entry}`,
|
|
7768
|
+
"aria-current": entry === safePage ? 'page' : undefined,
|
|
7769
|
+
disabled: disabled,
|
|
7770
|
+
onClick: ()=>goTo(entry),
|
|
7771
|
+
className: cn(v.pageButton(), entry === safePage && v.activeButton(), slotProps == null ? void 0 : (_slotProps_pageButton = slotProps.pageButton) == null ? void 0 : _slotProps_pageButton.className, entry === safePage && (slotProps == null ? void 0 : (_slotProps_activeButton = slotProps.activeButton) == null ? void 0 : _slotProps_activeButton.className)),
|
|
7772
|
+
children: entry
|
|
7773
|
+
})
|
|
7774
|
+
}, entry);
|
|
7775
|
+
}),
|
|
7776
|
+
jsx("li", {
|
|
7777
|
+
children: jsx("button", {
|
|
7778
|
+
type: "button",
|
|
7779
|
+
"aria-label": labels.next,
|
|
7780
|
+
disabled: disabled || safePage === totalPages,
|
|
7781
|
+
onClick: ()=>goTo(safePage + 1),
|
|
7782
|
+
className: cn(v.navButton(), slotProps == null ? void 0 : (_slotProps_navButton2 = slotProps.navButton) == null ? void 0 : _slotProps_navButton2.className),
|
|
7783
|
+
children: "\u203A"
|
|
7784
|
+
})
|
|
7785
|
+
}),
|
|
7786
|
+
showFirstLast && !isMinimal && jsx("li", {
|
|
7787
|
+
children: jsx("button", {
|
|
7788
|
+
type: "button",
|
|
7789
|
+
"aria-label": labels.last,
|
|
7790
|
+
disabled: disabled || safePage === totalPages,
|
|
7791
|
+
onClick: ()=>goTo(totalPages),
|
|
7792
|
+
className: cn(v.navButton(), slotProps == null ? void 0 : (_slotProps_navButton3 = slotProps.navButton) == null ? void 0 : _slotProps_navButton3.className),
|
|
7793
|
+
children: "\u00BB"
|
|
7794
|
+
})
|
|
7795
|
+
})
|
|
7796
|
+
]
|
|
7797
|
+
}),
|
|
7798
|
+
variant === 'default' && onPageSizeChange && jsxs("label", {
|
|
7799
|
+
className: cn(v.pageSizeSelector(), slotProps == null ? void 0 : (_slotProps_pageSizeSelector = slotProps.pageSizeSelector) == null ? void 0 : _slotProps_pageSizeSelector.className),
|
|
7800
|
+
children: [
|
|
7801
|
+
jsx("span", {
|
|
7802
|
+
className: "sr-only",
|
|
7803
|
+
children: labels.perPage
|
|
7804
|
+
}),
|
|
7805
|
+
jsx("select", {
|
|
7806
|
+
value: pageSize,
|
|
7807
|
+
onChange: handlePageSize,
|
|
7808
|
+
disabled: disabled,
|
|
7809
|
+
"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'),
|
|
7811
|
+
children: pageSizeOptions.map((opt)=>jsx("option", {
|
|
7812
|
+
value: opt,
|
|
7813
|
+
children: opt
|
|
7814
|
+
}, opt))
|
|
7815
|
+
}),
|
|
7816
|
+
jsx("span", {
|
|
7817
|
+
children: labels.perPage
|
|
7818
|
+
})
|
|
7819
|
+
]
|
|
7820
|
+
}),
|
|
7821
|
+
variant === 'default' && showJumpInput && jsxs("label", {
|
|
7822
|
+
className: "inline-flex items-center gap-2",
|
|
7823
|
+
children: [
|
|
7824
|
+
jsx("span", {
|
|
7825
|
+
className: "sr-only",
|
|
7826
|
+
children: labels.goToPage
|
|
7827
|
+
}),
|
|
7828
|
+
jsx("input", {
|
|
7829
|
+
type: "number",
|
|
7830
|
+
min: 1,
|
|
7831
|
+
max: totalPages,
|
|
7832
|
+
value: jumpDraft,
|
|
7833
|
+
placeholder: labels.goToPage,
|
|
7834
|
+
disabled: disabled,
|
|
7835
|
+
onChange: (e)=>setJumpDraft(e.target.value),
|
|
7836
|
+
onKeyDown: onJumpKeyDown,
|
|
7837
|
+
onBlur: commitJump,
|
|
7838
|
+
"aria-label": labels.goToPage,
|
|
7839
|
+
className: cn(v.jumpInput(), slotProps == null ? void 0 : (_slotProps_jumpInput = slotProps.jumpInput) == null ? void 0 : _slotProps_jumpInput.className)
|
|
7840
|
+
})
|
|
7841
|
+
]
|
|
7842
|
+
})
|
|
7843
|
+
]
|
|
7844
|
+
});
|
|
7845
|
+
}
|
|
7846
|
+
|
|
7847
|
+
/**
|
|
7848
|
+
* `<Table>` variant recipe.
|
|
7849
|
+
*
|
|
7850
|
+
* Slots:
|
|
7851
|
+
* - `root` — outer wrapper
|
|
7852
|
+
* - `toolbar` — search + filter chips row above table
|
|
7853
|
+
* - `search` — search input
|
|
7854
|
+
* - `scroll` — `<div class="overflow-auto">` wrapping the table
|
|
7855
|
+
* - `table` — `<table>` element
|
|
7856
|
+
* - `thead` — `<thead>` (sticky positioning here)
|
|
7857
|
+
* - `tbody` — `<tbody>`
|
|
7858
|
+
* - `headerRow` — `<tr>` inside `<thead>`
|
|
7859
|
+
* - `headerCell` — `<th>` cell
|
|
7860
|
+
* - `headerCellButton` — `<button>` inside sortable `<th>`
|
|
7861
|
+
* - `row` — `<tr>` inside `<tbody>` (data row)
|
|
7862
|
+
* - `cell` — `<td>` data cell
|
|
7863
|
+
* - `selectionCell` — first `<td>` when selection mode active
|
|
7864
|
+
* - `expandToggleCell` — second `<td>` when expandable active
|
|
7865
|
+
* - `expandedRow` — full-width `<tr>` rendered after an expanded row
|
|
7866
|
+
* - `rowActionsCell` — last `<td>` when `rowActions` provided
|
|
7867
|
+
* - `emptyState` — placeholder when rows is empty
|
|
7868
|
+
* - `loadingOverlay` — skeleton overlay when loading
|
|
7869
|
+
* - `bulkActionFooter` — sticky footer with bulk actions
|
|
7870
|
+
*
|
|
7871
|
+
* Variant axes:
|
|
7872
|
+
* - `variant` — visual row-division style:
|
|
7873
|
+
* - `plain` — minimal separators
|
|
7874
|
+
* - `lines` — Stripe-style thin horizontal dividers (default)
|
|
7875
|
+
* - `striped` — alternating row backgrounds (zebra)
|
|
7876
|
+
* - `bordered` — full cell borders
|
|
7877
|
+
* - `card` — each row rendered as a separate surface card
|
|
7878
|
+
* - `size` — `sm` / `md` (default) / `lg`
|
|
7879
|
+
* - `density` — `compact` (40px row) / `comfortable` (default 48px) / `spacious` (56px)
|
|
7880
|
+
*
|
|
7881
|
+
* Smart-default alignment (per column) is applied by the Table
|
|
7882
|
+
* component code, NOT here — it depends on the runtime-detected
|
|
7883
|
+
* value type, which the recipe doesn't see.
|
|
7884
|
+
*
|
|
7885
|
+
* **Dashforge identity rule** (memory: `feedback_dashforge_preset_is_identity`):
|
|
7886
|
+
* This recipe uses NO `dark:` variants on the neutral palette. The
|
|
7887
|
+
* `dashforgePreset()` CSS-variable swap auto-inverts neutral tokens
|
|
7888
|
+
* (`bg-neutral-50` is light surface in light mode, dark surface in
|
|
7889
|
+
* dark mode — same class, different physical color). Adding a
|
|
7890
|
+
* `dark:` variant on neutral palette = DOUBLE inversion = breaks
|
|
7891
|
+
* dark mode. The selected-row state uses the canonical LeftNav
|
|
7892
|
+
* `itemActive` pattern (`bg-primary-100 text-primary-900 font-medium`),
|
|
7893
|
+
* which works in both modes because the primary palette has no
|
|
7894
|
+
* inversion (both `bg` and `text` resolve to the same physical
|
|
7895
|
+
* colors regardless of mode → guaranteed contrast).
|
|
7896
|
+
*/ const tableVariants = tv({
|
|
7897
|
+
slots: {
|
|
7898
|
+
root: 'w-full',
|
|
7899
|
+
toolbar: 'flex flex-wrap items-center gap-3 mb-3',
|
|
7900
|
+
search: [
|
|
7901
|
+
'inline-flex items-center gap-2',
|
|
7902
|
+
'w-full max-w-sm rounded-md',
|
|
7903
|
+
'border border-neutral-300',
|
|
7904
|
+
// bg-neutral-50 auto-inverts via CSS var swap (light in light mode,
|
|
7905
|
+
// dark in dark mode). Same semantic as TextField wrapper.
|
|
7906
|
+
'bg-neutral-50',
|
|
7907
|
+
'focus-within:ring-2 focus-within:ring-primary-500',
|
|
7908
|
+
'transition-colors motion-reduce:transition-none'
|
|
7909
|
+
],
|
|
7910
|
+
scroll: 'relative w-full overflow-auto rounded-md',
|
|
7911
|
+
table: 'w-full caption-bottom text-left',
|
|
7912
|
+
// thead matches the page surface (auto-inverts). When sticky the
|
|
7913
|
+
// header sits opaque against the scroll content beneath it.
|
|
7914
|
+
thead: 'bg-neutral-50',
|
|
7915
|
+
tbody: '',
|
|
7916
|
+
headerRow: '',
|
|
7917
|
+
headerCell: [
|
|
7918
|
+
// Less prominent than the body — secondary-text intent
|
|
7919
|
+
// (auto-inverts: dark grey in light mode, light grey in dark).
|
|
7920
|
+
'text-neutral-700',
|
|
7921
|
+
'font-semibold whitespace-nowrap select-none',
|
|
7922
|
+
'border-b border-neutral-200'
|
|
7923
|
+
],
|
|
7924
|
+
headerCellButton: [
|
|
7925
|
+
'group inline-flex items-center gap-1 select-none',
|
|
7926
|
+
// hover increases prominence (auto-inverts).
|
|
7927
|
+
'hover:text-neutral-900',
|
|
7928
|
+
'focus:outline-none focus-visible:underline',
|
|
7929
|
+
'transition-colors motion-reduce:transition-none'
|
|
7930
|
+
],
|
|
7931
|
+
row: [
|
|
7932
|
+
'group/row transition-colors motion-reduce:transition-none',
|
|
7933
|
+
// bg-neutral-100 = "elevated above surface" in both modes via
|
|
7934
|
+
// auto-inversion. Slightly visible against the table bg.
|
|
7935
|
+
'hover:bg-neutral-100',
|
|
7936
|
+
// Canonical LeftNav `itemActive` pattern — primary palette
|
|
7937
|
+
// doesn't invert, so dark-navy text on light-blue bg in BOTH
|
|
7938
|
+
// modes. Guaranteed contrast.
|
|
7939
|
+
'data-[selected=true]:bg-primary-100',
|
|
7940
|
+
'data-[selected=true]:text-primary-900',
|
|
7941
|
+
'data-[selected=true]:font-medium'
|
|
7942
|
+
],
|
|
7943
|
+
cell: [
|
|
7944
|
+
// Auto-inverts — dark text in light mode, light text in dark mode.
|
|
7945
|
+
'text-neutral-900',
|
|
7946
|
+
'border-b border-neutral-200',
|
|
7947
|
+
'align-middle'
|
|
7948
|
+
],
|
|
7949
|
+
selectionCell: 'w-10 text-center',
|
|
7950
|
+
expandToggleCell: 'w-10 text-center',
|
|
7951
|
+
// Expanded panel uses one tier of elevation above the page surface.
|
|
7952
|
+
expandedRow: 'bg-neutral-100',
|
|
7953
|
+
rowActionsCell: [
|
|
7954
|
+
'w-12 text-right',
|
|
7955
|
+
// Row actions hidden until row is hovered or focus-within
|
|
7956
|
+
// (Pencil & Paper UX pattern: reveal-on-hover reduces visual density).
|
|
7957
|
+
'opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100',
|
|
7958
|
+
'transition-opacity motion-reduce:transition-none'
|
|
7959
|
+
],
|
|
7960
|
+
emptyState: [
|
|
7961
|
+
'py-12 text-center text-neutral-500'
|
|
7962
|
+
],
|
|
7963
|
+
loadingOverlay: [
|
|
7964
|
+
'pointer-events-none'
|
|
7965
|
+
],
|
|
7966
|
+
bulkActionFooter: [
|
|
7967
|
+
'sticky bottom-0 z-20',
|
|
7968
|
+
'flex flex-wrap items-center justify-between gap-3',
|
|
7969
|
+
'px-3 py-2 mt-0',
|
|
7970
|
+
'border-t border-neutral-200',
|
|
7971
|
+
// Surface-elevated bg + shadow ladder.
|
|
7972
|
+
'bg-neutral-50 shadow-md'
|
|
7973
|
+
]
|
|
7974
|
+
},
|
|
7975
|
+
variants: {
|
|
7976
|
+
variant: {
|
|
7977
|
+
plain: {},
|
|
7978
|
+
lines: {
|
|
7979
|
+
},
|
|
7980
|
+
striped: {
|
|
7981
|
+
// bg-neutral-100/60 = subtle elevation tier (auto-inverts).
|
|
7982
|
+
row: 'odd:bg-neutral-100/60'
|
|
7983
|
+
},
|
|
7984
|
+
bordered: {
|
|
7985
|
+
table: 'border border-neutral-200',
|
|
7986
|
+
cell: 'border-r last:border-r-0 border-neutral-200',
|
|
7987
|
+
headerCell: 'border-r last:border-r-0 border-neutral-200'
|
|
7988
|
+
},
|
|
7989
|
+
card: {
|
|
7990
|
+
scroll: 'overflow-visible',
|
|
7991
|
+
table: 'border-separate border-spacing-y-2',
|
|
7992
|
+
row: [
|
|
7993
|
+
'rounded-lg shadow-sm',
|
|
7994
|
+
// Each row becomes an elevated card above the surface.
|
|
7995
|
+
'bg-neutral-50'
|
|
7996
|
+
],
|
|
7997
|
+
cell: 'border-b-0 first:rounded-l-lg last:rounded-r-lg',
|
|
7998
|
+
headerCell: 'border-b-0'
|
|
7999
|
+
}
|
|
8000
|
+
},
|
|
8001
|
+
size: {
|
|
8002
|
+
sm: {
|
|
8003
|
+
headerCell: 'text-xs',
|
|
8004
|
+
cell: 'text-xs',
|
|
8005
|
+
search: 'text-xs'
|
|
8006
|
+
},
|
|
8007
|
+
md: {
|
|
8008
|
+
headerCell: 'text-sm',
|
|
8009
|
+
cell: 'text-sm',
|
|
8010
|
+
search: 'text-sm'
|
|
8011
|
+
},
|
|
8012
|
+
lg: {
|
|
8013
|
+
headerCell: 'text-base',
|
|
8014
|
+
cell: 'text-base',
|
|
8015
|
+
search: 'text-base'
|
|
8016
|
+
}
|
|
8017
|
+
},
|
|
8018
|
+
density: {
|
|
8019
|
+
// Pencil & Paper UX research: 40px / 48px / 56px row height.
|
|
8020
|
+
compact: {
|
|
8021
|
+
headerCell: 'px-2 py-1',
|
|
8022
|
+
cell: 'px-2 py-1'
|
|
8023
|
+
},
|
|
8024
|
+
comfortable: {
|
|
8025
|
+
headerCell: 'px-3 py-2',
|
|
8026
|
+
cell: 'px-3 py-2'
|
|
8027
|
+
},
|
|
8028
|
+
spacious: {
|
|
8029
|
+
headerCell: 'px-4 py-3',
|
|
8030
|
+
cell: 'px-4 py-3'
|
|
8031
|
+
}
|
|
8032
|
+
},
|
|
8033
|
+
stickyHeader: {
|
|
8034
|
+
true: {
|
|
8035
|
+
thead: 'sticky top-0 z-10'
|
|
8036
|
+
},
|
|
8037
|
+
false: {}
|
|
8038
|
+
}
|
|
8039
|
+
},
|
|
8040
|
+
defaultVariants: {
|
|
8041
|
+
variant: 'lines',
|
|
8042
|
+
size: 'md',
|
|
8043
|
+
density: 'comfortable',
|
|
8044
|
+
stickyHeader: true
|
|
8045
|
+
}
|
|
8046
|
+
});
|
|
8047
|
+
|
|
8048
|
+
/**
|
|
8049
|
+
* Generic "controllable" state hook — switches between controlled
|
|
8050
|
+
* (via parent-supplied value + setter) and uncontrolled (internal
|
|
8051
|
+
* `useState`).
|
|
8052
|
+
*
|
|
8053
|
+
* - If `controlledValue !== undefined`, the hook is in **controlled**
|
|
8054
|
+
* mode: the internal state is ignored; the returned setter calls
|
|
8055
|
+
* `onControlledChange` only.
|
|
8056
|
+
* - Otherwise it's in **uncontrolled** mode: internal `useState`,
|
|
8057
|
+
* defaulting to `defaultValue`. The optional `onControlledChange`
|
|
8058
|
+
* is still fired on every change so the parent can react.
|
|
8059
|
+
*
|
|
8060
|
+
* The control mode is determined per-render. Mixing modes (passing
|
|
8061
|
+
* `controlledValue` undefined sometimes) is a consumer bug — we don't
|
|
8062
|
+
* try to "remember" the last mode (React's rules-of-hooks make that
|
|
8063
|
+
* unsafe).
|
|
8064
|
+
*/ function useControllableState(args) {
|
|
8065
|
+
const { controlledValue, defaultValue, onChange } = args;
|
|
8066
|
+
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
|
|
8067
|
+
const isControlled = controlledValue !== undefined;
|
|
8068
|
+
const value = isControlled ? controlledValue : uncontrolledValue;
|
|
8069
|
+
// Stable ref to `onChange` so the setter doesn't change identity
|
|
8070
|
+
// every render (consumer can pass an inline lambda safely).
|
|
8071
|
+
const onChangeRef = useRef(onChange);
|
|
8072
|
+
onChangeRef.current = onChange;
|
|
8073
|
+
const setValue = useCallback((next)=>{
|
|
8074
|
+
const resolved = typeof next === 'function' ? next(value) : next;
|
|
8075
|
+
if (!isControlled) {
|
|
8076
|
+
setUncontrolledValue(resolved);
|
|
8077
|
+
}
|
|
8078
|
+
onChangeRef.current == null ? void 0 : onChangeRef.current.call(onChangeRef, resolved);
|
|
8079
|
+
}, [
|
|
8080
|
+
isControlled,
|
|
8081
|
+
value
|
|
8082
|
+
]);
|
|
8083
|
+
return [
|
|
8084
|
+
value,
|
|
8085
|
+
setValue
|
|
8086
|
+
];
|
|
8087
|
+
}
|
|
8088
|
+
|
|
8089
|
+
/**
|
|
8090
|
+
* Returns a debounced version of `value` — only updates after `delayMs`
|
|
8091
|
+
* of no further changes. Used by the search input so we don't re-filter
|
|
8092
|
+
* the entire row set on every keystroke.
|
|
8093
|
+
*
|
|
8094
|
+
* `delayMs <= 0` short-circuits to the input value (no debounce —
|
|
8095
|
+
* useful for tests).
|
|
8096
|
+
*/ function useDebouncedValue(value, delayMs) {
|
|
8097
|
+
const [debounced, setDebounced] = useState(value);
|
|
8098
|
+
useEffect(()=>{
|
|
8099
|
+
if (delayMs <= 0) {
|
|
8100
|
+
setDebounced(value);
|
|
8101
|
+
return;
|
|
8102
|
+
}
|
|
8103
|
+
const timer = setTimeout(()=>setDebounced(value), delayMs);
|
|
8104
|
+
return ()=>clearTimeout(timer);
|
|
8105
|
+
}, [
|
|
8106
|
+
value,
|
|
8107
|
+
delayMs
|
|
8108
|
+
]);
|
|
8109
|
+
return debounced;
|
|
8110
|
+
}
|
|
8111
|
+
|
|
8112
|
+
/**
|
|
8113
|
+
* Walk a dotted path through an object and return the value, or
|
|
8114
|
+
* `undefined` if any segment is missing / nullish.
|
|
8115
|
+
*
|
|
8116
|
+
* Bug fix from the MUI-side Table (Sprint 4.1 motivation): the
|
|
8117
|
+
* existing wrapper typed `field: NestedKeyOf<T>` but performed a
|
|
8118
|
+
* flat `row[col.field]` lookup at runtime — nested keys silently
|
|
8119
|
+
* returned `undefined`. This helper makes the runtime match the
|
|
8120
|
+
* type contract.
|
|
8121
|
+
*
|
|
8122
|
+
* @example
|
|
8123
|
+
* ```ts
|
|
8124
|
+
* getNestedValue({ user: { name: 'Jane' } }, 'user.name') // 'Jane'
|
|
8125
|
+
* getNestedValue({ user: null }, 'user.name') // undefined
|
|
8126
|
+
* getNestedValue({}, 'a.b.c') // undefined
|
|
8127
|
+
* getNestedValue({ a: { b: 0 } }, 'a.b') // 0 (NOT undefined)
|
|
8128
|
+
* ```
|
|
8129
|
+
*/ function getNestedValue(row, path) {
|
|
8130
|
+
if (!path) return undefined;
|
|
8131
|
+
const segments = path.split('.');
|
|
8132
|
+
let current = row;
|
|
8133
|
+
for (const segment of segments){
|
|
8134
|
+
if (current == null) return undefined;
|
|
8135
|
+
if (typeof current !== 'object') return undefined;
|
|
8136
|
+
current = current[segment];
|
|
8137
|
+
}
|
|
8138
|
+
return current;
|
|
8139
|
+
}
|
|
8140
|
+
|
|
8141
|
+
/**
|
|
8142
|
+
* Stringify any value into a search-comparable form. Handles all
|
|
8143
|
+
* the edge cases that bit the MUI-side Table:
|
|
8144
|
+
*
|
|
8145
|
+
* - `null` / `undefined` → `''`
|
|
8146
|
+
* - `string` → as-is
|
|
8147
|
+
* - `number` / `bigint` → `String(value)`
|
|
8148
|
+
* - `boolean` → `'true'` / `'false'`
|
|
8149
|
+
* - `Date` → ISO 8601
|
|
8150
|
+
* - `Array` / object → `JSON.stringify` (best effort)
|
|
8151
|
+
*/ function stringifyForSearch(value) {
|
|
8152
|
+
if (value == null) return '';
|
|
8153
|
+
if (typeof value === 'string') return value;
|
|
8154
|
+
if (typeof value === 'number' || typeof value === 'bigint') return String(value);
|
|
8155
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
8156
|
+
if (value instanceof Date) return value.toISOString();
|
|
8157
|
+
if (Array.isArray(value)) {
|
|
8158
|
+
try {
|
|
8159
|
+
return value.map(stringifyForSearch).join(' ');
|
|
8160
|
+
} catch (unused) {
|
|
8161
|
+
return '';
|
|
8162
|
+
}
|
|
8163
|
+
}
|
|
8164
|
+
if (typeof value === 'object') {
|
|
8165
|
+
try {
|
|
8166
|
+
return JSON.stringify(value);
|
|
8167
|
+
} catch (unused) {
|
|
8168
|
+
return '';
|
|
8169
|
+
}
|
|
8170
|
+
}
|
|
8171
|
+
return String(value);
|
|
8172
|
+
}
|
|
8173
|
+
/**
|
|
8174
|
+
* Filters `rows` by `query` against the columns flagged
|
|
8175
|
+
* `searchable: true`. Empty / whitespace-only query is a no-op
|
|
8176
|
+
* (returns the original rows).
|
|
8177
|
+
*
|
|
8178
|
+
* Case-insensitive substring match. The caller is responsible for
|
|
8179
|
+
* debouncing the input (use `useDebouncedValue`).
|
|
8180
|
+
*/ function useTableSearch(rows, cols, query) {
|
|
8181
|
+
return useMemo(()=>{
|
|
8182
|
+
const trimmed = query.trim();
|
|
8183
|
+
if (!trimmed) return rows;
|
|
8184
|
+
const needle = trimmed.toLowerCase();
|
|
8185
|
+
const searchableCols = cols.filter((c)=>c.searchable);
|
|
8186
|
+
if (searchableCols.length === 0) return rows;
|
|
8187
|
+
return rows.filter((row)=>searchableCols.some((col)=>stringifyForSearch(getNestedValue(row, col.field)).toLowerCase().includes(needle)));
|
|
8188
|
+
}, [
|
|
8189
|
+
rows,
|
|
8190
|
+
cols,
|
|
8191
|
+
query
|
|
8192
|
+
]);
|
|
8193
|
+
}
|
|
8194
|
+
|
|
8195
|
+
/**
|
|
8196
|
+
* Filters `rows` by the per-column filter model. v1 only supports
|
|
8197
|
+
* the `contains` operator (text substring) — additional ops
|
|
8198
|
+
* (`equals`, `in`, `gte`, `lte`, `between`, `before`, `after`)
|
|
8199
|
+
* land in v1-bis once the filter UI surface for each shape is
|
|
8200
|
+
* fleshed out.
|
|
8201
|
+
*
|
|
8202
|
+
* AND semantics across columns: a row must match EVERY active
|
|
8203
|
+
* filter. Within a single filter, the op decides the predicate.
|
|
8204
|
+
*/ function useTableFilter(rows, filterModel) {
|
|
8205
|
+
return useMemo(()=>{
|
|
8206
|
+
if (!filterModel || filterModel.length === 0) return rows;
|
|
8207
|
+
return rows.filter((row)=>filterModel.every((filter)=>{
|
|
8208
|
+
const value = getNestedValue(row, filter.field);
|
|
8209
|
+
const stringValue = stringifyForSearch(value).toLowerCase();
|
|
8210
|
+
const needle = filter.value.trim().toLowerCase();
|
|
8211
|
+
if (!needle) return true; // empty filter == no-op
|
|
8212
|
+
switch(filter.op){
|
|
8213
|
+
case 'contains':
|
|
8214
|
+
return stringValue.includes(needle);
|
|
8215
|
+
default:
|
|
8216
|
+
return true;
|
|
8217
|
+
}
|
|
8218
|
+
}));
|
|
8219
|
+
}, [
|
|
8220
|
+
rows,
|
|
8221
|
+
filterModel
|
|
8222
|
+
]);
|
|
8223
|
+
}
|
|
8224
|
+
|
|
8225
|
+
/**
|
|
8226
|
+
* Default comparator. Handles the common primitive types in a
|
|
8227
|
+
* sensible way:
|
|
8228
|
+
*
|
|
8229
|
+
* - `null` / `undefined` always sort LAST (stable across asc/desc)
|
|
8230
|
+
* - `number` / `bigint` → numeric comparison
|
|
8231
|
+
* - `string` → locale-aware compare via `Intl.Collator`
|
|
8232
|
+
* - `Date` / ISO-string-date → numeric compare on epoch ms
|
|
8233
|
+
* - `boolean` → `false < true`
|
|
8234
|
+
* - mixed types fall back to string compare
|
|
8235
|
+
*
|
|
8236
|
+
* The locale collator is created once per call (cheap; cached
|
|
8237
|
+
* upstream via memoization).
|
|
8238
|
+
*/ function defaultCompare(a, b) {
|
|
8239
|
+
// Numbers
|
|
8240
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
8241
|
+
return a - b;
|
|
8242
|
+
}
|
|
8243
|
+
if (typeof a === 'bigint' && typeof b === 'bigint') {
|
|
8244
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
8245
|
+
}
|
|
8246
|
+
// Dates
|
|
8247
|
+
if (a instanceof Date && b instanceof Date) {
|
|
8248
|
+
return a.getTime() - b.getTime();
|
|
8249
|
+
}
|
|
8250
|
+
// Booleans
|
|
8251
|
+
if (typeof a === 'boolean' && typeof b === 'boolean') {
|
|
8252
|
+
return a === b ? 0 : a ? 1 : -1;
|
|
8253
|
+
}
|
|
8254
|
+
// Strings (incl. ISO date strings — natural lexical ordering matches chronology)
|
|
8255
|
+
if (typeof a === 'string' && typeof b === 'string') {
|
|
8256
|
+
return a.localeCompare(b, undefined, {
|
|
8257
|
+
numeric: true,
|
|
8258
|
+
sensitivity: 'base'
|
|
8259
|
+
});
|
|
8260
|
+
}
|
|
8261
|
+
// Fallback: stringify and compare
|
|
8262
|
+
return String(a).localeCompare(String(b), undefined, {
|
|
8263
|
+
numeric: true,
|
|
8264
|
+
sensitivity: 'base'
|
|
8265
|
+
});
|
|
8266
|
+
}
|
|
8267
|
+
/**
|
|
8268
|
+
* Applies the `sortModel` to `rows`. Returns a NEW sorted array
|
|
8269
|
+
* (does not mutate input). Stable sort guaranteed by the underlying
|
|
8270
|
+
* `Array.prototype.sort` in modern engines.
|
|
8271
|
+
*
|
|
8272
|
+
* Multi-column sort: items earlier in `sortModel` have higher
|
|
8273
|
+
* precedence. Equal values fall through to the next sort item; if
|
|
8274
|
+
* all sort items tie, the original order is preserved (stable).
|
|
8275
|
+
*/ function useTableSort(rows, cols, sortModel) {
|
|
8276
|
+
return useMemo(()=>{
|
|
8277
|
+
if (!sortModel || sortModel.length === 0) return rows;
|
|
8278
|
+
const sortConfigs = sortModel.map((item)=>{
|
|
8279
|
+
const col = cols.find((c)=>c.field === item.field);
|
|
8280
|
+
if (!col || !col.sortable) return null;
|
|
8281
|
+
const customCompare = typeof col.sortable === 'function' ? col.sortable : undefined;
|
|
8282
|
+
return {
|
|
8283
|
+
item,
|
|
8284
|
+
col,
|
|
8285
|
+
customCompare
|
|
8286
|
+
};
|
|
8287
|
+
}).filter((x)=>x != null);
|
|
8288
|
+
if (sortConfigs.length === 0) return rows;
|
|
8289
|
+
// Decorate-sort-undecorate for stability across engines.
|
|
8290
|
+
//
|
|
8291
|
+
// Per sort item: null/undefined values always sort LAST, regardless
|
|
8292
|
+
// of direction. This invariant lives outside the comparator so it's
|
|
8293
|
+
// not inverted by `-cmp` when direction is `desc`. (Without this
|
|
8294
|
+
// separation, `desc` flipped null-handling and placed nulls FIRST,
|
|
8295
|
+
// which broke user expectations — fixed in the v1 implementation.)
|
|
8296
|
+
return [
|
|
8297
|
+
...rows
|
|
8298
|
+
].sort((a, b)=>{
|
|
8299
|
+
for (const { item, customCompare } of sortConfigs){
|
|
8300
|
+
const aVal = customCompare ? a : getNestedValue(a, item.field);
|
|
8301
|
+
const bVal = customCompare ? b : getNestedValue(b, item.field);
|
|
8302
|
+
// Null-last (direction-independent)
|
|
8303
|
+
const aNull = aVal == null;
|
|
8304
|
+
const bNull = bVal == null;
|
|
8305
|
+
if (aNull && bNull) continue;
|
|
8306
|
+
if (aNull) return 1;
|
|
8307
|
+
if (bNull) return -1;
|
|
8308
|
+
const cmp = customCompare ? customCompare(a, b) : defaultCompare(aVal, bVal);
|
|
8309
|
+
if (cmp !== 0) {
|
|
8310
|
+
return item.direction === 'desc' ? -cmp : cmp;
|
|
8311
|
+
}
|
|
8312
|
+
}
|
|
8313
|
+
return 0;
|
|
8314
|
+
});
|
|
8315
|
+
}, [
|
|
8316
|
+
rows,
|
|
8317
|
+
cols,
|
|
8318
|
+
sortModel
|
|
8319
|
+
]);
|
|
8320
|
+
}
|
|
8321
|
+
/**
|
|
8322
|
+
* Three-state sort cycler: `none` → `asc` → `desc` → `none`.
|
|
8323
|
+
*
|
|
8324
|
+
* Used by the header click handler. Returns the next sort model
|
|
8325
|
+
* to apply, given the current model + the clicked field + whether
|
|
8326
|
+
* the user is multi-sorting (shift key).
|
|
8327
|
+
*/ function cycleSortFor(current, field, multi) {
|
|
8328
|
+
const existing = current.find((s)=>s.field === field);
|
|
8329
|
+
const nextDirection = !existing ? 'asc' : existing.direction === 'asc' ? 'desc' : null;
|
|
8330
|
+
if (multi) {
|
|
8331
|
+
// Multi-sort: keep other entries, update / add / remove this one.
|
|
8332
|
+
const rest = current.filter((s)=>s.field !== field);
|
|
8333
|
+
if (nextDirection == null) return rest;
|
|
8334
|
+
return [
|
|
8335
|
+
...rest,
|
|
8336
|
+
{
|
|
8337
|
+
field,
|
|
8338
|
+
direction: nextDirection
|
|
8339
|
+
}
|
|
8340
|
+
];
|
|
8341
|
+
}
|
|
8342
|
+
// Single-sort: replace the whole model.
|
|
8343
|
+
if (nextDirection == null) return [];
|
|
8344
|
+
return [
|
|
8345
|
+
{
|
|
8346
|
+
field,
|
|
8347
|
+
direction: nextDirection
|
|
8348
|
+
}
|
|
8349
|
+
];
|
|
8350
|
+
}
|
|
8351
|
+
|
|
8352
|
+
/**
|
|
8353
|
+
* Selection logic helper. Given the controllable selection state +
|
|
8354
|
+
* the setter (from `useControllableState`), exposes ergonomic
|
|
8355
|
+
* helpers the Table calls from its render path.
|
|
8356
|
+
*
|
|
8357
|
+
* - `single` mode: at most one row selected. Toggling another
|
|
8358
|
+
* replaces the previous.
|
|
8359
|
+
* - `multiple` mode: any subset selectable; toggling adds/removes.
|
|
8360
|
+
* - `none` mode: every toggle is a no-op (the Table shouldn't
|
|
8361
|
+
* render the checkbox cells anyway, but this is defensive).
|
|
8362
|
+
*/ function useTableSelection(mode, selectedRowIds, setSelectedRowIds) {
|
|
8363
|
+
const selectedSet = useMemo(()=>new Set(selectedRowIds), [
|
|
8364
|
+
selectedRowIds
|
|
8365
|
+
]);
|
|
8366
|
+
const isSelected = useCallback((rowId)=>selectedSet.has(rowId), [
|
|
8367
|
+
selectedSet
|
|
8368
|
+
]);
|
|
8369
|
+
const toggleRow = useCallback((rowId)=>{
|
|
8370
|
+
if (mode === 'none') return;
|
|
8371
|
+
if (mode === 'single') {
|
|
8372
|
+
setSelectedRowIds(selectedSet.has(rowId) ? [] : [
|
|
8373
|
+
rowId
|
|
8374
|
+
]);
|
|
8375
|
+
return;
|
|
8376
|
+
}
|
|
8377
|
+
// multiple
|
|
8378
|
+
setSelectedRowIds((prev)=>{
|
|
8379
|
+
const next = new Set(prev);
|
|
8380
|
+
if (next.has(rowId)) next.delete(rowId);
|
|
8381
|
+
else next.add(rowId);
|
|
8382
|
+
return [
|
|
8383
|
+
...next
|
|
8384
|
+
];
|
|
8385
|
+
});
|
|
8386
|
+
}, [
|
|
8387
|
+
mode,
|
|
8388
|
+
selectedSet,
|
|
8389
|
+
setSelectedRowIds
|
|
8390
|
+
]);
|
|
8391
|
+
const toggleAll = useCallback((allRowIds)=>{
|
|
8392
|
+
if (mode !== 'multiple') return;
|
|
8393
|
+
const allSelected = allRowIds.every((id)=>selectedSet.has(id));
|
|
8394
|
+
setSelectedRowIds(allSelected ? [] : [
|
|
8395
|
+
...allRowIds
|
|
8396
|
+
]);
|
|
8397
|
+
}, [
|
|
8398
|
+
mode,
|
|
8399
|
+
selectedSet,
|
|
8400
|
+
setSelectedRowIds
|
|
8401
|
+
]);
|
|
8402
|
+
const clearSelection = useCallback(()=>{
|
|
8403
|
+
setSelectedRowIds([]);
|
|
8404
|
+
}, [
|
|
8405
|
+
setSelectedRowIds
|
|
8406
|
+
]);
|
|
8407
|
+
const isAllSelected = useCallback((allRowIds)=>{
|
|
8408
|
+
if (allRowIds.length === 0) return false;
|
|
8409
|
+
return allRowIds.every((id)=>selectedSet.has(id));
|
|
8410
|
+
}, [
|
|
8411
|
+
selectedSet
|
|
8412
|
+
]);
|
|
8413
|
+
const isIndeterminate = useCallback((allRowIds)=>{
|
|
8414
|
+
if (allRowIds.length === 0) return false;
|
|
8415
|
+
const some = allRowIds.some((id)=>selectedSet.has(id));
|
|
8416
|
+
const all = allRowIds.every((id)=>selectedSet.has(id));
|
|
8417
|
+
return some && !all;
|
|
8418
|
+
}, [
|
|
8419
|
+
selectedSet
|
|
8420
|
+
]);
|
|
8421
|
+
return {
|
|
8422
|
+
selectedRowIds,
|
|
8423
|
+
isSelected,
|
|
8424
|
+
toggleRow,
|
|
8425
|
+
toggleAll,
|
|
8426
|
+
clearSelection,
|
|
8427
|
+
isAllSelected,
|
|
8428
|
+
isIndeterminate
|
|
8429
|
+
};
|
|
8430
|
+
}
|
|
8431
|
+
|
|
8432
|
+
/**
|
|
8433
|
+
* Auto-detects the primitive type of each column by scanning the
|
|
8434
|
+
* first non-null value across the supplied rows. Drives smart
|
|
8435
|
+
* defaults for `align` and `monospace` on the cell render path.
|
|
8436
|
+
*
|
|
8437
|
+
* Scans only the first N rows (default 50) for performance — if the
|
|
8438
|
+
* dataset's first 50 rows are unrepresentative, the consumer can
|
|
8439
|
+
* override per-column via explicit `align` / `monospace` props.
|
|
8440
|
+
*
|
|
8441
|
+
* Returns `Map<columnField, inferredType>`.
|
|
8442
|
+
*/ function useColumnAutoDetect(rows, cols, sampleSize = 50) {
|
|
8443
|
+
return useMemo(()=>{
|
|
8444
|
+
const result = new Map();
|
|
8445
|
+
const sample = rows.slice(0, sampleSize);
|
|
8446
|
+
for (const col of cols){
|
|
8447
|
+
const field = col.field;
|
|
8448
|
+
let inferred = 'unknown';
|
|
8449
|
+
for (const row of sample){
|
|
8450
|
+
const value = getNestedValue(row, field);
|
|
8451
|
+
if (value == null) continue;
|
|
8452
|
+
inferred = inferType(value);
|
|
8453
|
+
break; // first non-null wins
|
|
8454
|
+
}
|
|
8455
|
+
result.set(field, inferred);
|
|
8456
|
+
}
|
|
8457
|
+
return result;
|
|
8458
|
+
}, [
|
|
8459
|
+
rows,
|
|
8460
|
+
cols,
|
|
8461
|
+
sampleSize
|
|
8462
|
+
]);
|
|
8463
|
+
}
|
|
8464
|
+
function inferType(value) {
|
|
8465
|
+
if (typeof value === 'number') return 'number';
|
|
8466
|
+
if (typeof value === 'boolean') return 'boolean';
|
|
8467
|
+
if (value instanceof Date) return 'date';
|
|
8468
|
+
if (typeof value === 'string') {
|
|
8469
|
+
// Heuristic: ISO date string (YYYY-MM-DD[T...]) → 'date'
|
|
8470
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(value)) return 'date';
|
|
8471
|
+
return 'string';
|
|
8472
|
+
}
|
|
8473
|
+
return 'unknown';
|
|
8474
|
+
}
|
|
8475
|
+
/**
|
|
8476
|
+
* Resolves the effective `align` for a column, honoring explicit
|
|
8477
|
+
* `col.align` first and falling back to the inferred type.
|
|
8478
|
+
*/ function resolveAlign(col, inferred) {
|
|
8479
|
+
if (col.align) return col.align;
|
|
8480
|
+
switch(inferred){
|
|
8481
|
+
case 'number':
|
|
8482
|
+
return 'right';
|
|
8483
|
+
case 'boolean':
|
|
8484
|
+
return 'center';
|
|
8485
|
+
default:
|
|
8486
|
+
return 'left';
|
|
8487
|
+
}
|
|
8488
|
+
}
|
|
8489
|
+
/**
|
|
8490
|
+
* Resolves the effective `tabularNums` flag for a column.
|
|
8491
|
+
*
|
|
8492
|
+
* `tabular-nums` is a `font-variant-numeric` rule that makes every
|
|
8493
|
+
* digit occupy the same width — it does NOT change the font family.
|
|
8494
|
+
* Auto-applied for `number` and `date` columns so currency values
|
|
8495
|
+
* and ISO dates align cleanly. Explicit `col.tabularNums` wins.
|
|
8496
|
+
*/ function resolveTabularNums(col, inferred) {
|
|
8497
|
+
if (col.tabularNums !== undefined) return col.tabularNums;
|
|
8498
|
+
return inferred === 'number' || inferred === 'date';
|
|
8499
|
+
}
|
|
8500
|
+
/**
|
|
8501
|
+
* Resolves the effective `monospace` flag for a column.
|
|
8502
|
+
*
|
|
8503
|
+
* `font-mono` changes the font FAMILY to a monospace stack, which
|
|
8504
|
+
* would override the consumer's theme `font-sans`. The library
|
|
8505
|
+
* never auto-applies it — only an explicit `col.monospace: true`
|
|
8506
|
+
* opts in. Consumers wanting a custom mono stack configure it
|
|
8507
|
+
* through Tailwind's `theme.extend.fontFamily.mono` in their own
|
|
8508
|
+
* preset (the dashforgePreset does not own the `fontFamily` axis).
|
|
8509
|
+
*/ function resolveMonospace(col) {
|
|
8510
|
+
return col.monospace === true;
|
|
8511
|
+
}
|
|
8512
|
+
|
|
8513
|
+
const DEFAULT_LABELS = {
|
|
8514
|
+
searchPlaceholder: 'Search...',
|
|
8515
|
+
noData: 'No data',
|
|
8516
|
+
loading: 'Loading…',
|
|
8517
|
+
ariaSortNone: 'Not sorted. Click to sort ascending.',
|
|
8518
|
+
ariaSortAscending: 'Sorted ascending. Click to sort descending.',
|
|
8519
|
+
ariaSortDescending: 'Sorted descending. Click to clear sort.',
|
|
8520
|
+
ariaSelectRow: 'Select row',
|
|
8521
|
+
ariaSelectAllRows: 'Select all rows',
|
|
8522
|
+
ariaExpandRow: 'Expand row',
|
|
8523
|
+
ariaCollapseRow: 'Collapse row',
|
|
8524
|
+
selectedCount: '{count} selected',
|
|
8525
|
+
clearSelection: 'Clear selection',
|
|
8526
|
+
filterColumn: 'Filter',
|
|
8527
|
+
filterApply: 'Apply',
|
|
8528
|
+
filterClear: 'Clear',
|
|
8529
|
+
density: 'Density',
|
|
8530
|
+
densityCompact: 'Compact',
|
|
8531
|
+
densityComfortable: 'Comfortable',
|
|
8532
|
+
densitySpacious: 'Spacious'
|
|
8533
|
+
};
|
|
8534
|
+
const DEFAULT_GET_ROW_ID = (_row, index)=>String(index);
|
|
8535
|
+
/**
|
|
8536
|
+
* Dashforge TW `<Table>` — declarative-first, market-grounded table.
|
|
8537
|
+
*
|
|
8538
|
+
* Design references:
|
|
8539
|
+
* - Visual: Stripe (clean line dividers, hover row actions, sticky header)
|
|
8540
|
+
* - Density: Pencil & Paper UX research (compact / comfortable / spacious)
|
|
8541
|
+
* - A11Y: W3C WAI Table tutorial (semantic `<table>`, scope, aria-sort)
|
|
8542
|
+
* - Column model: extended from Dashforge MUI Table existing API
|
|
8543
|
+
*
|
|
8544
|
+
* Smart defaults: column types auto-detected → numbers right-aligned
|
|
8545
|
+
* + tabular-nums, dates monospace, booleans center-aligned. Override
|
|
8546
|
+
* via explicit `align` / `monospace` per column.
|
|
8547
|
+
*
|
|
8548
|
+
* State (sort / search / filter / selection / expansion) is internal
|
|
8549
|
+
* by default; pass the controlled prop + matching change handler to
|
|
8550
|
+
* lift state up — same pattern as `<TextField>` value.
|
|
8551
|
+
*
|
|
8552
|
+
* i18n: pass `header: t('users.name')` for column headers; pass
|
|
8553
|
+
* `labels={{ searchPlaceholder: t('...'), ... }}` for internal default
|
|
8554
|
+
* strings.
|
|
8555
|
+
*/ function Table(props) {
|
|
8556
|
+
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;
|
|
8557
|
+
const { rows, cols, getRowId = DEFAULT_GET_ROW_ID, // State props (controllable)
|
|
8558
|
+
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;
|
|
8559
|
+
const labels = _extends({}, DEFAULT_LABELS, labelsProp);
|
|
8560
|
+
const tableAccessState = useAccessState(tableAccess);
|
|
8561
|
+
// ───── Controllable state machinery ─────
|
|
8562
|
+
const [sortModel, setSortModel] = useControllableState({
|
|
8563
|
+
controlledValue: sortModelProp,
|
|
8564
|
+
defaultValue: defaultSortModel != null ? defaultSortModel : [],
|
|
8565
|
+
onChange: onSortChange
|
|
8566
|
+
});
|
|
8567
|
+
const [searchQuery, setSearchQuery] = useControllableState({
|
|
8568
|
+
controlledValue: searchQueryProp,
|
|
8569
|
+
defaultValue: '',
|
|
8570
|
+
onChange: onSearchQueryChange
|
|
8571
|
+
});
|
|
8572
|
+
const filterModel = filterModelProp != null ? filterModelProp : [];
|
|
8573
|
+
const [selectedRowIds, setSelectedRowIds] = useControllableState({
|
|
8574
|
+
controlledValue: selectedRowIdsProp,
|
|
8575
|
+
defaultValue: [],
|
|
8576
|
+
onChange: onSelectionChange
|
|
8577
|
+
});
|
|
8578
|
+
const [expandedRowIds, setExpandedRowIds] = useControllableState({
|
|
8579
|
+
controlledValue: expandable == null ? void 0 : expandable.expandedRowIds,
|
|
8580
|
+
defaultValue: [],
|
|
8581
|
+
onChange: expandable == null ? void 0 : expandable.onExpandChange
|
|
8582
|
+
});
|
|
8583
|
+
// ───── RBAC: filter out hidden columns ─────
|
|
8584
|
+
const visibleCols = useMemo(()=>cols.filter((col)=>!col.access || col.access.onUnauthorized !== 'hide'), [
|
|
8585
|
+
cols
|
|
8586
|
+
]);
|
|
8587
|
+
// ───── Smart-default column types ─────
|
|
8588
|
+
const columnTypes = useColumnAutoDetect(rows, visibleCols);
|
|
8589
|
+
// ───── Pipeline: search → filter → sort ─────
|
|
8590
|
+
const debouncedSearchQuery = useDebouncedValue(searchQuery, searchDebounceMs);
|
|
8591
|
+
const searchedRows = useTableSearch(rows, visibleCols, debouncedSearchQuery);
|
|
8592
|
+
const filteredRows = useTableFilter(searchedRows, filterModel);
|
|
8593
|
+
const sortedRows = useTableSort(filteredRows, visibleCols, sortModel);
|
|
8594
|
+
// ───── Selection helpers ─────
|
|
8595
|
+
const visibleRowIds = useMemo(()=>sortedRows.map((row, idx)=>getRowId(row, idx)), [
|
|
8596
|
+
sortedRows,
|
|
8597
|
+
getRowId
|
|
8598
|
+
]);
|
|
8599
|
+
const selection = useTableSelection(rowSelection, selectedRowIds, setSelectedRowIds);
|
|
8600
|
+
const selectedRows = useMemo(()=>sortedRows.filter((row, idx)=>selection.isSelected(getRowId(row, idx))), [
|
|
8601
|
+
sortedRows,
|
|
8602
|
+
selection,
|
|
8603
|
+
getRowId
|
|
8604
|
+
]);
|
|
8605
|
+
// ───── Variant recipe ─────
|
|
8606
|
+
const v = tableVariants({
|
|
8607
|
+
variant,
|
|
8608
|
+
size,
|
|
8609
|
+
density,
|
|
8610
|
+
stickyHeader
|
|
8611
|
+
});
|
|
8612
|
+
// ───── Render ─────
|
|
8613
|
+
if (!tableAccessState.visible) return null;
|
|
8614
|
+
const isTableInteractive = !tableAccessState.disabled;
|
|
8615
|
+
const showSelectionColumn = rowSelection !== 'none';
|
|
8616
|
+
const showExpandColumn = expandable != null;
|
|
8617
|
+
const showRowActionsColumn = rowActions != null;
|
|
8618
|
+
const totalColumnCount = visibleCols.length + (showSelectionColumn ? 1 : 0) + (showExpandColumn ? 1 : 0) + (showRowActionsColumn ? 1 : 0);
|
|
8619
|
+
return jsxs("div", {
|
|
8620
|
+
className: cn(v.root(), sx, slotProps == null ? void 0 : (_slotProps_root = slotProps.root) == null ? void 0 : _slotProps_root.className),
|
|
8621
|
+
"data-disabled": !isTableInteractive ? 'true' : undefined,
|
|
8622
|
+
children: [
|
|
8623
|
+
enableSearch && jsx("div", {
|
|
8624
|
+
className: cn(v.toolbar(), slotProps == null ? void 0 : (_slotProps_toolbar = slotProps.toolbar) == null ? void 0 : _slotProps_toolbar.className),
|
|
8625
|
+
children: jsxs("label", {
|
|
8626
|
+
className: cn(v.search(), slotProps == null ? void 0 : (_slotProps_search = slotProps.search) == null ? void 0 : _slotProps_search.className),
|
|
8627
|
+
children: [
|
|
8628
|
+
jsx("span", {
|
|
8629
|
+
className: "sr-only",
|
|
8630
|
+
children: labels.searchPlaceholder
|
|
8631
|
+
}),
|
|
8632
|
+
jsx(SearchIcon, {
|
|
8633
|
+
className: "ml-2 shrink-0 text-neutral-500"
|
|
8634
|
+
}),
|
|
8635
|
+
jsx("input", {
|
|
8636
|
+
type: "text",
|
|
8637
|
+
value: searchQuery,
|
|
8638
|
+
onChange: (e)=>setSearchQuery(e.target.value),
|
|
8639
|
+
placeholder: searchPlaceholder != null ? searchPlaceholder : labels.searchPlaceholder,
|
|
8640
|
+
disabled: !isTableInteractive,
|
|
8641
|
+
className: cn('flex-1 bg-transparent border-0 outline-none', 'px-2 py-1.5', // placeholder color auto-inverts via the CSS-var preset;
|
|
8642
|
+
// no `dark:` variant needed on neutral palette.
|
|
8643
|
+
'placeholder:text-neutral-400', 'disabled:opacity-50 disabled:cursor-not-allowed')
|
|
8644
|
+
})
|
|
8645
|
+
]
|
|
8646
|
+
})
|
|
8647
|
+
}),
|
|
8648
|
+
jsx("div", {
|
|
8649
|
+
className: cn(v.scroll(), slotProps == null ? void 0 : (_slotProps_scroll = slotProps.scroll) == null ? void 0 : _slotProps_scroll.className),
|
|
8650
|
+
children: jsxs("table", {
|
|
8651
|
+
className: cn(v.table(), slotProps == null ? void 0 : (_slotProps_table = slotProps.table) == null ? void 0 : _slotProps_table.className),
|
|
8652
|
+
children: [
|
|
8653
|
+
caption != null && jsx("caption", {
|
|
8654
|
+
className: cn(!showCaption && 'sr-only', 'py-2 text-sm text-neutral-500'),
|
|
8655
|
+
children: caption
|
|
8656
|
+
}),
|
|
8657
|
+
jsx("thead", {
|
|
8658
|
+
className: cn(v.thead(), slotProps == null ? void 0 : (_slotProps_thead = slotProps.thead) == null ? void 0 : _slotProps_thead.className),
|
|
8659
|
+
children: jsxs("tr", {
|
|
8660
|
+
className: cn(v.headerRow(), slotProps == null ? void 0 : (_slotProps_headerRow = slotProps.headerRow) == null ? void 0 : _slotProps_headerRow.className),
|
|
8661
|
+
children: [
|
|
8662
|
+
showSelectionColumn && jsx("th", {
|
|
8663
|
+
scope: "col",
|
|
8664
|
+
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),
|
|
8665
|
+
children: rowSelection === 'multiple' && jsx("input", {
|
|
8666
|
+
type: "checkbox",
|
|
8667
|
+
"aria-label": labels.ariaSelectAllRows,
|
|
8668
|
+
checked: selection.isAllSelected(visibleRowIds),
|
|
8669
|
+
ref: (el)=>{
|
|
8670
|
+
if (el) el.indeterminate = selection.isIndeterminate(visibleRowIds);
|
|
8671
|
+
},
|
|
8672
|
+
onChange: ()=>selection.toggleAll(visibleRowIds),
|
|
8673
|
+
disabled: !isTableInteractive,
|
|
8674
|
+
className: "cursor-pointer"
|
|
8675
|
+
})
|
|
8676
|
+
}),
|
|
8677
|
+
showExpandColumn && jsx("th", {
|
|
8678
|
+
scope: "col",
|
|
8679
|
+
"aria-hidden": "true",
|
|
8680
|
+
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)
|
|
8681
|
+
}),
|
|
8682
|
+
visibleCols.map((col)=>{
|
|
8683
|
+
var _slotProps_headerCell;
|
|
8684
|
+
return jsx(HeaderCell, {
|
|
8685
|
+
col: col,
|
|
8686
|
+
sortModel: sortModel,
|
|
8687
|
+
onSortClick: (e)=>handleSortClick(col, e, sortModel, setSortModel),
|
|
8688
|
+
inferredType: columnTypes.get(col.field),
|
|
8689
|
+
labels: labels,
|
|
8690
|
+
className: cn(v.headerCell(), slotProps == null ? void 0 : (_slotProps_headerCell = slotProps.headerCell) == null ? void 0 : _slotProps_headerCell.className),
|
|
8691
|
+
buttonClassName: v.headerCellButton(),
|
|
8692
|
+
disabled: !isTableInteractive
|
|
8693
|
+
}, col.field);
|
|
8694
|
+
}),
|
|
8695
|
+
showRowActionsColumn && jsx("th", {
|
|
8696
|
+
scope: "col",
|
|
8697
|
+
"aria-hidden": "true",
|
|
8698
|
+
className: cn(v.headerCell(), 'w-12', slotProps == null ? void 0 : (_slotProps_headerCell2 = slotProps.headerCell) == null ? void 0 : _slotProps_headerCell2.className)
|
|
8699
|
+
})
|
|
8700
|
+
]
|
|
8701
|
+
})
|
|
8702
|
+
}),
|
|
8703
|
+
jsx("tbody", {
|
|
8704
|
+
className: cn(v.tbody(), slotProps == null ? void 0 : (_slotProps_tbody = slotProps.tbody) == null ? void 0 : _slotProps_tbody.className),
|
|
8705
|
+
children: loading ? renderLoadingRows({
|
|
8706
|
+
count: loadingRowCount,
|
|
8707
|
+
visibleCols,
|
|
8708
|
+
showSelectionColumn,
|
|
8709
|
+
showExpandColumn,
|
|
8710
|
+
showRowActionsColumn,
|
|
8711
|
+
rowClass: cn(v.row(), slotProps == null ? void 0 : (_slotProps_row = slotProps.row) == null ? void 0 : _slotProps_row.className),
|
|
8712
|
+
cellClass: cn(v.cell(), slotProps == null ? void 0 : (_slotProps_cell = slotProps.cell) == null ? void 0 : _slotProps_cell.className)
|
|
8713
|
+
}) : sortedRows.length === 0 ? renderEmptyState({
|
|
8714
|
+
totalColumnCount,
|
|
8715
|
+
emptyState,
|
|
8716
|
+
fallback: labels.noData,
|
|
8717
|
+
cellClass: cn(v.cell(), v.emptyState(), slotProps == null ? void 0 : (_slotProps_emptyState = slotProps.emptyState) == null ? void 0 : _slotProps_emptyState.className)
|
|
8718
|
+
}) : sortedRows.map((row, idx)=>{
|
|
8719
|
+
var _slotProps_row, _slotProps_cell, _slotProps_cell1, _slotProps_selectionCell, _slotProps_cell2, _slotProps_expandToggleCell, _slotProps_cell3, _slotProps_rowActionsCell, _slotProps_expandedRow;
|
|
8720
|
+
const rowId = getRowId(row, idx);
|
|
8721
|
+
const isSelected = selection.isSelected(rowId);
|
|
8722
|
+
const isExpanded = expandedRowIds.includes(rowId);
|
|
8723
|
+
return jsx(Row, {
|
|
8724
|
+
row: row,
|
|
8725
|
+
rowId: rowId,
|
|
8726
|
+
rowIndex: idx,
|
|
8727
|
+
isSelected: isSelected,
|
|
8728
|
+
isExpanded: isExpanded,
|
|
8729
|
+
visibleCols: visibleCols,
|
|
8730
|
+
columnTypes: columnTypes,
|
|
8731
|
+
rowSelection: rowSelection,
|
|
8732
|
+
showSelectionColumn: showSelectionColumn,
|
|
8733
|
+
showExpandColumn: showExpandColumn,
|
|
8734
|
+
showRowActionsColumn: showRowActionsColumn,
|
|
8735
|
+
expandable: expandable,
|
|
8736
|
+
rowActions: rowActions,
|
|
8737
|
+
labels: labels,
|
|
8738
|
+
toggleRow: selection.toggleRow,
|
|
8739
|
+
toggleExpand: (id)=>setExpandedRowIds((prev)=>prev.includes(id) ? prev.filter((p)=>p !== id) : [
|
|
8740
|
+
...prev,
|
|
8741
|
+
id
|
|
8742
|
+
]),
|
|
8743
|
+
isInteractive: isTableInteractive,
|
|
8744
|
+
rowClass: cn(v.row(), slotProps == null ? void 0 : (_slotProps_row = slotProps.row) == null ? void 0 : _slotProps_row.className),
|
|
8745
|
+
cellClass: cn(v.cell(), slotProps == null ? void 0 : (_slotProps_cell = slotProps.cell) == null ? void 0 : _slotProps_cell.className),
|
|
8746
|
+
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),
|
|
8747
|
+
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),
|
|
8748
|
+
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),
|
|
8749
|
+
expandedRowClass: cn(v.expandedRow(), slotProps == null ? void 0 : (_slotProps_expandedRow = slotProps.expandedRow) == null ? void 0 : _slotProps_expandedRow.className),
|
|
8750
|
+
totalColumnCount: totalColumnCount
|
|
8751
|
+
}, rowId);
|
|
8752
|
+
})
|
|
8753
|
+
})
|
|
8754
|
+
]
|
|
8755
|
+
})
|
|
8756
|
+
}),
|
|
8757
|
+
bulkActions && selectedRows.length > 0 && jsxs("div", {
|
|
8758
|
+
className: cn(v.bulkActionFooter(), slotProps == null ? void 0 : (_slotProps_bulkActionFooter = slotProps.bulkActionFooter) == null ? void 0 : _slotProps_bulkActionFooter.className),
|
|
8759
|
+
role: "region",
|
|
8760
|
+
"aria-label": labels.selectedCount.replace('{count}', String(selectedRows.length)),
|
|
8761
|
+
children: [
|
|
8762
|
+
jsx("span", {
|
|
8763
|
+
className: "text-sm text-neutral-700",
|
|
8764
|
+
children: labels.selectedCount.replace('{count}', String(selectedRows.length))
|
|
8765
|
+
}),
|
|
8766
|
+
jsxs("div", {
|
|
8767
|
+
className: "flex items-center gap-2",
|
|
8768
|
+
children: [
|
|
8769
|
+
bulkActions(selectedRows),
|
|
8770
|
+
jsx("button", {
|
|
8771
|
+
type: "button",
|
|
8772
|
+
onClick: selection.clearSelection,
|
|
8773
|
+
className: cn(// text-neutral-500 → text-neutral-900 on hover; both auto-invert.
|
|
8774
|
+
'text-sm text-neutral-500 hover:text-neutral-900', 'underline-offset-2 hover:underline', 'focus:outline-none focus-visible:underline'),
|
|
8775
|
+
children: labels.clearSelection
|
|
8776
|
+
})
|
|
8777
|
+
]
|
|
8778
|
+
})
|
|
8779
|
+
]
|
|
8780
|
+
})
|
|
8781
|
+
]
|
|
8782
|
+
});
|
|
8783
|
+
}
|
|
8784
|
+
// ───── Header cell (with sort button) ─────
|
|
8785
|
+
function HeaderCell(props) {
|
|
8786
|
+
const { col, sortModel, onSortClick, inferredType, labels, className, buttonClassName, disabled } = props;
|
|
8787
|
+
const align = resolveAlign(col, inferredType);
|
|
8788
|
+
const alignClass = align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left';
|
|
8789
|
+
const sortEntry = sortModel.find((s)=>s.field === col.field);
|
|
8790
|
+
const ariaSort = (sortEntry == null ? void 0 : sortEntry.direction) === 'asc' ? 'ascending' : (sortEntry == null ? void 0 : sortEntry.direction) === 'desc' ? 'descending' : 'none';
|
|
8791
|
+
const headerContent = typeof col.header === 'function' ? col.header() : col.header;
|
|
8792
|
+
const cellStyle = {
|
|
8793
|
+
width: col.width != null ? `${col.width}px` : undefined,
|
|
8794
|
+
minWidth: col.minWidth != null ? `${col.minWidth}px` : undefined,
|
|
8795
|
+
flex: col.flex
|
|
8796
|
+
};
|
|
8797
|
+
if (col.sortable) {
|
|
8798
|
+
return jsx("th", {
|
|
8799
|
+
scope: "col",
|
|
8800
|
+
"aria-sort": ariaSort,
|
|
8801
|
+
className: cn(className, alignClass),
|
|
8802
|
+
style: cellStyle,
|
|
8803
|
+
children: jsxs("button", {
|
|
8804
|
+
type: "button",
|
|
8805
|
+
onClick: onSortClick,
|
|
8806
|
+
disabled: disabled,
|
|
8807
|
+
"aria-label": ariaSort === 'ascending' ? labels.ariaSortAscending : ariaSort === 'descending' ? labels.ariaSortDescending : labels.ariaSortNone,
|
|
8808
|
+
className: cn(buttonClassName, align === 'right' && 'flex-row-reverse', 'disabled:cursor-not-allowed disabled:opacity-50'),
|
|
8809
|
+
children: [
|
|
8810
|
+
jsx("span", {
|
|
8811
|
+
children: headerContent
|
|
8812
|
+
}),
|
|
8813
|
+
jsx(SortIcon, {
|
|
8814
|
+
direction: sortEntry == null ? void 0 : sortEntry.direction
|
|
8815
|
+
})
|
|
8816
|
+
]
|
|
8817
|
+
})
|
|
8818
|
+
});
|
|
8819
|
+
}
|
|
8820
|
+
return jsx("th", {
|
|
8821
|
+
scope: "col",
|
|
8822
|
+
className: cn(className, alignClass),
|
|
8823
|
+
style: cellStyle,
|
|
8824
|
+
children: headerContent
|
|
8825
|
+
});
|
|
8826
|
+
}
|
|
8827
|
+
// ───── Data row ─────
|
|
8828
|
+
function Row(props) {
|
|
8829
|
+
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;
|
|
8830
|
+
return jsxs(Fragment$1, {
|
|
8831
|
+
children: [
|
|
8832
|
+
jsxs("tr", {
|
|
8833
|
+
"data-selected": isSelected ? 'true' : 'false',
|
|
8834
|
+
"aria-selected": rowSelection !== 'none' ? isSelected : undefined,
|
|
8835
|
+
className: rowClass,
|
|
8836
|
+
children: [
|
|
8837
|
+
showSelectionColumn && jsx("td", {
|
|
8838
|
+
className: selectionCellClass,
|
|
8839
|
+
children: jsx("input", {
|
|
8840
|
+
type: "checkbox",
|
|
8841
|
+
"aria-label": labels.ariaSelectRow,
|
|
8842
|
+
checked: isSelected,
|
|
8843
|
+
onChange: ()=>toggleRow(rowId),
|
|
8844
|
+
disabled: !isInteractive,
|
|
8845
|
+
className: "cursor-pointer"
|
|
8846
|
+
})
|
|
8847
|
+
}),
|
|
8848
|
+
showExpandColumn && jsx("td", {
|
|
8849
|
+
className: expandToggleCellClass,
|
|
8850
|
+
children: jsx("button", {
|
|
8851
|
+
type: "button",
|
|
8852
|
+
onClick: ()=>toggleExpand(rowId),
|
|
8853
|
+
"aria-label": isExpanded ? labels.ariaCollapseRow : labels.ariaExpandRow,
|
|
8854
|
+
"aria-expanded": isExpanded,
|
|
8855
|
+
disabled: !isInteractive,
|
|
8856
|
+
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.
|
|
8857
|
+
'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'),
|
|
8858
|
+
children: jsx(ChevronRightIcon, {})
|
|
8859
|
+
})
|
|
8860
|
+
}),
|
|
8861
|
+
visibleCols.map((col)=>jsx(DataCell, {
|
|
8862
|
+
col: col,
|
|
8863
|
+
row: row,
|
|
8864
|
+
rowIndex: rowIndex,
|
|
8865
|
+
isSelected: isSelected,
|
|
8866
|
+
isExpanded: isExpanded,
|
|
8867
|
+
inferredType: columnTypes.get(col.field),
|
|
8868
|
+
cellClass: cellClass
|
|
8869
|
+
}, col.field)),
|
|
8870
|
+
showRowActionsColumn && rowActions && jsx("td", {
|
|
8871
|
+
className: rowActionsCellClass,
|
|
8872
|
+
children: rowActions(row)
|
|
8873
|
+
})
|
|
8874
|
+
]
|
|
8875
|
+
}),
|
|
8876
|
+
isExpanded && expandable && jsx("tr", {
|
|
8877
|
+
className: expandedRowClass,
|
|
8878
|
+
children: jsx("td", {
|
|
8879
|
+
colSpan: totalColumnCount,
|
|
8880
|
+
className: "p-3",
|
|
8881
|
+
children: expandable.render(row)
|
|
8882
|
+
})
|
|
8883
|
+
})
|
|
8884
|
+
]
|
|
8885
|
+
});
|
|
8886
|
+
}
|
|
8887
|
+
// ───── Data cell ─────
|
|
8888
|
+
function DataCell(props) {
|
|
8889
|
+
const { col, row, rowIndex, isSelected, isExpanded, inferredType, cellClass } = props;
|
|
8890
|
+
const value = getNestedValue(row, col.field);
|
|
8891
|
+
const align = resolveAlign(col, inferredType);
|
|
8892
|
+
// tabularNums = digit alignment via font-variant-numeric — does NOT
|
|
8893
|
+
// change the font family. Auto-true for number / date columns.
|
|
8894
|
+
const tabularNums = resolveTabularNums(col, inferredType);
|
|
8895
|
+
// monospace = font-family override (font-mono). Library never
|
|
8896
|
+
// auto-picks a font family — only explicit consumer opt-in. The
|
|
8897
|
+
// consumer's theme font-sans is preserved by default.
|
|
8898
|
+
const monospace = resolveMonospace(col);
|
|
8899
|
+
const alignClass = align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left';
|
|
8900
|
+
// Per-column RBAC state for the cellRenderer context
|
|
8901
|
+
const columnAccessState = col.access ? {
|
|
8902
|
+
hidden: col.access.onUnauthorized === 'hide',
|
|
8903
|
+
disabled: col.access.onUnauthorized === 'disable',
|
|
8904
|
+
readonly: col.access.onUnauthorized === 'readonly'
|
|
8905
|
+
} : null;
|
|
8906
|
+
const content = col.cellRenderer ? col.cellRenderer({
|
|
8907
|
+
row,
|
|
8908
|
+
value,
|
|
8909
|
+
rowIndex,
|
|
8910
|
+
isSelected,
|
|
8911
|
+
isExpanded,
|
|
8912
|
+
access: columnAccessState
|
|
8913
|
+
}) : renderDefaultCell(value);
|
|
8914
|
+
return jsx("td", {
|
|
8915
|
+
className: cn(cellClass, alignClass, tabularNums && 'tabular-nums', monospace && 'font-mono'),
|
|
8916
|
+
style: {
|
|
8917
|
+
width: col.width != null ? `${col.width}px` : undefined,
|
|
8918
|
+
minWidth: col.minWidth != null ? `${col.minWidth}px` : undefined
|
|
8919
|
+
},
|
|
8920
|
+
children: content
|
|
8921
|
+
});
|
|
8922
|
+
}
|
|
8923
|
+
// ───── Helpers ─────
|
|
8924
|
+
function renderDefaultCell(value) {
|
|
8925
|
+
if (value == null) return null;
|
|
8926
|
+
if (typeof value === 'boolean') return value ? '✓' : '✗';
|
|
8927
|
+
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
|
8928
|
+
if (typeof value === 'object') return JSON.stringify(value);
|
|
8929
|
+
return value;
|
|
8930
|
+
}
|
|
8931
|
+
function handleSortClick(col, event, current, setSortModel) {
|
|
8932
|
+
if (!col.sortable) return;
|
|
8933
|
+
const multi = event.shiftKey;
|
|
8934
|
+
setSortModel((prev)=>cycleSortFor(prev, col.field, multi));
|
|
8935
|
+
}
|
|
8936
|
+
function renderLoadingRows(args) {
|
|
8937
|
+
const { count, visibleCols, showSelectionColumn, showExpandColumn, showRowActionsColumn, rowClass, cellClass } = args;
|
|
8938
|
+
return Array.from({
|
|
8939
|
+
length: count
|
|
8940
|
+
}).map((_, i)=>jsxs("tr", {
|
|
8941
|
+
className: rowClass,
|
|
8942
|
+
"aria-busy": "true",
|
|
8943
|
+
children: [
|
|
8944
|
+
showSelectionColumn && jsx("td", {
|
|
8945
|
+
className: cellClass,
|
|
8946
|
+
children: jsx(Skeleton, {
|
|
8947
|
+
variant: "rectangle",
|
|
8948
|
+
width: "16px",
|
|
8949
|
+
height: "16px"
|
|
8950
|
+
})
|
|
8951
|
+
}),
|
|
8952
|
+
showExpandColumn && jsx("td", {
|
|
8953
|
+
className: cellClass,
|
|
8954
|
+
children: jsx(Skeleton, {
|
|
8955
|
+
variant: "rectangle",
|
|
8956
|
+
width: "16px",
|
|
8957
|
+
height: "16px"
|
|
8958
|
+
})
|
|
8959
|
+
}),
|
|
8960
|
+
visibleCols.map((col)=>jsx("td", {
|
|
8961
|
+
className: cellClass,
|
|
8962
|
+
children: jsx(Skeleton, {
|
|
8963
|
+
variant: "text",
|
|
8964
|
+
width: "80%"
|
|
8965
|
+
})
|
|
8966
|
+
}, col.field)),
|
|
8967
|
+
showRowActionsColumn && jsx("td", {
|
|
8968
|
+
className: cellClass,
|
|
8969
|
+
children: jsx(Skeleton, {
|
|
8970
|
+
variant: "rectangle",
|
|
8971
|
+
width: "24px",
|
|
8972
|
+
height: "24px"
|
|
8973
|
+
})
|
|
8974
|
+
})
|
|
8975
|
+
]
|
|
8976
|
+
}, `loading-${i}`));
|
|
8977
|
+
}
|
|
8978
|
+
function renderEmptyState(args) {
|
|
8979
|
+
var _args_emptyState;
|
|
8980
|
+
return jsx("tr", {
|
|
8981
|
+
children: jsx("td", {
|
|
8982
|
+
colSpan: args.totalColumnCount,
|
|
8983
|
+
className: args.cellClass,
|
|
8984
|
+
children: (_args_emptyState = args.emptyState) != null ? _args_emptyState : args.fallback
|
|
8985
|
+
})
|
|
8986
|
+
});
|
|
8987
|
+
}
|
|
8988
|
+
// ───── Icons ─────
|
|
8989
|
+
function SortIcon(props) {
|
|
8990
|
+
return jsx("svg", {
|
|
8991
|
+
width: "0.75em",
|
|
8992
|
+
height: "0.75em",
|
|
8993
|
+
viewBox: "0 0 16 16",
|
|
8994
|
+
fill: "currentColor",
|
|
8995
|
+
"aria-hidden": "true",
|
|
8996
|
+
className: "shrink-0 opacity-60 group-hover:opacity-100",
|
|
8997
|
+
children: props.direction === 'asc' ? jsx("path", {
|
|
8998
|
+
d: "M8 4l4 6H4z"
|
|
8999
|
+
}) : props.direction === 'desc' ? jsx("path", {
|
|
9000
|
+
d: "M8 12L4 6h8z"
|
|
9001
|
+
}) : jsxs(Fragment$1, {
|
|
9002
|
+
children: [
|
|
9003
|
+
jsx("path", {
|
|
9004
|
+
d: "M8 3l3 4H5z",
|
|
9005
|
+
opacity: "0.5"
|
|
9006
|
+
}),
|
|
9007
|
+
jsx("path", {
|
|
9008
|
+
d: "M8 13l-3-4h6z",
|
|
9009
|
+
opacity: "0.5"
|
|
9010
|
+
})
|
|
9011
|
+
]
|
|
9012
|
+
})
|
|
9013
|
+
});
|
|
9014
|
+
}
|
|
9015
|
+
function ChevronRightIcon() {
|
|
9016
|
+
return jsx("svg", {
|
|
9017
|
+
width: "0.875em",
|
|
9018
|
+
height: "0.875em",
|
|
9019
|
+
viewBox: "0 0 16 16",
|
|
9020
|
+
fill: "none",
|
|
9021
|
+
stroke: "currentColor",
|
|
9022
|
+
strokeWidth: "1.5",
|
|
9023
|
+
strokeLinecap: "round",
|
|
9024
|
+
strokeLinejoin: "round",
|
|
9025
|
+
"aria-hidden": "true",
|
|
9026
|
+
children: jsx("path", {
|
|
9027
|
+
d: "M6 4l4 4-4 4"
|
|
9028
|
+
})
|
|
9029
|
+
});
|
|
9030
|
+
}
|
|
9031
|
+
function SearchIcon(props) {
|
|
9032
|
+
return jsxs("svg", {
|
|
9033
|
+
width: "0.875em",
|
|
9034
|
+
height: "0.875em",
|
|
9035
|
+
viewBox: "0 0 16 16",
|
|
9036
|
+
fill: "none",
|
|
9037
|
+
stroke: "currentColor",
|
|
9038
|
+
strokeWidth: "1.5",
|
|
9039
|
+
strokeLinecap: "round",
|
|
9040
|
+
strokeLinejoin: "round",
|
|
9041
|
+
"aria-hidden": "true",
|
|
9042
|
+
className: props.className,
|
|
9043
|
+
children: [
|
|
9044
|
+
jsx("circle", {
|
|
9045
|
+
cx: "7",
|
|
9046
|
+
cy: "7",
|
|
9047
|
+
r: "4.5"
|
|
9048
|
+
}),
|
|
9049
|
+
jsx("path", {
|
|
9050
|
+
d: "M11 11l3 3"
|
|
9051
|
+
})
|
|
9052
|
+
]
|
|
9053
|
+
});
|
|
9054
|
+
}
|
|
9055
|
+
|
|
9056
|
+
/**
|
|
9057
|
+
* Plain text cell renderer. Wraps the value in a `<span>` and
|
|
9058
|
+
* optionally truncates on overflow. Use this for the most common
|
|
9059
|
+
* case: render a single string value.
|
|
9060
|
+
*
|
|
9061
|
+
* The Table component does NOT use this by default for plain
|
|
9062
|
+
* string columns — it just renders `{value}` directly. Use
|
|
9063
|
+
* `RenderText` when you need the truncate / muted styling.
|
|
9064
|
+
*/ function RenderText(props) {
|
|
9065
|
+
const { value, truncate, muted, className } = props;
|
|
9066
|
+
return jsx("span", {
|
|
9067
|
+
className: cn(truncate && 'block max-w-full truncate', // Neutral palette auto-inverts via dashforgePreset() CSS-var swap.
|
|
9068
|
+
muted && 'text-neutral-500', className),
|
|
9069
|
+
children: value
|
|
9070
|
+
});
|
|
9071
|
+
}
|
|
9072
|
+
|
|
9073
|
+
/**
|
|
9074
|
+
* Two-line cell renderer: a bold primary line stacked above a muted
|
|
9075
|
+
* secondary line. Useful for `Name + email`, `Title + ID`, etc.
|
|
9076
|
+
*
|
|
9077
|
+
* Replaces the MUI-side `RenderTwoLineCell` without the magic-number
|
|
9078
|
+
* vertical-align fudges (`mt: 0.8 / 0.2`) — flexbox handles the
|
|
9079
|
+
* vertical layout cleanly.
|
|
9080
|
+
*
|
|
9081
|
+
* Dashforge identity rule: text colors auto-invert via the
|
|
9082
|
+
* `dashforgePreset()` CSS-variable swap. No `dark:` variants needed
|
|
9083
|
+
* on neutral palette.
|
|
9084
|
+
*/ function RenderTwoLine(props) {
|
|
9085
|
+
const { primary, secondary, className } = props;
|
|
9086
|
+
return jsxs("div", {
|
|
9087
|
+
className: cn('flex flex-col leading-tight', className),
|
|
9088
|
+
children: [
|
|
9089
|
+
jsx("span", {
|
|
9090
|
+
className: "font-medium text-neutral-900",
|
|
9091
|
+
children: primary
|
|
9092
|
+
}),
|
|
9093
|
+
jsx("span", {
|
|
9094
|
+
className: "text-xs text-neutral-500 mt-0.5",
|
|
9095
|
+
children: secondary
|
|
9096
|
+
})
|
|
9097
|
+
]
|
|
9098
|
+
});
|
|
9099
|
+
}
|
|
9100
|
+
|
|
9101
|
+
/**
|
|
9102
|
+
* Internal mini-chip used by the cell renderer library. Not exported
|
|
9103
|
+
* from `@dashforge/tw` as a standalone component (per Sprint 4.1
|
|
9104
|
+
* scope decision — a standalone `<Chip>` may ship in Sprint 5 if
|
|
9105
|
+
* demand emerges).
|
|
9106
|
+
*
|
|
9107
|
+
* Variants:
|
|
9108
|
+
* - `color`: 7 intents (neutral / primary / success / warning /
|
|
9109
|
+
* danger / info / secondary)
|
|
9110
|
+
* - `variant`: `soft` (default) / `solid` / `outline`
|
|
9111
|
+
* - `size`: `sm` / `md`
|
|
9112
|
+
*/ const chipVariants = tv({
|
|
9113
|
+
base: [
|
|
9114
|
+
'inline-flex items-center gap-1 rounded-full font-medium',
|
|
9115
|
+
'whitespace-nowrap'
|
|
9116
|
+
],
|
|
9117
|
+
variants: {
|
|
9118
|
+
color: {
|
|
9119
|
+
neutral: '',
|
|
9120
|
+
primary: '',
|
|
9121
|
+
success: '',
|
|
9122
|
+
warning: '',
|
|
9123
|
+
danger: '',
|
|
9124
|
+
info: '',
|
|
9125
|
+
secondary: ''
|
|
9126
|
+
},
|
|
9127
|
+
variant: {
|
|
9128
|
+
soft: '',
|
|
9129
|
+
solid: '',
|
|
9130
|
+
outline: ''
|
|
9131
|
+
},
|
|
9132
|
+
size: {
|
|
9133
|
+
sm: 'h-5 px-2 text-xs',
|
|
9134
|
+
md: 'h-6 px-2.5 text-xs'
|
|
9135
|
+
}
|
|
9136
|
+
},
|
|
9137
|
+
compoundVariants: [
|
|
9138
|
+
// soft = subtle bg + dark text.
|
|
9139
|
+
// Neutral row uses NO `dark:` variants — the dashforgePreset()
|
|
9140
|
+
// CSS-var swap auto-inverts the neutral palette already.
|
|
9141
|
+
// Color rows below KEEP `dark:` variants because color palettes
|
|
9142
|
+
// don't auto-invert: the `dark:` shift is an intentional design
|
|
9143
|
+
// choice (darker bg + lighter text in dark mode for visual cohesion).
|
|
9144
|
+
{
|
|
9145
|
+
variant: 'soft',
|
|
9146
|
+
color: 'neutral',
|
|
9147
|
+
class: 'bg-neutral-100 text-neutral-700'
|
|
9148
|
+
},
|
|
9149
|
+
{
|
|
9150
|
+
variant: 'soft',
|
|
9151
|
+
color: 'primary',
|
|
9152
|
+
class: 'bg-primary-100 dark:bg-primary-950 text-primary-700 dark:text-primary-300'
|
|
9153
|
+
},
|
|
9154
|
+
{
|
|
9155
|
+
variant: 'soft',
|
|
9156
|
+
color: 'success',
|
|
9157
|
+
class: 'bg-success-100 dark:bg-success-950 text-success-700 dark:text-success-300'
|
|
9158
|
+
},
|
|
9159
|
+
{
|
|
9160
|
+
variant: 'soft',
|
|
9161
|
+
color: 'warning',
|
|
9162
|
+
class: 'bg-warning-100 dark:bg-warning-950 text-warning-700 dark:text-warning-300'
|
|
9163
|
+
},
|
|
9164
|
+
{
|
|
9165
|
+
variant: 'soft',
|
|
9166
|
+
color: 'danger',
|
|
9167
|
+
class: 'bg-danger-100 dark:bg-danger-950 text-danger-700 dark:text-danger-300'
|
|
9168
|
+
},
|
|
9169
|
+
{
|
|
9170
|
+
variant: 'soft',
|
|
9171
|
+
color: 'info',
|
|
9172
|
+
class: 'bg-info-100 dark:bg-info-950 text-info-700 dark:text-info-300'
|
|
9173
|
+
},
|
|
9174
|
+
{
|
|
9175
|
+
variant: 'soft',
|
|
9176
|
+
color: 'secondary',
|
|
9177
|
+
class: 'bg-secondary-100 dark:bg-secondary-950 text-secondary-700 dark:text-secondary-300'
|
|
9178
|
+
},
|
|
9179
|
+
// solid = full bg + light text
|
|
9180
|
+
// solid neutral: bg + text both anchored to neutral palette
|
|
9181
|
+
// (auto-inverts). `text-white` would not invert and would break
|
|
9182
|
+
// contrast in light mode against a `bg-neutral-700` dark bg, so
|
|
9183
|
+
// we stick to `text-neutral-50` (light text in light mode, dark
|
|
9184
|
+
// in dark — wait, that's wrong too). Actually `text-neutral-50`
|
|
9185
|
+
// auto-inverts: light text in light mode (against dark bg) is
|
|
9186
|
+
// what we want; in dark mode `bg-neutral-700` becomes light AND
|
|
9187
|
+
// `text-neutral-50` becomes dark — contrast preserved.
|
|
9188
|
+
{
|
|
9189
|
+
variant: 'solid',
|
|
9190
|
+
color: 'neutral',
|
|
9191
|
+
class: 'bg-neutral-700 text-neutral-50'
|
|
9192
|
+
},
|
|
9193
|
+
{
|
|
9194
|
+
variant: 'solid',
|
|
9195
|
+
color: 'primary',
|
|
9196
|
+
class: 'bg-primary-500 text-white'
|
|
9197
|
+
},
|
|
9198
|
+
{
|
|
9199
|
+
variant: 'solid',
|
|
9200
|
+
color: 'success',
|
|
9201
|
+
class: 'bg-success-500 text-white'
|
|
9202
|
+
},
|
|
9203
|
+
{
|
|
9204
|
+
variant: 'solid',
|
|
9205
|
+
color: 'warning',
|
|
9206
|
+
class: 'bg-warning-500 text-white'
|
|
9207
|
+
},
|
|
9208
|
+
{
|
|
9209
|
+
variant: 'solid',
|
|
9210
|
+
color: 'danger',
|
|
9211
|
+
class: 'bg-danger-500 text-white'
|
|
9212
|
+
},
|
|
9213
|
+
{
|
|
9214
|
+
variant: 'solid',
|
|
9215
|
+
color: 'info',
|
|
9216
|
+
class: 'bg-info-500 text-white'
|
|
9217
|
+
},
|
|
9218
|
+
{
|
|
9219
|
+
variant: 'solid',
|
|
9220
|
+
color: 'secondary',
|
|
9221
|
+
class: 'bg-secondary-500 text-white'
|
|
9222
|
+
},
|
|
9223
|
+
// outline = border + colored text
|
|
9224
|
+
// outline neutral: both border + text auto-invert via CSS-var swap.
|
|
9225
|
+
{
|
|
9226
|
+
variant: 'outline',
|
|
9227
|
+
color: 'neutral',
|
|
9228
|
+
class: 'border border-neutral-300 text-neutral-700'
|
|
9229
|
+
},
|
|
9230
|
+
{
|
|
9231
|
+
variant: 'outline',
|
|
9232
|
+
color: 'primary',
|
|
9233
|
+
class: 'border border-primary-500 text-primary-700 dark:text-primary-300'
|
|
9234
|
+
},
|
|
9235
|
+
{
|
|
9236
|
+
variant: 'outline',
|
|
9237
|
+
color: 'success',
|
|
9238
|
+
class: 'border border-success-500 text-success-700 dark:text-success-300'
|
|
9239
|
+
},
|
|
9240
|
+
{
|
|
9241
|
+
variant: 'outline',
|
|
9242
|
+
color: 'warning',
|
|
9243
|
+
class: 'border border-warning-500 text-warning-700 dark:text-warning-300'
|
|
9244
|
+
},
|
|
9245
|
+
{
|
|
9246
|
+
variant: 'outline',
|
|
9247
|
+
color: 'danger',
|
|
9248
|
+
class: 'border border-danger-500 text-danger-700 dark:text-danger-300'
|
|
9249
|
+
},
|
|
9250
|
+
{
|
|
9251
|
+
variant: 'outline',
|
|
9252
|
+
color: 'info',
|
|
9253
|
+
class: 'border border-info-500 text-info-700 dark:text-info-300'
|
|
9254
|
+
},
|
|
9255
|
+
{
|
|
9256
|
+
variant: 'outline',
|
|
9257
|
+
color: 'secondary',
|
|
9258
|
+
class: 'border border-secondary-500 text-secondary-700 dark:text-secondary-300'
|
|
9259
|
+
}
|
|
9260
|
+
],
|
|
9261
|
+
defaultVariants: {
|
|
9262
|
+
color: 'neutral',
|
|
9263
|
+
variant: 'soft',
|
|
9264
|
+
size: 'sm'
|
|
9265
|
+
}
|
|
9266
|
+
});
|
|
9267
|
+
function RenderChip(props) {
|
|
9268
|
+
const { children, color, variant, size, className } = props;
|
|
9269
|
+
return jsx("span", {
|
|
9270
|
+
className: cn(chipVariants({
|
|
9271
|
+
color,
|
|
9272
|
+
variant,
|
|
9273
|
+
size
|
|
9274
|
+
}), className),
|
|
9275
|
+
children: children
|
|
9276
|
+
});
|
|
9277
|
+
}
|
|
9278
|
+
|
|
9279
|
+
/**
|
|
9280
|
+
* Inline button cell renderer. Thin wrapper over `<Button>` with
|
|
9281
|
+
* the size narrowed to `sm` by default (table density expects compact
|
|
9282
|
+
* controls).
|
|
9283
|
+
*/ function RenderButton(props) {
|
|
9284
|
+
const { label, onClick, variant = 'ghost', size = 'sm' } = props, rest = _object_without_properties_loose(props, [
|
|
9285
|
+
"label",
|
|
9286
|
+
"onClick",
|
|
9287
|
+
"variant",
|
|
9288
|
+
"size"
|
|
9289
|
+
]);
|
|
9290
|
+
return jsx(Button, _extends({
|
|
9291
|
+
variant: variant,
|
|
9292
|
+
size: size,
|
|
9293
|
+
onClick: onClick
|
|
9294
|
+
}, rest, {
|
|
9295
|
+
children: label
|
|
9296
|
+
}));
|
|
9297
|
+
}
|
|
9298
|
+
|
|
9299
|
+
/**
|
|
9300
|
+
* 3-dot row actions menu. Renders a trigger button that opens a
|
|
9301
|
+
* Popover (Sprint 3 component) with a list of action menu items.
|
|
9302
|
+
*
|
|
9303
|
+
* Replaces the MUI-side `RowActions.tsx` (IconButton + Menu) with
|
|
9304
|
+
* the TW equivalents. Per-action RBAC honored: hidden actions are
|
|
9305
|
+
* filtered out, disabled actions render disabled.
|
|
9306
|
+
*/ function RowActionsMenu(props) {
|
|
9307
|
+
const { row, actions, trigger, ariaLabel = 'Row actions' } = props;
|
|
9308
|
+
const [open, setOpen] = useState(false);
|
|
9309
|
+
// Filter actions by RBAC (hidden actions removed entirely)
|
|
9310
|
+
const visibleActions = actions.filter((action)=>{
|
|
9311
|
+
if (!action.access) return true;
|
|
9312
|
+
return action.access.onUnauthorized !== 'hide';
|
|
9313
|
+
});
|
|
9314
|
+
if (visibleActions.length === 0) return null;
|
|
9315
|
+
return jsx(Popover, {
|
|
9316
|
+
open: open,
|
|
9317
|
+
onOpenChange: setOpen,
|
|
9318
|
+
side: "bottom",
|
|
9319
|
+
align: "end",
|
|
9320
|
+
content: jsx("ul", {
|
|
9321
|
+
className: "flex flex-col gap-0.5 min-w-[140px]",
|
|
9322
|
+
role: "menu",
|
|
9323
|
+
children: visibleActions.map((action, idx)=>jsx(RowActionMenuItem, {
|
|
9324
|
+
action: action,
|
|
9325
|
+
row: row,
|
|
9326
|
+
onClose: ()=>setOpen(false)
|
|
9327
|
+
}, idx))
|
|
9328
|
+
}),
|
|
9329
|
+
children: trigger != null ? trigger : jsx("button", {
|
|
9330
|
+
type: "button",
|
|
9331
|
+
"aria-label": ariaLabel,
|
|
9332
|
+
"aria-haspopup": "menu",
|
|
9333
|
+
"aria-expanded": open,
|
|
9334
|
+
className: cn('inline-flex items-center justify-center h-8 w-8 rounded-md', // Neutral palette auto-inverts via the dashforgePreset()
|
|
9335
|
+
// CSS-var swap — no `dark:` variant needed.
|
|
9336
|
+
'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'),
|
|
9337
|
+
children: jsxs("svg", {
|
|
9338
|
+
width: "1em",
|
|
9339
|
+
height: "1em",
|
|
9340
|
+
viewBox: "0 0 16 16",
|
|
9341
|
+
fill: "currentColor",
|
|
9342
|
+
"aria-hidden": "true",
|
|
9343
|
+
children: [
|
|
9344
|
+
jsx("circle", {
|
|
9345
|
+
cx: "8",
|
|
9346
|
+
cy: "3",
|
|
9347
|
+
r: "1.4"
|
|
9348
|
+
}),
|
|
9349
|
+
jsx("circle", {
|
|
9350
|
+
cx: "8",
|
|
9351
|
+
cy: "8",
|
|
9352
|
+
r: "1.4"
|
|
9353
|
+
}),
|
|
9354
|
+
jsx("circle", {
|
|
9355
|
+
cx: "8",
|
|
9356
|
+
cy: "13",
|
|
9357
|
+
r: "1.4"
|
|
9358
|
+
})
|
|
9359
|
+
]
|
|
9360
|
+
})
|
|
9361
|
+
})
|
|
9362
|
+
});
|
|
9363
|
+
}
|
|
9364
|
+
function RowActionMenuItem(props) {
|
|
9365
|
+
const { action, row, onClose } = props;
|
|
9366
|
+
const accessState = useAccessState(action.access);
|
|
9367
|
+
const isDisabled = action.disabled || accessState.disabled || accessState.readonly;
|
|
9368
|
+
return jsx("li", {
|
|
9369
|
+
role: "none",
|
|
9370
|
+
children: jsxs("button", {
|
|
9371
|
+
type: "button",
|
|
9372
|
+
role: "menuitem",
|
|
9373
|
+
disabled: isDisabled,
|
|
9374
|
+
onClick: ()=>{
|
|
9375
|
+
onClose();
|
|
9376
|
+
action.onClick(row);
|
|
9377
|
+
},
|
|
9378
|
+
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
|
|
9379
|
+
// palettes (no auto-invert) so they keep their `dark:` shift
|
|
9380
|
+
// intentionally — design choice, not a bug.
|
|
9381
|
+
'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'),
|
|
9382
|
+
children: [
|
|
9383
|
+
action.icon && jsx("span", {
|
|
9384
|
+
className: "inline-flex shrink-0",
|
|
9385
|
+
"aria-hidden": "true",
|
|
9386
|
+
children: action.icon
|
|
9387
|
+
}),
|
|
9388
|
+
jsx("span", {
|
|
9389
|
+
children: action.label
|
|
9390
|
+
})
|
|
9391
|
+
]
|
|
9392
|
+
})
|
|
9393
|
+
});
|
|
9394
|
+
}
|
|
9395
|
+
|
|
7285
9396
|
/**
|
|
7286
9397
|
* @dashforge/tw
|
|
7287
9398
|
*
|
|
@@ -7309,6 +9420,6 @@ const accordionVariants = tv({
|
|
|
7309
9420
|
*/ // ───── Components ─────
|
|
7310
9421
|
/**
|
|
7311
9422
|
* Package version (synced with `package.json` at publish time).
|
|
7312
|
-
*/ const VERSION = '0.
|
|
9423
|
+
*/ const VERSION = '0.6.0-beta';
|
|
7313
9424
|
|
|
7314
|
-
export { Accordion, AppShell, AspectRatio, Autocomplete, Box, Breadcrumbs, Button, Checkbox, ConfirmDialogProvider, Container, DateTimePicker, Dialog, Divider, Grid, LeftNav, NumberField, OTPField, Popover, RadioGroup, 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, popoverVariants, radioGroupVariants, snackbarVariants, stackVariants, switchVariants, tabsVariants, textFieldVariants, textareaVariants, tooltipVariants, topBarVariants, typographyVariants, useAccessState, useConfirm, useSnackbar };
|
|
9425
|
+
export { Accordion, AppShell, AspectRatio, Autocomplete, Box, Breadcrumbs, Button, Checkbox, ConfirmDialogProvider, Container, 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, 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 };
|