@eifi1/ui-kit 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +32 -0
- package/README.md +498 -57
- package/dist/chart.d.ts +10 -0
- package/dist/chart.js +10 -0
- package/dist/chart.js.map +1 -0
- package/dist/components/account-settings.d.ts +11 -0
- package/dist/components/account-settings.js +19 -12
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/alert-banner.d.ts +10 -6
- package/dist/components/alert-banner.js +20 -16
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +30 -4
- package/dist/components/amount-input.js +36 -14
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/calculator.d.ts +24 -7
- package/dist/components/calculator.js +37 -20
- package/dist/components/calculator.js.map +1 -1
- package/dist/components/chart-zoom.d.ts +156 -0
- package/dist/components/chart-zoom.js +262 -0
- package/dist/components/chart-zoom.js.map +1 -0
- package/dist/components/chart.d.ts +28 -3
- package/dist/components/chart.js +71 -32
- package/dist/components/chart.js.map +1 -1
- package/dist/components/checkbox.d.ts +51 -0
- package/dist/components/checkbox.js +123 -0
- package/dist/components/checkbox.js.map +1 -0
- package/dist/components/chip.d.ts +128 -0
- package/dist/components/chip.js +357 -0
- package/dist/components/chip.js.map +1 -0
- package/dist/components/choice-parts.d.ts +24 -0
- package/dist/components/choice-parts.js +18 -0
- package/dist/components/choice-parts.js.map +1 -0
- package/dist/components/combobox-core.d.ts +52 -9
- package/dist/components/combobox-core.js +89 -19
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +43 -33
- package/dist/components/combobox.js +409 -340
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/currency-select.d.ts +48 -8
- package/dist/components/currency-select.js +107 -65
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +15 -3
- package/dist/components/data-table-filter-popover.js +23 -17
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-filters.js +3 -2
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +53 -2
- package/dist/components/data-table-labels.js +31 -2
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table-pagination.d.ts +3 -1
- package/dist/components/data-table-pagination.js +16 -10
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table-sort.js +1 -0
- package/dist/components/data-table-sort.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +201 -85
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +25 -9
- package/dist/components/date-picker.js +232 -94
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/dropdown.d.ts +74 -19
- package/dist/components/dropdown.js +72 -22
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +5 -4
- package/dist/components/entity-combobox.js +107 -76
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/facing-pair.d.ts +36 -0
- package/dist/components/facing-pair.js +40 -0
- package/dist/components/facing-pair.js.map +1 -0
- package/dist/components/field-sync.d.ts +164 -0
- package/dist/components/field-sync.js +264 -0
- package/dist/components/field-sync.js.map +1 -0
- package/dist/components/file-dropzone.d.ts +21 -7
- package/dist/components/file-dropzone.js +28 -7
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/full-bleed-dialog.d.ts +37 -26
- package/dist/components/full-bleed-dialog.js +20 -5
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/grouped-picker.d.ts +21 -10
- package/dist/components/grouped-picker.js +66 -52
- package/dist/components/grouped-picker.js.map +1 -1
- package/dist/components/hover-menu.d.ts +20 -4
- package/dist/components/hover-menu.js +15 -4
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/mini-calendar.d.ts +102 -14
- package/dist/components/mini-calendar.js +256 -70
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +9 -3
- package/dist/components/modal.js +34 -45
- package/dist/components/modal.js.map +1 -1
- package/dist/components/month-picker.d.ts +79 -0
- package/dist/components/month-picker.js +342 -0
- package/dist/components/month-picker.js.map +1 -0
- package/dist/components/multi-entity-combobox.d.ts +7 -6
- package/dist/components/multi-entity-combobox.js +112 -72
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.d.ts +13 -11
- package/dist/components/multi-select.js +161 -84
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +92 -0
- package/dist/components/number-field.js +111 -0
- package/dist/components/number-field.js.map +1 -0
- package/dist/components/number-input.d.ts +21 -2
- package/dist/components/number-input.js +2 -1
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +25 -4
- package/dist/components/numpad-sheet.js +30 -13
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/page-contents.d.ts +90 -0
- package/dist/components/page-contents.js +170 -0
- package/dist/components/page-contents.js.map +1 -0
- package/dist/components/password-strength.d.ts +106 -0
- package/dist/components/password-strength.js +132 -0
- package/dist/components/password-strength.js.map +1 -0
- package/dist/components/picker-sheet.d.ts +23 -15
- package/dist/components/picker-sheet.js +34 -14
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/popover.d.ts +62 -4
- package/dist/components/popover.js +52 -2
- package/dist/components/popover.js.map +1 -1
- package/dist/components/search-field.d.ts +25 -7
- package/dist/components/search-field.js +7 -5
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/series-chart-labels.d.ts +14 -0
- package/dist/components/series-chart-labels.js +11 -0
- package/dist/components/series-chart-labels.js.map +1 -0
- package/dist/components/series-chart.d.ts +207 -0
- package/dist/components/series-chart.js +223 -0
- package/dist/components/series-chart.js.map +1 -0
- package/dist/components/settings-fields.d.ts +14 -4
- package/dist/components/settings-fields.js +19 -8
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/signature-pad.d.ts +119 -0
- package/dist/components/signature-pad.js +405 -0
- package/dist/components/signature-pad.js.map +1 -0
- package/dist/components/slider.d.ts +80 -0
- package/dist/components/slider.js +155 -0
- package/dist/components/slider.js.map +1 -0
- package/dist/components/sparkline.d.ts +93 -0
- package/dist/components/sparkline.js +205 -0
- package/dist/components/sparkline.js.map +1 -0
- package/dist/components/stat-tile.d.ts +151 -0
- package/dist/components/stat-tile.js +236 -0
- package/dist/components/stat-tile.js.map +1 -0
- package/dist/components/swipeable-row.d.ts +25 -2
- package/dist/components/swipeable-row.js +31 -0
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/switch.d.ts +60 -0
- package/dist/components/switch.js +81 -0
- package/dist/components/switch.js.map +1 -0
- package/dist/components/time-input.d.ts +58 -0
- package/dist/components/time-input.js +50 -0
- package/dist/components/time-input.js.map +1 -0
- package/dist/components/toggle-group.d.ts +18 -3
- package/dist/components/toggle-group.js +10 -6
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.d.ts +115 -0
- package/dist/components/toggle-legend.js +114 -0
- package/dist/components/toggle-legend.js.map +1 -0
- package/dist/components/tooltip.d.ts +40 -11
- package/dist/components/tooltip.js +77 -20
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/treemap.d.ts +132 -0
- package/dist/components/treemap.js +179 -0
- package/dist/components/treemap.js.map +1 -0
- package/dist/components/ui.d.ts +159 -46
- package/dist/components/ui.js +220 -115
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-mobile-reveal.js +1 -0
- package/dist/components/use-mobile-reveal.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/components/use-table-state.js +38 -17
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/components/user-avatar.d.ts +5 -3
- package/dist/components/user-avatar.js +4 -2
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/components/wizard-stepper.js +3 -2
- package/dist/components/wizard-stepper.js.map +1 -1
- package/dist/{data-table-filters-aXyf0Xub.d.ts → data-table-filters-DM1auUfH.d.ts} +27 -5
- package/dist/data-table.d.ts +7 -0
- package/dist/data-table.js +12 -0
- package/dist/data-table.js.map +1 -0
- package/dist/feedback/feedback-attachment.js +12 -11
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.js +2 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +33 -7
- package/dist/feedback/feedback-inbox.js +49 -37
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback.d.ts +5 -0
- package/dist/feedback.js +5 -0
- package/dist/feedback.js.map +1 -0
- package/dist/hooks/use-anchored-panel.js +1 -0
- package/dist/hooks/use-anchored-panel.js.map +1 -1
- package/dist/hooks/use-anchored-rect.js +1 -0
- package/dist/hooks/use-anchored-rect.js.map +1 -1
- package/dist/hooks/use-announce.d.ts +46 -0
- package/dist/hooks/use-announce.js +37 -0
- package/dist/hooks/use-announce.js.map +1 -0
- package/dist/hooks/use-body-scroll-lock.js +1 -0
- package/dist/hooks/use-body-scroll-lock.js.map +1 -1
- package/dist/hooks/use-close-transition.js +1 -0
- package/dist/hooks/use-close-transition.js.map +1 -1
- package/dist/hooks/use-dismiss.js +1 -0
- package/dist/hooks/use-dismiss.js.map +1 -1
- package/dist/hooks/use-focus-trap.d.ts +16 -0
- package/dist/hooks/use-focus-trap.js +67 -0
- package/dist/hooks/use-focus-trap.js.map +1 -0
- package/dist/hooks/use-media-query.js +1 -0
- package/dist/hooks/use-media-query.js.map +1 -1
- package/dist/hooks/use-overlay-history.js +27 -1
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/hooks/use-row-swipe.js +1 -0
- package/dist/hooks/use-row-swipe.js.map +1 -1
- package/dist/i18n/defaults.d.ts +31 -0
- package/dist/i18n/defaults.js +63 -0
- package/dist/i18n/defaults.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +250 -0
- package/dist/i18n/kit-labels.js +184 -0
- package/dist/i18n/kit-labels.js.map +1 -0
- package/dist/index.d.ts +47 -22
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/calc.js +1 -0
- package/dist/lib/calc.js.map +1 -1
- package/dist/lib/cn.js +1 -0
- package/dist/lib/cn.js.map +1 -1
- package/dist/lib/dates.js +1 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/lib/logger.js +1 -0
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/safe-storage.js +1 -0
- package/dist/lib/safe-storage.js.map +1 -1
- package/dist/search/command-palette.d.ts +11 -1
- package/dist/search/command-palette.js +21 -15
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search.d.ts +2 -0
- package/dist/search.js +3 -0
- package/dist/search.js.map +1 -0
- package/dist/shell/app-shell.d.ts +33 -4
- package/dist/shell/app-shell.js +275 -78
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/option-switcher-menu.js +3 -2
- package/dist/shell/option-switcher-menu.js.map +1 -1
- package/dist/shell/role-switcher.d.ts +6 -0
- package/dist/shell/role-switcher.js +7 -4
- package/dist/shell/role-switcher.js.map +1 -1
- package/dist/shell/top-bar.d.ts +15 -7
- package/dist/shell/top-bar.js +3 -5
- package/dist/shell/top-bar.js.map +1 -1
- package/dist/shell/topbar-action-menu.js +3 -2
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.d.ts +5 -2
- package/dist/shell/topbar-controls.js +20 -15
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/shell.d.ts +13 -0
- package/dist/shell.js +8 -0
- package/dist/shell.js.map +1 -0
- package/dist/theme/chart-palette.d.ts +19 -12
- package/dist/theme/chart-palette.js +24 -30
- package/dist/theme/chart-palette.js.map +1 -1
- package/dist/theme/color.d.ts +78 -0
- package/dist/theme/color.js +148 -0
- package/dist/theme/color.js.map +1 -0
- package/dist/theme/palette-derive.d.ts +140 -0
- package/dist/theme/palette-derive.js +376 -0
- package/dist/theme/palette-derive.js.map +1 -0
- package/dist/theme/palette-presets.d.ts +14 -1
- package/dist/theme/palette-presets.js +231 -3
- package/dist/theme/palette-presets.js.map +1 -1
- package/dist/theme/palette-store.js +1 -0
- package/dist/theme/palette-store.js.map +1 -1
- package/dist/theme/theme-store.js +1 -0
- package/dist/theme/theme-store.js.map +1 -1
- package/dist/tour/tour.d.ts +2 -1
- package/dist/tour/tour.js +15 -9
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour.d.ts +2 -0
- package/dist/tour.js +3 -0
- package/dist/tour.js.map +1 -0
- package/dist/wizard/stepper-nav.js +10 -8
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +18 -2
- package/dist/wizard/types.js +4 -1
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +12 -4
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/validation.js +1 -0
- package/dist/wizard/validation.js.map +1 -1
- package/dist/wizard/wizard-context.js +1 -0
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-step.js +1 -0
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard/wizard-summary.js +5 -3
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +8 -0
- package/dist/wizard.js +9 -0
- package/dist/wizard.js.map +1 -0
- package/package.json +66 -13
- package/src/chart.ts +22 -0
- package/src/components/account-settings.tsx +275 -0
- package/src/components/alert-banner.tsx +75 -0
- package/src/components/amount-input.tsx +471 -0
- package/src/components/calculator.tsx +252 -0
- package/src/components/chart-zoom.tsx +499 -0
- package/src/components/chart.tsx +363 -0
- package/src/components/checkbox.tsx +222 -0
- package/src/components/chip.tsx +570 -0
- package/src/components/choice-parts.ts +33 -0
- package/src/components/combobox-core.tsx +540 -0
- package/src/components/combobox.tsx +1026 -0
- package/src/components/currency-select.tsx +269 -0
- package/src/components/data-table-filter-popover.tsx +252 -0
- package/src/components/data-table-filters.ts +176 -0
- package/src/components/data-table-labels.ts +190 -0
- package/src/components/data-table-pagination.tsx +126 -0
- package/src/components/data-table-sort.ts +70 -0
- package/src/components/data-table.tsx +1740 -0
- package/src/components/date-picker.tsx +631 -0
- package/src/components/dropdown.tsx +341 -0
- package/src/components/entity-combobox.tsx +215 -0
- package/src/components/facing-pair.ts +86 -0
- package/src/components/field-sync.tsx +472 -0
- package/src/components/file-dropzone.tsx +181 -0
- package/src/components/full-bleed-dialog.tsx +179 -0
- package/src/components/grouped-picker.tsx +157 -0
- package/src/components/hover-menu.tsx +183 -0
- package/src/components/mini-calendar.tsx +554 -0
- package/src/components/modal.tsx +287 -0
- package/src/components/month-picker.tsx +549 -0
- package/src/components/multi-entity-combobox.tsx +226 -0
- package/src/components/multi-select.tsx +303 -0
- package/src/components/number-field.tsx +225 -0
- package/src/components/number-input.tsx +211 -0
- package/src/components/numpad-sheet.tsx +264 -0
- package/src/components/page-contents.tsx +289 -0
- package/src/components/password-strength.tsx +258 -0
- package/src/components/picker-sheet.tsx +251 -0
- package/src/components/popover.tsx +195 -0
- package/src/components/search-field.tsx +146 -0
- package/src/components/series-chart-labels.ts +26 -0
- package/src/components/series-chart.tsx +481 -0
- package/src/components/settings-fields.tsx +68 -0
- package/src/components/signature-pad.tsx +630 -0
- package/src/components/slider.tsx +313 -0
- package/src/components/sparkline.tsx +329 -0
- package/src/components/stat-tile.tsx +448 -0
- package/src/components/swipeable-row.tsx +271 -0
- package/src/components/switch.tsx +170 -0
- package/src/components/time-input.tsx +107 -0
- package/src/components/toggle-group.tsx +153 -0
- package/src/components/toggle-legend.tsx +229 -0
- package/src/components/tooltip.tsx +477 -0
- package/src/components/treemap.tsx +390 -0
- package/src/components/ui.tsx +1284 -0
- package/src/components/use-mobile-reveal.ts +54 -0
- package/src/components/use-table-state.ts +295 -0
- package/src/components/user-avatar.tsx +61 -0
- package/src/components/wizard-stepper.tsx +37 -0
- package/src/data-table.ts +19 -0
- package/src/feedback/feedback-attachment.tsx +246 -0
- package/src/feedback/feedback-dialog.tsx +209 -0
- package/src/feedback/feedback-inbox.tsx +615 -0
- package/src/feedback.ts +8 -0
- package/src/hooks/use-anchored-panel.ts +169 -0
- package/src/hooks/use-anchored-rect.ts +78 -0
- package/src/hooks/use-announce.ts +85 -0
- package/src/hooks/use-body-scroll-lock.ts +94 -0
- package/src/hooks/use-close-transition.ts +99 -0
- package/src/hooks/use-dismiss.ts +77 -0
- package/src/hooks/use-focus-trap.ts +160 -0
- package/src/hooks/use-media-query.ts +23 -0
- package/src/hooks/use-overlay-history.ts +345 -0
- package/src/hooks/use-row-swipe.ts +212 -0
- package/src/i18n/defaults.ts +68 -0
- package/src/i18n/kit-labels.tsx +447 -0
- package/src/index.ts +169 -0
- package/src/lib/calc.ts +309 -0
- package/src/lib/cn.ts +5 -0
- package/src/lib/dates.ts +187 -0
- package/src/lib/logger.ts +73 -0
- package/src/lib/safe-storage.ts +37 -0
- package/src/search/command-palette.tsx +342 -0
- package/src/search.ts +3 -0
- package/src/shell/app-shell.tsx +772 -0
- package/src/shell/option-switcher-menu.tsx +72 -0
- package/src/shell/role-switcher.tsx +46 -0
- package/src/shell/top-bar.tsx +39 -0
- package/src/shell/topbar-action-menu.tsx +91 -0
- package/src/shell/topbar-controls.tsx +229 -0
- package/src/shell.ts +10 -0
- package/src/theme/chart-palette.ts +145 -0
- package/src/theme/color.ts +266 -0
- package/src/theme/palette-derive.ts +681 -0
- package/src/theme/palette-presets.ts +760 -0
- package/src/theme/palette-store.ts +94 -0
- package/src/theme/theme-store.ts +145 -0
- package/src/tour/tour.tsx +586 -0
- package/src/tour.ts +3 -0
- package/src/vite-env.d.ts +26 -0
- package/src/wizard/stepper-nav.tsx +221 -0
- package/src/wizard/types.ts +178 -0
- package/src/wizard/use-wizard.ts +345 -0
- package/src/wizard/validation.ts +37 -0
- package/src/wizard/wizard-context.tsx +29 -0
- package/src/wizard/wizard-step.tsx +14 -0
- package/src/wizard/wizard-summary.tsx +69 -0
- package/src/wizard.ts +14 -0
- package/tokens.css +222 -20
|
@@ -0,0 +1,1740 @@
|
|
|
1
|
+
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
import {
|
|
5
|
+
ArrowDown,
|
|
6
|
+
ArrowUp,
|
|
7
|
+
ArrowUpDown,
|
|
8
|
+
ChevronDown,
|
|
9
|
+
ChevronRight,
|
|
10
|
+
Filter,
|
|
11
|
+
X,
|
|
12
|
+
} from "lucide-react";
|
|
13
|
+
import { useSearchParams } from "react-router";
|
|
14
|
+
import { Card } from "./ui";
|
|
15
|
+
import { cn } from "../lib/cn";
|
|
16
|
+
import {
|
|
17
|
+
defaultFilterState,
|
|
18
|
+
isFilterActive,
|
|
19
|
+
resolveFilter,
|
|
20
|
+
rowMatches,
|
|
21
|
+
} from "./data-table-filters";
|
|
22
|
+
import type { ColumnFilter, FilterValue } from "./data-table-filters";
|
|
23
|
+
import { nextSorts } from "./data-table-sort";
|
|
24
|
+
import type { SortState } from "./data-table-sort";
|
|
25
|
+
import { FilterPopover } from "./data-table-filter-popover";
|
|
26
|
+
import { Pagination } from "./data-table-pagination";
|
|
27
|
+
import { SwipeableRow, type SwipeAction } from "./swipeable-row";
|
|
28
|
+
import { useBackdropClose } from "./modal";
|
|
29
|
+
import { useBodyScrollLock } from "../hooks/use-body-scroll-lock";
|
|
30
|
+
import { useOverlayHistory } from "../hooks/use-overlay-history";
|
|
31
|
+
import { FullBleedDialog } from "./full-bleed-dialog";
|
|
32
|
+
import { Popover } from "./popover";
|
|
33
|
+
import { useMediaQuery } from "../hooks/use-media-query";
|
|
34
|
+
import { useAnnounce } from "../hooks/use-announce";
|
|
35
|
+
import { resolveDataTableLabels, type DataTableLabels } from "./data-table-labels";
|
|
36
|
+
import { useKitLabelOverrides, useKitLocale } from "../i18n/kit-labels";
|
|
37
|
+
import { Tooltip } from "./tooltip";
|
|
38
|
+
|
|
39
|
+
// ---------- Types ----------
|
|
40
|
+
|
|
41
|
+
export interface DataTableColumn<T> {
|
|
42
|
+
key: string;
|
|
43
|
+
header: ReactNode;
|
|
44
|
+
cell: (row: T) => ReactNode;
|
|
45
|
+
sortBy?: (row: T) => string | number | null | undefined;
|
|
46
|
+
filter?: ColumnFilter<T>;
|
|
47
|
+
filterBy?: (row: T) => string;
|
|
48
|
+
className?: string;
|
|
49
|
+
headClassName?: string;
|
|
50
|
+
// Mobile card layout: when no `mobilePrimary` column is set the first
|
|
51
|
+
// visible non-hidden column acts as the primary. The primary cell renders
|
|
52
|
+
// bold at the top of the card without a label; the rest stack below as
|
|
53
|
+
// labelled key/value pairs. `mobileHidden` skips a column entirely on
|
|
54
|
+
// narrow viewports — useful for noisy details (IDs, raw URLs, internal
|
|
55
|
+
// bookkeeping) that would crowd a card without aiding scanning.
|
|
56
|
+
mobilePrimary?: boolean;
|
|
57
|
+
mobileHidden?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Set on a column whose cell renders its own `<a>` (or anything else that must
|
|
60
|
+
* keep its own click): the row anchor of `rowHref` then skips it and lands on
|
|
61
|
+
* the next column that allows wrapping (Keksdose feedback #451). Without it the
|
|
62
|
+
* fallback can wrap such a cell the moment the headline column is hidden from
|
|
63
|
+
* the settings panel — `<a>` inside `<a>`, which React warns about and which
|
|
64
|
+
* kills the inner link outright, since RowLink cancels the click on the capture
|
|
65
|
+
* phase and the cell's own `stopPropagation` then blocks the row handler too.
|
|
66
|
+
* The rejected alternative was sniffing the rendered output for an anchor: a
|
|
67
|
+
* cell that renders a link is a fact its author knows and the renderer cannot
|
|
68
|
+
* see without reaching into React elements it deliberately treats as opaque.
|
|
69
|
+
*/
|
|
70
|
+
noRowLink?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A column resize in flight. Held in a ref rather than in state: the live width is
|
|
74
|
+
* written straight onto the column's cells, so a drag costs zero renders and the
|
|
75
|
+
* one `setWidths` happens when the pointer comes up. */
|
|
76
|
+
interface Resize {
|
|
77
|
+
key: string;
|
|
78
|
+
pointerId: number;
|
|
79
|
+
startX: number;
|
|
80
|
+
startWidth: number;
|
|
81
|
+
/** What React last rendered this column at, so a CANCELLED drag can put the header
|
|
82
|
+
* back — React cannot, it never saw the inline width the drag wrote. */
|
|
83
|
+
hadWidth: number | undefined;
|
|
84
|
+
/** The header and the body cells under it, collected ONCE when the drag starts.
|
|
85
|
+
* A `<th>` alone cannot shrink a column whose `<td>`s already carry a pinned
|
|
86
|
+
* width — and nothing re-renders during a drag, so the list cannot go stale. */
|
|
87
|
+
cells: HTMLElement[];
|
|
88
|
+
width: number;
|
|
89
|
+
/** A press with no drag in it is not a resize — the handle is also the
|
|
90
|
+
* double-click target that CLEARS a width, and committing the header's current
|
|
91
|
+
* size on the way past would pin every column the user ever brushed. */
|
|
92
|
+
moved: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ServerPagination {
|
|
96
|
+
page: number; // 0-based
|
|
97
|
+
pageSize: number;
|
|
98
|
+
total: number;
|
|
99
|
+
onPageChange: (page: number) => void;
|
|
100
|
+
onPageSizeChange?: (pageSize: number) => void;
|
|
101
|
+
isLoading?: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface DataTableProps<T> {
|
|
105
|
+
rows: T[];
|
|
106
|
+
columns: DataTableColumn<T>[];
|
|
107
|
+
rowKey: (row: T) => string | number;
|
|
108
|
+
defaultPageSize?: number;
|
|
109
|
+
onRowClick?: (row: T) => void;
|
|
110
|
+
/**
|
|
111
|
+
* The URL that shows this row, when one exists — the table then renders a REAL
|
|
112
|
+
* anchor for it (Keksdose feedback #451, "to be able to middle click with the
|
|
113
|
+
* mouse and open in a new tab"). A row that opens through `onRowClick` alone is
|
|
114
|
+
* invisible to the browser: no middle click, no ⌘/Ctrl-click, no "copy link
|
|
115
|
+
* address", no status-bar preview, no long-press menu on a phone. The
|
|
116
|
+
* alternative was an `onAuxClick` that calls `window.open` — it buys back only
|
|
117
|
+
* the middle click and loses the other four, none of which JavaScript can fake.
|
|
118
|
+
*
|
|
119
|
+
* Return `undefined` for a row that has no address of its own. Only wire this up
|
|
120
|
+
* where the OPEN row is genuinely in the URL (a `?row=` param, a detail route);
|
|
121
|
+
* a row whose expansion lives in local component state has no link to hand out,
|
|
122
|
+
* and a link the target page cannot honour is worse than no link.
|
|
123
|
+
*
|
|
124
|
+
* The anchor covers the row's primary cell, not the whole `<tr>`: an `<a>` may
|
|
125
|
+
* not wrap table cells, and the absolutely-positioned row overlay that is the
|
|
126
|
+
* usual workaround would sit on top of the per-cell controls these rows carry
|
|
127
|
+
* (status toggles, the selection checkbox, nested links) and swallow them.
|
|
128
|
+
*/
|
|
129
|
+
rowHref?: (row: T) => string | undefined;
|
|
130
|
+
expandedRow?: (row: T) => ReactNode | null;
|
|
131
|
+
isExpanded?: (row: T) => boolean;
|
|
132
|
+
rowClassName?: (row: T) => string | undefined;
|
|
133
|
+
/**
|
|
134
|
+
* Extra DOM attributes for a row's container — the `<tr>` on desktop, the
|
|
135
|
+
* `<li>` on mobile. For hooks that have to sit on the row itself rather than
|
|
136
|
+
* inside a cell: guided-tour anchors, analytics ids, e2e selectors. Undefined
|
|
137
|
+
* values are dropped, so a per-row conditional needs no filtering at the call
|
|
138
|
+
* site. Not a styling escape hatch — that is `rowClassName`.
|
|
139
|
+
*/
|
|
140
|
+
rowAttributes?: (row: T) => Record<string, string | undefined> | undefined;
|
|
141
|
+
/**
|
|
142
|
+
* Set false for a table that is short by construction — an invoice's line
|
|
143
|
+
* items, a wizard's picks — where the pager is chrome around a list that can
|
|
144
|
+
* never need one. Renders every row and drops the footer entirely. Ignored in
|
|
145
|
+
* `serverPagination` mode, where the pager is the only way to reach the rest
|
|
146
|
+
* of the data.
|
|
147
|
+
*/
|
|
148
|
+
paginated?: boolean;
|
|
149
|
+
empty?: ReactNode;
|
|
150
|
+
/**
|
|
151
|
+
* If set, the table persists filter / sort / page-size state to localStorage under this key.
|
|
152
|
+
* Use a stable, unique key per table (e.g. "transactions-table").
|
|
153
|
+
*/
|
|
154
|
+
storageKey?: string;
|
|
155
|
+
/**
|
|
156
|
+
* When true, mirror filter/sort/page state into URL search params so views are
|
|
157
|
+
* shareable and bookmarkable. URL params used: `f.<column>`, `sort`, `p`, `ps`.
|
|
158
|
+
* URL state takes precedence over localStorage on initial load. Requires the
|
|
159
|
+
* consuming app to render inside a react-router Router.
|
|
160
|
+
*/
|
|
161
|
+
urlSync?: boolean;
|
|
162
|
+
/**
|
|
163
|
+
* When true (desktop only), the table fills its parent's height and scrolls
|
|
164
|
+
* INTERNALLY, with the header pinned — so the page itself doesn't add a second,
|
|
165
|
+
* outer scrollbar. The parent must give it a bounded height (e.g. a flex
|
|
166
|
+
* column inside the viewport-locked app shell). See feedback #207.
|
|
167
|
+
*/
|
|
168
|
+
fillHeight?: boolean;
|
|
169
|
+
/** How tall the scrolling body may get before it scrolls, as a CSS length.
|
|
170
|
+
*
|
|
171
|
+
* Default `calc(100dvh - 12rem)`, which is what every caller had before this
|
|
172
|
+
* existed: a bound is what makes the sticky header work, because a header
|
|
173
|
+
* can only stick to a wrapper that actually scrolls. The cap assumes the
|
|
174
|
+
* table has page chrome above and below it, and a table that is the last
|
|
175
|
+
* thing on its page can afford more — ASPICE Atlas report #12, "for the 25
|
|
176
|
+
* elements there should in best case be no scrolling necessary".
|
|
177
|
+
*
|
|
178
|
+
* Ignored under `fillHeight`, which bounds the body by its flex parent
|
|
179
|
+
* instead and is the stronger statement of the same intent. */
|
|
180
|
+
maxBodyHeight?: string;
|
|
181
|
+
/**
|
|
182
|
+
* When provided, the table renders the supplied rows as-is (no client-side
|
|
183
|
+
* filtering/sorting/slicing) and the pagination footer is driven by these
|
|
184
|
+
* server-controlled values. Column-level filters and sorting are hidden
|
|
185
|
+
* UNLESS the caller takes them over via `onFiltersChange`/`onSortsChange` —
|
|
186
|
+
* the caller is then expected to translate the state into the server query
|
|
187
|
+
* that produces `rows`.
|
|
188
|
+
*/
|
|
189
|
+
serverPagination?: ServerPagination;
|
|
190
|
+
/**
|
|
191
|
+
* Controlled filter/sort state. When the `on*Change` callback is provided
|
|
192
|
+
* the table stops owning that piece of state: it renders `filters`/`sorts`
|
|
193
|
+
* as given and reports user interactions through the callback (including
|
|
194
|
+
* in serverPagination mode, where the UI is otherwise disabled). The owner
|
|
195
|
+
* is responsible for any page reset on change.
|
|
196
|
+
*/
|
|
197
|
+
filters?: FilterState;
|
|
198
|
+
onFiltersChange?: (next: FilterState) => void;
|
|
199
|
+
sorts?: SortState[];
|
|
200
|
+
onSortsChange?: (next: SortState[]) => void;
|
|
201
|
+
/**
|
|
202
|
+
* Opt-in multi-row selection (desktop table only). When provided, a leading
|
|
203
|
+
* checkbox column is rendered with a select-all box in the header; the owner
|
|
204
|
+
* holds the selected set and reacts via the callbacks. Rows for which
|
|
205
|
+
* `isSelectable` returns false render no checkbox and are excluded from
|
|
206
|
+
* select-all. See feedback #285 (bulk edit).
|
|
207
|
+
*/
|
|
208
|
+
selection?: {
|
|
209
|
+
isSelectable?: (row: T) => boolean;
|
|
210
|
+
isSelected: (row: T) => boolean;
|
|
211
|
+
onToggle: (row: T, checked: boolean) => void;
|
|
212
|
+
/**
|
|
213
|
+
* Commit a whole Shift+click range in ONE call (audit 2026-09-22).
|
|
214
|
+
*
|
|
215
|
+
* Optional and purely additive: without it the range is still walked row by
|
|
216
|
+
* row, exactly as before. With it, an owner holding a `Set` copies that Set
|
|
217
|
+
* once instead of once per row — a 400-row range was 400 `onToggle` calls,
|
|
218
|
+
* 400 Set copies and 400 renders of this table, all to reach a state the
|
|
219
|
+
* owner could have reached in one.
|
|
220
|
+
*
|
|
221
|
+
* `rows` is already filtered by `isSelectable` and is in the order the table
|
|
222
|
+
* is showing, so it can be applied as-is.
|
|
223
|
+
*/
|
|
224
|
+
onToggleMany?: (rows: T[], checked: boolean) => void;
|
|
225
|
+
allSelected: boolean;
|
|
226
|
+
someSelected: boolean;
|
|
227
|
+
onToggleAll: (checked: boolean) => void;
|
|
228
|
+
};
|
|
229
|
+
/** User-facing strings (English defaults); pass translated overrides. Merged over
|
|
230
|
+
* `dataTable` from `<UiKitProvider labels>`, so a call site only states what
|
|
231
|
+
* differs for THIS table — typically `table`, its accessible name. */
|
|
232
|
+
labels?: Partial<DataTableLabels>;
|
|
233
|
+
/** BCP-47 locale for the date filter's calendar and the pager's numbers. Falls
|
|
234
|
+
* back to `<UiKitProvider locale>`, then to the runtime's default. */
|
|
235
|
+
locale?: string;
|
|
236
|
+
/** localStorage namespace prefix for `storageKey` persistence. Defaults to
|
|
237
|
+
* "hbui-table:"; pass your app's own prefix to keep a stable namespace. */
|
|
238
|
+
storageKeyPrefix?: string;
|
|
239
|
+
/**
|
|
240
|
+
* On phones, surface the expanded row's detail in a full-screen dialog instead
|
|
241
|
+
* of unfolding it inline (feedback #204). Desktop always uses inline expansion.
|
|
242
|
+
*/
|
|
243
|
+
mobileExpandAsDialog?: boolean;
|
|
244
|
+
/**
|
|
245
|
+
* Group the mobile card list into sections, each with a sticky header — the
|
|
246
|
+
* "assistance"-style list (like the transactions list grouped by date, but by
|
|
247
|
+
* whatever key you return, e.g. a name's first letter). The returned string is
|
|
248
|
+
* the section a row belongs to; rows must already be ordered so equal keys are
|
|
249
|
+
* contiguous (pair with a matching default sort). `mobileGroupLabel` formats
|
|
250
|
+
* the header. Desktop is unaffected. Feedback #317.
|
|
251
|
+
*/
|
|
252
|
+
mobileGroupBy?: (row: T) => string;
|
|
253
|
+
mobileGroupLabel?: (key: string) => ReactNode;
|
|
254
|
+
/**
|
|
255
|
+
* Fully replace the default mobile card body (bold primary + labelled
|
|
256
|
+
* key/value rows) with a compact custom layout, while keeping the shared
|
|
257
|
+
* clickable/expandable row wrapper. Use when the stacked label/value grid
|
|
258
|
+
* wastes space (feedback #317). Desktop is unaffected.
|
|
259
|
+
*/
|
|
260
|
+
mobileCard?: (row: T) => ReactNode;
|
|
261
|
+
/**
|
|
262
|
+
* Swipe actions for a mobile row, revealed by dragging it horizontally. Return
|
|
263
|
+
* `null` (or empty sides) for rows that should not move — a locked row, one whose
|
|
264
|
+
* mutation is in flight, one the user has expanded.
|
|
265
|
+
*
|
|
266
|
+
* Mobile only, and deliberately so: the desktop table already has room for an
|
|
267
|
+
* actions column, and a drag gesture on a pointer device is a worse version of a
|
|
268
|
+
* button. Wraps only the row body, so an expansion panel below stays put while the
|
|
269
|
+
* row above it slides.
|
|
270
|
+
*/
|
|
271
|
+
mobileSwipeActions?: (row: T) => { left?: SwipeAction[]; right?: SwipeAction[] } | null;
|
|
272
|
+
/**
|
|
273
|
+
* Renders as a full-width row above the data rows in the desktop table
|
|
274
|
+
* (mobile has no equivalent list-row slot, so it's desktop-only). For a
|
|
275
|
+
* toggle that shows/hides a slice of rows, style it as an expand/collapse
|
|
276
|
+
* disclosure (chevron + label) so it reads as part of the table rather
|
|
277
|
+
* than a floating control above it. See feedback #10/#11 (transactions'
|
|
278
|
+
* "hide upcoming/scheduled" toggle).
|
|
279
|
+
*/
|
|
280
|
+
leadingRow?: ReactNode;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export type FilterState = Record<string, FilterValue>;
|
|
284
|
+
export type { SortState };
|
|
285
|
+
|
|
286
|
+
/** Drop the undefined entries of a {@link DataTableProps.rowAttributes} map, so a
|
|
287
|
+
* caller can write `{ "data-x": cond ? "y" : undefined }` and get no attribute
|
|
288
|
+
* at all rather than the string "undefined" in the DOM. */
|
|
289
|
+
function cleanAttrs(
|
|
290
|
+
attrs: Record<string, string | undefined> | undefined,
|
|
291
|
+
): Record<string, string> | undefined {
|
|
292
|
+
if (!attrs) return undefined;
|
|
293
|
+
const out: Record<string, string> = {};
|
|
294
|
+
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) out[k] = v;
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A column's name as a plain string.
|
|
300
|
+
*
|
|
301
|
+
* `header` is a ReactNode — an icon, a unit suffix, a tooltip-wrapped span — and
|
|
302
|
+
* neither a live region nor the column-settings checklist can speak a React element.
|
|
303
|
+
* The `key` fallback is the one the settings panel has always used; a column whose
|
|
304
|
+
* header is not a string and whose key is not human-readable is a call site that
|
|
305
|
+
* should pass a string header, and that is visible in what gets announced.
|
|
306
|
+
*/
|
|
307
|
+
function columnLabel<T>(col: DataTableColumn<T>): string {
|
|
308
|
+
return typeof col.header === "string" ? col.header : col.key;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** A click the APP owns. Anything else — middle, ⌘/Ctrl, Shift, Alt — belongs to
|
|
312
|
+
* the browser, and the whole feature is not touching it. */
|
|
313
|
+
const isPlainLeftClick = (e: React.MouseEvent) =>
|
|
314
|
+
e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey;
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The anchor a linkable row is opened through (Keksdose feedback #451).
|
|
318
|
+
*
|
|
319
|
+
* The plain left click is OURS: cancelled, then handed to the row's own handler, so
|
|
320
|
+
* clicking a row still expands it in place instead of reloading the page. Every
|
|
321
|
+
* other click — middle, ⌘/Ctrl, Shift, Alt — is left completely untouched, which is
|
|
322
|
+
* the entire point of using an anchor rather than an `onAuxClick` + `window.open`:
|
|
323
|
+
* that imitation buys back the middle click and still loses ⌘/Ctrl-click, "copy link
|
|
324
|
+
* address", the status-bar preview and the phone's long-press menu.
|
|
325
|
+
*
|
|
326
|
+
* Modified clicks DO stop propagating: on a table with `selection`, ⌘/Ctrl-click and
|
|
327
|
+
* Shift-click on a row mean "select" (feedback #289), and a row must not change its
|
|
328
|
+
* selection while the browser is opening a tab. The rest of the row still selects —
|
|
329
|
+
* the link is one cell, not the row.
|
|
330
|
+
*
|
|
331
|
+
* `draggable={false}` because an anchor otherwise hijacks a horizontal mouse drag as
|
|
332
|
+
* a link-drag: that is both drag-to-select-text on the desktop table and the mouse
|
|
333
|
+
* path through SwipeableRow's gesture on the mobile card.
|
|
334
|
+
*/
|
|
335
|
+
function RowLink({
|
|
336
|
+
href,
|
|
337
|
+
onActivate,
|
|
338
|
+
className,
|
|
339
|
+
children,
|
|
340
|
+
...rest
|
|
341
|
+
}: {
|
|
342
|
+
href: string;
|
|
343
|
+
/** Runs on a plain left click. Omit it where an ANCESTOR already handles the
|
|
344
|
+
* click (the desktop `<tr>`), or the row would toggle twice. */
|
|
345
|
+
onActivate?: () => void;
|
|
346
|
+
className?: string;
|
|
347
|
+
children: ReactNode;
|
|
348
|
+
} & Omit<
|
|
349
|
+
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
|
350
|
+
"href" | "className" | "children" | "onClick" | "onClickCapture"
|
|
351
|
+
>) {
|
|
352
|
+
return (
|
|
353
|
+
<a
|
|
354
|
+
{...rest}
|
|
355
|
+
href={href}
|
|
356
|
+
draggable={false}
|
|
357
|
+
className={className}
|
|
358
|
+
// CAPTURE phase, and it has to be: on the mobile card the whole card is the
|
|
359
|
+
// anchor, so a nested control sits INSIDE it — and the ones that matter
|
|
360
|
+
// (the feedback row's status toggles) stop the click propagating so the row
|
|
361
|
+
// won't also expand. A bubble-phase handler would never run for those, and
|
|
362
|
+
// the browser would follow the link out from under the button press. Cancelling
|
|
363
|
+
// here happens before any descendant can silence the event; `stopPropagation`
|
|
364
|
+
// does not undo a `preventDefault`.
|
|
365
|
+
onClickCapture={(e) => {
|
|
366
|
+
if (isPlainLeftClick(e)) e.preventDefault();
|
|
367
|
+
}}
|
|
368
|
+
onClick={(e) => {
|
|
369
|
+
if (!isPlainLeftClick(e)) {
|
|
370
|
+
e.stopPropagation();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
// Already cancelled above; this phase only decides what opens. A nested
|
|
374
|
+
// control that stopped propagation deliberately never reaches it, which is
|
|
375
|
+
// exactly how the row behaved before it became a link.
|
|
376
|
+
onActivate?.();
|
|
377
|
+
}}
|
|
378
|
+
>
|
|
379
|
+
{children}
|
|
380
|
+
</a>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---------- Main DataTable ----------
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
import { DEFAULT_PERSIST_PREFIX, useTableState } from "./use-table-state";
|
|
389
|
+
import { useMobileReveal } from "./use-mobile-reveal";
|
|
390
|
+
|
|
391
|
+
export function DataTable<T>({
|
|
392
|
+
rows,
|
|
393
|
+
columns,
|
|
394
|
+
rowKey,
|
|
395
|
+
defaultPageSize = 25,
|
|
396
|
+
onRowClick,
|
|
397
|
+
rowHref,
|
|
398
|
+
expandedRow,
|
|
399
|
+
isExpanded,
|
|
400
|
+
rowClassName,
|
|
401
|
+
rowAttributes,
|
|
402
|
+
paginated = true,
|
|
403
|
+
empty,
|
|
404
|
+
storageKey,
|
|
405
|
+
urlSync = false,
|
|
406
|
+
fillHeight = false,
|
|
407
|
+
maxBodyHeight = "calc(100dvh - 12rem)",
|
|
408
|
+
serverPagination,
|
|
409
|
+
filters: filtersProp,
|
|
410
|
+
onFiltersChange,
|
|
411
|
+
sorts: sortsProp,
|
|
412
|
+
onSortsChange,
|
|
413
|
+
selection,
|
|
414
|
+
labels: labelsProp,
|
|
415
|
+
locale: localeProp,
|
|
416
|
+
storageKeyPrefix = DEFAULT_PERSIST_PREFIX,
|
|
417
|
+
mobileExpandAsDialog = false,
|
|
418
|
+
mobileGroupBy,
|
|
419
|
+
mobileGroupLabel,
|
|
420
|
+
mobileCard,
|
|
421
|
+
mobileSwipeActions,
|
|
422
|
+
leadingRow,
|
|
423
|
+
}: DataTableProps<T>) {
|
|
424
|
+
const isServer = !!serverPagination;
|
|
425
|
+
// prop > provider > English, through the table's own resolver rather than
|
|
426
|
+
// `useKitLabels`: that resolver derives `columnsCount` from a translated `columns`,
|
|
427
|
+
// and a plain merge would skip it. `presets` is a record and is merged key by key,
|
|
428
|
+
// as the provider merges it, so a call site renaming one preset keeps the provider's
|
|
429
|
+
// other ten. Memoised because the announcement effects below list `labels` as a
|
|
430
|
+
// dependency, and a fresh object every render would re-run them for nothing.
|
|
431
|
+
const labelOverrides = useKitLabelOverrides("dataTable");
|
|
432
|
+
const labels = useMemo(
|
|
433
|
+
() =>
|
|
434
|
+
resolveDataTableLabels(
|
|
435
|
+
labelOverrides || labelsProp
|
|
436
|
+
? {
|
|
437
|
+
...labelOverrides,
|
|
438
|
+
...labelsProp,
|
|
439
|
+
presets: { ...labelOverrides?.presets, ...labelsProp?.presets },
|
|
440
|
+
}
|
|
441
|
+
: undefined,
|
|
442
|
+
),
|
|
443
|
+
[labelOverrides, labelsProp],
|
|
444
|
+
);
|
|
445
|
+
const locale = useKitLocale(localeProp);
|
|
446
|
+
const [, setSearchParams] = useSearchParams();
|
|
447
|
+
// Sorting, filtering and paging all change WHICH rows are on screen without
|
|
448
|
+
// moving focus — the header button the user pressed is still the header button
|
|
449
|
+
// they are on — so there is no other channel to say it on. See use-announce.ts.
|
|
450
|
+
const { announce, regionProps } = useAnnounce();
|
|
451
|
+
const {
|
|
452
|
+
sorts,
|
|
453
|
+
filters,
|
|
454
|
+
setInternalSorts,
|
|
455
|
+
setInternalFilters,
|
|
456
|
+
page,
|
|
457
|
+
setPage,
|
|
458
|
+
pageSize,
|
|
459
|
+
setPageSize,
|
|
460
|
+
widths,
|
|
461
|
+
setWidths,
|
|
462
|
+
hiddenCols,
|
|
463
|
+
setHiddenCols,
|
|
464
|
+
showSettings,
|
|
465
|
+
setShowSettings,
|
|
466
|
+
headRefs,
|
|
467
|
+
} = useTableState({
|
|
468
|
+
columns,
|
|
469
|
+
storageKey,
|
|
470
|
+
storageKeyPrefix,
|
|
471
|
+
urlSync,
|
|
472
|
+
setSearchParams,
|
|
473
|
+
defaultPageSize,
|
|
474
|
+
sortsProp,
|
|
475
|
+
filtersProp,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Every call site builds `columns` inline — `columns={[{ key: "name", … }]}` right
|
|
479
|
+
// there in the JSX — so the array is a new object on every render of the page around
|
|
480
|
+
// the table, and a `useMemo` keyed on its identity memoises nothing. The three below
|
|
481
|
+
// walk all the rows; a sibling field being typed into re-filtered and re-sorted the
|
|
482
|
+
// whole dataset on every keystroke.
|
|
483
|
+
//
|
|
484
|
+
// What they actually need from `columns` is WHICH columns there are, so that is what
|
|
485
|
+
// they are keyed on. The trade-off is deliberate and narrow: swapping a column's
|
|
486
|
+
// `sortBy`/`filter`/`filterBy` FUNCTION while its `key` stays the same no longer
|
|
487
|
+
// invalidates on its own. A rule that changes is a different column — give it a
|
|
488
|
+
// different key — and anything the rule reads (rows, filters, sorts) is a dependency
|
|
489
|
+
// in its own right.
|
|
490
|
+
const columnSig = columns.map((c) => c.key).join("\u0000");
|
|
491
|
+
// The select filters' declared option lists are the one part of a column definition
|
|
492
|
+
// the memo below reads as DATA rather than as a rule, and a caller may recompute
|
|
493
|
+
// them (a lookup that arrived, a saved view applied) without the column set moving.
|
|
494
|
+
const selectOptionsSig = columns
|
|
495
|
+
.map((c) => {
|
|
496
|
+
const f = resolveFilter(c);
|
|
497
|
+
if (!f || f.type !== "select" || !f.options) return "";
|
|
498
|
+
return `${c.key}:${f.options.map((o) => `${o.value}=${o.label ?? ""}`).join(",")}`;
|
|
499
|
+
})
|
|
500
|
+
.join("\u0000");
|
|
501
|
+
|
|
502
|
+
const selectOptionsByKey = useMemo(() => {
|
|
503
|
+
const map: Record<string, { value: string; label: string }[]> = {};
|
|
504
|
+
for (const col of columns) {
|
|
505
|
+
const f = resolveFilter(col);
|
|
506
|
+
if (!f || f.type !== "select") continue;
|
|
507
|
+
if (f.options && f.options.length > 0) {
|
|
508
|
+
map[col.key] = f.options.map((o) => ({ value: o.value, label: o.label ?? o.value }));
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
const seen = new Set<string>();
|
|
512
|
+
for (const row of rows) {
|
|
513
|
+
const v = f.getValue(row);
|
|
514
|
+
if (v && !seen.has(v)) seen.add(v);
|
|
515
|
+
}
|
|
516
|
+
map[col.key] = Array.from(seen)
|
|
517
|
+
.sort()
|
|
518
|
+
.map((v) => ({ value: v, label: v }));
|
|
519
|
+
}
|
|
520
|
+
return map;
|
|
521
|
+
// `columns` by signature — see the note above the signatures.
|
|
522
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
523
|
+
}, [columnSig, selectOptionsSig, rows]);
|
|
524
|
+
|
|
525
|
+
const filtered = useMemo(() => {
|
|
526
|
+
if (isServer) return rows;
|
|
527
|
+
let result = rows;
|
|
528
|
+
for (const col of columns) {
|
|
529
|
+
const state = filters[col.key];
|
|
530
|
+
if (!state || !isFilterActive(state)) continue;
|
|
531
|
+
result = result.filter((row) => rowMatches(col, row, state, locale));
|
|
532
|
+
}
|
|
533
|
+
return result;
|
|
534
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
535
|
+
}, [rows, columnSig, filters, isServer, locale]);
|
|
536
|
+
|
|
537
|
+
const sorted = useMemo(() => {
|
|
538
|
+
if (isServer) return filtered;
|
|
539
|
+
// Priority chain: the first sort key that distinguishes two rows wins;
|
|
540
|
+
// nulls sort last for that key regardless of direction (as before).
|
|
541
|
+
const chain = sorts
|
|
542
|
+
.map((s) => {
|
|
543
|
+
const col = columns.find((c) => c.key === s.key);
|
|
544
|
+
return col?.sortBy ? { sortBy: col.sortBy, dir: s.dir } : null;
|
|
545
|
+
})
|
|
546
|
+
.filter((c): c is { sortBy: (row: T) => string | number | null | undefined; dir: "asc" | "desc" } => c !== null);
|
|
547
|
+
if (!chain.length) return filtered;
|
|
548
|
+
const copy = [...filtered];
|
|
549
|
+
copy.sort((a, b) => {
|
|
550
|
+
for (const { sortBy, dir } of chain) {
|
|
551
|
+
const av = sortBy(a);
|
|
552
|
+
const bv = sortBy(b);
|
|
553
|
+
if (av == null && bv == null) continue;
|
|
554
|
+
if (av == null) return 1;
|
|
555
|
+
if (bv == null) return -1;
|
|
556
|
+
if (av < bv) return dir === "asc" ? -1 : 1;
|
|
557
|
+
if (av > bv) return dir === "asc" ? 1 : -1;
|
|
558
|
+
}
|
|
559
|
+
return 0;
|
|
560
|
+
});
|
|
561
|
+
return copy;
|
|
562
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
563
|
+
}, [filtered, sorts, columnSig, isServer]);
|
|
564
|
+
|
|
565
|
+
// `paginated={false}` is expressed as "page size = everything" rather than as a
|
|
566
|
+
// second code path, so slicing, the page clamp and the range summary all keep
|
|
567
|
+
// exactly one definition.
|
|
568
|
+
const unpaged = !isServer && !paginated;
|
|
569
|
+
const effectivePageSize = isServer
|
|
570
|
+
? serverPagination!.pageSize
|
|
571
|
+
: unpaged || pageSize === Infinity
|
|
572
|
+
? sorted.length || 1
|
|
573
|
+
: pageSize;
|
|
574
|
+
const paginationTotal = isServer ? serverPagination!.total : sorted.length;
|
|
575
|
+
const paginationPageSize = isServer ? serverPagination!.pageSize : pageSize;
|
|
576
|
+
const totalPages = isServer
|
|
577
|
+
? Math.max(1, Math.ceil(serverPagination!.total / Math.max(1, serverPagination!.pageSize)))
|
|
578
|
+
: Math.max(1, Math.ceil(sorted.length / effectivePageSize));
|
|
579
|
+
const safePage = isServer
|
|
580
|
+
? Math.min(Math.max(0, serverPagination!.page), totalPages - 1)
|
|
581
|
+
: Math.min(page, totalPages - 1);
|
|
582
|
+
const slice =
|
|
583
|
+
isServer || unpaged || pageSize === Infinity
|
|
584
|
+
? sorted
|
|
585
|
+
: sorted.slice(safePage * effectivePageSize, safePage * effectivePageSize + effectivePageSize);
|
|
586
|
+
|
|
587
|
+
const toggleSort = (key: string, additive: boolean) => {
|
|
588
|
+
const next = nextSorts(sorts, key, additive);
|
|
589
|
+
if (onSortsChange) onSortsChange(next);
|
|
590
|
+
else setInternalSorts(next);
|
|
591
|
+
|
|
592
|
+
// Announce the column the user just pressed, not `next[0]`: a shift-click adds a
|
|
593
|
+
// criterion BEHIND the existing one, and answering "Sorted by Date" to a click on
|
|
594
|
+
// Name misreports what the click did. The third click drops the column entirely,
|
|
595
|
+
// which is the state a user is most likely to have reached by accident.
|
|
596
|
+
const col = columns.find((c) => c.key === key);
|
|
597
|
+
const name = col ? columnLabel(col) : key;
|
|
598
|
+
const entry = next.find((s) => s.key === key);
|
|
599
|
+
announce(
|
|
600
|
+
!entry
|
|
601
|
+
? labels.sortCleared(name)
|
|
602
|
+
: entry.dir === "asc"
|
|
603
|
+
? labels.sortedAscending(name)
|
|
604
|
+
: labels.sortedDescending(name),
|
|
605
|
+
);
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
const setFilterValue = (key: string, value: FilterValue) => {
|
|
609
|
+
if (onFiltersChange) {
|
|
610
|
+
onFiltersChange({ ...filters, [key]: value });
|
|
611
|
+
return; // page reset is the controlling owner's job
|
|
612
|
+
}
|
|
613
|
+
setInternalFilters((s) => ({ ...s, [key]: value }));
|
|
614
|
+
setPage(0);
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const clearFilter = (key: string) => {
|
|
618
|
+
if (onFiltersChange) {
|
|
619
|
+
const rest = { ...filters };
|
|
620
|
+
delete rest[key];
|
|
621
|
+
onFiltersChange(rest);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
setInternalFilters((s) => {
|
|
625
|
+
const rest = { ...s };
|
|
626
|
+
delete rest[key];
|
|
627
|
+
return rest;
|
|
628
|
+
});
|
|
629
|
+
setPage(0);
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
// Clear every column filter at once (drives the mobile filter sheet's
|
|
633
|
+
// "clear all"). Only touches keys the table actually exposes as filters.
|
|
634
|
+
const clearAllFilters = () => {
|
|
635
|
+
const keys = columns
|
|
636
|
+
.filter((col) => ((!isServer || onFiltersChange) ? resolveFilter(col) : null))
|
|
637
|
+
.map((col) => col.key);
|
|
638
|
+
if (!keys.length) return;
|
|
639
|
+
if (onFiltersChange) {
|
|
640
|
+
const rest = { ...filters };
|
|
641
|
+
for (const k of keys) delete rest[k];
|
|
642
|
+
onFiltersChange(rest);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
setInternalFilters((s) => {
|
|
646
|
+
const rest = { ...s };
|
|
647
|
+
for (const k of keys) delete rest[k];
|
|
648
|
+
return rest;
|
|
649
|
+
});
|
|
650
|
+
setPage(0);
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
// The result count only exists AFTER the table has re-filtered, so the sentence is
|
|
654
|
+
// spoken from the render that produced the new number rather than from the handler
|
|
655
|
+
// that changed the filter. Keyed on a serialised copy of the filter state, for two
|
|
656
|
+
// reasons: a controlled `filters` prop arrives with a fresh identity on every
|
|
657
|
+
// parent render, and a change the OWNER makes (a saved view applied, a filter chip
|
|
658
|
+
// dismissed outside the table) moves the same rows and deserves the same sentence.
|
|
659
|
+
const filterKey = useMemo(() => JSON.stringify(filters), [filters]);
|
|
660
|
+
// Seeded with the mount value: a live region that speaks on first paint talks over
|
|
661
|
+
// the page the user has just opened, and every table on that page does it at once.
|
|
662
|
+
const announcedFilterKey = useRef(filterKey);
|
|
663
|
+
useEffect(() => {
|
|
664
|
+
if (announcedFilterKey.current === filterKey) return;
|
|
665
|
+
announcedFilterKey.current = filterKey;
|
|
666
|
+
// A server-paginated table does not do the filtering: the matching rows arrive on
|
|
667
|
+
// some later render from the caller, and `serverPagination.total` is ALREADY the
|
|
668
|
+
// filtered total — so the "n of m" sentence has no second number to give here.
|
|
669
|
+
// Saying nothing is better than announcing the page size as a result count.
|
|
670
|
+
if (isServer) return;
|
|
671
|
+
announce(labels.filterResults(filtered.length, rows.length));
|
|
672
|
+
}, [filterKey, filtered.length, rows.length, isServer, announce, labels]);
|
|
673
|
+
|
|
674
|
+
// 1-based, because that is what the page strip shows and what a person says.
|
|
675
|
+
const goToPage = (p: number) => {
|
|
676
|
+
if (isServer) serverPagination!.onPageChange(p);
|
|
677
|
+
else setPage(p);
|
|
678
|
+
announce(labels.pageChanged(p + 1, totalPages));
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
const visibleColumns = useMemo(
|
|
682
|
+
() => columns.filter((c) => !hiddenCols.has(c.key)),
|
|
683
|
+
[columns, hiddenCols],
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
// Which cell carries the row's link when `rowHref` is set (feedback #451). The
|
|
687
|
+
// `mobilePrimary` column is the row's headline by declaration, so it is both the
|
|
688
|
+
// widest target and the one the pointer is already over; falls back to the first
|
|
689
|
+
// column, and falls back again if the user has hidden the primary one from the
|
|
690
|
+
// column panel. Resolved from `visibleColumns` for exactly that reason — off a
|
|
691
|
+
// raw `columns` lookup a hidden headline would leave the row with no link at all.
|
|
692
|
+
//
|
|
693
|
+
// `noRowLink` columns are skipped in BOTH steps: wrapping a cell that owns an
|
|
694
|
+
// anchor nests two links and disables the inner one (see the prop's note). If
|
|
695
|
+
// nothing visible is left to wrap, the row gets NO anchor — an anchor over the
|
|
696
|
+
// one cell there is would cancel that cell's own click and hand back nothing,
|
|
697
|
+
// and a row without a link still behaves exactly as it did before #451.
|
|
698
|
+
const linkColumn = rowHref
|
|
699
|
+
? (visibleColumns.find((c) => c.mobilePrimary && !c.noRowLink) ??
|
|
700
|
+
visibleColumns.find((c) => !c.noRowLink))
|
|
701
|
+
: undefined;
|
|
702
|
+
|
|
703
|
+
// ---- Column resize ----
|
|
704
|
+
//
|
|
705
|
+
// Pointer events with capture, rather than a `mousemove`/`mouseup` pair on `window`.
|
|
706
|
+
// Three defects in one eleven-line handler (audit 2026-09-22):
|
|
707
|
+
//
|
|
708
|
+
// * a `setWidths` per `mousemove` re-rendered every row of the table on every pixel
|
|
709
|
+
// of the drag — on a 500-row table that is the whole body, sixty times a second;
|
|
710
|
+
// * the two `window` listeners were added from inside an event handler, so nothing
|
|
711
|
+
// removed them if the table unmounted mid-drag (the row beneath opens a route),
|
|
712
|
+
// and `document.body.style` stayed at `user-select: none; cursor: col-resize` for
|
|
713
|
+
// the rest of the session — the whole page unselectable, with the wrong cursor;
|
|
714
|
+
// * `mousedown` is a mouse. Under a finger the handle did nothing at all.
|
|
715
|
+
//
|
|
716
|
+
// `setPointerCapture` answers all three: the handle itself receives every move and
|
|
717
|
+
// the terminating `pointerup`/`pointercancel` wherever the pointer travels, so there
|
|
718
|
+
// is nothing global to leak, and a touch drag works because a finger is a pointer.
|
|
719
|
+
// The live width is written straight onto the column's own cells — the same three
|
|
720
|
+
// properties the render sets, so the drag and the commit cannot disagree — and
|
|
721
|
+
// `setWidths` is called once, when the drag ends.
|
|
722
|
+
const resizing = useRef<Resize | null>(null);
|
|
723
|
+
|
|
724
|
+
/** Every cell in one column, header first. `data-col` rather than a cell index:
|
|
725
|
+
* an expanded row holds a single `colSpan` cell, so the nth `<td>` of a row is
|
|
726
|
+
* not reliably the nth column. */
|
|
727
|
+
const columnCells = (key: string): HTMLElement[] => {
|
|
728
|
+
const th = headRefs.get(key);
|
|
729
|
+
if (!th) return [];
|
|
730
|
+
const table = th.closest("table");
|
|
731
|
+
if (!table) return [th];
|
|
732
|
+
return [
|
|
733
|
+
th,
|
|
734
|
+
...Array.from(table.querySelectorAll<HTMLElement>("tbody [data-col]")).filter(
|
|
735
|
+
(el) => el.dataset.col === key,
|
|
736
|
+
),
|
|
737
|
+
];
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const applyLiveWidth = (cells: HTMLElement[], width: number | undefined) => {
|
|
741
|
+
const px = width == null ? "" : `${width}px`;
|
|
742
|
+
for (const el of cells) {
|
|
743
|
+
el.style.width = px;
|
|
744
|
+
el.style.minWidth = px;
|
|
745
|
+
el.style.maxWidth = px;
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
const releaseResizeChrome = (handle: Element, pointerId: number) => {
|
|
750
|
+
document.body.style.userSelect = "";
|
|
751
|
+
document.body.style.cursor = "";
|
|
752
|
+
// Optional calls: jsdom implements neither, and the handlers sit on the handle
|
|
753
|
+
// itself, so a run without capture behaves the same for anything that stays
|
|
754
|
+
// inside the element.
|
|
755
|
+
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture?.(pointerId);
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
const startResize = (e: React.PointerEvent<HTMLElement>, key: string) => {
|
|
759
|
+
// Secondary buttons open menus; only a primary press starts a drag.
|
|
760
|
+
if (e.button !== 0) return;
|
|
761
|
+
e.preventDefault();
|
|
762
|
+
e.stopPropagation();
|
|
763
|
+
const th = headRefs.get(key);
|
|
764
|
+
const startWidth = th?.getBoundingClientRect().width ?? 100;
|
|
765
|
+
resizing.current = {
|
|
766
|
+
key,
|
|
767
|
+
pointerId: e.pointerId,
|
|
768
|
+
startX: e.clientX,
|
|
769
|
+
startWidth,
|
|
770
|
+
hadWidth: widths[key],
|
|
771
|
+
cells: columnCells(key),
|
|
772
|
+
width: startWidth,
|
|
773
|
+
moved: false,
|
|
774
|
+
};
|
|
775
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
776
|
+
document.body.style.userSelect = "none";
|
|
777
|
+
document.body.style.cursor = "col-resize";
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
const moveResize = (e: React.PointerEvent<HTMLElement>) => {
|
|
781
|
+
const drag = resizing.current;
|
|
782
|
+
if (!drag || drag.pointerId !== e.pointerId) return;
|
|
783
|
+
drag.width = Math.max(40, Math.round(drag.startWidth + (e.clientX - drag.startX)));
|
|
784
|
+
drag.moved = true;
|
|
785
|
+
applyLiveWidth(drag.cells, drag.width);
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
const finishResize = (e: React.PointerEvent<HTMLElement>, commit: boolean) => {
|
|
789
|
+
const drag = resizing.current;
|
|
790
|
+
// Before the early return: the page gets its selection and its cursor back even
|
|
791
|
+
// for a pointer we were not tracking.
|
|
792
|
+
releaseResizeChrome(e.currentTarget, e.pointerId);
|
|
793
|
+
if (!drag || drag.pointerId !== e.pointerId) return;
|
|
794
|
+
resizing.current = null;
|
|
795
|
+
if (commit && drag.moved) {
|
|
796
|
+
setWidths((w) => ({ ...w, [drag.key]: drag.width }));
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
// Cancelled — the browser took the pointer for a system gesture — or the press
|
|
800
|
+
// never became a drag. Either way the column goes back to the width React last
|
|
801
|
+
// rendered it at, which nothing else can do: the inline width the drag wrote is
|
|
802
|
+
// invisible to React, so a re-render with an unchanged `widths` would keep it.
|
|
803
|
+
applyLiveWidth(drag.cells, drag.hadWidth);
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
// The last exit path. A table that unmounts mid-drag takes the handle, and with it
|
|
807
|
+
// every handler above, out of the document — this is the only place left to hand the
|
|
808
|
+
// page back its text selection and its cursor.
|
|
809
|
+
useEffect(
|
|
810
|
+
() => () => {
|
|
811
|
+
if (!resizing.current) return;
|
|
812
|
+
resizing.current = null;
|
|
813
|
+
document.body.style.userSelect = "";
|
|
814
|
+
document.body.style.cursor = "";
|
|
815
|
+
},
|
|
816
|
+
[],
|
|
817
|
+
);
|
|
818
|
+
|
|
819
|
+
const autoSizeAll = () => {
|
|
820
|
+
// Clear widths so the table reflows to natural sizes, then snapshot each
|
|
821
|
+
// column's width and write it back — except for the last visible column,
|
|
822
|
+
// which is left unconstrained so it absorbs any remaining horizontal space.
|
|
823
|
+
setWidths({});
|
|
824
|
+
requestAnimationFrame(() => {
|
|
825
|
+
const next: Record<string, number> = {};
|
|
826
|
+
const cols = columns.filter((c) => !hiddenCols.has(c.key));
|
|
827
|
+
cols.forEach((col, idx) => {
|
|
828
|
+
if (idx === cols.length - 1) return;
|
|
829
|
+
const th = headRefs.get(col.key);
|
|
830
|
+
if (th) next[col.key] = Math.ceil(th.getBoundingClientRect().width);
|
|
831
|
+
});
|
|
832
|
+
setWidths(next);
|
|
833
|
+
});
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
const visibleCount = visibleColumns.length;
|
|
837
|
+
const totalCount = columns.length;
|
|
838
|
+
// Spans the full row width including the optional leading selection column.
|
|
839
|
+
const totalColSpan = visibleCount + (selection ? 1 : 0);
|
|
840
|
+
const columnsCountLabel = labels.columnsCount(visibleCount, totalCount);
|
|
841
|
+
// Match Tailwind's `md` breakpoint: we render either the table or the card
|
|
842
|
+
// list — never both — so we don't double up DOM nodes that screen readers and
|
|
843
|
+
// integration tests would have to disambiguate.
|
|
844
|
+
const isMdUp = useMediaQuery("(min-width: 768px)", true);
|
|
845
|
+
|
|
846
|
+
// ---- Mobile card list (md:hidden) ----
|
|
847
|
+
// Renders the same paged/filtered/sorted slice but as stacked cards instead
|
|
848
|
+
// of a horizontally-scrolling table. Filters / sort / column settings are
|
|
849
|
+
// intentionally hidden here — the mobile layout assumes the user wants to
|
|
850
|
+
// scan rows quickly. Switch to a wider viewport for fine-grained control.
|
|
851
|
+
const mobileColumns = visibleColumns.filter((c) => !c.mobileHidden);
|
|
852
|
+
const mobilePrimaryCol =
|
|
853
|
+
mobileColumns.find((c) => c.mobilePrimary) ?? mobileColumns[0] ?? null;
|
|
854
|
+
const mobileSecondaryColumns = mobileColumns.filter((c) => c !== mobilePrimaryCol);
|
|
855
|
+
// The phone card IS the anchor, so unlike the desktop table there is no other
|
|
856
|
+
// cell to move the row link into: if any cell the card renders owns a link, the
|
|
857
|
+
// card itself cannot be one (feedback #451 — same nesting rule as `linkColumn`
|
|
858
|
+
// above). It then falls back to the pre-#451 role="button" card, which still
|
|
859
|
+
// opens the row and leaves the cell's own link working. A caller's `mobileCard`
|
|
860
|
+
// renderer is opaque to us; a link inside one is the caller's to declare by
|
|
861
|
+
// marking the column it comes from.
|
|
862
|
+
const mobileCardLinkable = mobileColumns.every((c) => !c.noRowLink);
|
|
863
|
+
|
|
864
|
+
const { mobileSlice, canRevealMoreMobile, loadMoreRef } = useMobileReveal({
|
|
865
|
+
rows: sorted,
|
|
866
|
+
slice,
|
|
867
|
+
isServer,
|
|
868
|
+
isMdUp,
|
|
869
|
+
defaultPageSize,
|
|
870
|
+
});
|
|
871
|
+
// The row whose selection was last toggled, so Shift+click can select the range up
|
|
872
|
+
// to it (feedback #289). Held as its KEY, not as an index into `slice`: the slice is
|
|
873
|
+
// re-derived from the current sort, filter and page, so an index taken before a
|
|
874
|
+
// header was clicked addresses a different row afterwards — and the range then ran
|
|
875
|
+
// from a row the user had never touched.
|
|
876
|
+
const selectionAnchor = useRef<string | number | null>(null);
|
|
877
|
+
const canSelect = (row: T) => !!selection && (selection.isSelectable?.(row) ?? true);
|
|
878
|
+
const selectRange = (toIndex: number) => {
|
|
879
|
+
if (!selection || selectionAnchor.current === null) return false;
|
|
880
|
+
const fromIndex = slice.findIndex((r) => rowKey(r) === selectionAnchor.current);
|
|
881
|
+
// The anchor has been filtered, sorted or paged off the screen: there is no range
|
|
882
|
+
// on display to draw, so the click falls through to the plain single-row toggle.
|
|
883
|
+
if (fromIndex < 0) return false;
|
|
884
|
+
const [lo, hi] = [fromIndex, toIndex].sort((a, b) => a - b);
|
|
885
|
+
const range: T[] = [];
|
|
886
|
+
for (let i = lo; i <= hi; i++) {
|
|
887
|
+
const r = slice[i];
|
|
888
|
+
if (r && canSelect(r)) range.push(r);
|
|
889
|
+
}
|
|
890
|
+
// One call where the owner can take one. See `selection.onToggleMany`: the row-by-
|
|
891
|
+
// row loop below is the fallback for an owner that has not adopted it, not the
|
|
892
|
+
// shape this should be in.
|
|
893
|
+
if (selection.onToggleMany) selection.onToggleMany(range, true);
|
|
894
|
+
else for (const r of range) selection.onToggle(r, true);
|
|
895
|
+
return true;
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
// When dialog-mode is on, the expanded row's detail goes into a bottom-sheet
|
|
899
|
+
// modal instead of unfolding inline. Only one row is ever expanded at a time.
|
|
900
|
+
const mobileDialogRow = mobileExpandAsDialog && !isMdUp ? mobileSlice.find((r) => isExpanded?.(r)) : undefined;
|
|
901
|
+
const mobileDialogContent = mobileDialogRow ? expandedRow?.(mobileDialogRow) : null;
|
|
902
|
+
// The full-screen row dialog. Its panel, header, scroll lock and Back handling all
|
|
903
|
+
// live in {@link FullBleedDialog} now — this was the only copy of that shell until
|
|
904
|
+
// the transactions create card needed the same one (Keksdose live #307's follow-up),
|
|
905
|
+
// and two copies of a dialog is how the row editor and the create form come to look
|
|
906
|
+
// like two different products.
|
|
907
|
+
const dialogOpen = !!(mobileDialogRow && mobileDialogContent);
|
|
908
|
+
const closeDialog = () => {
|
|
909
|
+
if (mobileDialogRow) onRowClick?.(mobileDialogRow);
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
// One mobile card. Extracted so the flat list and the grouped list (below)
|
|
913
|
+
// share identical row markup.
|
|
914
|
+
const renderMobileRow = (row: T) => {
|
|
915
|
+
const expanded = isExpanded?.(row) ?? false;
|
|
916
|
+
const expansion = expanded ? expandedRow?.(row) : null;
|
|
917
|
+
const tint = rowClassName?.(row);
|
|
918
|
+
// Use div+role="button" rather than a real <button> so cells that
|
|
919
|
+
// contain their own interactive controls (status toggles, action
|
|
920
|
+
// icons) don't end up as illegal nested buttons.
|
|
921
|
+
const interactive = !!onRowClick;
|
|
922
|
+
// Say that the card OPENS something (Keksdose feedback #163). A phone card had
|
|
923
|
+
// only a cursor and a tap-tint to advertise its editor — on the invoice review
|
|
924
|
+
// screen, whose entire purpose is correcting mis-read lines, that read as "these
|
|
925
|
+
// values are not editable". A chevron is the affordance every list on a phone
|
|
926
|
+
// uses for exactly this, and it points the way the row actually opens: right into
|
|
927
|
+
// a sheet, or down into an inline panel that flips it when expanded.
|
|
928
|
+
const opensDetail = interactive && !!expandedRow;
|
|
929
|
+
const swipe = mobileSwipeActions?.(row);
|
|
930
|
+
// An expanded row never swipes: the editor below it owns the horizontal space,
|
|
931
|
+
// and dragging the header away from its own form reads as a glitch.
|
|
932
|
+
const swipeEnabled = !!swipe && !expanded;
|
|
933
|
+
const href = mobileCardLinkable ? rowHref?.(row) : undefined;
|
|
934
|
+
const cardClass = cn(
|
|
935
|
+
"w-full px-4 py-3 text-left flex items-center gap-3",
|
|
936
|
+
// Live #320's other half: the card acknowledges the touch before the sheet
|
|
937
|
+
// arrives. On a cold route the data can take a beat, and an unacknowledged tap
|
|
938
|
+
// reads as "did that register?" — which is most of what "abrupt" means here.
|
|
939
|
+
// `origin-center` + a 0.5% squeeze is deliberately almost subliminal: this fires
|
|
940
|
+
// on every row of a long list, so anything larger becomes the list's personality
|
|
941
|
+
// rather than feedback. `transition-transform` alone, so the existing background
|
|
942
|
+
// flip stays instant.
|
|
943
|
+
interactive &&
|
|
944
|
+
"cursor-pointer transition-transform duration-100 active:scale-[0.995] active:bg-[var(--bg-active)] motion-reduce:transition-none motion-reduce:active:scale-100",
|
|
945
|
+
);
|
|
946
|
+
// The card's content is written once and worn by either tag below. Note that
|
|
947
|
+
// the primary cell is rendered RAW here, never through `linkColumn` — the card
|
|
948
|
+
// itself is the anchor, and routing it through `linkColumn` too would nest an
|
|
949
|
+
// <a> inside an <a>. Desktop and mobile are mutually exclusive (`isMdUp`), so
|
|
950
|
+
// exactly one anchor exists per row.
|
|
951
|
+
const cardInner = (
|
|
952
|
+
<>
|
|
953
|
+
{/* The card's own content keeps the column it always had; the chevron sits
|
|
954
|
+
beside it rather than inside, so a caller's `mobileCard` is untouched. */}
|
|
955
|
+
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
|
956
|
+
{mobileCard ? (
|
|
957
|
+
mobileCard(row)
|
|
958
|
+
) : (
|
|
959
|
+
<>
|
|
960
|
+
{mobilePrimaryCol && <div className="font-medium">{mobilePrimaryCol.cell(row)}</div>}
|
|
961
|
+
{mobileSecondaryColumns.length > 0 && (
|
|
962
|
+
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
|
|
963
|
+
{mobileSecondaryColumns.map((col) => (
|
|
964
|
+
<Fragment key={col.key}>
|
|
965
|
+
<dt className="text-xs uppercase tracking-wide text-[var(--text-muted)] self-center">
|
|
966
|
+
{col.header}
|
|
967
|
+
</dt>
|
|
968
|
+
<dd className="min-w-0 text-[var(--text-secondary)] self-center">
|
|
969
|
+
{col.cell(row)}
|
|
970
|
+
</dd>
|
|
971
|
+
</Fragment>
|
|
972
|
+
))}
|
|
973
|
+
</dl>
|
|
974
|
+
)}
|
|
975
|
+
</>
|
|
976
|
+
)}
|
|
977
|
+
</div>
|
|
978
|
+
{opensDetail &&
|
|
979
|
+
// `aria-hidden`: the row already announces itself as a button with
|
|
980
|
+
// `aria-expanded`, so the icon would only add a second, wordless stop.
|
|
981
|
+
(mobileExpandAsDialog ? (
|
|
982
|
+
<ChevronRight aria-hidden className="size-4 shrink-0 text-[var(--text-placeholder)]" />
|
|
983
|
+
) : (
|
|
984
|
+
<ChevronDown
|
|
985
|
+
aria-hidden
|
|
986
|
+
className={cn(
|
|
987
|
+
"size-4 shrink-0 text-[var(--text-placeholder)] transition-transform",
|
|
988
|
+
expanded && "rotate-180",
|
|
989
|
+
)}
|
|
990
|
+
/>
|
|
991
|
+
))}
|
|
992
|
+
</>
|
|
993
|
+
);
|
|
994
|
+
const body =
|
|
995
|
+
interactive && href ? (
|
|
996
|
+
// A card with a URL of its own is a LINK, not a div wearing role="button"
|
|
997
|
+
// (feedback #451): the phone's long-press "open in new tab" and a screen
|
|
998
|
+
// reader's link semantics both come free, and the fake role goes away.
|
|
999
|
+
<RowLink
|
|
1000
|
+
href={href}
|
|
1001
|
+
onActivate={() => onRowClick!(row)}
|
|
1002
|
+
aria-expanded={expandedRow ? expanded : undefined}
|
|
1003
|
+
className={cardClass}
|
|
1004
|
+
// Enter already activates a link natively, and that routes through
|
|
1005
|
+
// RowLink's own onClick — handling it here too would open the row twice.
|
|
1006
|
+
// Space is the only key left, and only so it opens the row instead of
|
|
1007
|
+
// scrolling the list.
|
|
1008
|
+
onKeyDown={(e) => {
|
|
1009
|
+
if (e.key !== " ") return;
|
|
1010
|
+
e.preventDefault();
|
|
1011
|
+
onRowClick!(row);
|
|
1012
|
+
}}
|
|
1013
|
+
>
|
|
1014
|
+
{cardInner}
|
|
1015
|
+
</RowLink>
|
|
1016
|
+
) : (
|
|
1017
|
+
<div
|
|
1018
|
+
role={interactive ? "button" : undefined}
|
|
1019
|
+
tabIndex={interactive ? 0 : undefined}
|
|
1020
|
+
onClick={interactive ? () => onRowClick!(row) : undefined}
|
|
1021
|
+
onKeyDown={
|
|
1022
|
+
interactive
|
|
1023
|
+
? (e) => {
|
|
1024
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
1025
|
+
e.preventDefault();
|
|
1026
|
+
onRowClick!(row);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
: undefined
|
|
1030
|
+
}
|
|
1031
|
+
aria-expanded={expandedRow ? expanded : undefined}
|
|
1032
|
+
className={cardClass}
|
|
1033
|
+
>
|
|
1034
|
+
{cardInner}
|
|
1035
|
+
</div>
|
|
1036
|
+
);
|
|
1037
|
+
return (
|
|
1038
|
+
<li key={rowKey(row)} className={cn(tint)} {...cleanAttrs(rowAttributes?.(row))}>
|
|
1039
|
+
{swipeEnabled ? (
|
|
1040
|
+
<SwipeableRow enabled left={swipe!.left} right={swipe!.right}>
|
|
1041
|
+
{body}
|
|
1042
|
+
</SwipeableRow>
|
|
1043
|
+
) : (
|
|
1044
|
+
body
|
|
1045
|
+
)}
|
|
1046
|
+
{expansion && !mobileExpandAsDialog && (
|
|
1047
|
+
<div className="px-4 py-3 bg-[var(--bg-surface-2)] border-t border-[var(--border)]">
|
|
1048
|
+
{expansion}
|
|
1049
|
+
</div>
|
|
1050
|
+
)}
|
|
1051
|
+
</li>
|
|
1052
|
+
);
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
// Consecutive grouping of the (already-sorted) mobile slice into labelled
|
|
1056
|
+
// sections for the "assistance"-style list (feedback #317). Null unless the
|
|
1057
|
+
// caller opts in via `mobileGroupBy`.
|
|
1058
|
+
const mobileGroups: { key: string; rows: T[] }[] | null = mobileGroupBy
|
|
1059
|
+
? mobileSlice.reduce<{ key: string; rows: T[] }[]>((acc, row) => {
|
|
1060
|
+
const key = mobileGroupBy(row);
|
|
1061
|
+
const last = acc[acc.length - 1];
|
|
1062
|
+
if (last && last.key === key) last.rows.push(row);
|
|
1063
|
+
else acc.push({ key, rows: [row] });
|
|
1064
|
+
return acc;
|
|
1065
|
+
}, [])
|
|
1066
|
+
: null;
|
|
1067
|
+
|
|
1068
|
+
return (
|
|
1069
|
+
<Card flush className={cn("overflow-clip", fillHeight && "md:flex md:flex-1 md:flex-col md:min-h-0")}>
|
|
1070
|
+
{/* Outside both viewport branches on purpose. A screen reader subscribes to a
|
|
1071
|
+
live region when it encounters it, so one that appears together with its
|
|
1072
|
+
first message is usually missed — and crossing the md breakpoint swaps the
|
|
1073
|
+
card list for the table wholesale, which would remount a region living
|
|
1074
|
+
inside either one. */}
|
|
1075
|
+
<span {...regionProps} />
|
|
1076
|
+
{!isMdUp && (
|
|
1077
|
+
<div>
|
|
1078
|
+
{/* Mobile filter access (feedback #299): the per-column filter popovers
|
|
1079
|
+
live in the desktop header, which the card list doesn't render — so
|
|
1080
|
+
expose the same filters through a bottom sheet here. */}
|
|
1081
|
+
{(!isServer || onFiltersChange) && (
|
|
1082
|
+
<MobileFilters
|
|
1083
|
+
columns={columns}
|
|
1084
|
+
filters={filters}
|
|
1085
|
+
selectOptionsByKey={selectOptionsByKey}
|
|
1086
|
+
isServer={isServer}
|
|
1087
|
+
hasFilterCallback={!!onFiltersChange}
|
|
1088
|
+
onSetFilter={setFilterValue}
|
|
1089
|
+
onClearFilter={clearFilter}
|
|
1090
|
+
onClearAll={clearAllFilters}
|
|
1091
|
+
labels={labels}
|
|
1092
|
+
locale={locale}
|
|
1093
|
+
/>
|
|
1094
|
+
)}
|
|
1095
|
+
<ul className="divide-y divide-[var(--border)]">
|
|
1096
|
+
{mobileGroups
|
|
1097
|
+
? mobileGroups.map((g) => (
|
|
1098
|
+
<Fragment key={`hb-group:${g.key}`}>
|
|
1099
|
+
<li className="sticky top-0 z-10 bg-[var(--bg-surface-2)] px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)] backdrop-blur">
|
|
1100
|
+
{mobileGroupLabel ? mobileGroupLabel(g.key) : g.key}
|
|
1101
|
+
</li>
|
|
1102
|
+
{g.rows.map(renderMobileRow)}
|
|
1103
|
+
</Fragment>
|
|
1104
|
+
))
|
|
1105
|
+
: mobileSlice.map(renderMobileRow)}
|
|
1106
|
+
{mobileSlice.length === 0 && (
|
|
1107
|
+
<li className="px-4 py-6 text-center text-sm text-[var(--text-muted)]">
|
|
1108
|
+
{empty ?? "—"}
|
|
1109
|
+
</li>
|
|
1110
|
+
)}
|
|
1111
|
+
{/* Endless-scroll sentinel: observed by IntersectionObserver to pull in
|
|
1112
|
+
the next chunk as it nears the viewport (feedback #232). */}
|
|
1113
|
+
{canRevealMoreMobile && (
|
|
1114
|
+
<li
|
|
1115
|
+
ref={loadMoreRef}
|
|
1116
|
+
className="px-4 py-4 text-center text-xs text-[var(--text-placeholder)]"
|
|
1117
|
+
>
|
|
1118
|
+
{labels.loading}
|
|
1119
|
+
</li>
|
|
1120
|
+
)}
|
|
1121
|
+
</ul>
|
|
1122
|
+
{/* Client-side mobile lists scroll endlessly (sentinel above); only
|
|
1123
|
+
server-paginated tables keep the pager here. */}
|
|
1124
|
+
{isServer && (
|
|
1125
|
+
<Pagination
|
|
1126
|
+
page={safePage}
|
|
1127
|
+
totalPages={totalPages}
|
|
1128
|
+
pageSize={paginationPageSize}
|
|
1129
|
+
total={paginationTotal}
|
|
1130
|
+
onPage={goToPage}
|
|
1131
|
+
onPageSize={(n) => serverPagination!.onPageSizeChange?.(n)}
|
|
1132
|
+
labels={labels}
|
|
1133
|
+
locale={locale}
|
|
1134
|
+
/>
|
|
1135
|
+
)}
|
|
1136
|
+
{/* The row's own first column is this dialog's title — the same cell the card
|
|
1137
|
+
behind it shows, so the panel names the row it opened from. */}
|
|
1138
|
+
<FullBleedDialog
|
|
1139
|
+
open={dialogOpen}
|
|
1140
|
+
onClose={closeDialog}
|
|
1141
|
+
closeLabel={labels.close}
|
|
1142
|
+
header={mobileDialogRow ? mobilePrimaryCol?.cell(mobileDialogRow) : null}
|
|
1143
|
+
>
|
|
1144
|
+
{mobileDialogContent}
|
|
1145
|
+
</FullBleedDialog>
|
|
1146
|
+
</div>
|
|
1147
|
+
)}
|
|
1148
|
+
{isMdUp && (
|
|
1149
|
+
<div className={cn("flex", fillHeight && "min-h-0 flex-1")}>
|
|
1150
|
+
<div className={cn("relative min-w-0 flex-1", fillHeight && "flex flex-col min-h-0")}>
|
|
1151
|
+
{/* scrollbar-gutter:stable reserves the vertical-scrollbar space up
|
|
1152
|
+
front. Expanding a row adds height, which can bring the scrollbar
|
|
1153
|
+
in, which narrows the content box — and in an `auto` table layout
|
|
1154
|
+
that re-computes every column width. Reserving the gutter is the
|
|
1155
|
+
other half of the `w-0 min-w-full` fix on the expansion cell below
|
|
1156
|
+
(feedback #104: "expanding an item resizes the columns"). */}
|
|
1157
|
+
<div
|
|
1158
|
+
className={cn("overflow-auto [scrollbar-gutter:stable]", fillHeight && "flex-1 min-h-0")}
|
|
1159
|
+
style={fillHeight ? undefined : { maxHeight: maxBodyHeight }}
|
|
1160
|
+
>
|
|
1161
|
+
{/* A table with no name is "table" in a screen reader's list of tables, and
|
|
1162
|
+
an app renders several. `labels.table` is how a call site says which. */}
|
|
1163
|
+
<table className="w-full text-sm" aria-label={labels.table}>
|
|
1164
|
+
{/* Sticky header. position:sticky pins to the nearest scroll-container
|
|
1165
|
+
ancestor — so the header can only stick to THIS wrapper, never the
|
|
1166
|
+
page. For that to actually hold the header in place, the wrapper
|
|
1167
|
+
must be the thing that scrolls vertically: hence overflow-auto +
|
|
1168
|
+
a bounded max-height (rather than overflow-x-auto, which scrolls
|
|
1169
|
+
only sideways and lets the header ride away on page scroll). The
|
|
1170
|
+
header then stays put as you scroll the rows. position:sticky on
|
|
1171
|
+
<thead> isn't reliable across browsers, so we put it on each <th>
|
|
1172
|
+
(see headClassName below). */}
|
|
1173
|
+
<thead className="bg-[var(--bg-surface-2)] text-[var(--text-secondary)]">
|
|
1174
|
+
<tr className="text-left">
|
|
1175
|
+
{selection && (
|
|
1176
|
+
<th
|
|
1177
|
+
scope="col"
|
|
1178
|
+
className="sticky top-0 z-10 w-10 bg-[var(--bg-surface-2)] px-3 py-2 align-middle backdrop-blur-sm"
|
|
1179
|
+
>
|
|
1180
|
+
<input
|
|
1181
|
+
type="checkbox"
|
|
1182
|
+
className="size-4 align-middle accent-indigo-600"
|
|
1183
|
+
checked={selection.allSelected}
|
|
1184
|
+
ref={(el) => {
|
|
1185
|
+
if (el) el.indeterminate = selection.someSelected && !selection.allSelected;
|
|
1186
|
+
}}
|
|
1187
|
+
onChange={(e) => selection.onToggleAll(e.target.checked)}
|
|
1188
|
+
aria-label={labels.selectAllRows}
|
|
1189
|
+
/>
|
|
1190
|
+
</th>
|
|
1191
|
+
)}
|
|
1192
|
+
{visibleColumns.map((col) => {
|
|
1193
|
+
// In server mode the utilities stay hidden unless the caller
|
|
1194
|
+
// takes them over and feeds them back into the server query.
|
|
1195
|
+
const sortable = (!isServer || !!onSortsChange) && !!col.sortBy;
|
|
1196
|
+
const filter = !isServer || onFiltersChange ? resolveFilter(col) : null;
|
|
1197
|
+
const filterState = filters[col.key] ?? (filter ? defaultFilterState(filter) : undefined);
|
|
1198
|
+
const sortIdx = sorts.findIndex((s) => s.key === col.key);
|
|
1199
|
+
const active = sortIdx !== -1;
|
|
1200
|
+
const Icon = active ? (sorts[sortIdx].dir === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown;
|
|
1201
|
+
const filterActive = filterState && isFilterActive(filterState);
|
|
1202
|
+
const isRightAligned = col.headClassName?.includes("text-right");
|
|
1203
|
+
const width = widths[col.key];
|
|
1204
|
+
return (
|
|
1205
|
+
<th
|
|
1206
|
+
key={col.key}
|
|
1207
|
+
ref={(el) => {
|
|
1208
|
+
if (el) headRefs.set(col.key, el);
|
|
1209
|
+
else headRefs.delete(col.key);
|
|
1210
|
+
}}
|
|
1211
|
+
scope="col"
|
|
1212
|
+
// Only a header the user can actually sort says anything: an
|
|
1213
|
+
// `aria-sort` on a plain column is a control that is not there,
|
|
1214
|
+
// and a persisted or URL sort can name a column with no `sortBy`
|
|
1215
|
+
// (the sort chain drops it silently) — which would otherwise
|
|
1216
|
+
// claim a sort the table is not applying.
|
|
1217
|
+
//
|
|
1218
|
+
// "none" on the rest is the half that was missing. Without it a
|
|
1219
|
+
// sortable header is indistinguishable from a plain one until the
|
|
1220
|
+
// user presses it. Secondary criteria read "none" too: ARIA allows
|
|
1221
|
+
// exactly one sorted column, and the priority is carried by the
|
|
1222
|
+
// badge and by what the header button announces on press.
|
|
1223
|
+
aria-sort={
|
|
1224
|
+
sortable
|
|
1225
|
+
? sortIdx === 0
|
|
1226
|
+
? sorts[0].dir === "asc"
|
|
1227
|
+
? "ascending"
|
|
1228
|
+
: "descending"
|
|
1229
|
+
: "none"
|
|
1230
|
+
: undefined
|
|
1231
|
+
}
|
|
1232
|
+
style={width ? { width, minWidth: width, maxWidth: width } : undefined}
|
|
1233
|
+
className={cn(
|
|
1234
|
+
"relative px-3 py-2 font-medium align-middle whitespace-nowrap",
|
|
1235
|
+
// Keep the header visible while the user scrolls the page.
|
|
1236
|
+
// Each <th> carries its own bg so the row doesn't render
|
|
1237
|
+
// transparent over the data rows underneath.
|
|
1238
|
+
"sticky top-0 z-10 bg-[var(--bg-surface-2)] backdrop-blur-sm",
|
|
1239
|
+
col.headClassName,
|
|
1240
|
+
)}
|
|
1241
|
+
>
|
|
1242
|
+
<div
|
|
1243
|
+
className={cn(
|
|
1244
|
+
"flex items-center gap-1",
|
|
1245
|
+
isRightAligned && "flex-row-reverse",
|
|
1246
|
+
)}
|
|
1247
|
+
>
|
|
1248
|
+
<Tooltip label={sortable ? labels.sortHint : undefined} portal>
|
|
1249
|
+
<button
|
|
1250
|
+
type="button"
|
|
1251
|
+
onClick={(e) => sortable && toggleSort(col.key, e.shiftKey)}
|
|
1252
|
+
disabled={!sortable}
|
|
1253
|
+
className={cn(
|
|
1254
|
+
"flex items-center gap-1 text-left",
|
|
1255
|
+
sortable && "hover:text-[var(--text-primary)]",
|
|
1256
|
+
)}
|
|
1257
|
+
>
|
|
1258
|
+
<span>{col.header}</span>
|
|
1259
|
+
{sortable && (
|
|
1260
|
+
<Icon className={cn("size-3", active ? "opacity-100" : "opacity-40")} />
|
|
1261
|
+
)}
|
|
1262
|
+
{/* Priority badge, only meaningful with 2+ sort keys */}
|
|
1263
|
+
{active && sorts.length > 1 && (
|
|
1264
|
+
<span className="text-[9px] font-semibold leading-none text-brand">
|
|
1265
|
+
{sortIdx + 1}
|
|
1266
|
+
</span>
|
|
1267
|
+
)}
|
|
1268
|
+
</button>
|
|
1269
|
+
</Tooltip>
|
|
1270
|
+
{filter && (
|
|
1271
|
+
<Popover
|
|
1272
|
+
width={filter.type === "date" ? 420 : undefined}
|
|
1273
|
+
// The panel is the column's filter, and says so. Unnamed it
|
|
1274
|
+
// was announced as the kit-wide fallback, "Popover".
|
|
1275
|
+
labels={{ panel: labels.filter }}
|
|
1276
|
+
trigger={({ toggle, ref }) => (
|
|
1277
|
+
<button
|
|
1278
|
+
type="button"
|
|
1279
|
+
ref={ref}
|
|
1280
|
+
onClick={toggle}
|
|
1281
|
+
aria-label={labels.filter}
|
|
1282
|
+
className={cn(
|
|
1283
|
+
"rounded p-1 transition-colors",
|
|
1284
|
+
filterActive
|
|
1285
|
+
? "bg-[var(--brand-bg)] text-[var(--brand-muted)]"
|
|
1286
|
+
: "text-[var(--text-placeholder)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
|
|
1287
|
+
)}
|
|
1288
|
+
>
|
|
1289
|
+
<Filter className="size-3" />
|
|
1290
|
+
</button>
|
|
1291
|
+
)}
|
|
1292
|
+
>
|
|
1293
|
+
{() => (
|
|
1294
|
+
<FilterPopover
|
|
1295
|
+
column={col}
|
|
1296
|
+
state={filterState ?? defaultFilterState(filter)}
|
|
1297
|
+
onChange={(next) => setFilterValue(col.key, next)}
|
|
1298
|
+
onClear={() => clearFilter(col.key)}
|
|
1299
|
+
selectOptions={selectOptionsByKey[col.key] ?? []}
|
|
1300
|
+
labels={labels}
|
|
1301
|
+
locale={locale}
|
|
1302
|
+
/>
|
|
1303
|
+
)}
|
|
1304
|
+
</Popover>
|
|
1305
|
+
)}
|
|
1306
|
+
</div>
|
|
1307
|
+
<span
|
|
1308
|
+
role="separator"
|
|
1309
|
+
aria-orientation="vertical"
|
|
1310
|
+
onPointerDown={(e) => startResize(e, col.key)}
|
|
1311
|
+
onPointerMove={moveResize}
|
|
1312
|
+
onPointerUp={(e) => finishResize(e, true)}
|
|
1313
|
+
onPointerCancel={(e) => finishResize(e, false)}
|
|
1314
|
+
onDoubleClick={(e) => {
|
|
1315
|
+
e.preventDefault();
|
|
1316
|
+
setWidths((w) => {
|
|
1317
|
+
const next = { ...w };
|
|
1318
|
+
delete next[col.key];
|
|
1319
|
+
return next;
|
|
1320
|
+
});
|
|
1321
|
+
}}
|
|
1322
|
+
// `touch-none`: without it the browser claims a finger drag for
|
|
1323
|
+
// scrolling before the first `pointermove` ever arrives.
|
|
1324
|
+
className="absolute right-0 top-0 z-10 h-full w-1.5 -translate-x-1/2 cursor-col-resize select-none touch-none hover:bg-[var(--brand-bg-hover)]"
|
|
1325
|
+
/>
|
|
1326
|
+
</th>
|
|
1327
|
+
);
|
|
1328
|
+
})}
|
|
1329
|
+
</tr>
|
|
1330
|
+
</thead>
|
|
1331
|
+
<tbody>
|
|
1332
|
+
{leadingRow && (
|
|
1333
|
+
<tr className="border-t border-[var(--border)]">
|
|
1334
|
+
<td colSpan={totalColSpan} className="p-0">
|
|
1335
|
+
{leadingRow}
|
|
1336
|
+
</td>
|
|
1337
|
+
</tr>
|
|
1338
|
+
)}
|
|
1339
|
+
{slice.map((row, rowIndex) => {
|
|
1340
|
+
const expanded = isExpanded?.(row) ?? false;
|
|
1341
|
+
const expansion = expanded ? expandedRow?.(row) : null;
|
|
1342
|
+
const rowInteractive = !!onRowClick || !!selection;
|
|
1343
|
+
const href = rowHref?.(row);
|
|
1344
|
+
return (
|
|
1345
|
+
<Fragment key={rowKey(row)}>
|
|
1346
|
+
<tr
|
|
1347
|
+
{...cleanAttrs(rowAttributes?.(row))}
|
|
1348
|
+
className={cn(
|
|
1349
|
+
"border-t border-[var(--border)]",
|
|
1350
|
+
rowInteractive && "cursor-pointer hover:bg-[var(--bg-hover)]",
|
|
1351
|
+
rowClassName?.(row),
|
|
1352
|
+
)}
|
|
1353
|
+
onClick={
|
|
1354
|
+
rowInteractive
|
|
1355
|
+
? (e) => {
|
|
1356
|
+
// Modifier-clicks anywhere in the row drive
|
|
1357
|
+
// selection instead of expanding it (feedback #289):
|
|
1358
|
+
// Ctrl/Cmd toggles a single row, Shift selects the
|
|
1359
|
+
// range from the last-toggled row (inclusive of this
|
|
1360
|
+
// one) — the same as the checkbox, but for the whole
|
|
1361
|
+
// row, which is what the checkbox-only version was
|
|
1362
|
+
// missing.
|
|
1363
|
+
if (selection && canSelect(row)) {
|
|
1364
|
+
if (e.shiftKey) {
|
|
1365
|
+
// Drop the text selection a shift-click would
|
|
1366
|
+
// otherwise smear across the rows.
|
|
1367
|
+
window.getSelection?.()?.removeAllRanges();
|
|
1368
|
+
if (selectRange(rowIndex)) return;
|
|
1369
|
+
// No anchor yet: treat it as the first pick.
|
|
1370
|
+
selectionAnchor.current = rowKey(row);
|
|
1371
|
+
selection.onToggle(row, !selection.isSelected(row));
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
if (e.metaKey || e.ctrlKey) {
|
|
1375
|
+
selectionAnchor.current = rowKey(row);
|
|
1376
|
+
selection.onToggle(row, !selection.isSelected(row));
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
onRowClick?.(row);
|
|
1381
|
+
}
|
|
1382
|
+
: undefined
|
|
1383
|
+
}
|
|
1384
|
+
>
|
|
1385
|
+
{selection && (
|
|
1386
|
+
<td
|
|
1387
|
+
className="w-10 px-3 py-2 align-top"
|
|
1388
|
+
// Don't let selecting a row also trigger the row click
|
|
1389
|
+
// (which expands/edits it).
|
|
1390
|
+
onClick={(e) => e.stopPropagation()}
|
|
1391
|
+
>
|
|
1392
|
+
{canSelect(row) && (
|
|
1393
|
+
<input
|
|
1394
|
+
type="checkbox"
|
|
1395
|
+
className="size-4 accent-indigo-600"
|
|
1396
|
+
checked={selection.isSelected(row)}
|
|
1397
|
+
// Shift+click selects the range from the last toggled
|
|
1398
|
+
// row; preventDefault stops the native toggle (and the
|
|
1399
|
+
// onChange that would double-handle it).
|
|
1400
|
+
onClick={(e) => {
|
|
1401
|
+
if (e.shiftKey && selectRange(rowIndex)) e.preventDefault();
|
|
1402
|
+
}}
|
|
1403
|
+
onChange={(e) => {
|
|
1404
|
+
selectionAnchor.current = rowKey(row);
|
|
1405
|
+
selection.onToggle(row, e.target.checked);
|
|
1406
|
+
}}
|
|
1407
|
+
aria-label={labels.selectRow}
|
|
1408
|
+
/>
|
|
1409
|
+
)}
|
|
1410
|
+
</td>
|
|
1411
|
+
)}
|
|
1412
|
+
{visibleColumns.map((col) => {
|
|
1413
|
+
const width = widths[col.key];
|
|
1414
|
+
return (
|
|
1415
|
+
<td
|
|
1416
|
+
key={col.key}
|
|
1417
|
+
data-col={col.key}
|
|
1418
|
+
style={width ? { width, minWidth: width, maxWidth: width } : undefined}
|
|
1419
|
+
className={cn("px-3 py-2 align-top", width && "overflow-hidden text-ellipsis", col.className)}
|
|
1420
|
+
>
|
|
1421
|
+
{col === linkColumn && href ? (
|
|
1422
|
+
// No `onActivate`: the click is left to bubble to the
|
|
1423
|
+
// <tr> handler above, which already owns expand-vs-select.
|
|
1424
|
+
// Calling it here as well would toggle the row twice.
|
|
1425
|
+
<RowLink href={href} className="block">
|
|
1426
|
+
{col.cell(row)}
|
|
1427
|
+
</RowLink>
|
|
1428
|
+
) : (
|
|
1429
|
+
col.cell(row)
|
|
1430
|
+
)}
|
|
1431
|
+
</td>
|
|
1432
|
+
);
|
|
1433
|
+
})}
|
|
1434
|
+
</tr>
|
|
1435
|
+
{expansion && (
|
|
1436
|
+
<tr className="bg-[var(--bg-surface-2)] border-t border-[var(--border)]">
|
|
1437
|
+
{/* The detail panel spans every column. In an `auto` table
|
|
1438
|
+
layout (the default here — columns only get fixed style
|
|
1439
|
+
widths once the user resizes/auto-sizes them) the cell's
|
|
1440
|
+
content preferred-width is redistributed across the
|
|
1441
|
+
spanned columns, so a long expansion visibly re-sizes the
|
|
1442
|
+
columns above it — and one-line content does not, since
|
|
1443
|
+
its preferred width is small (feedback #104). Wrapping the
|
|
1444
|
+
content in `w-0 min-w-full` pins the cell's preferred
|
|
1445
|
+
width to ~0 (the explicit width:0 child), so it can never
|
|
1446
|
+
perturb the columns; the div then fills to the full row
|
|
1447
|
+
width only at paint time, and long text wraps within it. */}
|
|
1448
|
+
<td colSpan={totalColSpan} className="p-0">
|
|
1449
|
+
<div className="w-0 min-w-full px-3 py-3">{expansion}</div>
|
|
1450
|
+
</td>
|
|
1451
|
+
</tr>
|
|
1452
|
+
)}
|
|
1453
|
+
</Fragment>
|
|
1454
|
+
);
|
|
1455
|
+
})}
|
|
1456
|
+
{slice.length === 0 && (
|
|
1457
|
+
<tr>
|
|
1458
|
+
<td
|
|
1459
|
+
colSpan={totalColSpan}
|
|
1460
|
+
className="px-3 py-4 text-center text-[var(--text-muted)]"
|
|
1461
|
+
>
|
|
1462
|
+
{empty ?? "—"}
|
|
1463
|
+
</td>
|
|
1464
|
+
</tr>
|
|
1465
|
+
)}
|
|
1466
|
+
</tbody>
|
|
1467
|
+
</table>
|
|
1468
|
+
</div>
|
|
1469
|
+
{!unpaged && (
|
|
1470
|
+
<Pagination
|
|
1471
|
+
page={safePage}
|
|
1472
|
+
totalPages={totalPages}
|
|
1473
|
+
pageSize={paginationPageSize}
|
|
1474
|
+
total={paginationTotal}
|
|
1475
|
+
onPage={goToPage}
|
|
1476
|
+
onPageSize={
|
|
1477
|
+
isServer
|
|
1478
|
+
? (n) => serverPagination!.onPageSizeChange?.(n)
|
|
1479
|
+
: (n) => {
|
|
1480
|
+
setPageSize(n);
|
|
1481
|
+
setPage(0);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
labels={labels}
|
|
1485
|
+
locale={locale}
|
|
1486
|
+
/>
|
|
1487
|
+
)}
|
|
1488
|
+
</div>
|
|
1489
|
+
<Tooltip label={columnsCountLabel} portal>
|
|
1490
|
+
<button
|
|
1491
|
+
type="button"
|
|
1492
|
+
onClick={() => setShowSettings(true)}
|
|
1493
|
+
aria-label={columnsCountLabel}
|
|
1494
|
+
aria-hidden={showSettings}
|
|
1495
|
+
tabIndex={showSettings ? -1 : 0}
|
|
1496
|
+
className={cn(
|
|
1497
|
+
"group flex shrink-0 items-start justify-center overflow-hidden pt-3 transition-[width] duration-200 ease-out",
|
|
1498
|
+
"border-l border-[var(--border)]",
|
|
1499
|
+
"hover:bg-[var(--bg-hover)]",
|
|
1500
|
+
showSettings ? "w-0 border-l-0" : "w-8 cursor-pointer",
|
|
1501
|
+
)}
|
|
1502
|
+
>
|
|
1503
|
+
<span
|
|
1504
|
+
className="whitespace-nowrap text-xs font-medium tracking-wide text-[var(--text-muted)] group-hover:text-[var(--text-secondary)]"
|
|
1505
|
+
style={{ writingMode: "vertical-rl", transform: "rotate(180deg)" }}
|
|
1506
|
+
>
|
|
1507
|
+
{columnsCountLabel}
|
|
1508
|
+
</span>
|
|
1509
|
+
</button>
|
|
1510
|
+
</Tooltip>
|
|
1511
|
+
<div
|
|
1512
|
+
className={cn(
|
|
1513
|
+
"shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
|
|
1514
|
+
showSettings ? "w-56" : "w-0",
|
|
1515
|
+
)}
|
|
1516
|
+
>
|
|
1517
|
+
<div className="flex w-56 flex-col border-l border-[var(--border)] p-2">
|
|
1518
|
+
<div className="mb-2 flex items-center justify-between gap-1">
|
|
1519
|
+
<div className="text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]">
|
|
1520
|
+
{columnsCountLabel}
|
|
1521
|
+
</div>
|
|
1522
|
+
<Tooltip label={labels.close} portal>
|
|
1523
|
+
<button
|
|
1524
|
+
type="button"
|
|
1525
|
+
onClick={() => setShowSettings(false)}
|
|
1526
|
+
aria-label={labels.close}
|
|
1527
|
+
className="rounded p-0.5 text-[var(--text-placeholder)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
|
|
1528
|
+
>
|
|
1529
|
+
<X className="size-4" />
|
|
1530
|
+
</button>
|
|
1531
|
+
</Tooltip>
|
|
1532
|
+
</div>
|
|
1533
|
+
<button
|
|
1534
|
+
type="button"
|
|
1535
|
+
onClick={autoSizeAll}
|
|
1536
|
+
className="mb-2 rounded border border-[var(--border)] px-2 py-1 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
|
|
1537
|
+
>
|
|
1538
|
+
{labels.autoSize}
|
|
1539
|
+
</button>
|
|
1540
|
+
<div className="flex flex-col gap-0.5">
|
|
1541
|
+
{columns.map((col) => {
|
|
1542
|
+
const checked = !hiddenCols.has(col.key);
|
|
1543
|
+
const headerLabel = columnLabel(col);
|
|
1544
|
+
return (
|
|
1545
|
+
<label
|
|
1546
|
+
key={col.key}
|
|
1547
|
+
className="inline-flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 text-xs hover:bg-[var(--bg-hover)]"
|
|
1548
|
+
>
|
|
1549
|
+
<input
|
|
1550
|
+
type="checkbox"
|
|
1551
|
+
className="size-3.5"
|
|
1552
|
+
checked={checked}
|
|
1553
|
+
onChange={(e) => {
|
|
1554
|
+
setHiddenCols((s) => {
|
|
1555
|
+
const next = new Set(s);
|
|
1556
|
+
if (e.target.checked) next.delete(col.key);
|
|
1557
|
+
else next.add(col.key);
|
|
1558
|
+
return next;
|
|
1559
|
+
});
|
|
1560
|
+
}}
|
|
1561
|
+
/>
|
|
1562
|
+
<span className="truncate">{headerLabel}</span>
|
|
1563
|
+
</label>
|
|
1564
|
+
);
|
|
1565
|
+
})}
|
|
1566
|
+
</div>
|
|
1567
|
+
</div>
|
|
1568
|
+
</div>
|
|
1569
|
+
</div>
|
|
1570
|
+
)}
|
|
1571
|
+
</Card>
|
|
1572
|
+
);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// ---------- Mobile filter sheet ----------
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* Filter access for the mobile card list (feedback #299). The desktop table
|
|
1579
|
+
* houses per-column filters in its header; the card list has no header, so the
|
|
1580
|
+
* same filters were unreachable on phones. This renders a compact "Filters" bar
|
|
1581
|
+
* that opens a bottom sheet listing each filterable column as a collapsible
|
|
1582
|
+
* section, reusing the very same {@link FilterPopover} and filter state the
|
|
1583
|
+
* desktop table drives — so it filters identically and stays URL-synced.
|
|
1584
|
+
*/
|
|
1585
|
+
function MobileFilters<T>({
|
|
1586
|
+
columns,
|
|
1587
|
+
filters,
|
|
1588
|
+
selectOptionsByKey,
|
|
1589
|
+
isServer,
|
|
1590
|
+
hasFilterCallback,
|
|
1591
|
+
onSetFilter,
|
|
1592
|
+
onClearFilter,
|
|
1593
|
+
onClearAll,
|
|
1594
|
+
labels,
|
|
1595
|
+
locale,
|
|
1596
|
+
}: {
|
|
1597
|
+
columns: DataTableColumn<T>[];
|
|
1598
|
+
filters: FilterState;
|
|
1599
|
+
selectOptionsByKey: Record<string, { value: string; label: string }[]>;
|
|
1600
|
+
isServer: boolean;
|
|
1601
|
+
hasFilterCallback: boolean;
|
|
1602
|
+
onSetFilter: (key: string, value: FilterValue) => void;
|
|
1603
|
+
onClearFilter: (key: string) => void;
|
|
1604
|
+
onClearAll: () => void;
|
|
1605
|
+
labels: DataTableLabels;
|
|
1606
|
+
locale?: string;
|
|
1607
|
+
}) {
|
|
1608
|
+
const [open, setOpen] = useState(false);
|
|
1609
|
+
const [expanded, setExpanded] = useState<string | null>(null);
|
|
1610
|
+
const backdropClose = useBackdropClose(() => setOpen(false));
|
|
1611
|
+
useBodyScrollLock(open);
|
|
1612
|
+
// Back dismisses the filter sheet too (Keksdose feedback #172) — it is the same
|
|
1613
|
+
// full-screen surface, and the filters it sets are already in the URL, so closing
|
|
1614
|
+
// it never loses anything.
|
|
1615
|
+
useOverlayHistory(open, () => setOpen(false));
|
|
1616
|
+
|
|
1617
|
+
const filterable = useMemo(
|
|
1618
|
+
() =>
|
|
1619
|
+
columns
|
|
1620
|
+
.map((col) => ({ col, filter: !isServer || hasFilterCallback ? resolveFilter(col) : null }))
|
|
1621
|
+
.filter((x): x is { col: DataTableColumn<T>; filter: ColumnFilter<T> } => !!x.filter),
|
|
1622
|
+
[columns, isServer, hasFilterCallback],
|
|
1623
|
+
);
|
|
1624
|
+
|
|
1625
|
+
if (filterable.length === 0) return null;
|
|
1626
|
+
|
|
1627
|
+
const activeCount = filterable.filter(({ col }) => {
|
|
1628
|
+
const s = filters[col.key];
|
|
1629
|
+
return s && isFilterActive(s);
|
|
1630
|
+
}).length;
|
|
1631
|
+
|
|
1632
|
+
return (
|
|
1633
|
+
<div className="flex items-center justify-between gap-2 border-b border-[var(--border)] px-4 py-2">
|
|
1634
|
+
<button
|
|
1635
|
+
type="button"
|
|
1636
|
+
onClick={() => setOpen(true)}
|
|
1637
|
+
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
|
|
1638
|
+
>
|
|
1639
|
+
<Filter className="size-4" />
|
|
1640
|
+
<span>{labels.filters}</span>
|
|
1641
|
+
{activeCount > 0 && (
|
|
1642
|
+
<span className="inline-flex min-w-4 items-center justify-center rounded-full bg-[var(--info-bg)] px-1 text-[11px] font-semibold text-[var(--info)]">
|
|
1643
|
+
{activeCount}
|
|
1644
|
+
</span>
|
|
1645
|
+
)}
|
|
1646
|
+
</button>
|
|
1647
|
+
{activeCount > 0 && (
|
|
1648
|
+
<button
|
|
1649
|
+
type="button"
|
|
1650
|
+
onClick={onClearAll}
|
|
1651
|
+
className="text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
|
1652
|
+
>
|
|
1653
|
+
{labels.clearAll}
|
|
1654
|
+
</button>
|
|
1655
|
+
)}
|
|
1656
|
+
{open &&
|
|
1657
|
+
createPortal(
|
|
1658
|
+
<div
|
|
1659
|
+
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40"
|
|
1660
|
+
role="dialog"
|
|
1661
|
+
aria-modal="true"
|
|
1662
|
+
{...backdropClose}
|
|
1663
|
+
>
|
|
1664
|
+
<div className="flex max-h-[85vh] w-full flex-col overflow-hidden rounded-t-2xl bg-[var(--bg-surface)] shadow-xl">
|
|
1665
|
+
<div className="flex items-center justify-between gap-2 border-b border-[var(--border)] px-4 py-3">
|
|
1666
|
+
<div className="font-medium">{labels.filters}</div>
|
|
1667
|
+
<button
|
|
1668
|
+
type="button"
|
|
1669
|
+
onClick={() => setOpen(false)}
|
|
1670
|
+
aria-label={labels.close}
|
|
1671
|
+
className="-mr-1 rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
|
|
1672
|
+
>
|
|
1673
|
+
<X className="size-5" />
|
|
1674
|
+
</button>
|
|
1675
|
+
</div>
|
|
1676
|
+
<div className="flex-1 divide-y divide-[var(--border)] overflow-y-auto overscroll-contain">
|
|
1677
|
+
{filterable.map(({ col, filter }) => {
|
|
1678
|
+
const state = filters[col.key];
|
|
1679
|
+
const active = !!state && isFilterActive(state);
|
|
1680
|
+
const isItemOpen = expanded === col.key;
|
|
1681
|
+
return (
|
|
1682
|
+
<div key={col.key}>
|
|
1683
|
+
<button
|
|
1684
|
+
type="button"
|
|
1685
|
+
onClick={() => setExpanded(isItemOpen ? null : col.key)}
|
|
1686
|
+
aria-expanded={isItemOpen}
|
|
1687
|
+
className="flex w-full items-center justify-between gap-2 px-4 py-3 text-left"
|
|
1688
|
+
>
|
|
1689
|
+
<span className="flex items-center gap-2 text-sm font-medium text-[var(--text-secondary)]">
|
|
1690
|
+
{col.header}
|
|
1691
|
+
{active && <span className="size-1.5 rounded-full bg-brand" />}
|
|
1692
|
+
</span>
|
|
1693
|
+
<ChevronDown
|
|
1694
|
+
className={cn(
|
|
1695
|
+
"size-4 shrink-0 text-[var(--text-placeholder)] transition-transform",
|
|
1696
|
+
isItemOpen && "rotate-180",
|
|
1697
|
+
)}
|
|
1698
|
+
/>
|
|
1699
|
+
</button>
|
|
1700
|
+
{isItemOpen && (
|
|
1701
|
+
<div className="px-4 pb-3">
|
|
1702
|
+
<FilterPopover
|
|
1703
|
+
column={col}
|
|
1704
|
+
state={state ?? defaultFilterState(filter)}
|
|
1705
|
+
onChange={(next) => onSetFilter(col.key, next)}
|
|
1706
|
+
onClear={() => onClearFilter(col.key)}
|
|
1707
|
+
selectOptions={selectOptionsByKey[col.key] ?? []}
|
|
1708
|
+
labels={labels}
|
|
1709
|
+
locale={locale}
|
|
1710
|
+
/>
|
|
1711
|
+
</div>
|
|
1712
|
+
)}
|
|
1713
|
+
</div>
|
|
1714
|
+
);
|
|
1715
|
+
})}
|
|
1716
|
+
</div>
|
|
1717
|
+
<div className="flex items-center justify-between gap-2 border-t border-[var(--border)] px-4 py-3">
|
|
1718
|
+
<button
|
|
1719
|
+
type="button"
|
|
1720
|
+
onClick={onClearAll}
|
|
1721
|
+
disabled={activeCount === 0}
|
|
1722
|
+
className="text-sm text-[var(--text-muted)] hover:text-[var(--text-secondary)] disabled:opacity-40"
|
|
1723
|
+
>
|
|
1724
|
+
{labels.clearAll}
|
|
1725
|
+
</button>
|
|
1726
|
+
<button
|
|
1727
|
+
type="button"
|
|
1728
|
+
onClick={() => setOpen(false)}
|
|
1729
|
+
className="rounded-md bg-[var(--bg-inverse)] px-4 py-2 text-sm font-medium text-[var(--text-inverse)]"
|
|
1730
|
+
>
|
|
1731
|
+
{labels.done}
|
|
1732
|
+
</button>
|
|
1733
|
+
</div>
|
|
1734
|
+
</div>
|
|
1735
|
+
</div>,
|
|
1736
|
+
document.body,
|
|
1737
|
+
)}
|
|
1738
|
+
</div>
|
|
1739
|
+
);
|
|
1740
|
+
}
|