@krizaka/ui 3.53.0 → 3.55.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/README.md +1 -0
- package/dist/account-menu.d.ts +6 -2
- package/dist/account-menu.js +8 -1
- package/dist/account-menu.js.map +1 -1
- package/dist/account-settings.js +28 -88
- package/dist/account-settings.js.map +1 -1
- package/dist/anatomy-plate.d.ts +4 -1
- package/dist/anatomy-plate.js +4 -0
- package/dist/anatomy-plate.js.map +1 -1
- package/dist/app-header/search.d.ts +63 -0
- package/dist/app-header/search.js +70 -0
- package/dist/app-header/search.js.map +1 -0
- package/dist/app-header.d.ts +88 -4
- package/dist/app-header.js +63 -5
- package/dist/app-header.js.map +1 -1
- package/dist/app-shell.js +31 -40
- package/dist/app-shell.js.map +1 -1
- package/dist/argument-card.js +3 -3
- package/dist/bird-plate.d.ts +3 -1
- package/dist/bird-plate.js +2 -2
- package/dist/bird-plate.js.map +1 -1
- package/dist/blocks.css +202 -27
- package/dist/chapter.d.ts +20 -3
- package/dist/chapter.js +82 -31
- package/dist/chapter.js.map +1 -1
- package/dist/chat.js +2 -2
- package/dist/chat.js.map +1 -1
- package/dist/checkbox.js +1 -1
- package/dist/checkbox.js.map +1 -1
- package/dist/chip.js +5 -98
- package/dist/chip.js.map +1 -1
- package/dist/choice-grid.js +28 -88
- package/dist/choice-grid.js.map +1 -1
- package/dist/{chunk-YGIN2YIY.js → chunk-3X3OF4TI.js} +2 -2
- package/dist/chunk-3X3OF4TI.js.map +1 -0
- package/dist/chunk-5VEVYDVC.js +11 -0
- package/dist/chunk-5VEVYDVC.js.map +1 -0
- package/dist/chunk-7U5TXA5Q.js +105 -0
- package/dist/chunk-7U5TXA5Q.js.map +1 -0
- package/dist/{chunk-KBQD6HE3.js → chunk-AEP3K4YD.js} +3 -3
- package/dist/{chunk-KBQD6HE3.js.map → chunk-AEP3K4YD.js.map} +1 -1
- package/dist/chunk-BDIU5LZB.js +18 -0
- package/dist/chunk-BDIU5LZB.js.map +1 -0
- package/dist/chunk-CSRKYZ53.js +39 -0
- package/dist/chunk-CSRKYZ53.js.map +1 -0
- package/dist/chunk-HEKDKM5Y.js +128 -0
- package/dist/chunk-HEKDKM5Y.js.map +1 -0
- package/dist/chunk-KGLRZMCY.js +16 -0
- package/dist/chunk-KGLRZMCY.js.map +1 -0
- package/dist/chunk-KID32IRJ.js +18 -0
- package/dist/chunk-KID32IRJ.js.map +1 -0
- package/dist/{chunk-ZQW7LF4Y.js → chunk-OIMXKRXF.js} +2 -2
- package/dist/{chunk-YHKTYFOQ.js → chunk-QWKCNSPF.js} +271 -47
- package/dist/chunk-QWKCNSPF.js.map +1 -0
- package/dist/{chunk-4LYKBSOG.js → chunk-USPOIQV5.js} +2 -2
- package/dist/chunk-USPOIQV5.js.map +1 -0
- package/dist/{chunk-7NRSPHAF.js → chunk-VB7MWE2N.js} +2 -2
- package/dist/{chunk-7NRSPHAF.js.map → chunk-VB7MWE2N.js.map} +1 -1
- package/dist/chunk-VIPSZXO4.js +103 -0
- package/dist/chunk-VIPSZXO4.js.map +1 -0
- package/dist/chunk-YCZ7N6Q6.js +385 -0
- package/dist/chunk-YCZ7N6Q6.js.map +1 -0
- package/dist/code-block.js +81 -98
- package/dist/code-block.js.map +1 -1
- package/dist/combobox.js +2 -2
- package/dist/component-page.d.ts +4 -1
- package/dist/component-page.js +5 -0
- package/dist/component-page.js.map +1 -1
- package/dist/continue-prompt.d.ts +4 -1
- package/dist/continue-prompt.js +4 -0
- package/dist/continue-prompt.js.map +1 -1
- package/dist/data-table.d.ts +60 -4
- package/dist/data-table.js +19 -7
- package/dist/date-picker.js +2 -2
- package/dist/date-picker.js.map +1 -1
- package/dist/detail-layout.d.ts +28 -17
- package/dist/detail-layout.js +73 -16
- package/dist/detail-layout.js.map +1 -1
- package/dist/doodle.d.ts +21 -0
- package/dist/doodle.js +39 -0
- package/dist/doodle.js.map +1 -0
- package/dist/drop-overlay.d.ts +12 -4
- package/dist/drop-overlay.js +16 -6
- package/dist/drop-overlay.js.map +1 -1
- package/dist/elephant.d.ts +2 -0
- package/dist/elephant.js +4 -0
- package/dist/elephant.js.map +1 -1
- package/dist/field.js +1 -1
- package/dist/field.js.map +1 -1
- package/dist/filter-bar.js +2 -2
- package/dist/force-update-screen.d.ts +5 -1
- package/dist/force-update-screen.js +4 -0
- package/dist/force-update-screen.js.map +1 -1
- package/dist/gallery-page.d.ts +4 -1
- package/dist/gallery-page.js +4 -0
- package/dist/gallery-page.js.map +1 -1
- package/dist/genui.js +5 -5
- package/dist/image-cropper.d.ts +10 -2
- package/dist/image-cropper.js +4 -0
- package/dist/image-cropper.js.map +1 -1
- package/dist/kanban.d.ts +3 -0
- package/dist/kanban.js +4 -0
- package/dist/kanban.js.map +1 -1
- package/dist/landscape.d.ts +4 -1
- package/dist/landscape.js +4 -0
- package/dist/landscape.js.map +1 -1
- package/dist/list.js +1 -1
- package/dist/maintenance-screen.d.ts +5 -1
- package/dist/maintenance-screen.js +4 -0
- package/dist/maintenance-screen.js.map +1 -1
- package/dist/map.js +6 -0
- package/dist/map.js.map +1 -1
- package/dist/markdown.d.ts +5 -1
- package/dist/markdown.js +12 -5
- package/dist/markdown.js.map +1 -1
- package/dist/match-dialog.d.ts +4 -1
- package/dist/match-dialog.js +4 -0
- package/dist/match-dialog.js.map +1 -1
- package/dist/media-capture.d.ts +6 -1
- package/dist/media-capture.js +4 -0
- package/dist/media-capture.js.map +1 -1
- package/dist/meter.js +5 -121
- package/dist/meter.js.map +1 -1
- package/dist/native/map.js +6 -0
- package/dist/native/map.js.map +1 -1
- package/dist/native/markdown.d.ts +9 -2
- package/dist/native/markdown.js +46 -7
- package/dist/native/markdown.js.map +1 -1
- package/dist/native/media-capture.d.ts +1 -0
- package/dist/native/media-capture.js +5 -0
- package/dist/native/media-capture.js.map +1 -1
- package/dist/native/vertical-video.d.ts +8 -2
- package/dist/native/vertical-video.js +6 -0
- package/dist/native/vertical-video.js.map +1 -1
- package/dist/native.d.ts +94 -4
- package/dist/native.js +1236 -1110
- package/dist/native.js.map +1 -1
- package/dist/nav-bar/me.d.ts +32 -0
- package/dist/nav-bar/me.js +23 -0
- package/dist/nav-bar/me.js.map +1 -0
- package/dist/nav-bar.d.ts +60 -2
- package/dist/nav-bar.js +92 -48
- package/dist/nav-bar.js.map +1 -1
- package/dist/notification-preferences.d.ts +151 -0
- package/dist/notification-preferences.js +77 -0
- package/dist/notification-preferences.js.map +1 -0
- package/dist/page-header.js +1 -1
- package/dist/page-header.js.map +1 -1
- package/dist/pointer-light.js +5 -0
- package/dist/pointer-light.js.map +1 -1
- package/dist/product-page.d.ts +4 -1
- package/dist/product-page.js +4 -0
- package/dist/product-page.js.map +1 -1
- package/dist/profile-card.d.ts +119 -1
- package/dist/profile-card.js +68 -21
- package/dist/profile-card.js.map +1 -1
- package/dist/promise-card.d.ts +4 -1
- package/dist/promise-card.js +4 -0
- package/dist/promise-card.js.map +1 -1
- package/dist/quota-meter.d.ts +113 -0
- package/dist/quota-meter.js +58 -0
- package/dist/quota-meter.js.map +1 -0
- package/dist/radio-group.js +6 -8
- package/dist/radio-group.js.map +1 -1
- package/dist/rail-nav.d.ts +18 -2
- package/dist/rail-nav.js +25 -2
- package/dist/rail-nav.js.map +1 -1
- package/dist/retro-tv.d.ts +30 -0
- package/dist/retro-tv.js +124 -0
- package/dist/retro-tv.js.map +1 -0
- package/dist/router.d.ts +12 -1
- package/dist/router.js +85 -8
- package/dist/router.js.map +1 -1
- package/dist/scroll-stage.d.ts +5 -1
- package/dist/scroll-stage.js +5 -0
- package/dist/scroll-stage.js.map +1 -1
- package/dist/search-field.js +1 -1
- package/dist/showcase.d.ts +9 -2
- package/dist/showcase.js +5 -0
- package/dist/showcase.js.map +1 -1
- package/dist/signature.d.ts +19 -0
- package/dist/signature.js +39 -0
- package/dist/signature.js.map +1 -0
- package/dist/site-footer.js +1 -1
- package/dist/sites.js +1 -1
- package/dist/sites.js.map +1 -1
- package/dist/slot.d.ts +1 -1
- package/dist/stat.js +1 -1
- package/dist/stat.js.map +1 -1
- package/dist/status-page.d.ts +4 -1
- package/dist/status-page.js +5 -0
- package/dist/status-page.js.map +1 -1
- package/dist/stuck.d.ts +27 -0
- package/dist/stuck.js +26 -0
- package/dist/stuck.js.map +1 -0
- package/dist/swipe-deck.d.ts +9 -2
- package/dist/swipe-deck.js +5 -0
- package/dist/swipe-deck.js.map +1 -1
- package/dist/switch.js +4 -32
- package/dist/switch.js.map +1 -1
- package/dist/tabs.d.ts +47 -8
- package/dist/tabs.js +62 -12
- package/dist/tabs.js.map +1 -1
- package/dist/tag-input.js +2 -2
- package/dist/tag-selector.js +1 -1
- package/dist/terminal-replay.d.ts +4 -1
- package/dist/terminal-replay.js +4 -0
- package/dist/terminal-replay.js.map +1 -1
- package/dist/tree-view.js +4 -0
- package/dist/tree-view.js.map +1 -1
- package/dist/update-prompt.d.ts +5 -1
- package/dist/update-prompt.js +4 -0
- package/dist/update-prompt.js.map +1 -1
- package/dist/video-player.js +7 -376
- package/dist/video-player.js.map +1 -1
- package/dist/video-trimmer.d.ts +6 -1
- package/dist/video-trimmer.js +4 -0
- package/dist/video-trimmer.js.map +1 -1
- package/package.json +143 -31
- package/registry/account-menu.json +9 -8
- package/registry/account-settings.json +2 -2
- package/registry/achievement.json +3 -4
- package/registry/action-bar.json +3 -3
- package/registry/action-sheet.json +1 -0
- package/registry/activity-feed.json +2 -1
- package/registry/agenda.json +1 -1
- package/registry/alert.json +1 -1
- package/registry/anatomy-plate.json +13 -5
- package/registry/app-header.json +470 -23
- package/registry/app-shell.json +2 -2
- package/registry/argument-card.json +1 -1
- package/registry/audio-player.json +1 -1
- package/registry/auth-layout.json +1 -1
- package/registry/avatar.json +3 -2
- package/registry/badge.json +2 -1
- package/registry/bird-plate.json +16 -2
- package/registry/button.json +7 -6
- package/registry/captcha.json +1 -1
- package/registry/card-grid.json +1 -1
- package/registry/card.json +7 -6
- package/registry/catalogue-page.json +3 -4
- package/registry/changelog-page.json +3 -5
- package/registry/chapter.json +46 -10
- package/registry/chat.json +3 -2
- package/registry/checkbox.json +4 -3
- package/registry/chip-link.json +2 -2
- package/registry/chip-row.json +2 -2
- package/registry/chip.json +3 -2
- package/registry/choice-grid.json +3 -4
- package/registry/code-block.json +1 -1
- package/registry/combobox.json +3 -2
- package/registry/command.json +2 -2
- package/registry/comparison-table.json +1 -1
- package/registry/component-page.json +14 -5
- package/registry/confirm-button.json +4 -4
- package/registry/contact-page.json +1 -1
- package/registry/continue-prompt.json +13 -5
- package/registry/countdown.json +4 -3
- package/registry/cover-hero.json +3 -4
- package/registry/cta-panel.json +1 -1
- package/registry/dashboard-layout.json +1 -1
- package/registry/data-table.json +63 -7
- package/registry/date-picker.json +5 -4
- package/registry/decision-list.json +1 -1
- package/registry/description-list.json +2 -1
- package/registry/detail-layout.json +30 -6
- package/registry/dialog.json +7 -6
- package/registry/docs-article-page.json +3 -4
- package/registry/docs-hub-page.json +1 -1
- package/registry/docs-layout.json +1 -1
- package/registry/docs-nav.json +1 -1
- package/registry/document-viewer.json +3 -5
- package/registry/doodle.json +209 -0
- package/registry/drop-overlay.json +30 -16
- package/registry/dropdown-menu.json +2 -2
- package/registry/elephant.json +15 -7
- package/registry/empty-state.json +2 -1
- package/registry/engine-flow.json +1 -1
- package/registry/error-page.json +3 -5
- package/registry/examples/app-header/family.d.ts +4 -0
- package/registry/examples/app-header/family.js +41 -0
- package/registry/examples/app-header/family.tsx +37 -0
- package/registry/examples/app-header/native/family.d.ts +4 -0
- package/registry/examples/app-header/native/family.dark.png +0 -0
- package/registry/examples/app-header/native/family.js +25 -0
- package/registry/examples/app-header/native/family.light.png +0 -0
- package/registry/examples/app-header/native/family.tsx +33 -0
- package/registry/examples/chapter/album.d.ts +4 -0
- package/registry/examples/chapter/album.js +34 -0
- package/registry/examples/chapter/album.tsx +30 -0
- package/registry/examples/chapter/in-ruled-page.d.ts +4 -0
- package/registry/examples/chapter/in-ruled-page.js +23 -0
- package/registry/examples/chapter/in-ruled-page.tsx +19 -0
- package/registry/examples/data-table/applicant-files.d.ts +4 -0
- package/registry/examples/data-table/applicant-files.js +24 -0
- package/registry/examples/data-table/applicant-files.tsx +23 -0
- package/registry/examples/data-table/checks.d.ts +4 -0
- package/registry/examples/data-table/checks.js +25 -0
- package/registry/examples/data-table/checks.tsx +24 -0
- package/registry/examples/detail-layout/profile.d.ts +4 -0
- package/registry/examples/detail-layout/profile.js +26 -0
- package/registry/examples/detail-layout/profile.tsx +21 -0
- package/registry/examples/doodle/kinds.d.ts +4 -0
- package/registry/examples/doodle/kinds.js +17 -0
- package/registry/examples/doodle/kinds.tsx +14 -0
- package/registry/examples/doodle/margin.d.ts +4 -0
- package/registry/examples/doodle/margin.js +17 -0
- package/registry/examples/doodle/margin.tsx +17 -0
- package/registry/examples/markdown/native/basic.dark.png +0 -0
- package/registry/examples/markdown/native/basic.light.png +0 -0
- package/registry/examples/markdown/native/wide-table.d.ts +4 -0
- package/registry/examples/markdown/native/wide-table.dark.png +0 -0
- package/registry/examples/markdown/native/wide-table.js +23 -0
- package/registry/examples/markdown/native/wide-table.light.png +0 -0
- package/registry/examples/markdown/native/wide-table.tsx +22 -0
- package/registry/examples/markdown/wide-table.d.ts +4 -0
- package/registry/examples/markdown/wide-table.js +28 -0
- package/registry/examples/markdown/wide-table.tsx +23 -0
- package/registry/examples/nav-bar/family-product.js +2 -4
- package/registry/examples/nav-bar/family-product.tsx +3 -5
- package/registry/examples/notification-preferences/default.d.ts +4 -0
- package/registry/examples/notification-preferences/default.js +29 -0
- package/registry/examples/notification-preferences/default.tsx +28 -0
- package/registry/examples/profile-card/banner.d.ts +4 -0
- package/registry/examples/profile-card/banner.js +28 -0
- package/registry/examples/profile-card/banner.tsx +24 -0
- package/registry/examples/profile-card/native/banner.d.ts +4 -0
- package/registry/examples/profile-card/native/banner.dark.png +0 -0
- package/registry/examples/profile-card/native/banner.js +22 -0
- package/registry/examples/profile-card/native/banner.light.png +0 -0
- package/registry/examples/profile-card/native/banner.tsx +18 -0
- package/registry/examples/quota-meter/default.d.ts +4 -0
- package/registry/examples/quota-meter/default.js +26 -0
- package/registry/examples/quota-meter/default.tsx +20 -0
- package/registry/examples/quota-meter/full.d.ts +4 -0
- package/registry/examples/quota-meter/full.js +25 -0
- package/registry/examples/quota-meter/full.tsx +18 -0
- package/registry/examples/rail-nav/shortcuts.d.ts +4 -0
- package/registry/examples/rail-nav/shortcuts.js +49 -0
- package/registry/examples/rail-nav/shortcuts.tsx +40 -0
- package/registry/examples/retro-tv/cabinet.d.ts +4 -0
- package/registry/examples/retro-tv/cabinet.js +12 -0
- package/registry/examples/retro-tv/cabinet.tsx +7 -0
- package/registry/examples/retro-tv/portable.d.ts +4 -0
- package/registry/examples/retro-tv/portable.js +12 -0
- package/registry/examples/retro-tv/portable.tsx +7 -0
- package/registry/examples/signature/both.d.ts +4 -0
- package/registry/examples/signature/both.js +11 -0
- package/registry/examples/signature/both.tsx +6 -0
- package/registry/examples/signature/variants.d.ts +4 -0
- package/registry/examples/signature/variants.js +14 -0
- package/registry/examples/signature/variants.tsx +11 -0
- package/registry/examples/stuck/card.d.ts +4 -0
- package/registry/examples/stuck/card.js +13 -0
- package/registry/examples/stuck/card.tsx +14 -0
- package/registry/examples/stuck/holds.d.ts +4 -0
- package/registry/examples/stuck/holds.js +12 -0
- package/registry/examples/stuck/holds.tsx +16 -0
- package/registry/examples/stuck/tag.d.ts +4 -0
- package/registry/examples/stuck/tag.js +12 -0
- package/registry/examples/stuck/tag.tsx +13 -0
- package/registry/examples/tabs/binder.d.ts +4 -0
- package/registry/examples/tabs/binder.js +22 -0
- package/registry/examples/tabs/binder.tsx +20 -0
- package/registry/examples/tabs/native/binder.d.ts +4 -0
- package/registry/examples/tabs/native/binder.dark.png +0 -0
- package/registry/examples/tabs/native/binder.js +26 -0
- package/registry/examples/tabs/native/binder.light.png +0 -0
- package/registry/examples/tabs/native/binder.tsx +22 -0
- package/registry/fab.json +1 -0
- package/registry/field.json +3 -2
- package/registry/file-upload.json +6 -11
- package/registry/filter-bar.json +2 -2
- package/registry/flock.json +3 -4
- package/registry/footnote.json +2 -3
- package/registry/force-update-screen.json +14 -5
- package/registry/form.json +5 -4
- package/registry/gallery-page.json +14 -5
- package/registry/genui.json +2 -2
- package/registry/glossary-term.json +1 -1
- package/registry/glossary.json +1 -1
- package/registry/glow-card.json +2 -2
- package/registry/goal-gauge.json +2 -1
- package/registry/header.json +1 -0
- package/registry/how-it-works-page.json +3 -5
- package/registry/image-cropper.json +13 -5
- package/registry/image-viewer.json +3 -4
- package/registry/index.json +306 -91
- package/registry/iso.json +1 -1
- package/registry/kanban.json +13 -5
- package/registry/kbd.json +1 -1
- package/registry/kpi-explained.json +2 -2
- package/registry/landing-page.json +3 -5
- package/registry/landscape.json +13 -5
- package/registry/language-switch.json +1 -1
- package/registry/legal-page.json +1 -1
- package/registry/link-card.json +1 -1
- package/registry/list.json +2 -1
- package/registry/loader.json +2 -1
- package/registry/longform-page.json +1 -1
- package/registry/maintenance-screen.json +14 -5
- package/registry/map.json +3 -2
- package/registry/markdown.json +86 -9
- package/registry/marketing-layout.json +1 -1
- package/registry/marquee.json +1 -1
- package/registry/mascot-quote.json +1 -1
- package/registry/mascot-state.json +12 -11
- package/registry/mascots.json +2 -1
- package/registry/match-dialog.json +14 -5
- package/registry/media-capture.json +14 -5
- package/registry/media-gallery.json +3 -5
- package/registry/media-grid.json +1 -1
- package/registry/media-picker.json +2 -3
- package/registry/meta-row.json +2 -1
- package/registry/meter.json +4 -3
- package/registry/nav-bar.json +100 -9
- package/registry/next-actions.json +1 -1
- package/registry/notification-preferences.json +250 -0
- package/registry/number-ticker.json +2 -1
- package/registry/onboarding.json +1 -0
- package/registry/opening-hours.json +1 -1
- package/registry/otp-input.json +1 -0
- package/registry/page-header.json +2 -2
- package/registry/page-hero.json +1 -1
- package/registry/page-index.json +1 -1
- package/registry/page.json +3 -3
- package/registry/pagination.json +2 -1
- package/registry/permission-prompt.json +2 -2
- package/registry/photo-grid.json +4 -4
- package/registry/pointer-light.json +6 -2
- package/registry/popover.json +2 -2
- package/registry/pricing-page.json +4 -6
- package/registry/product-page.json +14 -5
- package/registry/profile-card.json +232 -8
- package/registry/progress.json +4 -3
- package/registry/promise-card.json +13 -5
- package/registry/pull-to-refresh.json +1 -0
- package/registry/quota-meter.json +308 -0
- package/registry/radio-group.json +4 -3
- package/registry/rail-nav.json +48 -10
- package/registry/recipes.json +1 -1
- package/registry/retro-tv.json +478 -0
- package/registry/router.json +3 -3
- package/registry/screen.json +1 -0
- package/registry/scroll-stage.json +14 -5
- package/registry/search-field.json +3 -2
- package/registry/section-backdrop.json +1 -1
- package/registry/segmented.json +1 -0
- package/registry/separator.json +2 -2
- package/registry/service-card.json +1 -1
- package/registry/settings-layout.json +2 -1
- package/registry/showcase.json +14 -5
- package/registry/signature.json +185 -0
- package/registry/site-footer.json +3 -3
- package/registry/sites.json +2 -2
- package/registry/skeleton.json +2 -1
- package/registry/skip-link.json +1 -1
- package/registry/slider.json +5 -4
- package/registry/slot-picker.json +1 -1
- package/registry/slot.json +2 -2
- package/registry/specimen-tag.json +1 -1
- package/registry/spinner.json +4 -3
- package/registry/splash-handoff.json +1 -0
- package/registry/split-view.json +1 -1
- package/registry/stage-banner.json +2 -3
- package/registry/stat.json +2 -2
- package/registry/status-page.json +13 -5
- package/registry/step-progress.json +2 -1
- package/registry/step-showcase.json +3 -5
- package/registry/stepper.json +2 -1
- package/registry/sticky-action.json +1 -1
- package/registry/stuck.json +219 -0
- package/registry/swipe-deck.json +14 -5
- package/registry/swipeable-row.json +1 -0
- package/registry/switch.json +3 -2
- package/registry/tab-bar.json +1 -0
- package/registry/tabs.json +154 -22
- package/registry/tag-input.json +1 -1
- package/registry/tag-selector.json +2 -1
- package/registry/taped-photo.json +1 -1
- package/registry/terminal-replay.json +14 -5
- package/registry/text-style.json +5 -5
- package/registry/theme.json +2 -1
- package/registry/toast.json +2 -1
- package/registry/toc.json +1 -1
- package/registry/tooltip.json +2 -2
- package/registry/tree-view.json +6 -2
- package/registry/txt.json +1 -0
- package/registry/update-prompt.json +14 -5
- package/registry/upload-queue.json +1 -1
- package/registry/verified-badge.json +6 -6
- package/registry/vertical-video.json +8 -3
- package/registry/video-player.json +5 -6
- package/registry/video-trimmer.json +13 -5
- package/registry/virtual.json +2 -2
- package/registry/waveform.json +4 -3
- package/dist/chunk-4LYKBSOG.js.map +0 -1
- package/dist/chunk-HHFWK2HH.js +0 -163
- package/dist/chunk-HHFWK2HH.js.map +0 -1
- package/dist/chunk-R3LTSMS5.js +0 -75
- package/dist/chunk-R3LTSMS5.js.map +0 -1
- package/dist/chunk-YGIN2YIY.js.map +0 -1
- package/dist/chunk-YHKTYFOQ.js.map +0 -1
- /package/dist/{chunk-ZQW7LF4Y.js.map → chunk-OIMXKRXF.js.map} +0 -0
package/registry/data-table.json
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
}
|
|
43
43
|
],
|
|
44
44
|
"bestPractices": [
|
|
45
|
+
"Give the table a `routeKey` (`routeKey=\"clients\"`) so its sort, filters and page are in the address: back, reload and a shared link all work.",
|
|
45
46
|
"Keep `rows` and `columns` stable (module scope, state, a query's data, `useMemo`): a new array at each render recomputes every model.",
|
|
46
47
|
"Give every column a `header` (it labels the value in a card and names its filter) and the row's name a `card: \"title\"` column.",
|
|
47
48
|
"Put `DataTableFilters` in `toolbar`; mark the columns it filters with `filter: \"text\" | \"facet\" | \"range\"`, and give a server table's facets their `options`.",
|
|
@@ -49,6 +50,7 @@
|
|
|
49
50
|
"Say what failed in `error` with a way to retry; say what to do next in `empty` (an `EmptyState` with an action).",
|
|
50
51
|
"Type the columns (`type`: title · badge · status · number · date · link · text) and give the secondary ones a `priority` (2 to 7): the table then never scrolls sideways — columns leave the row for its detail, then the rows become cards. Scrolling is an opt-in (`scroll`).",
|
|
51
52
|
"A `title` column takes the slack and truncates (full value on hover); badges, statuses, numbers and dates are as wide as their content. Do not give a title column a `maxWidth`.",
|
|
53
|
+
"A professional list on a phone reads as list rows, not cards: `phone=\"list\"` draws each row as the title (give it `href`: the whole row is its link), its `priority: 2` columns joined as one line of facts, and the `status` column at the end; the other columns go to the row's detail. Keep it to one or two facts.",
|
|
52
54
|
"Align amounts and counts to the end (`align: \"end\"`) in tabular numbers; pin the name column of a wide table.",
|
|
53
55
|
"Need something the props do not cover? `useDataTable()` gives the TanStack instance — never import `@tanstack/*` in a product."
|
|
54
56
|
],
|
|
@@ -68,7 +70,8 @@
|
|
|
68
70
|
}
|
|
69
71
|
],
|
|
70
72
|
"notes": [
|
|
71
|
-
"A `table` with a `caption` (hidden unless `showCaption`) and `scope=\"col\"` headers; the roles are written out, so the cards keep the table's semantics.",
|
|
73
|
+
"A `table` with a `caption` (hidden unless `showCaption`) and `scope=\"col\"` headers; the roles are written out, so the cards and the list rows keep the table's semantics.",
|
|
74
|
+
"In the list rows the facts are read inside the title's cell (their own cells are hidden at that width, never read twice); the checkbox and the title's link stay in the tab order, above the row's stretched link.",
|
|
72
75
|
"The sorted column carries `aria-sort`; a sortable header is a button that says its state with `aria-pressed`.",
|
|
73
76
|
"An expand button (a row's details, a group, a tree) says its state with `aria-expanded` and names the row; a group's button says its value and its count.",
|
|
74
77
|
"A resize handle is a focusable `separator` named after its column, with its width as `aria-valuenow`.",
|
|
@@ -365,6 +368,13 @@
|
|
|
365
368
|
"description": "The sizes the pagination offers (`[10, 25, 50]`).",
|
|
366
369
|
"required": false
|
|
367
370
|
},
|
|
371
|
+
{
|
|
372
|
+
"name": "phone",
|
|
373
|
+
"type": "\"list\" | \"cards\"",
|
|
374
|
+
"default": "cards",
|
|
375
|
+
"description": "What a row becomes on a phone (under 36 rem, or always with `layout=\"cards\"`): `cards` (a card per row, its values\nlabelled) or `list` — a list row: the selection's checkbox, the `title` column with its `priority: 2` columns joined as\none line of facts, the `status` / `badge` column at the end; the other columns in the row's detail.",
|
|
376
|
+
"required": false
|
|
377
|
+
},
|
|
368
378
|
{
|
|
369
379
|
"name": "pinning",
|
|
370
380
|
"type": "{ start?: readonly string[]; end?: readonly string[]; } | undefined",
|
|
@@ -386,6 +396,13 @@
|
|
|
386
396
|
"description": "Columns can be resized (pointer drag, or the arrow keys on the focused handle).",
|
|
387
397
|
"required": false
|
|
388
398
|
},
|
|
399
|
+
{
|
|
400
|
+
"name": "routeKey",
|
|
401
|
+
"type": "string",
|
|
402
|
+
"default": null,
|
|
403
|
+
"description": "Keeps the sort, the filters and the page in the address (`?clients.sort=…&clients.page=2`): back and forward, a reload\nand a shared link all land on the same table. Replaces `sort`, `filters` and `page`; needs the router's adapter\n(`RouteStateProvider`) in a framework.",
|
|
404
|
+
"required": false
|
|
405
|
+
},
|
|
389
406
|
{
|
|
390
407
|
"name": "rowCount",
|
|
391
408
|
"type": "number",
|
|
@@ -636,6 +653,22 @@
|
|
|
636
653
|
"module": "@krizaka/ui/registry/examples/data-table/cards",
|
|
637
654
|
"code": "\"use client\";\n\nimport { DataTable, type DataTableColumn } from \"@krizaka/ui/data-table\";\n\ntype Video = { id: string; title: string; views: number; tips: string; visibility: string };\nconst videos: Video[] = [\n { id: \"v1\", title: \"Sunset over Montréal\", views: 12840, tips: \"$184.00\", visibility: \"Public\" },\n { id: \"v2\", title: \"Behind the scenes, part 2\", views: 3120, tips: \"$62.50\", visibility: \"Followers\" },\n];\nconst columns: DataTableColumn<Video>[] = [\n { id: \"title\", header: \"Title\", card: \"title\" },\n { id: \"visibility\", header: \"Visibility\" },\n { id: \"views\", header: \"Views\", align: \"end\", cell: (v) => v.views.toLocaleString(\"en\") },\n { id: \"tips\", header: \"Tips\", align: \"end\" },\n];\n\n// A studio's videos on a phone: one card per row, the title on top, every value labelled.\nexport default function DataTableCards() {\n return <DataTable caption=\"Your videos\" rows={videos} columns={columns} getRowId={(v) => v.id} layout=\"cards\" />;\n}\n"
|
|
638
655
|
},
|
|
656
|
+
{
|
|
657
|
+
"name": "checks",
|
|
658
|
+
"title": "List rows on a phone",
|
|
659
|
+
"description": "`phone=\"list\"`: under 36 rem each check is a list row — the selection, the document as the row's link, « Lease · Oct 23 » as one line of facts, the result at the end; a table above.",
|
|
660
|
+
"path": "examples/data-table/checks.tsx",
|
|
661
|
+
"module": "@krizaka/ui/registry/examples/data-table/checks",
|
|
662
|
+
"code": "\"use client\";\n\nimport { Badge } from \"@krizaka/ui/badge\";\nimport { DataTable, type DataTableColumn } from \"@krizaka/ui/data-table\";\n\ntype Check = { id: string; document: string; type: string; check: string; when: string; result: keyof typeof tone };\nconst tone = { Authentic: \"success\", Review: \"warning\", Altered: \"danger\" } as const;\nconst checks: Check[] = [\n { id: \"c1\", document: \"Bail_2026_Tremblay.pdf\", type: \"Lease\", check: \"Authenticity\", when: \"Oct 23\", result: \"Authentic\" },\n { id: \"c2\", document: \"Facture_Hydro_sept.pdf\", type: \"Invoice\", check: \"Authenticity\", when: \"Oct 22\", result: \"Review\" },\n { id: \"c3\", document: \"Releve_paie_0915.pdf\", type: \"Pay stub\", check: \"Income\", when: \"Oct 21\", result: \"Altered\" },\n];\nconst columns: DataTableColumn<Check>[] = [\n { id: \"document\", header: \"Document\", type: \"title\", href: (c) => `#checks/${c.id}` },\n { id: \"type\", header: \"Type\", priority: 2, nowrap: true },\n { id: \"when\", header: \"When\", type: \"date\", priority: 2, cell: (c) => c.when },\n { id: \"check\", header: \"Check\" },\n { id: \"result\", header: \"Result\", type: \"status\", cell: (c) => <Badge size=\"sm\" tone={tone[c.result]}>{c.result}</Badge> },\n];\n\n// On a phone, each check is a list row: the document (its link), « Lease · Oct 23 », the result at the end.\nexport default function DataTableChecks() {\n return <DataTable caption=\"Checks\" rows={checks} columns={columns} getRowId={(c) => c.id} rowLabel={(c) => c.document} phone=\"list\" selectable />;\n}\n"
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
"name": "applicant-files",
|
|
666
|
+
"title": "Applicant files",
|
|
667
|
+
"description": "`layout=\"cards\" phone=\"list\"`: a landlord's files as list rows at every width — the name and unit, the documents and the date as one line, the status.",
|
|
668
|
+
"path": "examples/data-table/applicant-files.tsx",
|
|
669
|
+
"module": "@krizaka/ui/registry/examples/data-table/applicant-files",
|
|
670
|
+
"code": "\"use client\";\n\nimport { Badge } from \"@krizaka/ui/badge\";\nimport { DataTable, type DataTableColumn } from \"@krizaka/ui/data-table\";\n\ntype ApplicantFile = { id: string; applicant: string; unit: string; documents: number; updated: string; status: keyof typeof tone };\nconst tone = { Complete: \"success\", Missing: \"warning\", \"In review\": \"accent\" } as const;\nconst files: ApplicantFile[] = [\n { id: \"f1\", applicant: \"Eric Tremblay\", unit: \"Unit 4B · Demo building\", documents: 6, updated: \"Today\", status: \"Complete\" },\n { id: \"f2\", applicant: \"Eric (co-applicant)\", unit: \"Unit 2A · Demo building\", documents: 3, updated: \"Yesterday\", status: \"Missing\" },\n { id: \"f3\", applicant: \"Demo applicant\", unit: \"Unit 7C · Sample tower\", documents: 5, updated: \"Oct 20\", status: \"In review\" },\n];\nconst columns: DataTableColumn<ApplicantFile>[] = [\n { id: \"applicant\", header: \"Applicant\", type: \"title\", subtitle: (f) => f.unit, href: (f) => `#files/${f.id}` },\n { id: \"documents\", header: \"Documents\", type: \"number\", priority: 2, cell: (f) => `${f.documents} documents` },\n { id: \"updated\", header: \"Updated\", priority: 2 },\n { id: \"status\", header: \"Status\", type: \"status\", cell: (f) => <Badge size=\"sm\" tone={tone[f.status]}>{f.status}</Badge> },\n];\n\n// A landlord's applicant files as list rows on a phone (always, here): the name and unit, one line of facts, the status.\nexport default function DataTableApplicantFiles() {\n return <DataTable caption=\"Applicant files\" rows={files} columns={columns} getRowId={(f) => f.id} rowLabel={(f) => f.applicant} layout=\"cards\" phone=\"list\" selectable />;\n}\n"
|
|
671
|
+
},
|
|
639
672
|
{
|
|
640
673
|
"name": "loading",
|
|
641
674
|
"title": "Loading",
|
|
@@ -678,7 +711,7 @@
|
|
|
678
711
|
},
|
|
679
712
|
{
|
|
680
713
|
"path": "data-table/data-table.tsx",
|
|
681
|
-
"content": "// Client (the table's state, the windowing of long lists). A real `<table>` with a caption and column headers\n// (`scope=\"col\"`, `aria-sort` on the sorted one) that turns into a stack of cards in a narrow container — one DOM, the\n// roles written out so that the change of `display` never loses the table's semantics. The engine is TanStack Table v9\n// (engine.ts): sorting, filters and facets, grouping, expanding, pagination, selection, visibility, pinning, resizing —\n// in the browser, or on the server (`manual` + `onStateChange`). Long lists render by what is in view (`virtualized`).\nimport { useTable } from \"@tanstack/react-table\";\nimport * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\n\nimport { Checkbox } from \"../checkbox\";\nimport { cn } from \"../cn\";\nimport { ChevronGlyph, SortGlyph } from \"../glyphs\";\nimport { useControllable } from \"../media\";\nimport { Pagination, type PaginationLabels } from \"../pagination\";\nimport { useLayoutFallbacks, useVirtualizer } from \"../virtual/virtual\";\nimport {\n type CellValue,\n type ColumnDef,\n type ColumnResizingState,\n type DataTableFilterState,\n type DataTableInstance,\n features,\n FILTERS,\n type FilterValue,\n type RowData,\n sortByValue,\n type TableCell,\n type TableColumn,\n type TableRow,\n} from \"./engine\";\n\nexport const dataTable = tv({\n slots: {\n root: \"@container w-full overflow-clip rounded-xl border border-border-default bg-surface-1\",\n toolbar: \"border-b border-border-default p-3\",\n scroller: \"w-full overflow-x-auto\",\n table: \"w-full border-collapse text-left text-sm text-fg\",\n caption: \"px-4 pt-4 pb-2 text-left text-sm font-semibold text-fg\",\n thead: \"bg-surface-2/60\",\n headRow: \"border-b border-border-default\",\n th: \"relative whitespace-nowrap px-4 py-3 text-xs font-semibold text-fg-secondary\",\n sortButton:\n \"-mx-1.5 inline-flex items-center gap-1 rounded-md px-1.5 py-1 hover:bg-surface-3 hover:text-fg \" +\n \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring aria-[pressed=true]:text-fg\",\n resizer:\n \"absolute top-0 right-0 h-full w-1.5 cursor-col-resize touch-none select-none bg-transparent hover:bg-border-strong \" +\n \"focus-visible:bg-accent focus-visible:outline-hidden data-[resizing]:bg-accent\",\n tbody: \"\",\n tr: \"border-b border-border-subtle transition-colors last:border-b-0 hover:bg-surface-2/50 data-[state=selected]:bg-accent-soft\",\n groupRow: \"bg-surface-2/40 font-semibold\",\n td: \"px-4 py-3 align-middle\",\n select: \"w-10\",\n expander: \"w-10\",\n toggle:\n \"inline-flex h-7 min-w-7 items-center justify-center gap-1.5 rounded-md text-fg-secondary hover:bg-surface-3 hover:text-fg \" +\n \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring [&[aria-expanded=true]>svg]:rotate-90 [&>svg]:transition-transform motion-reduce:[&>svg]:transition-none\",\n detail: \"bg-surface-0 px-4 py-4\",\n message: \"px-4 py-10 text-center text-sm text-fg-secondary\",\n skeleton: \"h-4 w-full max-w-40 animate-pulse rounded bg-surface-3 motion-reduce:animate-none\",\n footer: \"border-t border-border-default px-4 py-3\",\n },\n variants: {\n layout: {\n table: {\n th: \"data-[pin]:sticky data-[pin]:z-[2] data-[pin]:bg-surface-2 data-[pin=start]:left-(--dt-pin) data-[pin=end]:right-(--dt-pin)\",\n td: \"data-[pin]:sticky data-[pin]:z-[1] data-[pin]:bg-surface-1 data-[pin=start]:left-(--dt-pin) data-[pin=end]:right-(--dt-pin)\",\n },\n // Below 36rem of container: cards. The sortable headers stay, as a row of sort buttons above the cards.\n auto: {\n table: \"@max-xl:block\",\n caption: \"@max-xl:block\",\n thead: \"@max-xl:sr-only @max-xl:bg-transparent @max-xl:has-[[data-keep]]:not-sr-only @max-xl:has-[[data-keep]]:block\",\n headRow: \"@max-xl:flex @max-xl:flex-wrap @max-xl:items-center @max-xl:gap-2 @max-xl:px-3 @max-xl:py-2\",\n th:\n \"@max-xl:sr-only @max-xl:data-[keep]:not-sr-only @max-xl:data-[keep]:block @max-xl:p-0 \" +\n \"@xl:data-[pin]:sticky @xl:data-[pin]:z-[2] @xl:data-[pin]:bg-surface-2 @xl:data-[pin=start]:left-(--dt-pin) @xl:data-[pin=end]:right-(--dt-pin)\",\n tbody: \"@max-xl:grid @max-xl:gap-3 @max-xl:p-3\",\n tr: \"@max-xl:relative @max-xl:grid @max-xl:gap-1.5 @max-xl:rounded-xl @max-xl:border @max-xl:border-border-default @max-xl:bg-surface-0 @max-xl:p-4 @max-xl:last:border-b\",\n td:\n \"@max-xl:flex @max-xl:items-baseline @max-xl:justify-between @max-xl:gap-4 @max-xl:p-0 @max-xl:text-right \" +\n \"@max-xl:before:shrink-0 @max-xl:before:text-xs @max-xl:before:font-semibold @max-xl:before:text-fg-secondary @max-xl:before:content-[attr(data-label)] \" +\n \"@max-xl:data-[card=title]:order-first @max-xl:data-[card=title]:pr-8 @max-xl:data-[card=title]:text-left @max-xl:data-[card=title]:text-base @max-xl:data-[card=title]:font-semibold @max-xl:data-[card=title]:before:content-none \" +\n \"@max-xl:data-[card=hidden]:hidden @max-xl:data-[card=message]:block @max-xl:data-[card=message]:text-center @max-xl:data-[card=message]:before:content-none \" +\n \"@xl:data-[pin]:sticky @xl:data-[pin]:z-[1] @xl:data-[pin]:bg-surface-1 @xl:data-[pin=start]:left-(--dt-pin) @xl:data-[pin=end]:right-(--dt-pin)\",\n select: \"@max-xl:absolute @max-xl:right-3 @max-xl:top-3 @max-xl:w-auto @max-xl:before:content-none\",\n expander: \"@max-xl:before:content-none\",\n },\n cards: {\n table: \"block\",\n caption: \"block\",\n thead: \"sr-only bg-transparent has-[[data-keep]]:not-sr-only has-[[data-keep]]:block\",\n headRow: \"flex flex-wrap items-center gap-2 px-3 py-2\",\n th: \"sr-only p-0 data-[keep]:not-sr-only data-[keep]:block\",\n tbody: \"grid gap-3 p-3\",\n tr: \"relative grid gap-1.5 rounded-xl border border-border-default bg-surface-0 p-4 last:border-b\",\n td:\n \"flex items-baseline justify-between gap-4 p-0 text-right before:shrink-0 before:text-xs before:font-semibold before:text-fg-secondary before:content-[attr(data-label)] \" +\n \"data-[card=title]:order-first data-[card=title]:pr-8 data-[card=title]:text-left data-[card=title]:text-base data-[card=title]:font-semibold data-[card=title]:before:content-none \" +\n \"data-[card=hidden]:hidden data-[card=message]:block data-[card=message]:text-center data-[card=message]:before:content-none\",\n select: \"absolute right-3 top-3 w-auto before:content-none\",\n expander: \"before:content-none\",\n },\n },\n density: {\n comfortable: {},\n compact: { th: \"py-2\", td: \"py-1.5\" },\n },\n // The header stays in view while the page scrolls (table layouts only; `--dt-top` clears a fixed bar).\n sticky: { true: {}, false: {} },\n // Zebra rows (the table layouts); the cards keep their frame.\n striped: { true: {}, false: {} },\n // Typed columns: one height per row (`typed`); untyped tables keep the height of their content.\n typed: { true: {}, false: {} },\n },\n compoundVariants: [\n { layout: \"table\", sticky: true, class: { th: \"sticky top-(--dt-top,0px) z-[3] bg-surface-2\" } },\n { layout: \"auto\", sticky: true, class: { th: \"@xl:sticky @xl:top-(--dt-top,0px) @xl:z-[3] @xl:bg-surface-2\" } },\n { layout: \"table\", striped: true, class: { tr: \"even:bg-surface-2/40\" } },\n { layout: \"auto\", striped: true, class: { tr: \"@xl:even:bg-surface-2/40\" } },\n // One row height per density (a two-line row — a name and its id — is the tallest).\n { layout: \"table\", density: \"comfortable\", typed: true, class: { td: \"h-15\" } },\n { layout: \"auto\", density: \"comfortable\", typed: true, class: { td: \"@xl:h-15\" } },\n { layout: \"table\", density: \"compact\", typed: true, class: { td: \"h-12\" } },\n { layout: \"auto\", density: \"compact\", typed: true, class: { td: \"@xl:h-12\" } },\n ],\n defaultVariants: { layout: \"auto\", density: \"comfortable\", sticky: false, striped: false, typed: false },\n});\n\n// Column priorities. `priority: 1` (default) is always in the row; a higher number leaves the row when the table's own\n// width (a container query, not the window's) falls under its tier and goes to the row's detail — the same\n// `@container` that turns the table into cards. Written out in full: Tailwind finds a class only as a whole string.\n// priority 2 → 42rem · 3 → 48rem · 4 → 56rem · 5 → 64rem · 6 → 72rem · 7 → 80rem\n// On the cards (under 36rem), priority 2 stays on the card's face and 3+ go to its detail.\ntype Priority = 2 | 3 | 4 | 5 | 6 | 7;\ntype ByLayout = { auto: Record<Priority, string>; table: Record<Priority, string>; cards: Record<Priority, string> };\nconst NO_CLASS: Record<Priority, string> = { 2: \"\", 3: \"\", 4: \"\", 5: \"\", 6: \"\", 7: \"\" };\n/** The cell of a column that leaves the row. */\nconst LEAVES: ByLayout = {\n auto: { 2: \"@xl:@max-2xl:hidden!\", 3: \"@max-3xl:hidden!\", 4: \"@max-4xl:hidden!\", 5: \"@max-5xl:hidden!\", 6: \"@max-6xl:hidden!\", 7: \"@max-7xl:hidden!\" },\n table: { 2: \"@max-2xl:hidden!\", 3: \"@max-3xl:hidden!\", 4: \"@max-4xl:hidden!\", 5: \"@max-5xl:hidden!\", 6: \"@max-6xl:hidden!\", 7: \"@max-7xl:hidden!\" },\n cards: { 2: \"\", 3: \"hidden!\", 4: \"hidden!\", 5: \"hidden!\", 6: \"hidden!\", 7: \"hidden!\" },\n};\n/** Its header (the cards keep the sort buttons). */\nconst LEAVES_HEAD: ByLayout = {\n auto: { 2: \"@xl:@max-2xl:hidden!\", 3: \"@xl:@max-3xl:hidden!\", 4: \"@xl:@max-4xl:hidden!\", 5: \"@xl:@max-5xl:hidden!\", 6: \"@xl:@max-6xl:hidden!\", 7: \"@xl:@max-7xl:hidden!\" },\n table: LEAVES.table,\n cards: NO_CLASS,\n};\n/** Its line in the row's detail: shown exactly when the cell is not. */\nconst IN_DETAIL: ByLayout = {\n auto: { 2: \"hidden @xl:@max-2xl:grid\", 3: \"hidden @max-3xl:grid\", 4: \"hidden @max-4xl:grid\", 5: \"hidden @max-5xl:grid\", 6: \"hidden @max-6xl:grid\", 7: \"hidden @max-7xl:grid\" },\n table: { 2: \"hidden @max-2xl:grid\", 3: \"hidden @max-3xl:grid\", 4: \"hidden @max-4xl:grid\", 5: \"hidden @max-5xl:grid\", 6: \"hidden @max-6xl:grid\", 7: \"hidden @max-7xl:grid\" },\n cards: { 2: \"hidden\", 3: \"grid\", 4: \"grid\", 5: \"grid\", 6: \"grid\", 7: \"grid\" },\n};\n/** The button that opens the detail, and its panel, keyed by the highest priority of the table: seen only when something left the row. */\nconst TOGGLE: ByLayout = {\n auto: { 2: \"hidden @xl:@max-2xl:inline-flex\", 3: \"hidden @max-3xl:inline-flex\", 4: \"hidden @max-4xl:inline-flex\", 5: \"hidden @max-5xl:inline-flex\", 6: \"hidden @max-6xl:inline-flex\", 7: \"hidden @max-7xl:inline-flex\" },\n table: { 2: \"hidden @max-2xl:inline-flex\", 3: \"hidden @max-3xl:inline-flex\", 4: \"hidden @max-4xl:inline-flex\", 5: \"hidden @max-5xl:inline-flex\", 6: \"hidden @max-6xl:inline-flex\", 7: \"hidden @max-7xl:inline-flex\" },\n cards: { 2: \"hidden\", 3: \"inline-flex\", 4: \"inline-flex\", 5: \"inline-flex\", 6: \"inline-flex\", 7: \"inline-flex\" },\n};\nconst PANEL: ByLayout = {\n auto: { 2: \"hidden @xl:@max-2xl:block\", 3: \"hidden @max-3xl:block\", 4: \"hidden @max-4xl:block\", 5: \"hidden @max-5xl:block\", 6: \"hidden @max-6xl:block\", 7: \"hidden @max-7xl:block\" },\n table: { 2: \"hidden @max-2xl:block\", 3: \"hidden @max-3xl:block\", 4: \"hidden @max-4xl:block\", 5: \"hidden @max-5xl:block\", 6: \"hidden @max-6xl:block\", 7: \"hidden @max-7xl:block\" },\n cards: { 2: \"hidden\", 3: \"block\", 4: \"block\", 5: \"block\", 6: \"block\", 7: \"block\" },\n};\n\nexport type DataTableColumnType = \"title\" | \"badge\" | \"number\" | \"link\" | \"date\" | \"status\" | \"text\";\n\n// What each type does to its cell. Sizes apply to the table layouts only (a card is not a column).\n// title: the column that takes the slack, truncated, full value on hover · badge, status, number, date: as wide as\n// their content, never stretched · link: truncated · text: wraps, two lines.\nconst TYPE_CELL: Record<DataTableColumnType, ByLayoutString> = {\n title: { auto: \"@xl:w-full @xl:max-w-0 @xl:min-w-44\", table: \"w-full max-w-0 min-w-44\", cards: \"\" },\n badge: { auto: \"@xl:w-px\", table: \"w-px\", cards: \"\" },\n status: { auto: \"@xl:w-px @xl:whitespace-nowrap\", table: \"w-px whitespace-nowrap\", cards: \"\" },\n number: { auto: \"@xl:w-px @xl:whitespace-nowrap tabular-nums\", table: \"w-px whitespace-nowrap tabular-nums\", cards: \"tabular-nums\" },\n date: { auto: \"@xl:w-px @xl:whitespace-nowrap tabular-nums\", table: \"w-px whitespace-nowrap tabular-nums\", cards: \"tabular-nums\" },\n link: { auto: \"@xl:min-w-28 @xl:max-w-56\", table: \"min-w-28 max-w-56\", cards: \"\" },\n text: { auto: \"@xl:min-w-36\", table: \"min-w-36\", cards: \"\" },\n};\ntype ByLayoutString = { auto: string; table: string; cards: string };\nconst CLAMP = { 1: \"line-clamp-1\", 2: \"line-clamp-2\", 3: \"line-clamp-3\" } as const;\n\nexport type SortDirection = \"ascending\" | \"descending\";\nexport type DataTableSort = { column: string; direction: SortDirection } | null;\n\n/** What the server needs to fetch a page (`manual` mode): given to `onStateChange` at each change. */\nexport type DataTableQuery = {\n /** The sorted column, or `null`. */\n sort: DataTableSort;\n /** The filters in force, by column id. */\n filters: DataTableFilterState;\n /** The page, from 1 (1 without pagination). */\n page: number;\n /** Rows per page (`Infinity` without pagination). */\n pageSize: number;\n};\n\nexport type DataTableColumn<T> = {\n /** A key of the column (and of the row, for the default value). */\n id: string;\n /** The column's name, passed translated: the header, the label of the value in a card, the filter's name. */\n header: string;\n /** What a cell shows. Default: the value as text. */\n cell?: (row: T) => React.ReactNode;\n /** The value sorted, filtered, grouped and exported. Default `row[id]`. */\n value?: (row: T) => CellValue;\n /** The header becomes a sort button (ascending → descending → none). */\n sortable?: boolean;\n /** How `DataTableFilters` filters it: `text` (a search), `facet` (values to choose), `range` (numbers between bounds). */\n filter?: \"text\" | \"facet\" | \"range\";\n /** The choices of a `facet` filter, with their words (and counts from the server). Default: the values found in the rows. */\n options?: readonly { value: string; label: string; count?: number }[];\n /** The rows can be grouped by this column (`groupBy`). */\n groupable?: boolean;\n /** What a group's row shows in this column, from the group's rows (a sum, a count). */\n aggregate?: (rows: readonly T[]) => React.ReactNode;\n /** Text alignment: `end` for amounts and counts. */\n align?: \"start\" | \"center\" | \"end\";\n /** The width, in px (the starting width when `resizing`). */\n width?: number;\n /** The narrowest the column may be, in px (also what a resized one may shrink to). Default 64. */\n minWidth?: number;\n /** In a card: `title` (the heading of the card), `label` (default: \"Header value\"), `hidden`. */\n card?: \"title\" | \"label\" | \"hidden\";\n /** Hides the header visually (an actions column), keeping it for screen readers. */\n headerHidden?: boolean;\n /** Listed in `DataTableColumnsMenu`, so a person can hide it. Default `true`. */\n hideable?: boolean;\n /**\n * What the column holds, which sizes and aligns it: `title` (the name, takes the slack, truncated, `subtitle` under it in\n * mono — and the card's heading), `badge` · `status` (as wide as the badge, never stretched), `number` (end-aligned,\n * tabular), `date`, `link` (truncated, `href`), `text` (wraps, two lines). Untyped: as before.\n */\n type?: DataTableColumnType;\n /**\n * 1 (default) always in the row; 2 to 7 leave it when the table is narrower than 42 · 48 · 56 · 64 · 72 · 80 rem and\n * go to the row's detail (a chevron opens it); under 36 rem (cards) 2 stays on the card, 3+ go to its detail.\n */\n priority?: 1 | 2 | 3 | 4 | 5 | 6 | 7;\n /** `title`: the line under the name (an id), in mono. */\n subtitle?: (row: T) => React.ReactNode;\n /** `link`: where the value leads. */\n href?: (row: T) => string | undefined;\n /** `number`: the unit after the value (muted). */\n unit?: string;\n /** The widest the column may be in the table layouts, in px; longer text is clamped, the full value on hover. */\n maxWidth?: number;\n /** One line only, truncated (the full value on hover). */\n nowrap?: boolean;\n /** Lines of text kept before the ellipsis (default 2 for `text`). */\n clamp?: 1 | 2 | 3;\n};\n\nexport type DataTableLabels = {\n /** The checkbox of the header (\"Select all rows\"). */\n selectAll: string;\n /** The checkbox of a row: `(name) => \"Select \" + name`, the name from `rowLabel`. */\n selectRow: (name: string) => string;\n /** Read while the rows load (\"Loading\"). */\n loading: string;\n /** The default empty state (\"Nothing to show\"). */\n empty: string;\n /** The header of the column of the expand buttons, for screen readers (\"Details\"). */\n details: string;\n /** The button that opens a row: `(name) => \"Show details of \" + name`. */\n expandRow: (name: string) => string;\n /** The button of a group's row: `(value, count) => value + \", \" + count + \" rows\"`. */\n group: (value: string, count: number) => string;\n /** The handle that resizes a column: `(header) => \"Resize \" + header`. */\n resize: (header: string) => string;\n /** The count of results under the filters: `(count) => count + \" rows\"`. */\n results: (count: number) => string;\n /** The accessible name of the filters' bar (\"Filter the table\"). */\n filters: string;\n /** \"Clear filters\". */\n clearFilters: string;\n /** A filter's chip remove button: `(label) => \"Remove \" + label`. */\n removeFilter: (label: string) => string;\n /** The words of a filter in force: `(header, value) => header + \": \" + value`. */\n activeFilter: (header: string, value: string) => string;\n /** A `text` filter's field: `(header) => \"Search \" + header`. */\n search: (header: string) => string;\n /** The lower bound of a `range` filter: `(header) => header + \" from\"`. */\n rangeFrom: (header: string) => string;\n /** The upper bound of a `range` filter: `(header) => header + \" to\"`. */\n rangeTo: (header: string) => string;\n /** The button of the columns menu (\"Columns\"). */\n columns: string;\n /** The button that opens what left the row: `(name) => \"Show more about \" + name`. */\n showMore: (name: string) => string;\n /** The export button (\"Export CSV\"). */\n export: string;\n /** The words of the pagination under the table. */\n pagination?: Partial<PaginationLabels>;\n};\n\nexport const dataTableLabels: DataTableLabels = {\n selectAll: \"Select all rows\",\n selectRow: (name) => `Select ${name}`,\n loading: \"Loading\",\n empty: \"Nothing to show\",\n details: \"Details\",\n expandRow: (name) => `Show details of ${name}`,\n group: (value, count) => `${value}, ${count} ${count === 1 ? \"row\" : \"rows\"}`,\n resize: (header) => `Resize ${header}`,\n results: (count) => `${count.toLocaleString(\"en\")} ${count === 1 ? \"row\" : \"rows\"}`,\n filters: \"Filter the table\",\n clearFilters: \"Clear filters\",\n removeFilter: (label) => `Remove ${label}`,\n activeFilter: (header, value) => `${header}: ${value}`,\n search: (header) => `Search ${header.toLocaleLowerCase()}`,\n rangeFrom: (header) => `${header} from`,\n rangeTo: (header) => `${header} to`,\n columns: \"Columns\",\n showMore: (name) => `Show more about ${name}`,\n export: \"Export CSV\",\n};\n\nexport type DataTableProps<T> = {\n /** The rows (stable: module scope, state or a query's data). Sorted, filtered and paginated here unless `manual`. */\n rows: readonly T[];\n /** The columns, in order (stable: module scope or `useMemo`). */\n columns: readonly DataTableColumn<T>[];\n /** A stable id per row: the React key, the selection's value, the expansion's key. */\n getRowId: (row: T) => string;\n /** What the table lists (\"Jobs\", \"Your videos\"): its `<caption>`, hidden unless `showCaption`. */\n caption: string;\n /** Shows the caption above the table. */\n showCaption?: boolean;\n\n /** The sorted column (controlled), `null` for none. */\n sort?: DataTableSort;\n /** The sort at first, uncontrolled. */\n defaultSort?: DataTableSort;\n /** Called when a header asks for a new sort. */\n onSortChange?: (sort: DataTableSort) => void;\n\n /** The filters in force (controlled), by column id — what `DataTableFilters` edits. */\n filters?: DataTableFilterState;\n /** The filters at first, uncontrolled. */\n defaultFilters?: DataTableFilterState;\n /** Called with the new filters. */\n onFiltersChange?: (filters: DataTableFilterState) => void;\n\n /** Adds a checkbox per row and one in the header. */\n selectable?: boolean;\n /** The ids selected (controlled). */\n selected?: readonly string[];\n /** The ids selected at first, uncontrolled. */\n defaultSelected?: readonly string[];\n /** Called with the new selection. */\n onSelectedChange?: (selected: string[]) => void;\n /** The name of a row in its labels (checkbox, expand button). Default: its id. */\n rowLabel?: (row: T) => string;\n\n /** The columns the rows are grouped by, in order (controlled) — each `groupable`. */\n groupBy?: readonly string[];\n /** The grouping at first, uncontrolled. */\n defaultGroupBy?: readonly string[];\n /** Called with the new grouping. */\n onGroupByChange?: (groupBy: string[]) => void;\n\n /** Visible (`true`) or hidden (`false`) columns, by id (controlled); a column not listed is visible. */\n columnVisibility?: Readonly<Record<string, boolean>>;\n /** The visibility at first, uncontrolled. */\n defaultColumnVisibility?: Readonly<Record<string, boolean>>;\n /** Called with the new visibility (`DataTableColumnsMenu`). */\n onColumnVisibilityChange?: (visibility: Record<string, boolean>) => void;\n\n /** Columns that stay in view while the table scrolls sideways: at the start (a name) or the end (actions). */\n pinning?: { start?: readonly string[]; end?: readonly string[] };\n /** Columns can be resized (pointer drag, or the arrow keys on the focused handle). */\n resizing?: boolean;\n\n /** The children of a row (a tree): each row with children gets a button that shows them. */\n getSubRows?: (row: T) => readonly T[] | undefined;\n /** A panel under an opened row (the details of a job): each row gets a button that opens it. */\n renderDetail?: (row: T) => React.ReactNode;\n /** The opened rows and groups (controlled), by id. */\n expanded?: Readonly<Record<string, boolean>>;\n /** The opened rows at first, uncontrolled. */\n defaultExpanded?: Readonly<Record<string, boolean>>;\n /** Called with the opened rows. */\n onExpandedChange?: (expanded: Record<string, boolean>) => void;\n\n /** Rows per page (controlled): turns on the pagination under the table. */\n pageSize?: number;\n /** Rows per page at first, uncontrolled: turns on the pagination under the table. */\n defaultPageSize?: number;\n /** Called with the size chosen in the pagination's select (`pageSizeOptions`). */\n onPageSizeChange?: (pageSize: number) => void;\n /** The sizes the pagination offers (`[10, 25, 50]`). */\n pageSizeOptions?: readonly number[];\n /** The page, from 1 (controlled). */\n page?: number;\n /** The page at first, uncontrolled. Default 1. */\n defaultPage?: number;\n /** Called with the page asked for. */\n onPageChange?: (page: number) => void;\n\n /** The server does the work: the rows are shown as given (sorted, filtered, a single page) — fetch in `onStateChange`. */\n manual?: { sorting?: boolean; filtering?: boolean; pagination?: boolean };\n /** The number of rows on the server (`manual.pagination`): the page count and the results' count. */\n rowCount?: number;\n /** Called with the sort, the filters and the page after each change: the query key of a server table. */\n onStateChange?: (query: DataTableQuery) => void;\n\n /** Rows are loading: skeleton rows (`loadingRows` of them) and `aria-busy`. */\n loading?: boolean;\n /** How many skeleton rows while loading. Default 5. */\n loadingRows?: number;\n /** A failure, shown in place of the rows (an `Alert`, a retry button); announced. */\n error?: React.ReactNode;\n /** Shown when there is no row (an `EmptyState`). Default: `labels.empty`. */\n empty?: React.ReactNode;\n\n /** `auto` (default): a table, cards below 36rem of container width. `table` or `cards` to force one. */\n layout?: \"auto\" | \"table\" | \"cards\";\n /** comfortable (default) · compact — one row height per density. */\n density?: \"comfortable\" | \"compact\";\n /** The header stays at the top of the window while the page scrolls (`--dt-top` on the table, or a number of px, clears a fixed bar). */\n stickyHeader?: boolean | number;\n /** Zebra rows. */\n striped?: boolean;\n /**\n * The table never scrolls sideways by default once its columns have a `type` or a `priority`: what does not fit goes to\n * the row's detail, then to cards. `true` opts in to a sideways scroll instead, with fades at the edges.\n */\n scroll?: boolean;\n /** Keeps the first column in view while the table scrolls sideways (with `scroll`). */\n stickyFirstColumn?: boolean;\n /** Renders only the rows in view, in a scrolling body of `height` px; rows are measured (`estimateRowHeight` is a guess). Forces the table layout. */\n virtualized?: { height: number; estimateRowHeight?: number; overscan?: number };\n\n /** Above the table, inside its frame: `DataTableFilters`, `DataTableColumnsMenu`, `DataTableExport`, actions. */\n toolbar?: React.ReactNode;\n /** Under the table (after the pagination): a total, a note. */\n footer?: React.ReactNode;\n /** The words, passed translated; English by default. */\n labels?: Partial<DataTableLabels>;\n /** Classes merged on the root. */\n className?: string;\n};\n\ntype Context = {\n table: DataTableInstance<object>;\n columns: readonly DataTableColumn<object>[];\n labels: DataTableLabels;\n filters: DataTableFilterState;\n setFilters: (filters: DataTableFilterState) => void;\n /** The rows after the filters (all pages), or the server's count. */\n resultCount: number;\n /** The rows to export: filtered and sorted, every page, without the group rows. */\n exportRows: () => readonly unknown[];\n manualFiltering: boolean;\n};\n\nconst DataTableContext = React.createContext<Context | null>(null);\n\n/**\n * The table a component sits in (`toolbar`, or anything under it): the TanStack Table instance (`table` — the escape\n * hatch for what the props do not cover), the columns, the words, the filters and their setter.\n */\nexport function useDataTable<T extends RowData = object>() {\n const context = React.useContext(DataTableContext);\n if (!context) throw new Error(\"useDataTable: call it inside a DataTable (its `toolbar`, `footer` or a cell)\");\n return context as unknown as Omit<Context, \"table\" | \"columns\" | \"exportRows\"> & {\n table: DataTableInstance<T>;\n columns: readonly DataTableColumn<T>[];\n exportRows: () => readonly T[];\n };\n}\n\nconst TRUNCATE = { auto: \"@xl:truncate\", table: \"truncate\", cards: \"\" } as const;\nconst MIN = { auto: \"@xl:min-w-(--dt-min)!\", table: \"min-w-(--dt-min)!\", cards: \"\" } as const;\nconst MAX = { auto: \"@xl:max-w-(--dt-max)!\", table: \"max-w-(--dt-max)!\", cards: \"\" } as const;\n// A sideways scroll asked for (`scroll`): the edges fade where there is more to see.\nconst SHADOWS =\n \"[background:linear-gradient(to_right,var(--kz-surface-1),transparent)_left/24px_100%_no-repeat_local,linear-gradient(to_left,var(--kz-surface-1),transparent)_right/24px_100%_no-repeat_local,linear-gradient(to_right,rgb(0_0_0/0.18),transparent)_left/10px_100%_no-repeat_scroll,linear-gradient(to_left,rgb(0_0_0/0.18),transparent)_right/10px_100%_no-repeat_scroll]\";\nconst ALIGN = { start: \"text-left\", center: \"text-center\", end: \"text-right\" } as const;\nconst NONE: readonly never[] = [];\nconst NO_RECORD: Readonly<Record<string, never>> = {};\nconst apply = <S,>(updater: S | ((old: S) => S), old: S): S => (typeof updater === \"function\" ? (updater as (old: S) => S)(old) : updater);\n\nconst valueOf = <T,>(column: DataTableColumn<T>, row: T): CellValue =>\n column.value ? column.value(row) : ((row as Record<string, unknown>)[column.id] as CellValue);\n\nconst display = (value: CellValue) => (value instanceof Date ? value.toISOString() : String(value ?? \"\"));\n\n/** A table of records — sort, filters and facets, groups, details, pages, selection, visibility, pinning, resizing — on the client or the server, cards on a phone, windowing for long lists. */\nexport function DataTable<T extends RowData>({\n rows,\n columns,\n getRowId,\n caption,\n showCaption,\n sort: sortProp,\n defaultSort = null,\n onSortChange,\n filters: filtersProp,\n defaultFilters = NO_RECORD,\n onFiltersChange,\n selectable,\n selected: selectedProp,\n defaultSelected = NONE,\n onSelectedChange,\n rowLabel,\n groupBy: groupByProp,\n defaultGroupBy = NONE,\n onGroupByChange,\n columnVisibility: visibilityProp,\n defaultColumnVisibility = NO_RECORD,\n onColumnVisibilityChange,\n pinning,\n resizing,\n getSubRows,\n renderDetail,\n expanded: expandedProp,\n defaultExpanded = NO_RECORD,\n onExpandedChange,\n pageSize: pageSizeProp,\n defaultPageSize,\n onPageSizeChange,\n pageSizeOptions,\n page: pageProp,\n defaultPage = 1,\n onPageChange,\n manual,\n rowCount,\n onStateChange,\n loading,\n loadingRows = 5,\n error,\n empty,\n layout = \"auto\",\n density,\n stickyHeader,\n striped,\n scroll,\n stickyFirstColumn,\n virtualized,\n toolbar,\n footer,\n labels: given,\n className,\n}: DataTableProps<T>) {\n const labels = { ...dataTableLabels, ...given };\n const effectiveLayout = virtualized ? \"table\" : layout;\n const L = effectiveLayout;\n const typed = columns.some((c) => c.type || c.priority);\n const s = dataTable({ layout: L, density, sticky: Boolean(stickyHeader), striped: Boolean(striped), typed });\n\n const [sort, setSort] = useControllable<DataTableSort>(sortProp, defaultSort, onSortChange);\n const [filters, setFiltersState] = useControllable<DataTableFilterState>(filtersProp, defaultFilters, onFiltersChange);\n const [selected, setSelected] = useControllable<readonly string[]>(selectedProp, defaultSelected, onSelectedChange as (next: readonly string[]) => void);\n const [groupBy, setGroupBy] = useControllable<readonly string[]>(groupByProp, defaultGroupBy, onGroupByChange as (next: readonly string[]) => void);\n const [visibility, setVisibility] = useControllable<Readonly<Record<string, boolean>>>(visibilityProp, defaultColumnVisibility, onColumnVisibilityChange as (next: Readonly<Record<string, boolean>>) => void);\n const [expanded, setExpanded] = useControllable<Readonly<Record<string, boolean>>>(expandedProp, defaultExpanded, onExpandedChange as (next: Readonly<Record<string, boolean>>) => void);\n const paginated = pageSizeProp !== undefined || defaultPageSize !== undefined;\n const [pageSize, setPageSize] = useControllable<number>(pageSizeProp, defaultPageSize ?? Number.POSITIVE_INFINITY, onPageSizeChange);\n const [page, setPage] = useControllable<number>(pageProp, defaultPage, onPageChange);\n const [sizing, setSizing] = React.useState<Record<string, number>>({});\n const [resizeState, setResizeState] = React.useState<ColumnResizingState | undefined>(undefined);\n\n // One place announces the server's query: after any change of sort, filters, page or page size.\n const announce = (next: Partial<DataTableQuery>) =>\n onStateChange?.({ sort, filters, page: paginated ? page : 1, pageSize: paginated ? pageSize : Number.POSITIVE_INFINITY, ...next });\n const changeSort = (next: DataTableSort) => {\n setSort(next);\n if (paginated && page !== 1) setPage(1);\n announce({ sort: next, page: 1 });\n };\n const changeFilters = (next: DataTableFilterState) => {\n setFiltersState(next);\n if (paginated && page !== 1) setPage(1);\n announce({ filters: next, page: 1 });\n };\n const changePage = (next: number) => {\n setPage(next);\n announce({ page: next });\n };\n const changePageSize = (next: number) => {\n setPageSize(next);\n setPage(1);\n announce({ pageSize: next, page: 1 });\n };\n\n const hasExpander = Boolean(renderDetail || getSubRows);\n // Columns that can leave the row: their lines go to the row's detail, opened by a chevron in the first cell.\n const priorityOf = (column: DataTableColumn<T>): 1 | Priority => (column.priority && column.priority > 1 ? (column.priority as Priority) : 1);\n const maxPriority = columns.reduce<1 | Priority>((max, column) => (priorityOf(column) > max ? priorityOf(column) : max), 1);\n const inlineToggle = maxPriority > 1 && !hasExpander;\n const detailed = Boolean(renderDetail) || inlineToggle;\n const defs = React.useMemo<ColumnDef<typeof features, T>[]>(\n () =>\n columns.map((column) => ({\n id: column.id,\n accessorFn: (row: T) => valueOf(column, row),\n header: column.header,\n enableSorting: Boolean(column.sortable),\n sortFn: sortByValue as never,\n sortUndefined: \"last\",\n enableColumnFilter: Boolean(column.filter),\n filterFn: (column.filter ? FILTERS[column.filter] : FILTERS.text) as never,\n enableGrouping: Boolean(column.groupable),\n enableHiding: column.hideable !== false,\n enableResizing: Boolean(resizing),\n ...(column.width ? { size: column.width } : {}),\n minSize: column.minWidth ?? 64,\n })),\n [columns, resizing],\n );\n\n const firstId = columns[0]?.id;\n const pinned = stickyFirstColumn && firstId && !pinning?.start?.length ? { ...pinning, start: [firstId] } : pinning;\n const pageIndex = Math.max(0, page - 1);\n const table = useTable<typeof features, T>({\n features,\n columns: defs,\n data: rows as T[],\n getRowId: (row) => getRowId(row),\n getSubRows: getSubRows ? (row) => getSubRows(row) as T[] | undefined : undefined,\n getRowCanExpand: detailed ? () => true : undefined,\n state: {\n sorting: sort ? [{ id: sort.column, desc: sort.direction === \"descending\" }] : [],\n columnFilters: Object.entries(filters).map(([id, value]) => ({ id, value })),\n rowSelection: Object.fromEntries(selected.map((id) => [id, true])),\n grouping: [...groupBy],\n columnVisibility: { ...visibility },\n expanded: { ...expanded },\n pagination: { pageIndex, pageSize: paginated ? pageSize : Math.max(1, rows.length) },\n columnPinning: { start: [...(pinned?.start ?? [])], end: [...(pinned?.end ?? [])] },\n columnSizing: sizing,\n ...(resizeState ? { columnResizing: resizeState } : {}),\n },\n onSortingChange: (updater) => {\n const [first] = apply(updater, sort ? [{ id: sort.column, desc: sort.direction === \"descending\" }] : []);\n changeSort(first ? { column: first.id, direction: first.desc ? \"descending\" : \"ascending\" } : null);\n },\n onColumnFiltersChange: (updater) => {\n const next = apply(updater, Object.entries(filters).map(([id, value]) => ({ id, value })));\n changeFilters(Object.fromEntries(next.map((f) => [f.id, f.value as FilterValue])));\n },\n onRowSelectionChange: (updater) => {\n const next = apply(updater, Object.fromEntries(selected.map((id) => [id, true])));\n setSelected(Object.keys(next).filter((id) => next[id]));\n },\n onGroupingChange: (updater) => setGroupBy(apply(updater, [...groupBy])),\n onColumnVisibilityChange: (updater) => setVisibility(apply(updater, { ...visibility })),\n onExpandedChange: (updater) => {\n const next = apply(updater, { ...expanded });\n setExpanded(next === true ? Object.fromEntries(table.getRowModel().rows.map((r) => [r.id, true])) : next);\n },\n onColumnSizingChange: (updater) => setSizing((old: Record<string, number>) => apply(updater, old)),\n onColumnResizingChange: (updater) => setResizeState((old) => apply(updater, old as ColumnResizingState)),\n enableSortingRemoval: true,\n sortDescFirst: false,\n manualSorting: manual?.sorting,\n manualFiltering: manual?.filtering,\n manualPagination: manual?.pagination || !paginated,\n rowCount: manual?.pagination ? rowCount : undefined,\n autoResetPageIndex: false,\n autoResetExpanded: false,\n groupedColumnMode: false,\n columnResizeMode: \"onChange\",\n enableRowSelection: (row) => !row.getIsGrouped(),\n });\n\n const visibleColumns = table.getVisibleLeafColumns();\n const byId = new Map(columns.map((c) => [c.id, c]));\n const pageRows = table.getRowModel().rows;\n const leafCount = (manual?.pagination || manual?.filtering) && rowCount !== undefined ? rowCount : table.getFilteredRowModel().rows.length;\n const pageCount = paginated ? Math.max(1, manual?.pagination ? Math.ceil((rowCount ?? rows.length) / pageSize) : table.getPageCount()) : 1;\n // A page beyond the last (the filters shrank the list): show the last one.\n React.useEffect(() => {\n if (paginated && !loading && page > pageCount) changePage(pageCount);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- only when the count of pages drops under the page\n }, [pageCount]);\n\n const selectableIds = pageRows.filter((r) => !r.getIsGrouped()).map((r) => r.id);\n const chosen = new Set(selected);\n const allChosen = selectableIds.length > 0 && selectableIds.every((id) => chosen.has(id));\n const someChosen = selectableIds.some((id) => chosen.has(id));\n const toggleAll = () =>\n setSelected(allChosen ? selected.filter((id) => !selectableIds.includes(id)) : [...new Set([...selected, ...selectableIds])]);\n\n const span = visibleColumns.length + (selectable ? 1 : 0) + (hasExpander ? 1 : 0);\n const fixedLayout = Boolean(resizing || pinned?.start?.length || pinned?.end?.length);\n const sideways = scroll ?? (!typed && !stickyHeader);\n const nameOf = (row: TableRow<T>) => (rowLabel ? rowLabel(row.original) : row.id);\n\n // Windowing: the rows in view, between two spacer rows that keep the scroll height.\n const scroller = React.useRef<HTMLDivElement>(null);\n const fallbacks = useLayoutFallbacks(virtualized?.height ?? 0, virtualized?.estimateRowHeight ?? 44);\n const virtualizer = useVirtualizer({\n ...fallbacks,\n count: pageRows.length,\n getScrollElement: () => scroller.current,\n estimateSize: () => virtualized?.estimateRowHeight ?? 44,\n overscan: virtualized?.overscan ?? 6,\n initialRect: { width: 0, height: virtualized?.height ?? 0 },\n getItemKey: (index) => pageRows[index]?.id ?? index,\n enabled: Boolean(virtualized) && !loading,\n });\n const windowItems = virtualized && !loading ? virtualizer.getVirtualItems() : null;\n const before = windowItems?.[0]?.start ?? 0;\n const after = windowItems && windowItems.length > 0 ? virtualizer.getTotalSize() - (windowItems.at(-1)?.end ?? 0) : 0;\n\n const pin = (column: TableColumn<T>): { \"data-pin\"?: \"start\" | \"end\"; style?: React.CSSProperties } => {\n const position = column.getIsPinned();\n if (!position) return {};\n const offset = position === \"start\" ? column.getStart(\"start\") : column.getAfter(\"end\");\n return { \"data-pin\": position, style: { [\"--dt-pin\" as string]: `${offset}px` } };\n };\n const width = (column: TableColumn<T>): React.CSSProperties | undefined =>\n fixedLayout || byId.get(column.id)?.width ? { width: column.getSize() } : undefined;\n\n const message = (content: React.ReactNode, alert?: boolean) => (\n <tr role=\"row\" className={s.tr({ className: \"hover:bg-transparent\" })}>\n <td role=\"cell\" colSpan={span} data-card=\"message\" className={s.td({ className: s.message() })}>\n {alert ? <div role=\"alert\">{content}</div> : content}\n </td>\n </tr>\n );\n\n const cardOf = (column: DataTableColumn<T> | undefined) =>\n column?.card === \"title\" || (column && !column.card && column.type === \"title\") ? \"title\" : column?.card === \"hidden\" ? \"hidden\" : undefined;\n\n // What a cell shows: the column's `cell`, or its value by type.\n const contentOf = (column: DataTableColumn<T>, original: T): React.ReactNode => {\n if (column.cell) return column.cell(original);\n const value = valueOf(column, original);\n const text = column.type === \"date\" && value instanceof Date ? value.toISOString().slice(0, 10) : display(value);\n const cut = TRUNCATE[L];\n switch (column.type) {\n case \"title\": {\n const sub = column.subtitle?.(original);\n return (\n <span className=\"block min-w-0\">\n <span className={cn(\"block font-semibold text-fg\", cut)} title={text}>\n {text}\n </span>\n {sub ? <span className={cn(\"block font-mono text-xs text-fg-tertiary\", cut)}>{sub}</span> : null}\n </span>\n );\n }\n case \"number\":\n return (\n <span>\n {typeof value === \"number\" ? value.toLocaleString(\"en\") : text}\n {column.unit ? <span className=\"ms-1 text-fg-secondary\">{column.unit}</span> : null}\n </span>\n );\n case \"link\": {\n const href = column.href?.(original);\n return href ? (\n <a href={href} title={text} className={cn(\"block text-fg-accent underline-offset-2 hover:underline\", cut)}>\n {text}\n </a>\n ) : (\n <span className={cn(\"block\", cut)} title={text}>\n {text}\n </span>\n );\n }\n default: {\n const lines = column.clamp ?? (column.type === \"text\" ? 2 : undefined);\n if (!column.nowrap && !lines) return text;\n return (\n <span className={column.nowrap ? cn(\"block\", cut || \"truncate\") : CLAMP[lines as 1 | 2 | 3]} title={text}>\n {text}\n </span>\n );\n }\n }\n };\n\n const dataCell = (row: TableRow<T>, cell: TableCell<T>, first: boolean) => {\n const column = byId.get(cell.column.id);\n if (!column) return null;\n const { style, ...pinnedProps } = pin(cell.column);\n let content: React.ReactNode;\n if (row.getIsGrouped()) {\n const leaves = row.getLeafRows().filter((r) => !r.getIsGrouped()).map((r) => r.original);\n if (cell.column.id === row.groupingColumnId) {\n const value = display(row.groupingValue as CellValue);\n content = (\n <button type=\"button\" className={s.toggle({ className: \"px-1.5 font-semibold text-fg\" })} aria-expanded={row.getIsExpanded()} onClick={row.getToggleExpandedHandler()}>\n <ChevronGlyph direction=\"right\" className=\"h-4 w-4\" />\n {labels.group(value, leaves.length)}\n </button>\n );\n } else content = column.aggregate ? column.aggregate(leaves) : null;\n } else {\n content = contentOf(column, row.original);\n if (first && inlineToggle) {\n content = (\n <span className=\"flex min-w-0 items-center gap-1\">\n <button\n type=\"button\"\n className={s.toggle({ className: TOGGLE[L][maxPriority as Priority] })}\n aria-expanded={row.getIsExpanded()}\n aria-label={labels.showMore(nameOf(row))}\n onClick={row.getToggleExpandedHandler()}\n >\n <ChevronGlyph direction=\"right\" className=\"h-4 w-4\" />\n </button>\n <span className=\"min-w-0 flex-1\">{content}</span>\n </span>\n );\n }\n }\n const level = priorityOf(column);\n const type = column.type;\n const cellClass = [\n ALIGN[column.align ?? (type === \"number\" ? \"end\" : \"start\")],\n type ? TYPE_CELL[type][L] : \"\",\n level > 1 ? LEAVES[L][level as Priority] : \"\",\n (type || column.priority) && column.minWidth !== undefined ? MIN[L] : \"\",\n column.maxWidth !== undefined ? MAX[L] : \"\",\n ].join(\" \");\n return (\n <td\n role=\"cell\"\n key={cell.id}\n data-label={column.header}\n data-card={row.getIsGrouped() ? (cell.column.id === row.groupingColumnId ? \"title\" : column.aggregate ? undefined : \"hidden\") : cardOf(column)}\n {...pinnedProps}\n className={s.td({ className: cellClass })}\n style={{\n ...style,\n ...(column.minWidth !== undefined ? { [\"--dt-min\" as string]: `${column.minWidth}px` } : {}),\n ...(column.maxWidth !== undefined ? { [\"--dt-max\" as string]: `${column.maxWidth}px` } : {}),\n ...(first && row.depth > 0 && !row.getIsGrouped() ? { paddingInlineStart: `${1 + row.depth * 1.25}rem` } : {}),\n }}\n >\n {content}\n </td>\n );\n };\n\n const renderRow = (row: TableRow<T>, index: number) => {\n const isChosen = chosen.has(row.id);\n const grouped = row.getIsGrouped();\n const canExpand = !grouped && (renderDetail ? true : row.getCanExpand());\n const showDetail = detailed && !grouped && row.getIsExpanded() && !virtualized;\n const cells = row.getVisibleCells();\n const main = (\n <tr\n role=\"row\"\n data-index={windowItems ? index : undefined}\n ref={windowItems ? virtualizer.measureElement : undefined}\n aria-rowindex={virtualized ? index + 2 : undefined}\n data-state={isChosen ? \"selected\" : undefined}\n data-group={grouped ? \"\" : undefined}\n className={s.tr({ className: grouped ? s.groupRow() : undefined })}\n >\n {selectable && (\n <td role=\"cell\" className={s.td({ className: s.select() })}>\n {grouped ? null : <Checkbox checked={isChosen} onCheckedChange={() => row.toggleSelected()} aria-label={labels.selectRow(nameOf(row))} />}\n </td>\n )}\n {hasExpander && (\n <td role=\"cell\" className={s.td({ className: s.expander() })}>\n {canExpand ? (\n <button type=\"button\" className={s.toggle()} aria-expanded={row.getIsExpanded()} aria-label={labels.expandRow(nameOf(row))} onClick={row.getToggleExpandedHandler()}>\n <ChevronGlyph direction=\"right\" className=\"h-4 w-4\" />\n </button>\n ) : null}\n </td>\n )}\n {cells.map((cell, i) => dataCell(row, cell, i === 0))}\n </tr>\n );\n // One Fragment keyed by the row either way: opening a row keeps its button (and the focus on it).\n if (!showDetail) return <React.Fragment key={row.id}>{main}</React.Fragment>;\n const left = visibleColumns.filter((c) => priorityOf(byId.get(c.id) as DataTableColumn<T>) > 1);\n return (\n <React.Fragment key={row.id}>\n {main}\n <tr role=\"row\" className={s.tr({ className: \"hover:bg-transparent\" })}>\n <td role=\"cell\" colSpan={span} data-card=\"message\" className={s.td({ className: renderDetail ? s.detail() : \"bg-surface-0 p-0 text-left\" })}>\n {left.length > 0 ? (\n <dl className={cn(\"m-0 grid gap-3 text-left\", renderDetail ? \"mb-4\" : cn(\"px-4 py-4\", PANEL[L][maxPriority as Priority]))}>\n {left.map((c) => {\n const column = byId.get(c.id) as DataTableColumn<T>;\n return (\n <div key={c.id} className={cn(IN_DETAIL[L][priorityOf(column) as Priority], \"grid-cols-[minmax(5rem,8rem)_minmax(0,1fr)] items-baseline gap-3\")}>\n <dt className=\"text-xs font-semibold text-fg-secondary\">{column.header}</dt>\n <dd className=\"m-0 min-w-0 text-sm text-fg\">{contentOf(column, row.original)}</dd>\n </div>\n );\n })}\n </dl>\n ) : null}\n {renderDetail ? renderDetail(row.original) : null}\n </td>\n </tr>\n </React.Fragment>\n );\n };\n\n let body: React.ReactNode;\n if (loading) {\n body = Array.from({ length: loadingRows }, (_, i) => (\n <tr role=\"row\" key={`loading-${i}`} aria-hidden className={s.tr()}>\n {selectable && <td role=\"cell\" className={s.td({ className: s.select() })} />}\n {hasExpander && <td role=\"cell\" className={s.td({ className: s.expander() })} />}\n {visibleColumns.map((column) => (\n <td role=\"cell\" key={column.id} data-card={cardOf(byId.get(column.id))} className={s.td()}>\n <div className={s.skeleton()} />\n </td>\n ))}\n </tr>\n ));\n } else if (error) body = message(error, true);\n else if (pageRows.length === 0) body = message(empty ?? labels.empty);\n else if (windowItems) body = windowItems.map((item) => renderRow(pageRows[item.index] as TableRow<T>, item.index));\n else body = pageRows.map((row, i) => renderRow(row, i));\n\n const headers = table.getHeaderGroups().at(-1)?.headers ?? [];\n const tableElement = (\n <table\n role=\"table\"\n aria-busy={loading || undefined}\n aria-rowcount={virtualized ? pageRows.length + 1 : undefined}\n className={s.table({ className: fixedLayout ? \"table-fixed\" : undefined })}\n style={fixedLayout ? { minWidth: table.getTotalSize() + (selectable ? 40 : 0) + (hasExpander ? 40 : 0) } : undefined}\n >\n <caption className={showCaption ? s.caption() : \"sr-only\"}>\n {caption}\n {loading ? <span className=\"sr-only\">{` — ${labels.loading}`}</span> : null}\n </caption>\n <thead role=\"rowgroup\" className={s.thead({ className: virtualized ? \"sticky top-0 z-10 bg-surface-2\" : undefined })}>\n <tr role=\"row\" aria-rowindex={virtualized ? 1 : undefined} className={s.headRow()}>\n {selectable && (\n <th role=\"columnheader\" scope=\"col\" data-keep=\"\" className={s.th({ className: s.select() })}>\n <Checkbox\n checked={allChosen ? true : someChosen ? \"indeterminate\" : false}\n onCheckedChange={toggleAll}\n disabled={loading || selectableIds.length === 0}\n aria-label={labels.selectAll}\n />\n </th>\n )}\n {hasExpander && (\n <th role=\"columnheader\" scope=\"col\" className={s.th({ className: s.expander() })}>\n <span className=\"sr-only\">{labels.details}</span>\n </th>\n )}\n {headers.map((header) => {\n const column = byId.get(header.column.id);\n if (!column) return null;\n const sorted = header.column.getIsSorted();\n const direction: SortDirection | undefined = sorted === \"asc\" ? \"ascending\" : sorted === \"desc\" ? \"descending\" : undefined;\n const { style, ...pinnedProps } = pin(header.column);\n const level = priorityOf(column);\n return (\n <th\n role=\"columnheader\"\n scope=\"col\"\n key={header.id}\n aria-sort={column.sortable ? (direction ?? \"none\") : undefined}\n data-keep={column.sortable ? \"\" : undefined}\n {...pinnedProps}\n className={s.th({ className: cn(ALIGN[column.align ?? (column.type === \"number\" ? \"end\" : \"start\")], level > 1 && LEAVES_HEAD[L][level as Priority]) })}\n style={{ ...style, ...(typeof stickyHeader === \"number\" ? { [\"--dt-top\" as string]: `${stickyHeader}px` } : {}), ...width(header.column) }}\n >\n {column.sortable ? (\n <button type=\"button\" aria-pressed={Boolean(direction)} className={s.sortButton()} onClick={() => header.column.toggleSorting(undefined, false)}>\n {column.header}\n <SortGlyph direction={direction} className=\"h-3.5 w-3.5\" />\n </button>\n ) : column.headerHidden ? (\n <span className=\"sr-only\">{column.header}</span>\n ) : (\n column.header\n )}\n {resizing && header.column.getCanResize() ? (\n <ResizeHandle\n label={labels.resize(column.header)}\n size={header.column.getSize()}\n min={column.minWidth ?? 64}\n resizing={header.column.getIsResizing()}\n className={s.resizer()}\n onPointerStart={header.getResizeHandler()}\n onSize={(size) => setSizing((old: Record<string, number>) => ({ ...old, [header.column.id]: size }))}\n />\n ) : null}\n </th>\n );\n })}\n </tr>\n </thead>\n <tbody role=\"rowgroup\" className={s.tbody()}>\n {before > 0 && (\n <tr aria-hidden style={{ height: before }}>\n <td colSpan={span} />\n </tr>\n )}\n {body}\n {after > 0 && (\n <tr aria-hidden style={{ height: after }}>\n <td colSpan={span} />\n </tr>\n )}\n </tbody>\n </table>\n );\n\n const context: Context = {\n table: table as unknown as DataTableInstance<object>,\n columns: columns as readonly DataTableColumn<object>[],\n labels,\n filters,\n setFilters: changeFilters,\n resultCount: leafCount,\n exportRows: () => table.getSortedRowModel().flatRows.filter((r) => !r.getIsGrouped()).map((r) => r.original),\n manualFiltering: Boolean(manual?.filtering),\n };\n\n const pagination = paginated ? (\n <Pagination\n page={Math.min(page, pageCount)}\n pageCount={pageCount}\n onPageChange={changePage}\n pageSize={pageSizeOptions ? pageSize : undefined}\n pageSizeOptions={pageSizeOptions}\n onPageSizeChange={pageSizeOptions ? changePageSize : undefined}\n disabled={loading}\n labels={labels.pagination}\n />\n ) : null;\n\n return (\n <DataTableContext.Provider value={context}>\n <div className={s.root({ className })}>\n {toolbar ? <div className={s.toolbar()}>{toolbar}</div> : null}\n {virtualized ? (\n // A scrolling region the keyboard can reach: Tab lands on it, the arrows scroll it.\n <div\n ref={scroller}\n tabIndex={0}\n role=\"region\"\n aria-label={caption}\n className={s.scroller({ className: \"overflow-y-auto focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\" })}\n style={{ height: virtualized.height }}\n >\n {tableElement}\n </div>\n ) : (\n <div className={s.scroller({ className: sideways ? (scroll ? SHADOWS : undefined) : \"overflow-x-clip\" })}>{tableElement}</div>\n )}\n {pagination || footer ? (\n <div className={s.footer({ className: \"flex flex-col gap-3\" })}>\n {pagination}\n {footer}\n </div>\n ) : null}\n </div>\n </DataTableContext.Provider>\n );\n}\n\n/** The handle on a header's edge: drag it, or focus it and use ← → (Shift for larger steps). */\nfunction ResizeHandle({\n label,\n size,\n min,\n resizing,\n className,\n onPointerStart,\n onSize,\n}: {\n label: string;\n size: number;\n min: number;\n resizing: boolean;\n className: string;\n onPointerStart: (event: unknown) => void;\n onSize: (size: number) => void;\n}) {\n return (\n // A focusable separator is interactive (WAI-ARIA \"window splitter\"): its value is the width, the arrows change it.\n <div\n role=\"separator\"\n aria-orientation=\"vertical\"\n aria-label={label}\n aria-valuenow={Math.round(size)}\n aria-valuemin={min}\n tabIndex={0}\n data-resizing={resizing ? \"\" : undefined}\n className={className}\n onMouseDown={onPointerStart}\n onTouchStart={onPointerStart}\n onClick={(event) => event.stopPropagation()}\n onKeyDown={(event) => {\n const step = event.shiftKey ? 48 : 16;\n if (event.key === \"ArrowLeft\") onSize(Math.max(min, size - step));\n else if (event.key === \"ArrowRight\") onSize(size + step);\n else return;\n event.preventDefault();\n }}\n />\n );\n}\n"
|
|
714
|
+
"content": "// Client (the table's state, the windowing of long lists). A real `<table>` with a caption and column headers\n// (`scope=\"col\"`, `aria-sort` on the sorted one) that turns into a stack of cards in a narrow container — one DOM, the\n// roles written out so that the change of `display` never loses the table's semantics. The engine is TanStack Table v9\n// (engine.ts): sorting, filters and facets, grouping, expanding, pagination, selection, visibility, pinning, resizing —\n// in the browser, or on the server (`manual` + `onStateChange`). Long lists render by what is in view (`virtualized`).\nimport { useTable } from \"@tanstack/react-table\";\nimport * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\n\nimport { Checkbox } from \"../checkbox\";\nimport { cn } from \"../cn\";\nimport { ChevronGlyph, SortGlyph } from \"../glyphs\";\nimport { useControllable } from \"../media\";\nimport { Pagination, type PaginationLabels } from \"../pagination\";\nimport { useLayoutFallbacks, useVirtualizer } from \"../virtual/virtual\";\nimport {\n type CellValue,\n type ColumnDef,\n type ColumnResizingState,\n type DataTableFilterState,\n type DataTableInstance,\n features,\n FILTERS,\n type FilterValue,\n type RowData,\n sortByValue,\n type TableCell,\n type TableColumn,\n type TableRow,\n} from \"./engine\";\nimport { useRouteTable } from \"./use-route-table\";\n\nexport const dataTable = tv({\n slots: {\n root: \"@container w-full overflow-clip rounded-xl border border-border-default bg-surface-1\",\n toolbar: \"border-b border-border-default p-3\",\n scroller: \"w-full overflow-x-auto\",\n table: \"w-full border-collapse text-left text-sm text-fg\",\n caption: \"px-4 pt-4 pb-2 text-left text-sm font-semibold text-fg\",\n thead: \"bg-surface-2/60\",\n headRow: \"border-b border-border-default\",\n th: \"relative whitespace-nowrap px-4 py-3 text-xs font-semibold text-fg-secondary\",\n sortButton:\n \"-mx-1.5 inline-flex items-center gap-1 rounded-md px-1.5 py-1 hover:bg-surface-3 hover:text-fg \" +\n \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring aria-[pressed=true]:text-fg\",\n resizer:\n \"absolute top-0 right-0 h-full w-1.5 cursor-col-resize touch-none select-none bg-transparent hover:bg-border-strong \" +\n \"focus-visible:bg-accent focus-visible:outline-hidden data-[resizing]:bg-accent\",\n tbody: \"\",\n tr: \"border-b border-border-subtle transition-colors last:border-b-0 hover:bg-surface-2/50 data-[state=selected]:bg-accent-soft\",\n groupRow: \"bg-surface-2/40 font-semibold\",\n td: \"px-4 py-3 align-middle\",\n select: \"w-10\",\n expander: \"w-10\",\n toggle:\n \"inline-flex h-7 min-w-7 items-center justify-center gap-1.5 rounded-md text-fg-secondary hover:bg-surface-3 hover:text-fg \" +\n \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring [&[aria-expanded=true]>svg]:rotate-90 [&>svg]:transition-transform motion-reduce:[&>svg]:transition-none\",\n detail: \"bg-surface-0 px-4 py-4\",\n message: \"px-4 py-10 text-center text-sm text-fg-secondary\",\n skeleton: \"h-4 w-full max-w-40 animate-pulse rounded bg-surface-3 motion-reduce:animate-none\",\n footer: \"border-t border-border-default px-4 py-3\",\n },\n variants: {\n layout: {\n table: {\n th: \"data-[pin]:sticky data-[pin]:z-[2] data-[pin]:bg-surface-2 data-[pin=start]:left-(--dt-pin) data-[pin=end]:right-(--dt-pin)\",\n td: \"data-[pin]:sticky data-[pin]:z-[1] data-[pin]:bg-surface-1 data-[pin=start]:left-(--dt-pin) data-[pin=end]:right-(--dt-pin)\",\n },\n // Below 36rem of container: cards. The sortable headers stay, as a row of sort buttons above the cards.\n auto: {\n table: \"@max-xl:block\",\n caption: \"@max-xl:block\",\n thead: \"@max-xl:sr-only @max-xl:bg-transparent @max-xl:has-[[data-keep]]:not-sr-only @max-xl:has-[[data-keep]]:block\",\n headRow: \"@max-xl:flex @max-xl:flex-wrap @max-xl:items-center @max-xl:gap-2 @max-xl:px-3 @max-xl:py-2\",\n th:\n \"@max-xl:sr-only @max-xl:data-[keep]:not-sr-only @max-xl:data-[keep]:block @max-xl:p-0 \" +\n \"@xl:data-[pin]:sticky @xl:data-[pin]:z-[2] @xl:data-[pin]:bg-surface-2 @xl:data-[pin=start]:left-(--dt-pin) @xl:data-[pin=end]:right-(--dt-pin)\",\n tbody: \"@max-xl:grid @max-xl:gap-3 @max-xl:p-3\",\n tr: \"@max-xl:relative @max-xl:grid @max-xl:gap-1.5 @max-xl:rounded-xl @max-xl:border @max-xl:border-border-default @max-xl:bg-surface-0 @max-xl:p-4 @max-xl:last:border-b\",\n td:\n \"@max-xl:flex @max-xl:min-w-0 @max-xl:items-baseline @max-xl:justify-between @max-xl:gap-4 @max-xl:p-0 @max-xl:text-right @max-xl:[overflow-wrap:anywhere] \" +\n \"@max-xl:[&>*]:min-w-0 @max-xl:[&>*]:shrink @max-xl:[&>*]:whitespace-normal \" +\n \"@max-xl:before:shrink-0 @max-xl:before:text-xs @max-xl:before:font-semibold @max-xl:before:text-fg-secondary @max-xl:before:content-[attr(data-label)] \" +\n \"@max-xl:data-[card=title]:order-first @max-xl:data-[card=title]:pr-8 @max-xl:data-[card=title]:text-left @max-xl:data-[card=title]:text-base @max-xl:data-[card=title]:font-semibold @max-xl:data-[card=title]:before:content-none \" +\n \"@max-xl:data-[card=hidden]:hidden @max-xl:data-[card=message]:block @max-xl:data-[card=message]:text-center @max-xl:data-[card=message]:before:content-none \" +\n \"@xl:data-[pin]:sticky @xl:data-[pin]:z-[1] @xl:data-[pin]:bg-surface-1 @xl:data-[pin=start]:left-(--dt-pin) @xl:data-[pin=end]:right-(--dt-pin)\",\n select: \"@max-xl:absolute @max-xl:right-3 @max-xl:top-3 @max-xl:w-auto @max-xl:before:content-none\",\n expander: \"@max-xl:before:content-none\",\n },\n cards: {\n table: \"block\",\n caption: \"block\",\n thead: \"sr-only bg-transparent has-[[data-keep]]:not-sr-only has-[[data-keep]]:block\",\n headRow: \"flex flex-wrap items-center gap-2 px-3 py-2\",\n th: \"sr-only p-0 data-[keep]:not-sr-only data-[keep]:block\",\n tbody: \"grid gap-3 p-3\",\n tr: \"relative grid gap-1.5 rounded-xl border border-border-default bg-surface-0 p-4 last:border-b\",\n td:\n \"flex min-w-0 items-baseline justify-between gap-4 p-0 text-right [overflow-wrap:anywhere] before:shrink-0 before:text-xs before:font-semibold before:text-fg-secondary before:content-[attr(data-label)] \" +\n \"[&>*]:min-w-0 [&>*]:shrink [&>*]:whitespace-normal \" +\n \"data-[card=title]:order-first data-[card=title]:pr-8 data-[card=title]:text-left data-[card=title]:text-base data-[card=title]:font-semibold data-[card=title]:before:content-none \" +\n \"data-[card=hidden]:hidden data-[card=message]:block data-[card=message]:text-center data-[card=message]:before:content-none\",\n select: \"absolute right-3 top-3 w-auto before:content-none\",\n expander: \"before:content-none\",\n },\n },\n density: {\n comfortable: {},\n compact: { th: \"py-2\", td: \"py-1.5\" },\n },\n // The header stays in view while the page scrolls (table layouts only; `--dt-top` clears a fixed bar).\n sticky: { true: {}, false: {} },\n // Zebra rows (the table layouts); the cards keep their frame.\n striped: { true: {}, false: {} },\n // Typed columns: one height per row (`typed`); untyped tables keep the height of their content.\n typed: { true: {}, false: {} },\n // What a row becomes on a phone (under 36 rem, or with `layout=\"cards\"`): a card, or a list row (#636).\n phone: { cards: {}, list: {} },\n },\n compoundVariants: [\n { layout: \"table\", sticky: true, class: { th: \"sticky top-(--dt-top,0px) z-[3] bg-surface-2\" } },\n { layout: \"auto\", sticky: true, class: { th: \"@xl:sticky @xl:top-(--dt-top,0px) @xl:z-[3] @xl:bg-surface-2\" } },\n { layout: \"table\", striped: true, class: { tr: \"even:bg-surface-2/40\" } },\n { layout: \"auto\", striped: true, class: { tr: \"@xl:even:bg-surface-2/40\" } },\n // One row height per density (a two-line row — a name and its id — is the tallest).\n { layout: \"table\", density: \"comfortable\", typed: true, class: { td: \"h-15\" } },\n { layout: \"auto\", density: \"comfortable\", typed: true, class: { td: \"@xl:h-15\" } },\n { layout: \"table\", density: \"compact\", typed: true, class: { td: \"h-12\" } },\n { layout: \"auto\", density: \"compact\", typed: true, class: { td: \"@xl:h-12\" } },\n // The list rows (#636): no box in the box, no label/value lines — the checkbox, the title and its line of facts, the\n // status at the end, a hairline between rows. Each cell says its place in `data-list` (title · status · away).\n {\n layout: \"auto\",\n phone: \"list\",\n class: {\n tbody: \"@max-xl:gap-0 @max-xl:p-0\",\n tr:\n \"@max-xl:flex @max-xl:items-center @max-xl:gap-3 @max-xl:rounded-none @max-xl:border-0 @max-xl:border-b @max-xl:border-border-subtle @max-xl:bg-transparent \" +\n \"@max-xl:px-4 @max-xl:py-3 @max-xl:last:border-b-0\",\n td:\n \"@max-xl:block @max-xl:text-left @max-xl:before:content-none @max-xl:data-[list=title]:min-w-0 @max-xl:data-[list=title]:flex-1 \" +\n \"@max-xl:data-[list=status]:shrink-0 @max-xl:data-[list=away]:hidden @max-xl:data-[card=message]:w-full\",\n select: \"@max-xl:relative @max-xl:right-auto @max-xl:top-auto @max-xl:z-[1] @max-xl:shrink-0 @max-xl:self-center\",\n expander: \"@max-xl:relative @max-xl:z-[1] @max-xl:shrink-0\",\n },\n },\n {\n layout: \"cards\",\n phone: \"list\",\n class: {\n tbody: \"gap-0 p-0\",\n tr: \"flex items-center gap-3 rounded-none border-0 border-b border-border-subtle bg-transparent px-4 py-3 last:border-b-0\",\n td: \"block text-left before:content-none data-[list=title]:min-w-0 data-[list=title]:flex-1 data-[list=status]:shrink-0 data-[list=away]:hidden data-[card=message]:w-full\",\n select: \"relative right-auto top-auto z-[1] shrink-0 self-center\",\n expander: \"relative z-[1] shrink-0\",\n },\n },\n ],\n defaultVariants: { layout: \"auto\", density: \"comfortable\", sticky: false, striped: false, typed: false, phone: \"cards\" },\n});\n\n// Column priorities. `priority: 1` (default) is always in the row; a higher number leaves the row when the table's own\n// width (a container query, not the window's) falls under its tier and goes to the row's detail — the same\n// `@container` that turns the table into cards. Written out in full: Tailwind finds a class only as a whole string.\n// priority 2 → 42rem · 3 → 48rem · 4 → 56rem · 5 → 64rem · 6 → 72rem · 7 → 80rem\n// On the cards (under 36rem), priority 2 stays on the card's face and 3+ go to its detail.\ntype Priority = 2 | 3 | 4 | 5 | 6 | 7;\ntype ByLayout = { auto: Record<Priority, string>; table: Record<Priority, string>; cards: Record<Priority, string> };\nconst NO_CLASS: Record<Priority, string> = { 2: \"\", 3: \"\", 4: \"\", 5: \"\", 6: \"\", 7: \"\" };\n/** The cell of a column that leaves the row. */\nconst LEAVES: ByLayout = {\n auto: { 2: \"@xl:@max-2xl:hidden!\", 3: \"@max-3xl:hidden!\", 4: \"@max-4xl:hidden!\", 5: \"@max-5xl:hidden!\", 6: \"@max-6xl:hidden!\", 7: \"@max-7xl:hidden!\" },\n table: { 2: \"@max-2xl:hidden!\", 3: \"@max-3xl:hidden!\", 4: \"@max-4xl:hidden!\", 5: \"@max-5xl:hidden!\", 6: \"@max-6xl:hidden!\", 7: \"@max-7xl:hidden!\" },\n cards: { 2: \"\", 3: \"hidden!\", 4: \"hidden!\", 5: \"hidden!\", 6: \"hidden!\", 7: \"hidden!\" },\n};\n/** Its header (the cards keep the sort buttons). */\nconst LEAVES_HEAD: ByLayout = {\n auto: { 2: \"@xl:@max-2xl:hidden!\", 3: \"@xl:@max-3xl:hidden!\", 4: \"@xl:@max-4xl:hidden!\", 5: \"@xl:@max-5xl:hidden!\", 6: \"@xl:@max-6xl:hidden!\", 7: \"@xl:@max-7xl:hidden!\" },\n table: LEAVES.table,\n cards: NO_CLASS,\n};\n/** Its line in the row's detail: shown exactly when the cell is not. */\nconst IN_DETAIL: ByLayout = {\n auto: { 2: \"hidden @xl:@max-2xl:grid\", 3: \"hidden @max-3xl:grid\", 4: \"hidden @max-4xl:grid\", 5: \"hidden @max-5xl:grid\", 6: \"hidden @max-6xl:grid\", 7: \"hidden @max-7xl:grid\" },\n table: { 2: \"hidden @max-2xl:grid\", 3: \"hidden @max-3xl:grid\", 4: \"hidden @max-4xl:grid\", 5: \"hidden @max-5xl:grid\", 6: \"hidden @max-6xl:grid\", 7: \"hidden @max-7xl:grid\" },\n cards: { 2: \"hidden\", 3: \"grid\", 4: \"grid\", 5: \"grid\", 6: \"grid\", 7: \"grid\" },\n};\n/** The button that opens the detail, and its panel, keyed by the highest priority of the table: seen only when something left the row. */\nconst TOGGLE: ByLayout = {\n auto: { 2: \"hidden @xl:@max-2xl:inline-flex\", 3: \"hidden @max-3xl:inline-flex\", 4: \"hidden @max-4xl:inline-flex\", 5: \"hidden @max-5xl:inline-flex\", 6: \"hidden @max-6xl:inline-flex\", 7: \"hidden @max-7xl:inline-flex\" },\n table: { 2: \"hidden @max-2xl:inline-flex\", 3: \"hidden @max-3xl:inline-flex\", 4: \"hidden @max-4xl:inline-flex\", 5: \"hidden @max-5xl:inline-flex\", 6: \"hidden @max-6xl:inline-flex\", 7: \"hidden @max-7xl:inline-flex\" },\n cards: { 2: \"hidden\", 3: \"inline-flex\", 4: \"inline-flex\", 5: \"inline-flex\", 6: \"inline-flex\", 7: \"inline-flex\" },\n};\nconst PANEL: ByLayout = {\n auto: { 2: \"hidden @xl:@max-2xl:block\", 3: \"hidden @max-3xl:block\", 4: \"hidden @max-4xl:block\", 5: \"hidden @max-5xl:block\", 6: \"hidden @max-6xl:block\", 7: \"hidden @max-7xl:block\" },\n table: { 2: \"hidden @max-2xl:block\", 3: \"hidden @max-3xl:block\", 4: \"hidden @max-4xl:block\", 5: \"hidden @max-5xl:block\", 6: \"hidden @max-6xl:block\", 7: \"hidden @max-7xl:block\" },\n cards: { 2: \"hidden\", 3: \"block\", 4: \"block\", 5: \"block\", 6: \"block\", 7: \"block\" },\n};\n\n// The list rows (`phone=\"list\"`): the facts line under the title, the columns that go to the row's detail (every\n// priority-1 column that is neither the title nor a status), the button and the panel that show them, the title's link\n// stretched over the row.\nconst LIST_FACTS = { auto: \"hidden @max-xl:mt-0.5 @max-xl:block @max-xl:truncate text-xs font-normal text-fg-secondary\", table: \"hidden\", cards: \"mt-0.5 block truncate text-xs font-normal text-fg-secondary\" } as const;\nconst LIST_IN_DETAIL = { auto: \"hidden @max-xl:grid\", table: \"hidden\", cards: \"grid\" } as const;\nconst LIST_TOGGLE = { auto: \"@max-xl:inline-flex\", table: \"\", cards: \"inline-flex\" } as const;\nconst LIST_PANEL = { auto: \"@max-xl:block\", table: \"\", cards: \"block\" } as const;\nconst LIST_LINK = { auto: \"@max-xl:after:absolute @max-xl:after:inset-0 @max-xl:after:content-['']\", table: \"\", cards: \"after:absolute after:inset-0 after:content-['']\" } as const;\n\nexport type DataTableColumnType = \"title\" | \"badge\" | \"number\" | \"link\" | \"date\" | \"status\" | \"text\";\n\n// What each type does to its cell. Sizes apply to the table layouts only (a card is not a column).\n// title: the column that takes the slack, truncated, full value on hover · badge, status, number, date: as wide as\n// their content, never stretched · link: truncated · text: wraps, two lines.\nconst TYPE_CELL: Record<DataTableColumnType, ByLayoutString> = {\n title: { auto: \"@xl:w-full @xl:max-w-0 @xl:min-w-44\", table: \"w-full max-w-0 min-w-44\", cards: \"\" },\n badge: { auto: \"@xl:w-px\", table: \"w-px\", cards: \"\" },\n status: { auto: \"@xl:w-px @xl:whitespace-nowrap\", table: \"w-px whitespace-nowrap\", cards: \"\" },\n number: { auto: \"@xl:w-px @xl:whitespace-nowrap tabular-nums\", table: \"w-px whitespace-nowrap tabular-nums\", cards: \"tabular-nums\" },\n date: { auto: \"@xl:w-px @xl:whitespace-nowrap tabular-nums\", table: \"w-px whitespace-nowrap tabular-nums\", cards: \"tabular-nums\" },\n link: { auto: \"@xl:min-w-28 @xl:max-w-56\", table: \"min-w-28 max-w-56\", cards: \"\" },\n text: { auto: \"@xl:min-w-36\", table: \"min-w-36\", cards: \"\" },\n};\ntype ByLayoutString = { auto: string; table: string; cards: string };\nconst CLAMP = { 1: \"line-clamp-1\", 2: \"line-clamp-2\", 3: \"line-clamp-3\" } as const;\n\nexport type SortDirection = \"ascending\" | \"descending\";\nexport type DataTableSort = { column: string; direction: SortDirection } | null;\n\n/** What the server needs to fetch a page (`manual` mode): given to `onStateChange` at each change. */\nexport type DataTableQuery = {\n /** The sorted column, or `null`. */\n sort: DataTableSort;\n /** The filters in force, by column id. */\n filters: DataTableFilterState;\n /** The page, from 1 (1 without pagination). */\n page: number;\n /** Rows per page (`Infinity` without pagination). */\n pageSize: number;\n};\n\nexport type DataTableColumn<T> = {\n /** A key of the column (and of the row, for the default value). */\n id: string;\n /** The column's name, passed translated: the header, the label of the value in a card, the filter's name. */\n header: string;\n /** What a cell shows. Default: the value as text. */\n cell?: (row: T) => React.ReactNode;\n /** The value sorted, filtered, grouped and exported. Default `row[id]`. */\n value?: (row: T) => CellValue;\n /** The header becomes a sort button (ascending → descending → none). */\n sortable?: boolean;\n /** How `DataTableFilters` filters it: `text` (a search), `facet` (values to choose), `range` (numbers between bounds). */\n filter?: \"text\" | \"facet\" | \"range\";\n /** The choices of a `facet` filter, with their words (and counts from the server). Default: the values found in the rows. */\n options?: readonly { value: string; label: string; count?: number }[];\n /** The rows can be grouped by this column (`groupBy`). */\n groupable?: boolean;\n /** What a group's row shows in this column, from the group's rows (a sum, a count). */\n aggregate?: (rows: readonly T[]) => React.ReactNode;\n /** Text alignment: `end` for amounts and counts. */\n align?: \"start\" | \"center\" | \"end\";\n /** The width, in px (the starting width when `resizing`). */\n width?: number;\n /** The narrowest the column may be, in px (also what a resized one may shrink to). Default 64. */\n minWidth?: number;\n /** In a card: `title` (the heading of the card), `label` (default: \"Header value\"), `hidden`. */\n card?: \"title\" | \"label\" | \"hidden\";\n /** Hides the header visually (an actions column), keeping it for screen readers. */\n headerHidden?: boolean;\n /** Listed in `DataTableColumnsMenu`, so a person can hide it. Default `true`. */\n hideable?: boolean;\n /**\n * What the column holds, which sizes and aligns it: `title` (the name, takes the slack, truncated, `subtitle` under it in\n * mono — and the card's heading), `badge` · `status` (as wide as the badge, never stretched), `number` (end-aligned,\n * tabular), `date`, `link` (truncated, `href`), `text` (wraps, two lines). Untyped: as before.\n */\n type?: DataTableColumnType;\n /**\n * 1 (default) always in the row; 2 to 7 leave it when the table is narrower than 42 · 48 · 56 · 64 · 72 · 80 rem and\n * go to the row's detail (a chevron opens it); under 36 rem (cards) 2 stays on the card, 3+ go to its detail. In the phone's\n * list rows (`phone=\"list\"`), the 2s are joined as the line of facts under the title.\n */\n priority?: 1 | 2 | 3 | 4 | 5 | 6 | 7;\n /** `title`: the line under the name (an id), in mono. */\n subtitle?: (row: T) => React.ReactNode;\n /** `link`: where the value leads. `title`: the row's page — the name becomes its link (the whole row, in the phone's list rows). */\n href?: (row: T) => string | undefined;\n /** `number`: the unit after the value (muted). */\n unit?: string;\n /** The widest the column may be in the table layouts, in px; longer text is clamped, the full value on hover. */\n maxWidth?: number;\n /** One line only, truncated (the full value on hover). */\n nowrap?: boolean;\n /** Lines of text kept before the ellipsis (default 2 for `text`). */\n clamp?: 1 | 2 | 3;\n};\n\nexport type DataTableLabels = {\n /** The checkbox of the header (\"Select all rows\"). */\n selectAll: string;\n /** The checkbox of a row: `(name) => \"Select \" + name`, the name from `rowLabel`. */\n selectRow: (name: string) => string;\n /** Read while the rows load (\"Loading\"). */\n loading: string;\n /** The default empty state (\"Nothing to show\"). */\n empty: string;\n /** The header of the column of the expand buttons, for screen readers (\"Details\"). */\n details: string;\n /** The button that opens a row: `(name) => \"Show details of \" + name`. */\n expandRow: (name: string) => string;\n /** The button of a group's row: `(value, count) => value + \", \" + count + \" rows\"`. */\n group: (value: string, count: number) => string;\n /** The handle that resizes a column: `(header) => \"Resize \" + header`. */\n resize: (header: string) => string;\n /** The count of results under the filters: `(count) => count + \" rows\"`. */\n results: (count: number) => string;\n /** The accessible name of the filters' bar (\"Filter the table\"). */\n filters: string;\n /** \"Clear filters\". */\n clearFilters: string;\n /** A filter's chip remove button: `(label) => \"Remove \" + label`. */\n removeFilter: (label: string) => string;\n /** The words of a filter in force: `(header, value) => header + \": \" + value`. */\n activeFilter: (header: string, value: string) => string;\n /** A `text` filter's field: `(header) => \"Search \" + header`. */\n search: (header: string) => string;\n /** The lower bound of a `range` filter: `(header) => header + \" from\"`. */\n rangeFrom: (header: string) => string;\n /** The upper bound of a `range` filter: `(header) => header + \" to\"`. */\n rangeTo: (header: string) => string;\n /** The button of the columns menu (\"Columns\"). */\n columns: string;\n /** The button that opens what left the row: `(name) => \"Show more about \" + name`. */\n showMore: (name: string) => string;\n /** The export button (\"Export CSV\"). */\n export: string;\n /** The words of the pagination under the table. */\n pagination?: Partial<PaginationLabels>;\n};\n\nexport const dataTableLabels: DataTableLabels = {\n selectAll: \"Select all rows\",\n selectRow: (name) => `Select ${name}`,\n loading: \"Loading\",\n empty: \"Nothing to show\",\n details: \"Details\",\n expandRow: (name) => `Show details of ${name}`,\n group: (value, count) => `${value}, ${count} ${count === 1 ? \"row\" : \"rows\"}`,\n resize: (header) => `Resize ${header}`,\n results: (count) => `${count.toLocaleString(\"en\")} ${count === 1 ? \"row\" : \"rows\"}`,\n filters: \"Filter the table\",\n clearFilters: \"Clear filters\",\n removeFilter: (label) => `Remove ${label}`,\n activeFilter: (header, value) => `${header}: ${value}`,\n search: (header) => `Search ${header.toLocaleLowerCase()}`,\n rangeFrom: (header) => `${header} from`,\n rangeTo: (header) => `${header} to`,\n columns: \"Columns\",\n showMore: (name) => `Show more about ${name}`,\n export: \"Export CSV\",\n};\n\nexport type DataTableProps<T> = {\n /** The rows (stable: module scope, state or a query's data). Sorted, filtered and paginated here unless `manual`. */\n rows: readonly T[];\n /** The columns, in order (stable: module scope or `useMemo`). */\n columns: readonly DataTableColumn<T>[];\n /** A stable id per row: the React key, the selection's value, the expansion's key. */\n getRowId: (row: T) => string;\n /** What the table lists (\"Jobs\", \"Your videos\"): its `<caption>`, hidden unless `showCaption`. */\n caption: string;\n /** Shows the caption above the table. */\n showCaption?: boolean;\n\n /** The sorted column (controlled), `null` for none. */\n sort?: DataTableSort;\n /** The sort at first, uncontrolled. */\n defaultSort?: DataTableSort;\n /** Called when a header asks for a new sort. */\n onSortChange?: (sort: DataTableSort) => void;\n\n /** The filters in force (controlled), by column id — what `DataTableFilters` edits. */\n filters?: DataTableFilterState;\n /** The filters at first, uncontrolled. */\n defaultFilters?: DataTableFilterState;\n /** Called with the new filters. */\n onFiltersChange?: (filters: DataTableFilterState) => void;\n\n /**\n * Keeps the sort, the filters and the page in the address (`?clients.sort=…&clients.page=2`): back and forward, a reload\n * and a shared link all land on the same table. Replaces `sort`, `filters` and `page`; needs the router's adapter\n * (`RouteStateProvider`) in a framework.\n */\n routeKey?: string;\n\n /** Adds a checkbox per row and one in the header. */\n selectable?: boolean;\n /** The ids selected (controlled). */\n selected?: readonly string[];\n /** The ids selected at first, uncontrolled. */\n defaultSelected?: readonly string[];\n /** Called with the new selection. */\n onSelectedChange?: (selected: string[]) => void;\n /** The name of a row in its labels (checkbox, expand button). Default: its id. */\n rowLabel?: (row: T) => string;\n\n /** The columns the rows are grouped by, in order (controlled) — each `groupable`. */\n groupBy?: readonly string[];\n /** The grouping at first, uncontrolled. */\n defaultGroupBy?: readonly string[];\n /** Called with the new grouping. */\n onGroupByChange?: (groupBy: string[]) => void;\n\n /** Visible (`true`) or hidden (`false`) columns, by id (controlled); a column not listed is visible. */\n columnVisibility?: Readonly<Record<string, boolean>>;\n /** The visibility at first, uncontrolled. */\n defaultColumnVisibility?: Readonly<Record<string, boolean>>;\n /** Called with the new visibility (`DataTableColumnsMenu`). */\n onColumnVisibilityChange?: (visibility: Record<string, boolean>) => void;\n\n /** Columns that stay in view while the table scrolls sideways: at the start (a name) or the end (actions). */\n pinning?: { start?: readonly string[]; end?: readonly string[] };\n /** Columns can be resized (pointer drag, or the arrow keys on the focused handle). */\n resizing?: boolean;\n\n /** The children of a row (a tree): each row with children gets a button that shows them. */\n getSubRows?: (row: T) => readonly T[] | undefined;\n /** A panel under an opened row (the details of a job): each row gets a button that opens it. */\n renderDetail?: (row: T) => React.ReactNode;\n /** The opened rows and groups (controlled), by id. */\n expanded?: Readonly<Record<string, boolean>>;\n /** The opened rows at first, uncontrolled. */\n defaultExpanded?: Readonly<Record<string, boolean>>;\n /** Called with the opened rows. */\n onExpandedChange?: (expanded: Record<string, boolean>) => void;\n\n /** Rows per page (controlled): turns on the pagination under the table. */\n pageSize?: number;\n /** Rows per page at first, uncontrolled: turns on the pagination under the table. */\n defaultPageSize?: number;\n /** Called with the size chosen in the pagination's select (`pageSizeOptions`). */\n onPageSizeChange?: (pageSize: number) => void;\n /** The sizes the pagination offers (`[10, 25, 50]`). */\n pageSizeOptions?: readonly number[];\n /** The page, from 1 (controlled). */\n page?: number;\n /** The page at first, uncontrolled. Default 1. */\n defaultPage?: number;\n /** Called with the page asked for. */\n onPageChange?: (page: number) => void;\n\n /** The server does the work: the rows are shown as given (sorted, filtered, a single page) — fetch in `onStateChange`. */\n manual?: { sorting?: boolean; filtering?: boolean; pagination?: boolean };\n /** The number of rows on the server (`manual.pagination`): the page count and the results' count. */\n rowCount?: number;\n /** Called with the sort, the filters and the page after each change: the query key of a server table. */\n onStateChange?: (query: DataTableQuery) => void;\n\n /** Rows are loading: skeleton rows (`loadingRows` of them) and `aria-busy`. */\n loading?: boolean;\n /** How many skeleton rows while loading. Default 5. */\n loadingRows?: number;\n /** A failure, shown in place of the rows (an `Alert`, a retry button); announced. */\n error?: React.ReactNode;\n /** Shown when there is no row (an `EmptyState`). Default: `labels.empty`. */\n empty?: React.ReactNode;\n\n /** `auto` (default): a table, cards below 36rem of container width. `table` or `cards` to force one. */\n layout?: \"auto\" | \"table\" | \"cards\";\n /**\n * What a row becomes on a phone (under 36 rem, or always with `layout=\"cards\"`): `cards` (a card per row, its values\n * labelled) or `list` — a list row: the selection's checkbox, the `title` column with its `priority: 2` columns joined as\n * one line of facts, the `status` / `badge` column at the end; the other columns in the row's detail.\n * @default \"cards\"\n */\n phone?: \"cards\" | \"list\";\n /** comfortable (default) · compact — one row height per density. */\n density?: \"comfortable\" | \"compact\";\n /** The header stays at the top of the window while the page scrolls (`--dt-top` on the table, or a number of px, clears a fixed bar). */\n stickyHeader?: boolean | number;\n /** Zebra rows. */\n striped?: boolean;\n /**\n * The table never scrolls sideways by default once its columns have a `type` or a `priority`: what does not fit goes to\n * the row's detail, then to cards. `true` opts in to a sideways scroll instead, with fades at the edges.\n */\n scroll?: boolean;\n /** Keeps the first column in view while the table scrolls sideways (with `scroll`). */\n stickyFirstColumn?: boolean;\n /** Renders only the rows in view, in a scrolling body of `height` px; rows are measured (`estimateRowHeight` is a guess). Forces the table layout. */\n virtualized?: { height: number; estimateRowHeight?: number; overscan?: number };\n\n /** Above the table, inside its frame: `DataTableFilters`, `DataTableColumnsMenu`, `DataTableExport`, actions. */\n toolbar?: React.ReactNode;\n /** Under the table (after the pagination): a total, a note. */\n footer?: React.ReactNode;\n /** The words, passed translated; English by default. */\n labels?: Partial<DataTableLabels>;\n /** Classes merged on the root. */\n className?: string;\n};\n\ntype Context = {\n table: DataTableInstance<object>;\n columns: readonly DataTableColumn<object>[];\n labels: DataTableLabels;\n filters: DataTableFilterState;\n setFilters: (filters: DataTableFilterState) => void;\n /** The rows after the filters (all pages), or the server's count. */\n resultCount: number;\n /** The rows to export: filtered and sorted, every page, without the group rows. */\n exportRows: () => readonly unknown[];\n manualFiltering: boolean;\n};\n\nconst DataTableContext = React.createContext<Context | null>(null);\n\n/**\n * The table a component sits in (`toolbar`, or anything under it): the TanStack Table instance (`table` — the escape\n * hatch for what the props do not cover), the columns, the words, the filters and their setter.\n */\nexport function useDataTable<T extends RowData = object>() {\n const context = React.useContext(DataTableContext);\n if (!context) throw new Error(\"useDataTable: call it inside a DataTable (its `toolbar`, `footer` or a cell)\");\n return context as unknown as Omit<Context, \"table\" | \"columns\" | \"exportRows\"> & {\n table: DataTableInstance<T>;\n columns: readonly DataTableColumn<T>[];\n exportRows: () => readonly T[];\n };\n}\n\n// A card never scrolls sideways, even when untyped columns keep the table's scroller.\nconst CARD_CLIP = { auto: \"@max-xl:overflow-x-clip!\", table: undefined, cards: \"overflow-x-clip!\" } as const;\nconst TRUNCATE = { auto: \"@xl:truncate\", table: \"truncate\", cards: \"\" } as const;\nconst MIN = { auto: \"@xl:min-w-(--dt-min)!\", table: \"min-w-(--dt-min)!\", cards: \"\" } as const;\nconst MAX = { auto: \"@xl:max-w-(--dt-max)!\", table: \"max-w-(--dt-max)!\", cards: \"\" } as const;\n// A sideways scroll asked for (`scroll`): the edges fade where there is more to see.\nconst SHADOWS =\n \"[background:linear-gradient(to_right,var(--kz-surface-1),transparent)_left/24px_100%_no-repeat_local,linear-gradient(to_left,var(--kz-surface-1),transparent)_right/24px_100%_no-repeat_local,linear-gradient(to_right,rgb(0_0_0/0.18),transparent)_left/10px_100%_no-repeat_scroll,linear-gradient(to_left,rgb(0_0_0/0.18),transparent)_right/10px_100%_no-repeat_scroll]\";\nconst ALIGN = { start: \"text-left\", center: \"text-center\", end: \"text-right\" } as const;\nconst NONE: readonly never[] = [];\nconst NO_RECORD: Readonly<Record<string, never>> = {};\nconst apply = <S,>(updater: S | ((old: S) => S), old: S): S => (typeof updater === \"function\" ? (updater as (old: S) => S)(old) : updater);\n\nconst valueOf = <T,>(column: DataTableColumn<T>, row: T): CellValue =>\n column.value ? column.value(row) : ((row as Record<string, unknown>)[column.id] as CellValue);\n\nconst display = (value: CellValue) => (value instanceof Date ? value.toISOString() : String(value ?? \"\"));\n\n/** A table of records — sort, filters and facets, groups, details, pages, selection, visibility, pinning, resizing — on the client or the server, cards on a phone, windowing for long lists. */\nexport function DataTable<T extends RowData>({\n rows,\n columns,\n getRowId,\n caption,\n showCaption,\n sort: sortProp,\n routeKey,\n defaultSort = null,\n onSortChange,\n filters: filtersProp,\n defaultFilters = NO_RECORD,\n onFiltersChange,\n selectable,\n selected: selectedProp,\n defaultSelected = NONE,\n onSelectedChange,\n rowLabel,\n groupBy: groupByProp,\n defaultGroupBy = NONE,\n onGroupByChange,\n columnVisibility: visibilityProp,\n defaultColumnVisibility = NO_RECORD,\n onColumnVisibilityChange,\n pinning,\n resizing,\n getSubRows,\n renderDetail,\n expanded: expandedProp,\n defaultExpanded = NO_RECORD,\n onExpandedChange,\n pageSize: pageSizeProp,\n defaultPageSize,\n onPageSizeChange,\n pageSizeOptions,\n page: pageProp,\n defaultPage = 1,\n onPageChange,\n manual,\n rowCount,\n onStateChange,\n loading,\n loadingRows = 5,\n error,\n empty,\n layout = \"auto\",\n phone = \"cards\",\n density,\n stickyHeader,\n striped,\n scroll,\n stickyFirstColumn,\n virtualized,\n toolbar,\n footer,\n labels: given,\n className,\n}: DataTableProps<T>) {\n const labels = { ...dataTableLabels, ...given };\n const effectiveLayout = virtualized ? \"table\" : layout;\n const L = effectiveLayout;\n const typed = columns.some((c) => c.type || c.priority);\n const list = phone === \"list\" && L !== \"table\";\n const s = dataTable({ layout: L, density, sticky: Boolean(stickyHeader), striped: Boolean(striped), typed, phone: list ? \"list\" : \"cards\" });\n\n const route = useRouteTable(routeKey, { sort: defaultSort, page: defaultPage, filters: defaultFilters }, columns.map((c) => c.id));\n const [sort, setSort] = useControllable<DataTableSort>(route ? route.sort : sortProp, defaultSort, route?.onSortChange ?? onSortChange);\n const [filters, setFiltersState] = useControllable<DataTableFilterState>(route ? route.filters : filtersProp, defaultFilters, route?.onFiltersChange ?? onFiltersChange);\n const [selected, setSelected] = useControllable<readonly string[]>(selectedProp, defaultSelected, onSelectedChange as (next: readonly string[]) => void);\n const [groupBy, setGroupBy] = useControllable<readonly string[]>(groupByProp, defaultGroupBy, onGroupByChange as (next: readonly string[]) => void);\n const [visibility, setVisibility] = useControllable<Readonly<Record<string, boolean>>>(visibilityProp, defaultColumnVisibility, onColumnVisibilityChange as (next: Readonly<Record<string, boolean>>) => void);\n const [expanded, setExpanded] = useControllable<Readonly<Record<string, boolean>>>(expandedProp, defaultExpanded, onExpandedChange as (next: Readonly<Record<string, boolean>>) => void);\n const paginated = pageSizeProp !== undefined || defaultPageSize !== undefined;\n const [pageSize, setPageSize] = useControllable<number>(pageSizeProp, defaultPageSize ?? Number.POSITIVE_INFINITY, onPageSizeChange);\n const [page, setPage] = useControllable<number>(route ? route.page : pageProp, defaultPage, route?.onPageChange ?? onPageChange);\n const [sizing, setSizing] = React.useState<Record<string, number>>({});\n const [resizeState, setResizeState] = React.useState<ColumnResizingState | undefined>(undefined);\n\n const announced = React.useRef(JSON.stringify([defaultSort, defaultFilters, paginated ? defaultPage : 1, paginated ? pageSize : null]));\n // One place announces the server's query: after any change of sort, filters, page or page size.\n const announce = (next: Partial<DataTableQuery>) => {\n const query = { sort, filters, page: paginated ? page : 1, pageSize: paginated ? pageSize : Number.POSITIVE_INFINITY, ...next };\n if (route) announced.current = JSON.stringify([query.sort, query.filters, query.page, paginated ? query.pageSize : null]);\n onStateChange?.(query);\n };\n // In the address (routeKey), the sort or the filters reset the page in the same navigation: no second write.\n const changeSort = (next: DataTableSort) => {\n setSort(next);\n if (paginated && page !== 1 && !route) setPage(1);\n announce({ sort: next, page: 1 });\n };\n const changeFilters = (next: DataTableFilterState) => {\n setFiltersState(next);\n if (paginated && page !== 1 && !route) setPage(1);\n announce({ filters: next, page: 1 });\n };\n // The address can change under the table (back, forward, a shared link): the server is told the query it now shows,\n // once — a change the table made itself was announced already. A first visit on the defaults says nothing.\n const routeQuery = route ? JSON.stringify([route.sort, route.filters, paginated ? route.page : 1, paginated ? pageSize : null]) : null;\n React.useEffect(() => {\n if (routeQuery === null || routeQuery === announced.current) return;\n announced.current = routeQuery;\n announce({});\n // eslint-disable-next-line react-hooks/exhaustive-deps -- only a change of the address's query announces\n }, [routeQuery]);\n const changePage = (next: number) => {\n setPage(next);\n announce({ page: next });\n };\n const changePageSize = (next: number) => {\n setPageSize(next);\n setPage(1);\n announce({ pageSize: next, page: 1 });\n };\n\n const hasExpander = Boolean(renderDetail || getSubRows);\n // Columns that can leave the row: their lines go to the row's detail, opened by a chevron in the first cell.\n const priorityOf = (column: DataTableColumn<T>): 1 | Priority => (column.priority && column.priority > 1 ? (column.priority as Priority) : 1);\n const maxPriority = columns.reduce<1 | Priority>((max, column) => (priorityOf(column) > max ? priorityOf(column) : max), 1);\n // The list rows' places: the title (the `title` column, else the first), the status at the end, the facts (the 2s) under\n // the title, and the rest away, in the row's detail.\n const titleColumn = columns.find((c) => c.card === \"title\" || (!c.card && c.type === \"title\")) ?? columns[0];\n const placeOf = (column: DataTableColumn<T>): \"title\" | \"status\" | \"facts\" | \"away\" | \"hidden\" => {\n if (column.card === \"hidden\") return \"hidden\";\n if (column === titleColumn) return \"title\";\n if (column.type === \"status\" || column.type === \"badge\") return \"status\";\n return priorityOf(column) === 2 ? \"facts\" : \"away\";\n };\n const listAway = list ? columns.filter((c) => placeOf(c) === \"away\" && priorityOf(c) === 1) : [];\n const facts = list ? columns.filter((c) => placeOf(c) === \"facts\") : [];\n const inlineToggle = (maxPriority > 1 || listAway.length > 0) && !hasExpander;\n const detailed = Boolean(renderDetail) || inlineToggle;\n const defs = React.useMemo<ColumnDef<typeof features, T>[]>(\n () =>\n columns.map((column) => ({\n id: column.id,\n accessorFn: (row: T) => valueOf(column, row),\n header: column.header,\n enableSorting: Boolean(column.sortable),\n sortFn: sortByValue as never,\n sortUndefined: \"last\",\n enableColumnFilter: Boolean(column.filter),\n filterFn: (column.filter ? FILTERS[column.filter] : FILTERS.text) as never,\n enableGrouping: Boolean(column.groupable),\n enableHiding: column.hideable !== false,\n enableResizing: Boolean(resizing),\n ...(column.width ? { size: column.width } : {}),\n minSize: column.minWidth ?? 64,\n })),\n [columns, resizing],\n );\n\n const firstId = columns[0]?.id;\n const pinned = stickyFirstColumn && firstId && !pinning?.start?.length ? { ...pinning, start: [firstId] } : pinning;\n const pageIndex = Math.max(0, page - 1);\n const table = useTable<typeof features, T>({\n features,\n columns: defs,\n data: rows as T[],\n getRowId: (row) => getRowId(row),\n getSubRows: getSubRows ? (row) => getSubRows(row) as T[] | undefined : undefined,\n getRowCanExpand: detailed ? () => true : undefined,\n state: {\n sorting: sort ? [{ id: sort.column, desc: sort.direction === \"descending\" }] : [],\n columnFilters: Object.entries(filters).map(([id, value]) => ({ id, value })),\n rowSelection: Object.fromEntries(selected.map((id) => [id, true])),\n grouping: [...groupBy],\n columnVisibility: { ...visibility },\n expanded: { ...expanded },\n pagination: { pageIndex, pageSize: paginated ? pageSize : Math.max(1, rows.length) },\n columnPinning: { start: [...(pinned?.start ?? [])], end: [...(pinned?.end ?? [])] },\n columnSizing: sizing,\n ...(resizeState ? { columnResizing: resizeState } : {}),\n },\n onSortingChange: (updater) => {\n const [first] = apply(updater, sort ? [{ id: sort.column, desc: sort.direction === \"descending\" }] : []);\n changeSort(first ? { column: first.id, direction: first.desc ? \"descending\" : \"ascending\" } : null);\n },\n onColumnFiltersChange: (updater) => {\n const next = apply(updater, Object.entries(filters).map(([id, value]) => ({ id, value })));\n changeFilters(Object.fromEntries(next.map((f) => [f.id, f.value as FilterValue])));\n },\n onRowSelectionChange: (updater) => {\n const next = apply(updater, Object.fromEntries(selected.map((id) => [id, true])));\n setSelected(Object.keys(next).filter((id) => next[id]));\n },\n onGroupingChange: (updater) => setGroupBy(apply(updater, [...groupBy])),\n onColumnVisibilityChange: (updater) => setVisibility(apply(updater, { ...visibility })),\n onExpandedChange: (updater) => {\n const next = apply(updater, { ...expanded });\n setExpanded(next === true ? Object.fromEntries(table.getRowModel().rows.map((r) => [r.id, true])) : next);\n },\n onColumnSizingChange: (updater) => setSizing((old: Record<string, number>) => apply(updater, old)),\n onColumnResizingChange: (updater) => setResizeState((old) => apply(updater, old as ColumnResizingState)),\n enableSortingRemoval: true,\n sortDescFirst: false,\n manualSorting: manual?.sorting,\n manualFiltering: manual?.filtering,\n manualPagination: manual?.pagination || !paginated,\n rowCount: manual?.pagination ? rowCount : undefined,\n autoResetPageIndex: false,\n autoResetExpanded: false,\n groupedColumnMode: false,\n columnResizeMode: \"onChange\",\n enableRowSelection: (row) => !row.getIsGrouped(),\n });\n\n const visibleColumns = table.getVisibleLeafColumns();\n const byId = new Map(columns.map((c) => [c.id, c]));\n const pageRows = table.getRowModel().rows;\n const leafCount = (manual?.pagination || manual?.filtering) && rowCount !== undefined ? rowCount : table.getFilteredRowModel().rows.length;\n const pageCount = paginated ? Math.max(1, manual?.pagination ? Math.ceil((rowCount ?? rows.length) / pageSize) : table.getPageCount()) : 1;\n // A page beyond the last (the filters shrank the list): show the last one.\n React.useEffect(() => {\n if (paginated && !loading && page > pageCount) changePage(pageCount);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- only when the count of pages drops under the page\n }, [pageCount]);\n\n const selectableIds = pageRows.filter((r) => !r.getIsGrouped()).map((r) => r.id);\n const chosen = new Set(selected);\n const allChosen = selectableIds.length > 0 && selectableIds.every((id) => chosen.has(id));\n const someChosen = selectableIds.some((id) => chosen.has(id));\n const toggleAll = () =>\n setSelected(allChosen ? selected.filter((id) => !selectableIds.includes(id)) : [...new Set([...selected, ...selectableIds])]);\n\n const span = visibleColumns.length + (selectable ? 1 : 0) + (hasExpander ? 1 : 0);\n const fixedLayout = Boolean(resizing || pinned?.start?.length || pinned?.end?.length);\n const sideways = scroll ?? (!typed && !stickyHeader);\n const nameOf = (row: TableRow<T>) => (rowLabel ? rowLabel(row.original) : row.id);\n\n // Windowing: the rows in view, between two spacer rows that keep the scroll height.\n const scroller = React.useRef<HTMLDivElement>(null);\n const fallbacks = useLayoutFallbacks(virtualized?.height ?? 0, virtualized?.estimateRowHeight ?? 44);\n const virtualizer = useVirtualizer({\n ...fallbacks,\n count: pageRows.length,\n getScrollElement: () => scroller.current,\n estimateSize: () => virtualized?.estimateRowHeight ?? 44,\n overscan: virtualized?.overscan ?? 6,\n initialRect: { width: 0, height: virtualized?.height ?? 0 },\n getItemKey: (index) => pageRows[index]?.id ?? index,\n enabled: Boolean(virtualized) && !loading,\n });\n const windowItems = virtualized && !loading ? virtualizer.getVirtualItems() : null;\n const before = windowItems?.[0]?.start ?? 0;\n const after = windowItems && windowItems.length > 0 ? virtualizer.getTotalSize() - (windowItems.at(-1)?.end ?? 0) : 0;\n\n const pin = (column: TableColumn<T>): { \"data-pin\"?: \"start\" | \"end\"; style?: React.CSSProperties } => {\n const position = column.getIsPinned();\n if (!position) return {};\n const offset = position === \"start\" ? column.getStart(\"start\") : column.getAfter(\"end\");\n return { \"data-pin\": position, style: { [\"--dt-pin\" as string]: `${offset}px` } };\n };\n const width = (column: TableColumn<T>): React.CSSProperties | undefined =>\n fixedLayout || byId.get(column.id)?.width ? { width: column.getSize() } : undefined;\n\n const message = (content: React.ReactNode, alert?: boolean) => (\n <tr role=\"row\" className={s.tr({ className: \"hover:bg-transparent\" })}>\n <td role=\"cell\" colSpan={span} data-card=\"message\" className={s.td({ className: s.message() })}>\n {alert ? <div role=\"alert\">{content}</div> : content}\n </td>\n </tr>\n );\n\n const cardOf = (column: DataTableColumn<T> | undefined) =>\n column?.card === \"title\" || (column && !column.card && column.type === \"title\") ? \"title\" : column?.card === \"hidden\" ? \"hidden\" : undefined;\n\n // What a cell shows: the column's `cell`, or its value by type.\n const contentOf = (column: DataTableColumn<T>, original: T): React.ReactNode => {\n if (column.cell) return column.cell(original);\n const value = valueOf(column, original);\n const text = column.type === \"date\" && value instanceof Date ? value.toISOString().slice(0, 10) : display(value);\n const cut = TRUNCATE[L];\n switch (column.type) {\n case \"title\": {\n const sub = column.subtitle?.(original);\n const href = column.href?.(original);\n return (\n <span className=\"block min-w-0\">\n {href ? (\n <a href={href} className={cn(\"block font-semibold text-fg no-underline hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\", cut, list && LIST_LINK[L])} title={text}>\n {text}\n </a>\n ) : (\n <span className={cn(\"block font-semibold text-fg\", cut)} title={text}>\n {text}\n </span>\n )}\n {sub ? <span className={cn(\"block font-mono text-xs text-fg-tertiary\", cut)}>{sub}</span> : null}\n </span>\n );\n }\n case \"number\":\n return (\n <span>\n {typeof value === \"number\" ? value.toLocaleString(\"en\") : text}\n {column.unit ? <span className=\"ms-1 text-fg-secondary\">{column.unit}</span> : null}\n </span>\n );\n case \"link\": {\n const href = column.href?.(original);\n return href ? (\n <a href={href} title={text} className={cn(\"block text-fg-accent underline-offset-2 hover:underline\", cut)}>\n {text}\n </a>\n ) : (\n <span className={cn(\"block\", cut)} title={text}>\n {text}\n </span>\n );\n }\n default: {\n const lines = column.clamp ?? (column.type === \"text\" ? 2 : undefined);\n if (!column.nowrap && !lines) return text;\n return (\n <span className={column.nowrap ? cn(\"block\", cut || \"truncate\") : CLAMP[lines as 1 | 2 | 3]} title={text}>\n {text}\n </span>\n );\n }\n }\n };\n\n // A group's row keeps its value as the title and its aggregates; a facts column is away (its value is in the title's line).\n const listPlace = (row: TableRow<T> | null, column: DataTableColumn<T>) => {\n if (row?.getIsGrouped()) return row.groupingColumnId === column.id ? \"title\" : column.aggregate ? \"status\" : \"away\";\n const place = placeOf(column);\n return place === \"facts\" || place === \"hidden\" ? \"away\" : place;\n };\n\n const dataCell = (row: TableRow<T>, cell: TableCell<T>, first: boolean) => {\n const column = byId.get(cell.column.id);\n if (!column) return null;\n const { style, ...pinnedProps } = pin(cell.column);\n let content: React.ReactNode;\n if (row.getIsGrouped()) {\n const leaves = row.getLeafRows().filter((r) => !r.getIsGrouped()).map((r) => r.original);\n if (cell.column.id === row.groupingColumnId) {\n const value = display(row.groupingValue as CellValue);\n content = (\n <button type=\"button\" className={s.toggle({ className: \"px-1.5 font-semibold text-fg\" })} aria-expanded={row.getIsExpanded()} onClick={row.getToggleExpandedHandler()}>\n <ChevronGlyph direction=\"right\" className=\"h-4 w-4\" />\n {labels.group(value, leaves.length)}\n </button>\n );\n } else content = column.aggregate ? column.aggregate(leaves) : null;\n } else {\n content = contentOf(column, row.original);\n if (list && column === titleColumn && facts.length > 0) {\n content = (\n <span className=\"block min-w-0\">\n {content}\n <span data-facts=\"\" className={LIST_FACTS[L]}>\n {facts.map((c, i) => (\n <React.Fragment key={c.id}>\n {i > 0 ? <span aria-hidden>{\" · \"}</span> : null}\n {contentOf(c, row.original)}\n </React.Fragment>\n ))}\n </span>\n </span>\n );\n }\n if (first && inlineToggle) {\n content = (\n <span className=\"flex min-w-0 items-center gap-1\">\n <button\n type=\"button\"\n className={s.toggle({ className: cn(maxPriority > 1 ? TOGGLE[L][maxPriority as Priority] : \"hidden\", listAway.length > 0 && LIST_TOGGLE[L], list && \"relative z-[1]\") })}\n aria-expanded={row.getIsExpanded()}\n aria-label={labels.showMore(nameOf(row))}\n onClick={row.getToggleExpandedHandler()}\n >\n <ChevronGlyph direction=\"right\" className=\"h-4 w-4\" />\n </button>\n <span className=\"min-w-0 flex-1\">{content}</span>\n </span>\n );\n }\n }\n const level = priorityOf(column);\n const type = column.type;\n const cellClass = [\n ALIGN[column.align ?? (type === \"number\" ? \"end\" : \"start\")],\n type ? TYPE_CELL[type][L] : \"\",\n level > 1 ? LEAVES[L][level as Priority] : \"\",\n (type || column.priority) && column.minWidth !== undefined ? MIN[L] : \"\",\n column.maxWidth !== undefined ? MAX[L] : \"\",\n ].join(\" \");\n return (\n <td\n role=\"cell\"\n key={cell.id}\n data-label={column.header}\n data-card={list ? (placeOf(column) === \"hidden\" ? \"hidden\" : undefined) : row.getIsGrouped() ? (cell.column.id === row.groupingColumnId ? \"title\" : column.aggregate ? undefined : \"hidden\") : cardOf(column)}\n data-list={list ? listPlace(row, column) : undefined}\n {...pinnedProps}\n className={s.td({ className: cellClass })}\n style={{\n ...style,\n ...(column.minWidth !== undefined ? { [\"--dt-min\" as string]: `${column.minWidth}px` } : {}),\n ...(column.maxWidth !== undefined ? { [\"--dt-max\" as string]: `${column.maxWidth}px` } : {}),\n ...(first && row.depth > 0 && !row.getIsGrouped() ? { paddingInlineStart: `${1 + row.depth * 1.25}rem` } : {}),\n }}\n >\n {content}\n </td>\n );\n };\n\n const renderRow = (row: TableRow<T>, index: number) => {\n const isChosen = chosen.has(row.id);\n const grouped = row.getIsGrouped();\n const canExpand = !grouped && (renderDetail ? true : row.getCanExpand());\n const showDetail = detailed && !grouped && row.getIsExpanded() && !virtualized;\n const cells = row.getVisibleCells();\n const main = (\n <tr\n role=\"row\"\n data-index={windowItems ? index : undefined}\n ref={windowItems ? virtualizer.measureElement : undefined}\n aria-rowindex={virtualized ? index + 2 : undefined}\n data-state={isChosen ? \"selected\" : undefined}\n data-group={grouped ? \"\" : undefined}\n className={s.tr({ className: grouped ? s.groupRow() : undefined })}\n >\n {selectable && (\n <td role=\"cell\" className={s.td({ className: s.select() })}>\n {grouped ? null : <Checkbox checked={isChosen} onCheckedChange={() => row.toggleSelected()} aria-label={labels.selectRow(nameOf(row))} />}\n </td>\n )}\n {hasExpander && (\n <td role=\"cell\" className={s.td({ className: s.expander() })}>\n {canExpand ? (\n <button type=\"button\" className={s.toggle()} aria-expanded={row.getIsExpanded()} aria-label={labels.expandRow(nameOf(row))} onClick={row.getToggleExpandedHandler()}>\n <ChevronGlyph direction=\"right\" className=\"h-4 w-4\" />\n </button>\n ) : null}\n </td>\n )}\n {cells.map((cell, i) => dataCell(row, cell, i === 0))}\n </tr>\n );\n // One Fragment keyed by the row either way: opening a row keeps its button (and the focus on it).\n if (!showDetail) return <React.Fragment key={row.id}>{main}</React.Fragment>;\n const left = visibleColumns.filter((c) => {\n const column = byId.get(c.id) as DataTableColumn<T>;\n return priorityOf(column) > 1 || listAway.includes(column);\n });\n return (\n <React.Fragment key={row.id}>\n {main}\n <tr role=\"row\" className={s.tr({ className: \"hover:bg-transparent\" })}>\n <td role=\"cell\" colSpan={span} data-card=\"message\" className={s.td({ className: renderDetail ? s.detail() : \"bg-surface-0 p-0 text-left\" })}>\n {left.length > 0 ? (\n <dl className={cn(\"m-0 grid gap-3 text-left\", renderDetail ? \"mb-4\" : cn(\"px-4 py-4\", maxPriority > 1 ? PANEL[L][maxPriority as Priority] : \"hidden\", listAway.length > 0 && LIST_PANEL[L]))}>\n {left.map((c) => {\n const column = byId.get(c.id) as DataTableColumn<T>;\n return (\n <div key={c.id} className={cn(listAway.includes(column) ? LIST_IN_DETAIL[L] : IN_DETAIL[L][priorityOf(column) as Priority], \"grid-cols-[minmax(5rem,8rem)_minmax(0,1fr)] items-baseline gap-3\")}>\n <dt className=\"text-xs font-semibold text-fg-secondary\">{column.header}</dt>\n <dd className=\"m-0 min-w-0 text-sm text-fg\">{contentOf(column, row.original)}</dd>\n </div>\n );\n })}\n </dl>\n ) : null}\n {renderDetail ? renderDetail(row.original) : null}\n </td>\n </tr>\n </React.Fragment>\n );\n };\n\n let body: React.ReactNode;\n if (loading) {\n body = Array.from({ length: loadingRows }, (_, i) => (\n <tr role=\"row\" key={`loading-${i}`} aria-hidden className={s.tr()}>\n {selectable && <td role=\"cell\" className={s.td({ className: s.select() })} />}\n {hasExpander && <td role=\"cell\" className={s.td({ className: s.expander() })} />}\n {visibleColumns.map((column) => (\n <td role=\"cell\" key={column.id} data-card={list ? undefined : cardOf(byId.get(column.id))} data-list={list ? listPlace(null, byId.get(column.id) as DataTableColumn<T>) : undefined} className={s.td()}>\n <div className={s.skeleton()} />\n </td>\n ))}\n </tr>\n ));\n } else if (error) body = message(error, true);\n else if (pageRows.length === 0) body = message(empty ?? labels.empty);\n else if (windowItems) body = windowItems.map((item) => renderRow(pageRows[item.index] as TableRow<T>, item.index));\n else body = pageRows.map((row, i) => renderRow(row, i));\n\n const headers = table.getHeaderGroups().at(-1)?.headers ?? [];\n const tableElement = (\n <table\n role=\"table\"\n aria-busy={loading || undefined}\n aria-rowcount={virtualized ? pageRows.length + 1 : undefined}\n className={s.table({ className: fixedLayout ? \"table-fixed\" : undefined })}\n style={fixedLayout ? { minWidth: table.getTotalSize() + (selectable ? 40 : 0) + (hasExpander ? 40 : 0) } : undefined}\n >\n <caption className={showCaption ? s.caption() : \"sr-only\"}>\n {caption}\n {loading ? <span className=\"sr-only\">{` — ${labels.loading}`}</span> : null}\n </caption>\n <thead role=\"rowgroup\" className={s.thead({ className: virtualized ? \"sticky top-0 z-10 bg-surface-2\" : undefined })}>\n <tr role=\"row\" aria-rowindex={virtualized ? 1 : undefined} className={s.headRow()}>\n {selectable && (\n <th role=\"columnheader\" scope=\"col\" data-keep=\"\" className={s.th({ className: s.select() })}>\n <Checkbox\n checked={allChosen ? true : someChosen ? \"indeterminate\" : false}\n onCheckedChange={toggleAll}\n disabled={loading || selectableIds.length === 0}\n aria-label={labels.selectAll}\n />\n </th>\n )}\n {hasExpander && (\n <th role=\"columnheader\" scope=\"col\" className={s.th({ className: s.expander() })}>\n <span className=\"sr-only\">{labels.details}</span>\n </th>\n )}\n {headers.map((header) => {\n const column = byId.get(header.column.id);\n if (!column) return null;\n const sorted = header.column.getIsSorted();\n const direction: SortDirection | undefined = sorted === \"asc\" ? \"ascending\" : sorted === \"desc\" ? \"descending\" : undefined;\n const { style, ...pinnedProps } = pin(header.column);\n const level = priorityOf(column);\n return (\n <th\n role=\"columnheader\"\n scope=\"col\"\n key={header.id}\n aria-sort={column.sortable ? (direction ?? \"none\") : undefined}\n data-keep={column.sortable ? \"\" : undefined}\n {...pinnedProps}\n className={s.th({ className: cn(ALIGN[column.align ?? (column.type === \"number\" ? \"end\" : \"start\")], level > 1 && LEAVES_HEAD[L][level as Priority]) })}\n style={{ ...style, ...(typeof stickyHeader === \"number\" ? { [\"--dt-top\" as string]: `${stickyHeader}px` } : {}), ...width(header.column) }}\n >\n {column.sortable ? (\n <button type=\"button\" aria-pressed={Boolean(direction)} className={s.sortButton()} onClick={() => header.column.toggleSorting(undefined, false)}>\n {column.header}\n <SortGlyph direction={direction} className=\"h-3.5 w-3.5\" />\n </button>\n ) : column.headerHidden ? (\n <span className=\"sr-only\">{column.header}</span>\n ) : (\n column.header\n )}\n {resizing && header.column.getCanResize() ? (\n <ResizeHandle\n label={labels.resize(column.header)}\n size={header.column.getSize()}\n min={column.minWidth ?? 64}\n resizing={header.column.getIsResizing()}\n className={s.resizer()}\n onPointerStart={header.getResizeHandler()}\n onSize={(size) => setSizing((old: Record<string, number>) => ({ ...old, [header.column.id]: size }))}\n />\n ) : null}\n </th>\n );\n })}\n </tr>\n </thead>\n <tbody role=\"rowgroup\" className={s.tbody()}>\n {before > 0 && (\n <tr aria-hidden style={{ height: before }}>\n <td colSpan={span} />\n </tr>\n )}\n {body}\n {after > 0 && (\n <tr aria-hidden style={{ height: after }}>\n <td colSpan={span} />\n </tr>\n )}\n </tbody>\n </table>\n );\n\n const context: Context = {\n table: table as unknown as DataTableInstance<object>,\n columns: columns as readonly DataTableColumn<object>[],\n labels,\n filters,\n setFilters: changeFilters,\n resultCount: leafCount,\n exportRows: () => table.getSortedRowModel().flatRows.filter((r) => !r.getIsGrouped()).map((r) => r.original),\n manualFiltering: Boolean(manual?.filtering),\n };\n\n const pagination = paginated ? (\n <Pagination\n page={Math.min(page, pageCount)}\n pageCount={pageCount}\n onPageChange={changePage}\n pageSize={pageSizeOptions ? pageSize : undefined}\n pageSizeOptions={pageSizeOptions}\n onPageSizeChange={pageSizeOptions ? changePageSize : undefined}\n disabled={loading}\n labels={labels.pagination}\n />\n ) : null;\n\n return (\n <DataTableContext.Provider value={context}>\n <div className={s.root({ className })}>\n {toolbar ? <div className={s.toolbar()}>{toolbar}</div> : null}\n {virtualized ? (\n // A scrolling region the keyboard can reach: Tab lands on it, the arrows scroll it.\n <div\n ref={scroller}\n tabIndex={0}\n role=\"region\"\n aria-label={caption}\n className={s.scroller({ className: \"overflow-y-auto focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\" })}\n style={{ height: virtualized.height }}\n >\n {tableElement}\n </div>\n ) : (\n <div className={s.scroller({ className: sideways ? (scroll ? SHADOWS : CARD_CLIP[L]) : \"overflow-x-clip\" })}>{tableElement}</div>\n )}\n {pagination || footer ? (\n <div className={s.footer({ className: \"flex flex-col gap-3\" })}>\n {pagination}\n {footer}\n </div>\n ) : null}\n </div>\n </DataTableContext.Provider>\n );\n}\n\n/** The handle on a header's edge: drag it, or focus it and use ← → (Shift for larger steps). */\nfunction ResizeHandle({\n label,\n size,\n min,\n resizing,\n className,\n onPointerStart,\n onSize,\n}: {\n label: string;\n size: number;\n min: number;\n resizing: boolean;\n className: string;\n onPointerStart: (event: unknown) => void;\n onSize: (size: number) => void;\n}) {\n return (\n // A focusable separator is interactive (WAI-ARIA \"window splitter\"): its value is the width, the arrows change it.\n <div\n role=\"separator\"\n aria-orientation=\"vertical\"\n aria-label={label}\n aria-valuenow={Math.round(size)}\n aria-valuemin={min}\n tabIndex={0}\n data-resizing={resizing ? \"\" : undefined}\n className={className}\n onMouseDown={onPointerStart}\n onTouchStart={onPointerStart}\n onClick={(event) => event.stopPropagation()}\n onKeyDown={(event) => {\n const step = event.shiftKey ? 48 : 16;\n if (event.key === \"ArrowLeft\") onSize(Math.max(min, size - step));\n else if (event.key === \"ArrowRight\") onSize(size + step);\n else return;\n event.preventDefault();\n }}\n />\n );\n}\n"
|
|
682
715
|
},
|
|
683
716
|
{
|
|
684
717
|
"path": "data-table/engine.ts",
|
|
@@ -686,15 +719,23 @@
|
|
|
686
719
|
},
|
|
687
720
|
{
|
|
688
721
|
"path": "data-table/index.ts",
|
|
689
|
-
"content": "/** @krizaka/ui/data-table — `DataTable` (TanStack Table v9: sort, filters and facets, groups, details, pages, selection, visibility, pinning, resizing, server mode, windowing, cards on a phone), its toolbar (`DataTableFilters`, `DataTableColumnsMenu`, `DataTableExport`), `useDataTable` (the TanStack instance), `dataTableCsv`, `dataTable` (the slots). Client (\"use client\"). */\nexport { dataTableCsv } from \"./csv\";\nexport {\n DataTable,\n dataTable,\n type DataTableColumn,\n type DataTableLabels,\n dataTableLabels,\n type DataTableProps,\n type DataTableQuery,\n type DataTableSort,\n type SortDirection,\n useDataTable,\n} from \"./data-table\";\nexport { type CellValue, compareValues, type DataTableFilterState, type DataTableInstance, type FilterValue } from \"./engine\";\nexport {\n DataTableColumnsMenu,\n type DataTableColumnsMenuProps,\n DataTableExport,\n type DataTableExportProps,\n DataTableFilters,\n type DataTableFiltersProps,\n} from \"./toolbar\";\n"
|
|
722
|
+
"content": "/** @krizaka/ui/data-table — `DataTable` (TanStack Table v9: sort, filters and facets, groups, details, pages, selection, visibility, pinning, resizing, server mode, windowing, cards on a phone), its toolbar (`DataTableFilters`, `DataTableColumnsMenu`, `DataTableExport`), `useDataTable` (the TanStack instance), `dataTableCsv`, `dataTable` (the slots). Client (\"use client\"). */\nexport { dataTableCsv } from \"./csv\";\nexport {\n DataTable,\n dataTable,\n type DataTableColumn,\n type DataTableLabels,\n dataTableLabels,\n type DataTableProps,\n type DataTableQuery,\n type DataTableSort,\n type SortDirection,\n useDataTable,\n} from \"./data-table\";\nexport { type CellValue, compareValues, type DataTableFilterState, type DataTableInstance, type FilterValue } from \"./engine\";\nexport { decodeFilter, decodeSort, encodeFilter, encodeSort, readTable, type RouteSort, writeTable } from \"./route-codec\";\nexport {\n DataTableColumnsMenu,\n type DataTableColumnsMenuProps,\n DataTableExport,\n type DataTableExportProps,\n DataTableFilters,\n type DataTableFiltersProps,\n} from \"./toolbar\";\n"
|
|
690
723
|
},
|
|
691
724
|
{
|
|
692
725
|
"path": "data-table/meta.ts",
|
|
693
|
-
"content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Data table\",\n summary:\n \"A table of records on TanStack Table v9 — sort, filters and facets, groups, details, pages, selection, column visibility, pinning and resizing — in the browser or on the server, cards on a phone, windowing for long lists.\",\n status: \"beta\",\n category: \"data-display\",\n platforms: \"web\",\n parity: { native: { status: \"n/a\", reason: \"On a phone the rows are a list of cards, each with its fields as a description list — not a table to scroll sideways.\", use: \"list\" }, desktop: \"web\" },\n whenToUse: [\n \"To compare records on the same columns: jobs, videos of a studio, users of an admin, invoices, payouts.\",\n \"When people sort, filter, group, select several rows for a bulk action, or export what they see.\",\n \"In server mode (`manual` + `onStateChange`) when the list lives in a database: the API sorts, filters and pages.\",\n \"With `virtualized` for a long list loaded at once (logs, 50,000 events) that must stay smooth.\",\n ],\n whenNotToUse: [\n { when: \"For a few figures side by side: stats.\", use: \"stat\" },\n { when: \"For a gallery of media where the picture matters more than the columns.\", use: \"virtual\" },\n { when: \"For a list on a phone app: FlashList, through the native list.\", use: \"list\" },\n ],\n bestPractices: [\n \"Keep `rows` and `columns` stable (module scope, state, a query's data, `useMemo`): a new array at each render recomputes every model.\",\n \"Give every column a `header` (it labels the value in a card and names its filter) and the row's name a `card: \\\"title\\\"` column.\",\n \"Put `DataTableFilters` in `toolbar`; mark the columns it filters with `filter: \\\"text\\\" | \\\"facet\\\" | \\\"range\\\"`, and give a server table's facets their `options`.\",\n \"Server mode: keep the `DataTableQuery` from `onStateChange` in state (or the address), put it in the query key, and pass `rowCount` — `placeholderData: keepPreviousData` keeps the page while the next one loads.\",\n \"Say what failed in `error` with a way to retry; say what to do next in `empty` (an `EmptyState` with an action).\",\n \"Type the columns (`type`: title · badge · status · number · date · link · text) and give the secondary ones a `priority` (2 to 7): the table then never scrolls sideways — columns leave the row for its detail, then the rows become cards. Scrolling is an opt-in (`scroll`).\",\n \"A `title` column takes the slack and truncates (full value on hover); badges, statuses, numbers and dates are as wide as their content. Do not give a title column a `maxWidth`.\",\n \"Align amounts and counts to the end (`align: \\\"end\\\"`) in tabular numbers; pin the name column of a wide table.\",\n \"Need something the props do not cover? `useDataTable()` gives the TanStack instance — never import `@tanstack/*` in a product.\",\n ],\n accessibility: {\n keyboard: [\n { keys: \"Tab\", action: \"Reaches the toolbar, the sort buttons of the headers, the resize handles, the checkboxes, the expand buttons and the links of the cells.\" },\n { keys: \"Space\", action: \"Toggles the focused checkbox, sorts by the focused header, or opens the focused row or group.\" },\n { keys: \"Arrow keys\", action: \"On a focused resize handle, narrow or widen the column (Shift for larger steps).\" },\n ],\n notes: [\n \"A `table` with a `caption` (hidden unless `showCaption`) and `scope=\\\"col\\\"` headers; the roles are written out, so the cards keep the table's semantics.\",\n \"The sorted column carries `aria-sort`; a sortable header is a button that says its state with `aria-pressed`.\",\n \"An expand button (a row's details, a group, a tree) says its state with `aria-expanded` and names the row; a group's button says its value and its count.\",\n \"A resize handle is a focusable `separator` named after its column, with its width as `aria-valuenow`.\",\n \"The filters' bar is a named group; the count of results is announced politely when it changes; each filter in force is a chip with a remove button.\",\n \"While loading the table is `aria-busy` and its caption says so; an error is announced (`role=\\\"alert\\\"`).\",\n \"Windowed rows carry `aria-rowindex` and the table `aria-rowcount`: a screen reader knows where it is in the whole list; the scrolling body is a focusable region.\",\n ],\n },\n related: [\"filter-bar\", \"pagination\", \"virtual\", \"checkbox\", \"empty-state\"],\n web: {\n imports: [\"DataTable\", \"DataTableFilters\", \"DataTableColumnsMenu\", \"DataTableExport\", \"useDataTable\"],\n examples: [\n { name: \"catalogue\", title: \"Models catalogue\", description: \"Eight typed columns that never overflow: priorities move the maker, licence, context and date into the row's detail as the table narrows, then cards under 36 rem.\" },\n { name: \"invoices\", title: \"Invoice list\", description: \"Title, status and amount always in the row; dates and contact in the detail on a narrow screen; sticky header and zebra rows.\" },\n { name: \"runs\", title: \"Admin runs\", description: \"An admin console: filters, columns menu and CSV export in the toolbar, selection, a range filter on the duration, the error in the detail.\" },\n { name: \"jobs\", title: \"Jobs\", description: \"Sortable columns, a status badge, row selection for a bulk action.\" },\n { name: \"filters\", title: \"Filters, columns, export\", description: \"A search, facets with their counts, a range; the filters as chips; hide columns; export the rows shown as CSV.\" },\n { name: \"server\", title: \"Server mode\", description: \"Sort, filters and page go to the API through `krizakaFetch` and `defineKeys` of @krizaka/query; the previous page stays while the next one loads.\" },\n { name: \"paginated\", title: \"Pages\", description: \"Client pagination under the table, with the rows per page to choose.\" },\n { name: \"grouped\", title: \"Groups\", description: \"`groupBy`: a row per group with its count and its total (`aggregate`), opened to see its rows.\" },\n { name: \"details\", title: \"Details\", description: \"`renderDetail`: a button per row opens a panel under it.\" },\n { name: \"pinned\", title: \"Pinned and resizable\", description: \"A wide table: the name pinned at the start, columns resized by drag or by the arrow keys.\" },\n { name: \"cards\", title: \"Cards on a phone\", description: \"`layout=\\\"cards\\\"`: each row a card, the title on top — what `auto` does in a narrow container.\" },\n { name: \"loading\", title: \"Loading\", description: \"Skeleton rows while the first page loads, the table busy.\" },\n { name: \"empty\", title: \"Empty\", description: \"No record yet: an empty state with the next step.\" },\n { name: \"error\", title: \"Error\", description: \"The request failed: an alert with a retry, in place of the rows.\" },\n { name: \"virtualized\", title: \"50,000 rows\", description: \"`virtualized`: only the rows in view are rendered and measured, the header stays.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
|
|
726
|
+
"content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Data table\",\n summary:\n \"A table of records on TanStack Table v9 — sort, filters and facets, groups, details, pages, selection, column visibility, pinning and resizing — in the browser or on the server, cards on a phone, windowing for long lists.\",\n status: \"beta\",\n category: \"data-display\",\n platforms: \"web\",\n parity: { native: { status: \"n/a\", reason: \"On a phone the rows are a list of cards, each with its fields as a description list — not a table to scroll sideways.\", use: \"list\" }, desktop: \"web\" },\n whenToUse: [\n \"To compare records on the same columns: jobs, videos of a studio, users of an admin, invoices, payouts.\",\n \"When people sort, filter, group, select several rows for a bulk action, or export what they see.\",\n \"In server mode (`manual` + `onStateChange`) when the list lives in a database: the API sorts, filters and pages.\",\n \"With `virtualized` for a long list loaded at once (logs, 50,000 events) that must stay smooth.\",\n ],\n whenNotToUse: [\n { when: \"For a few figures side by side: stats.\", use: \"stat\" },\n { when: \"For a gallery of media where the picture matters more than the columns.\", use: \"virtual\" },\n { when: \"For a list on a phone app: FlashList, through the native list.\", use: \"list\" },\n ],\n bestPractices: [\"Give the table a `routeKey` (`routeKey=\\\"clients\\\"`) so its sort, filters and page are in the address: back, reload and a shared link all work.\", \n \"Keep `rows` and `columns` stable (module scope, state, a query's data, `useMemo`): a new array at each render recomputes every model.\",\n \"Give every column a `header` (it labels the value in a card and names its filter) and the row's name a `card: \\\"title\\\"` column.\",\n \"Put `DataTableFilters` in `toolbar`; mark the columns it filters with `filter: \\\"text\\\" | \\\"facet\\\" | \\\"range\\\"`, and give a server table's facets their `options`.\",\n \"Server mode: keep the `DataTableQuery` from `onStateChange` in state (or the address), put it in the query key, and pass `rowCount` — `placeholderData: keepPreviousData` keeps the page while the next one loads.\",\n \"Say what failed in `error` with a way to retry; say what to do next in `empty` (an `EmptyState` with an action).\",\n \"Type the columns (`type`: title · badge · status · number · date · link · text) and give the secondary ones a `priority` (2 to 7): the table then never scrolls sideways — columns leave the row for its detail, then the rows become cards. Scrolling is an opt-in (`scroll`).\",\n \"A `title` column takes the slack and truncates (full value on hover); badges, statuses, numbers and dates are as wide as their content. Do not give a title column a `maxWidth`.\",\n \"A professional list on a phone reads as list rows, not cards: `phone=\\\"list\\\"` draws each row as the title (give it `href`: the whole row is its link), its `priority: 2` columns joined as one line of facts, and the `status` column at the end; the other columns go to the row's detail. Keep it to one or two facts.\",\n \"Align amounts and counts to the end (`align: \\\"end\\\"`) in tabular numbers; pin the name column of a wide table.\",\n \"Need something the props do not cover? `useDataTable()` gives the TanStack instance — never import `@tanstack/*` in a product.\",\n ],\n accessibility: {\n keyboard: [\n { keys: \"Tab\", action: \"Reaches the toolbar, the sort buttons of the headers, the resize handles, the checkboxes, the expand buttons and the links of the cells.\" },\n { keys: \"Space\", action: \"Toggles the focused checkbox, sorts by the focused header, or opens the focused row or group.\" },\n { keys: \"Arrow keys\", action: \"On a focused resize handle, narrow or widen the column (Shift for larger steps).\" },\n ],\n notes: [\n \"A `table` with a `caption` (hidden unless `showCaption`) and `scope=\\\"col\\\"` headers; the roles are written out, so the cards and the list rows keep the table's semantics.\",\n \"In the list rows the facts are read inside the title's cell (their own cells are hidden at that width, never read twice); the checkbox and the title's link stay in the tab order, above the row's stretched link.\",\n \"The sorted column carries `aria-sort`; a sortable header is a button that says its state with `aria-pressed`.\",\n \"An expand button (a row's details, a group, a tree) says its state with `aria-expanded` and names the row; a group's button says its value and its count.\",\n \"A resize handle is a focusable `separator` named after its column, with its width as `aria-valuenow`.\",\n \"The filters' bar is a named group; the count of results is announced politely when it changes; each filter in force is a chip with a remove button.\",\n \"While loading the table is `aria-busy` and its caption says so; an error is announced (`role=\\\"alert\\\"`).\",\n \"Windowed rows carry `aria-rowindex` and the table `aria-rowcount`: a screen reader knows where it is in the whole list; the scrolling body is a focusable region.\",\n ],\n },\n related: [\"filter-bar\", \"pagination\", \"virtual\", \"checkbox\", \"empty-state\"],\n web: {\n imports: [\"DataTable\", \"DataTableFilters\", \"DataTableColumnsMenu\", \"DataTableExport\", \"useDataTable\"],\n examples: [\n { name: \"catalogue\", title: \"Models catalogue\", description: \"Eight typed columns that never overflow: priorities move the maker, licence, context and date into the row's detail as the table narrows, then cards under 36 rem.\" },\n { name: \"invoices\", title: \"Invoice list\", description: \"Title, status and amount always in the row; dates and contact in the detail on a narrow screen; sticky header and zebra rows.\" },\n { name: \"runs\", title: \"Admin runs\", description: \"An admin console: filters, columns menu and CSV export in the toolbar, selection, a range filter on the duration, the error in the detail.\" },\n { name: \"jobs\", title: \"Jobs\", description: \"Sortable columns, a status badge, row selection for a bulk action.\" },\n { name: \"filters\", title: \"Filters, columns, export\", description: \"A search, facets with their counts, a range; the filters as chips; hide columns; export the rows shown as CSV.\" },\n { name: \"server\", title: \"Server mode\", description: \"Sort, filters and page go to the API through `krizakaFetch` and `defineKeys` of @krizaka/query; the previous page stays while the next one loads.\" },\n { name: \"paginated\", title: \"Pages\", description: \"Client pagination under the table, with the rows per page to choose.\" },\n { name: \"grouped\", title: \"Groups\", description: \"`groupBy`: a row per group with its count and its total (`aggregate`), opened to see its rows.\" },\n { name: \"details\", title: \"Details\", description: \"`renderDetail`: a button per row opens a panel under it.\" },\n { name: \"pinned\", title: \"Pinned and resizable\", description: \"A wide table: the name pinned at the start, columns resized by drag or by the arrow keys.\" },\n { name: \"cards\", title: \"Cards on a phone\", description: \"`layout=\\\"cards\\\"`: each row a card, the title on top — what `auto` does in a narrow container.\" },\n { name: \"checks\", title: \"List rows on a phone\", description: \"`phone=\\\"list\\\"`: under 36 rem each check is a list row — the selection, the document as the row's link, « Lease · Oct 23 » as one line of facts, the result at the end; a table above.\" },\n { name: \"applicant-files\", title: \"Applicant files\", description: \"`layout=\\\"cards\\\" phone=\\\"list\\\"`: a landlord's files as list rows at every width — the name and unit, the documents and the date as one line, the status.\" },\n { name: \"loading\", title: \"Loading\", description: \"Skeleton rows while the first page loads, the table busy.\" },\n { name: \"empty\", title: \"Empty\", description: \"No record yet: an empty state with the next step.\" },\n { name: \"error\", title: \"Error\", description: \"The request failed: an alert with a retry, in place of the rows.\" },\n { name: \"virtualized\", title: \"50,000 rows\", description: \"`virtualized`: only the rows in view are rendered and measured, the header stays.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
"path": "data-table/route-codec.ts",
|
|
730
|
+
"content": "// The table's sort, filters and page as search parameters: pure, so the address and the tests share them. With\n// `routeKey=\"clients\"` the address reads `?clients.sort=visits.descending&clients.q.client=lea&clients.page=2`; a value equal to\n// the table's own default is not written (a clean URL).\nimport type { DataTableFilterState, FilterValue } from \"./engine\";\n\nexport type RouteSort = { column: string; direction: \"ascending\" | \"descending\" } | null;\n\nconst NONE = \"none\";\n\nexport function encodeSort(sort: RouteSort): string {\n return sort ? `${sort.column}.${sort.direction}` : NONE;\n}\n\nexport function decodeSort(raw: string | null, fallback: RouteSort, columns?: readonly string[]): RouteSort {\n if (raw === null) return fallback;\n if (raw === NONE) return null;\n const dot = raw.lastIndexOf(\".\");\n const column = raw.slice(0, dot);\n const direction = raw.slice(dot + 1);\n if (dot < 1 || (direction !== \"ascending\" && direction !== \"descending\") || (columns && !columns.includes(column))) return fallback;\n return { column, direction };\n}\n\n// A text is written as typed, an array as JSON. A text that would read as JSON (it starts with `[`) or as an escaped one\n// (`\\`) is escaped with a leading backslash, so `\"[1,2]\"` typed in a text filter comes back as text, never as an array.\nconst ESCAPE = \"\\\\\";\n\nexport function encodeFilter(value: FilterValue): string {\n if (typeof value !== \"string\") return JSON.stringify(value);\n return value.startsWith(\"[\") || value.startsWith(ESCAPE) ? `${ESCAPE}${value}` : value;\n}\n\nexport function decodeFilter(raw: string): FilterValue {\n if (raw.startsWith(ESCAPE)) return raw.slice(1);\n if (raw.startsWith(\"[\")) {\n try {\n const parsed: unknown = JSON.parse(raw);\n if (Array.isArray(parsed)) return parsed as FilterValue;\n } catch {\n // a text that merely starts with a bracket\n }\n }\n return raw;\n}\n\nconst sameSort = (a: RouteSort, b: RouteSort) => a?.column === b?.column && a?.direction === b?.direction;\nconst filterPrefix = (key: string) => `${key}.q.`;\n// Present (empty) when the filters were cleared while the table's defaults have some: « none » is not « the defaults ».\nconst clearedFilters = (key: string) => `${key}.q`;\n\n/** The table's own starting state: what an address without its parameters means. */\nexport type RouteDefaults = { sort: RouteSort; page: number; filters?: DataTableFilterState };\n\nconst sameFilters = (a: DataTableFilterState, b: DataTableFilterState) => {\n const written = (f: DataTableFilterState) =>\n JSON.stringify(Object.entries(f).map(([column, value]) => [column, encodeFilter(value)]).filter(([, v]) => v !== \"\" && v !== \"[]\").sort(([a], [b]) => String(a).localeCompare(String(b))));\n return written(a) === written(b);\n};\n\nexport function readTable(search: string, key: string, defaults: RouteDefaults, columns?: readonly string[]) {\n const params = new URLSearchParams(search);\n const filters: Record<string, FilterValue> = {};\n let explicit = params.has(clearedFilters(key));\n for (const [name, raw] of params) {\n if (!name.startsWith(filterPrefix(key)) || raw === \"\") continue;\n explicit = true;\n filters[name.slice(filterPrefix(key).length)] = decodeFilter(raw);\n }\n const raw = params.get(`${key}.page`);\n const page = raw !== null && /^[1-9]\\d*$/.test(raw) ? Number(raw) : defaults.page;\n return {\n sort: decodeSort(params.get(`${key}.sort`), defaults.sort, columns),\n filters: (explicit ? filters : (defaults.filters ?? {})) as DataTableFilterState,\n page,\n };\n}\n\ntype Change = { sort?: RouteSort; filters?: DataTableFilterState; page?: number };\n\n/**\n * The query string with the table's parameters changed; every other parameter is kept. A change of sort or filters goes\n * back to the first page (as the table does) unless it says otherwise; a value equal to the default is not written.\n */\nexport function writeTable(search: string, key: string, change: Change, defaults: RouteDefaults): string {\n const params = new URLSearchParams(search);\n if (change.sort !== undefined) {\n if (sameSort(change.sort, defaults.sort)) params.delete(`${key}.sort`);\n else params.set(`${key}.sort`, encodeSort(change.sort));\n }\n if (change.filters !== undefined) {\n for (const name of [...params.keys()]) if (name.startsWith(filterPrefix(key)) || name === clearedFilters(key)) params.delete(name);\n if (!sameFilters(change.filters, defaults.filters ?? {})) {\n let written = 0;\n for (const [column, value] of Object.entries(change.filters)) {\n const encoded = encodeFilter(value);\n if (encoded !== \"\" && encoded !== \"[]\") {\n params.set(`${filterPrefix(key)}${column}`, encoded);\n written += 1;\n }\n }\n if (written === 0) params.set(clearedFilters(key), \"\");\n }\n }\n const page = change.page ?? (change.sort !== undefined || change.filters !== undefined ? 1 : undefined);\n if (page !== undefined) {\n if (page === defaults.page) params.delete(`${key}.page`);\n else params.set(`${key}.page`, String(page));\n }\n const text = params.toString();\n return text ? `?${text}` : \"\";\n}\n"
|
|
694
731
|
},
|
|
695
732
|
{
|
|
696
733
|
"path": "data-table/toolbar.tsx",
|
|
697
|
-
"content": "// The toolbar of a DataTable — its filters (a FilterBar built from the columns), the columns menu and the CSV export —\n// connected to the table they sit in through `useDataTable()`. Words come from the table's `labels`.\nimport * as React from \"react\";\n\nimport { Button } from \"../button\";\nimport { DropdownMenu } from \"../dropdown-menu\";\nimport { Input } from \"../field\";\nimport { type ActiveFilter, FilterBar, FilterMenu } from \"../filter-bar\";\nimport { ChevronGlyph } from \"../glyphs\";\nimport { SearchField } from \"../search-field\";\nimport { dataTableCsv } from \"./csv\";\nimport { type DataTableColumn, useDataTable } from \"./data-table\";\nimport type { FilterValue } from \"./engine\";\n\nexport type DataTableFiltersProps = {\n /** The search's wait after the last key, in ms, before the filter applies (a request, in server mode). Default 250. */\n debounce?: number;\n /** More controls in the bar (a date range picker, a toggle). */\n children?: React.ReactNode;\n /** Classes merged on the bar. */\n className?: string;\n};\n\nconst asRange = (value: FilterValue | undefined): [number | null, number | null] =>\n Array.isArray(value) && value.length === 2 && (typeof value[0] !== \"string\" || typeof value[1] !== \"string\") ? [value[0] as number | null, value[1] as number | null] : [null, null];\nconst asList = (value: FilterValue | undefined): readonly string[] => (Array.isArray(value) ? (value as readonly string[]) : []);\nconst number = (text: string) => (text.trim() === \"\" || Number.isNaN(Number(text)) ? null : Number(text));\n\n/**\n * The filters of the table, from its columns: a search per `text` column, a menu of values (with their counts) per\n * `facet` column, two bounds per `range` column; the filters in force as removable chips, \"clear all\", and the count\n * of results, announced.\n */\nexport function DataTableFilters({ debounce = 250, children, className }: DataTableFiltersProps) {\n const { table, columns, labels, filters, setFilters, resultCount, manualFiltering } = useDataTable();\n const set = (id: string, value: FilterValue | null) => {\n const next = { ...filters };\n if (value === null || value === \"\" || (Array.isArray(value) && (value.length === 0 || (value.length === 2 && value[0] == null && value[1] == null)))) delete next[id];\n else next[id] = value;\n setFilters(next);\n };\n const filterable = columns.filter((column) => column.filter);\n const optionsOf = (column: DataTableColumn<object>) => {\n if (column.options) return column.options;\n // The values found in the rows, with their counts — the other filters applied (TanStack's facets).\n const facets = manualFiltering ? new Map<unknown, number>() : (table.getColumn(column.id)?.getFacetedUniqueValues() ?? new Map<unknown, number>());\n return [...facets.entries()]\n .filter(([value]) => value != null && value !== \"\")\n .map(([value, count]) => ({ value: String(value), label: String(value), count }))\n .sort((a, b) => a.label.localeCompare(b.label, undefined, { numeric: true }));\n };\n const active: ActiveFilter[] = filterable.flatMap((column) => {\n const value = filters[column.id];\n if (value === undefined) return [];\n let text: string;\n if (column.filter === \"facet\") {\n const options = optionsOf(column);\n text = asList(value).map((v) => options.find((o) => o.value === v)?.label ?? v).join(\", \");\n } else if (column.filter === \"range\") {\n const [min, max] = asRange(value);\n text = `${min ?? \"…\"}–${max ?? \"…\"}`;\n } else text = String(value);\n const label = labels.activeFilter(column.header, text);\n return [{ id: column.id, label, onRemove: () => set(column.id, null) }];\n });\n return (\n <FilterBar\n label={labels.filters}\n active={active}\n onClearAll={() => setFilters({})}\n clearLabel={labels.clearFilters}\n removeLabel={labels.removeFilter}\n results={labels.results(resultCount)}\n className={className}\n >\n {filterable.map((column) => {\n if (column.filter === \"text\")\n return (\n <
|
|
734
|
+
"content": "// The toolbar of a DataTable — its filters (a FilterBar built from the columns), the columns menu and the CSV export —\n// connected to the table they sit in through `useDataTable()`. Words come from the table's `labels`.\nimport * as React from \"react\";\n\nimport { Button } from \"../button\";\nimport { DropdownMenu } from \"../dropdown-menu\";\nimport { Input } from \"../field\";\nimport { type ActiveFilter, FilterBar, FilterMenu } from \"../filter-bar\";\nimport { ChevronGlyph } from \"../glyphs\";\nimport { SearchField } from \"../search-field\";\nimport { dataTableCsv } from \"./csv\";\nimport { type DataTableColumn, useDataTable } from \"./data-table\";\nimport type { FilterValue } from \"./engine\";\n\nexport type DataTableFiltersProps = {\n /** The search's wait after the last key, in ms, before the filter applies (a request, in server mode). Default 250. */\n debounce?: number;\n /** More controls in the bar (a date range picker, a toggle). */\n children?: React.ReactNode;\n /** Classes merged on the bar. */\n className?: string;\n};\n\nconst asRange = (value: FilterValue | undefined): [number | null, number | null] =>\n Array.isArray(value) && value.length === 2 && (typeof value[0] !== \"string\" || typeof value[1] !== \"string\") ? [value[0] as number | null, value[1] as number | null] : [null, null];\nconst asList = (value: FilterValue | undefined): readonly string[] => (Array.isArray(value) ? (value as readonly string[]) : []);\nconst number = (text: string) => (text.trim() === \"\" || Number.isNaN(Number(text)) ? null : Number(text));\n\n/**\n * The filters of the table, from its columns: a search per `text` column, a menu of values (with their counts) per\n * `facet` column, two bounds per `range` column; the filters in force as removable chips, \"clear all\", and the count\n * of results, announced.\n */\nexport function DataTableFilters({ debounce = 250, children, className }: DataTableFiltersProps) {\n const { table, columns, labels, filters, setFilters, resultCount, manualFiltering } = useDataTable();\n const set = (id: string, value: FilterValue | null) => {\n const next = { ...filters };\n if (value === null || value === \"\" || (Array.isArray(value) && (value.length === 0 || (value.length === 2 && value[0] == null && value[1] == null)))) delete next[id];\n else next[id] = value;\n setFilters(next);\n };\n const filterable = columns.filter((column) => column.filter);\n const optionsOf = (column: DataTableColumn<object>) => {\n if (column.options) return column.options;\n // The values found in the rows, with their counts — the other filters applied (TanStack's facets).\n const facets = manualFiltering ? new Map<unknown, number>() : (table.getColumn(column.id)?.getFacetedUniqueValues() ?? new Map<unknown, number>());\n return [...facets.entries()]\n .filter(([value]) => value != null && value !== \"\")\n .map(([value, count]) => ({ value: String(value), label: String(value), count }))\n .sort((a, b) => a.label.localeCompare(b.label, undefined, { numeric: true }));\n };\n const active: ActiveFilter[] = filterable.flatMap((column) => {\n const value = filters[column.id];\n if (value === undefined) return [];\n let text: string;\n if (column.filter === \"facet\") {\n const options = optionsOf(column);\n text = asList(value).map((v) => options.find((o) => o.value === v)?.label ?? v).join(\", \");\n } else if (column.filter === \"range\") {\n const [min, max] = asRange(value);\n text = `${min ?? \"…\"}–${max ?? \"…\"}`;\n } else text = String(value);\n const label = labels.activeFilter(column.header, text);\n return [{ id: column.id, label, onRemove: () => set(column.id, null) }];\n });\n return (\n <FilterBar\n label={labels.filters}\n active={active}\n onClearAll={() => setFilters({})}\n clearLabel={labels.clearFilters}\n removeLabel={labels.removeFilter}\n results={labels.results(resultCount)}\n className={className}\n >\n {filterable.map((column) => {\n if (column.filter === \"text\")\n return (\n <TextFilter\n key={column.id}\n label={labels.search(column.header)}\n value={typeof filters[column.id] === \"string\" ? (filters[column.id] as string) : \"\"}\n debounce={debounce}\n onCommit={(query) => set(column.id, query)}\n />\n );\n if (column.filter === \"facet\")\n return (\n <FilterMenu key={column.id} label={column.header} options={optionsOf(column)} value={asList(filters[column.id])} onValueChange={(value) => set(column.id, value)} />\n );\n const [min, max] = asRange(filters[column.id]);\n const bounds = manualFiltering ? undefined : table.getColumn(column.id)?.getFacetedMinMaxValues();\n return (\n <span key={column.id} className=\"inline-flex items-center gap-1.5\">\n <Input\n type=\"number\"\n inputMode=\"decimal\"\n aria-label={labels.rangeFrom(column.header)}\n placeholder={bounds ? String(bounds[0]) : labels.rangeFrom(column.header)}\n value={min ?? \"\"}\n onChange={(event) => set(column.id, [number(event.currentTarget.value), max])}\n className=\"h-11 w-28\"\n />\n <span aria-hidden className=\"text-fg-secondary\">\n –\n </span>\n <Input\n type=\"number\"\n inputMode=\"decimal\"\n aria-label={labels.rangeTo(column.header)}\n placeholder={bounds ? String(bounds[1]) : labels.rangeTo(column.header)}\n value={max ?? \"\"}\n onChange={(event) => set(column.id, [min, number(event.currentTarget.value)])}\n className=\"h-11 w-28\"\n />\n </span>\n );\n })}\n {children}\n </FilterBar>\n );\n}\n\nexport type DataTableColumnsMenuProps = {\n /** Opens the menu at first (a story, a test). */\n defaultOpen?: boolean;\n /** Classes merged on the button. */\n className?: string;\n};\n\n/** A menu of the `hideable` columns, each a checkbox: the person chooses what the table shows. */\nexport function DataTableColumnsMenu({ defaultOpen, className }: DataTableColumnsMenuProps) {\n const { table, columns, labels } = useDataTable();\n return (\n <DropdownMenu.Root defaultOpen={defaultOpen} modal={false}>\n <DropdownMenu.Trigger asChild>\n <Button variant=\"outline\" size=\"sm\" className={className}>\n {labels.columns}\n <ChevronGlyph className=\"h-4 w-4\" />\n </Button>\n </DropdownMenu.Trigger>\n <DropdownMenu.Content align=\"end\" aria-label={labels.columns}>\n {columns\n .filter((column) => column.hideable !== false)\n .map((column) => {\n const instance = table.getColumn(column.id);\n return (\n <DropdownMenu.CheckboxItem\n key={column.id}\n checked={instance?.getIsVisible() ?? true}\n onSelect={(event) => event.preventDefault()}\n onCheckedChange={(checked) => instance?.toggleVisibility(Boolean(checked))}\n >\n {column.header}\n </DropdownMenu.CheckboxItem>\n );\n })}\n </DropdownMenu.Content>\n </DropdownMenu.Root>\n );\n}\n\nexport type DataTableExportProps = {\n /** The file's name. Default `\"export.csv\"`. */\n filename?: string;\n /** Receives the CSV instead of downloading it (send it to the server, a test). */\n onExport?: (csv: string) => void;\n /** Classes merged on the button. */\n className?: string;\n};\n\n/** Downloads what the table shows — the filtered and sorted rows of every page, the visible columns — as CSV. */\nexport function DataTableExport({ filename = \"export.csv\", onExport, className }: DataTableExportProps) {\n const { table, columns, labels, exportRows } = useDataTable();\n const run = () => {\n const visible = columns.filter((column) => table.getColumn(column.id)?.getIsVisible() !== false);\n const csv = dataTableCsv(exportRows(), visible);\n if (onExport) return onExport(csv);\n const url = URL.createObjectURL(new Blob([\"\", csv], { type: \"text/csv;charset=utf-8\" }));\n const link = Object.assign(document.createElement(\"a\"), { href: url, download: filename });\n link.click();\n setTimeout(() => URL.revokeObjectURL(url), 0);\n };\n return (\n <Button variant=\"outline\" size=\"sm\" className={className} onClick={run}>\n {labels.export}\n </Button>\n );\n}\n\n/**\n * A text filter: what is typed stays a draft until the debounce commits it, and a value that changes from outside\n * (back or forward on a `routeKey` table, « Clear all ») replaces the draft — the box always says what filters the rows.\n */\nfunction TextFilter({ label, value, debounce, onCommit }: { label: string; value: string; debounce?: number; onCommit: (query: string) => void }) {\n const [draft, setDraft] = React.useState(value);\n const [seen, setSeen] = React.useState(value);\n const [committed, setCommitted] = React.useState(value);\n if (value !== seen) {\n setSeen(value);\n if (value !== committed) {\n setCommitted(value);\n setDraft(value);\n }\n }\n return (\n <SearchField\n label={label}\n placeholder={label}\n value={draft}\n onValueChange={setDraft}\n debounce={debounce}\n onSearch={(query) => {\n setCommitted(query);\n onCommit(query);\n }}\n landmark={false}\n size=\"sm\"\n className=\"min-w-48 flex-1\"\n />\n );\n}\n"
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
"path": "data-table/use-route-table.ts",
|
|
738
|
+
"content": "\"use client\";\nimport * as React from \"react\";\n\nimport { useRouteSearch } from \"../router\";\nimport type { DataTableFilterState } from \"./engine\";\nimport { decodeSort, encodeSort, readTable, type RouteDefaults, type RouteSort, writeTable } from \"./route-codec\";\n\n/** `DataTable`'s `routeKey`: the sort, the filters and the page read from the address, and written to it. Inert without a key. */\nexport function useRouteTable(routeKey: string | undefined, defaults: Required<RouteDefaults>, columns: readonly string[]) {\n const { search, write } = useRouteSearch();\n const key = routeKey;\n // The defaults by value: a table's `defaultFilters={{…}}` is a new object at each render.\n const sortKey = encodeSort(defaults.sort);\n const filtersKey = JSON.stringify(defaults.filters);\n const columnKey = columns.join();\n const page = defaults.page;\n const stable = React.useMemo<Required<RouteDefaults>>(\n () => ({ sort: decodeSort(sortKey, null), page, filters: JSON.parse(filtersKey) as DataTableFilterState }),\n [sortKey, filtersKey, page],\n );\n // Stable between renders while the address is the same: the table's filters are compared by identity.\n const table = React.useMemo(\n () => (key === undefined ? null : readTable(search, key, stable, columnKey.split(\",\"))),\n [search, key, stable, columnKey],\n );\n if (key === undefined || table === null) return null;\n return {\n sort: table.sort,\n filters: table.filters,\n page: table.page,\n // Typing in a filter is not a place to come back to: it replaces the entry; a sort or a page is a step. A sort or a\n // filter goes back to the first page in the same navigation.\n onSortChange: (sort: RouteSort) => write((s) => writeTable(s, key, { sort }, stable)),\n onFiltersChange: (filters: DataTableFilterState) => write((s) => writeTable(s, key, { filters }, stable), { replace: true }),\n onPageChange: (next: number) => write((s) => writeTable(s, key, { page: next }, stable)),\n };\n}\n"
|
|
698
739
|
},
|
|
699
740
|
{
|
|
700
741
|
"path": "glyphs.tsx",
|
|
@@ -706,11 +747,11 @@
|
|
|
706
747
|
},
|
|
707
748
|
{
|
|
708
749
|
"path": "meta.ts",
|
|
709
|
-
"content": "// The documentation of a component, carried by its code: each primitive has a `meta.ts` beside its source\n// (`src/<name>/meta.ts`; a component that exists only in React Native: `src/native/meta/<name>.ts`). Never built into\n// the package: `scripts/build-registry.mjs` reads them into `registry/<name>.json`, which ui.krizaka.com (apps/site) renders as\n// ui.krizaka.com/components/<name>, and `scripts/registry.test.mjs` fails when one is missing or incomplete.\n//\n// Why a typed module and not JSDoc tags: the fields are lists and links (`related`, `whenNotToUse[].use`, examples),\n// which a type checks and a comment does not; the file is plain data (erasable TypeScript), so Node reads it without a\n// compiler, and an editor completes it.\n\n/** Every documented component: the web primitives (one entry of the package each) and the native-only ones. */\nexport type ComponentName =\n | \"agenda\"\n | \"account-settings\"\n | \"drop-overlay\"\n | \"upload-queue\"\n | \"achievement\"\n | \"anatomy-plate\"\n | \"bird-plate\"\n | \"taped-photo\"\n | \"text-style\"\n | \"flock\"\n | \"elephant\"\n | \"footnote\"\n | \"page-index\"\n | \"chapter\"\n | \"specimen-tag\"\n | \"action-bar\"\n | \"action-sheet\"\n | \"activity-feed\"\n | \"alert\"\n | \"app-header\"\n | \"app-shell\"\n | \"argument-card\"\n | \"audio-player\"\n | \"auth-layout\"\n | \"avatar\"\n | \"badge\"\n | \"button\"\n | \"captcha\"\n | \"card\"\n | \"card-grid\"\n | \"catalogue-page\"\n | \"changelog-page\"\n | \"chat\"\n | \"checkbox\"\n | \"chip\"\n | \"chip-link\"\n | \"chip-row\"\n | \"choice-grid\"\n | \"cn\"\n | \"code-block\"\n | \"combobox\"\n | \"comparison-table\"\n | \"command\"\n | \"component-page\"\n | \"confirm-button\"\n | \"contact-page\"\n | \"continue-prompt\"\n | \"countdown\"\n | \"cover-hero\"\n | \"cta-panel\"\n | \"dashboard-layout\"\n | \"data-table\"\n | \"date-picker\"\n | \"decision-list\"\n | \"description-list\"\n | \"detail-layout\"\n | \"dialog\"\n | \"docs-article-page\"\n | \"docs-hub-page\"\n | \"docs-layout\"\n | \"docs-nav\"\n | \"document-viewer\"\n | \"dropdown-menu\"\n | \"empty-state\"\n | \"engine-flow\"\n | \"error-page\"\n | \"fab\"\n | \"field\"\n | \"file-upload\"\n | \"filter-bar\"\n | \"force-update-screen\"\n | \"form\"\n | \"gallery-page\"\n | \"genui\"\n | \"glossary\"\n | \"glossary-term\"\n | \"glow-card\"\n | \"goal-gauge\"\n | \"header\"\n | \"how-it-works-page\"\n | \"image-cropper\"\n | \"image-viewer\"\n | \"iso\"\n | \"kanban\"\n | \"kbd\"\n | \"kpi-explained\"\n | \"landing-page\"\n | \"landscape\"\n | \"legal-page\"\n | \"link-card\"\n | \"list\"\n | \"loader\"\n | \"longform-page\"\n | \"maintenance-screen\"\n | \"map\"\n | \"markdown\"\n | \"meta-row\"\n | \"waveform\"\n | \"marketing-layout\"\n | \"marquee\"\n | \"mascot-quote\"\n | \"mascot-state\"\n | \"mascots\"\n | \"match-dialog\"\n | \"media-capture\"\n | \"media-grid\"\n | \"language-switch\"\n | \"media-gallery\"\n | \"media-picker\"\n | \"meter\"\n | \"nav-bar\"\n | \"number-ticker\"\n | \"onboarding\"\n | \"opening-hours\"\n | \"otp-input\"\n | \"page\"\n | \"page-header\"\n | \"page-hero\"\n | \"pagination\"\n | \"permission-prompt\"\n | \"photo-grid\"\n | \"pointer-light\"\n | \"popover\"\n | \"pricing-page\"\n | \"product-page\"\n | \"profile-card\"\n | \"progress\"\n | \"promise-card\"\n | \"pull-to-refresh\"\n | \"radio-group\"\n | \"router\"\n | \"rail-nav\"\n | \"recipes\"\n | \"screen\"\n | \"scroll-stage\"\n | \"search-field\"\n | \"section-backdrop\"\n | \"segmented\"\n | \"separator\"\n | \"service-card\"\n | \"settings-layout\"\n | \"account-menu\"\n | \"showcase\"\n | \"step-showcase\"\n | \"site-footer\"\n | \"sites\"\n | \"skeleton\"\n | \"skip-link\"\n | \"slider\"\n | \"slot-picker\"\n | \"slot\"\n | \"spinner\"\n | \"sticky-action\"\n | \"splash-handoff\"\n | \"split-view\"\n | \"stage-banner\"\n | \"stat\"\n | \"status-page\"\n | \"step-progress\"\n | \"stepper\"\n | \"swipe-deck\"\n | \"swipeable-row\"\n | \"switch\"\n | \"tab-bar\"\n | \"tabs\"\n | \"tag-input\"\n | \"tag-selector\"\n | \"terminal-replay\"\n | \"theme\"\n | \"toast\"\n | \"toc\"\n | \"tooltip\"\n | \"tree-view\"\n | \"txt\"\n | \"update-prompt\"\n | \"verified-badge\"\n | \"vertical-video\"\n | \"video-player\"\n | \"video-trimmer\"\n | \"virtual\";\n\n/** Where a component exists: `@krizaka/ui/<name>` (web), `@krizaka/ui/native` (React Native), or both. */\nexport type Platform = \"web\" | \"native\" | \"both\";\n\n/**\n * Why a component is not on a platform — a decision, never a silence (the parity matrix of ui.krizaka.com/platforms):\n * - `gap` (✗): it belongs there and is not built yet; `issue` is the issue that tracks it (`krizaka/krizaka-ui#275`);\n * - `n/a`: it is not meant for that platform (a site's page block on a phone, a swipe on a desktop); `use` names the\n * component that answers there, when one does.\n */\nexport type Absence =\n | { readonly status: \"gap\"; readonly issue: string; readonly reason: string; readonly use?: undefined }\n | { readonly status: \"n/a\"; readonly reason: string; readonly use?: ComponentName; readonly issue?: undefined };\n\n/**\n * Desktop is the Orazaka Mac app (Tauri 2: the system WebView renders the web components — ADR-002). `web`: the web\n * component runs there as it is; `desktop`: it also has a desktop form (a window's title bar, a denser layout…), said in\n * its documentation; otherwise the absence and why.\n */\nexport type DesktopSupport = \"web\" | \"desktop\" | Absence;\n\n/**\n * The three platforms of Krizaka UI — web · native (React Native, Expo) · desktop — for one component. The side a\n * component has is `platforms`; the side it lacks says why here: `native` is required for (and only for) a web-only\n * component, `web` for a native-only one; `desktop` always. `scripts/registry.test.mjs` fails on a missing decision.\n */\nexport interface Parity {\n readonly web?: Absence;\n readonly native?: Absence;\n readonly desktop: DesktopSupport;\n}\n\n/**\n * `stable`: its API only changes in a major. `beta`: it may still change in a minor. `deprecated`: no product uses it and\n * no work plans to — it still works, says what replaces it, and goes in the major named by `deprecated.removedIn`.\n */\nexport type Status = \"stable\" | \"beta\" | \"deprecated\";\n\n/** What a deprecated component says (required when `status` is `deprecated`, absent otherwise). */\nexport interface Deprecation {\n /** The version that deprecated it (`3.6.0`). */\n readonly since: string;\n /** The major that removes it (`4.0.0`). */\n readonly removedIn: string;\n /** What replaces it, when a component does. */\n readonly use?: ComponentName;\n /** Why it goes: no product uses it, no planned work needs it. */\n readonly reason: string;\n}\n\n/**\n * The group of the catalogue. `media`: video, sound, camera, files, cropping. `blocks`: the page blocks — the frame,\n * the hero, sections, cards that lead or argue, decisions, code, calls to act — that every page of a site is made of.\n * `layouts`: the shells of whole screens — an application, a dashboard, settings, sign-in, an object, a list and its\n * item, a site's page, a documentation page — each with its screens. `templates`: the page templates — whole pages of a\n * site (a landing, a product, a docs hub, a long read, a changelog, pricing, status, an error…), each a distinct\n * composition of the blocks, words and content as props.\n */\nexport type Category = \"actions\" | \"forms\" | \"navigation\" | \"overlays\" | \"feedback\" | \"data-display\" | \"media\" | \"blocks\" | \"layouts\" | \"templates\" | \"layout\" | \"foundations\";\n\n/** Why a reader needs a template: the seven intents the templates are grouped by (sidebar sub-headings, catalogue filter). */\nexport type TemplatePurpose = \"convince\" | \"explain\" | \"show\" | \"act\" | \"operate\" | \"converse\" | \"recover\";\n\n/** The groups of templates, in reading order: the title, and the one line that says when to come to that group. */\nexport const templatePurposes = {\n convince: { title: \"Convince\", useWhen: \"You come here when a visitor must decide: a landing, a product, a price, a comparison, a proof.\" },\n explain: { title: \"Explain\", useWhen: \"You come here when a reader must understand: how it works, a documentation hub or article, a changelog, a FAQ, a policy.\" },\n show: { title: \"Show\", useWhen: \"You come here when the work itself is the argument: a gallery, a story, a case study, an object and its details.\" },\n act: { title: \"Act\", useWhen: \"You come here when a person must do something once: reach the team, sign in or up, set up, pay.\" },\n operate: { title: \"Operate\", useWhen: \"You come here when a person works every day: a dashboard, a console with a table, settings, an app shell.\" },\n converse: { title: \"Converse\", useWhen: \"You come here when people talk: an inbox, a chat, comments, a notifications centre.\" },\n recover: { title: \"Recover\", useWhen: \"You come here when the way is blocked: an empty list, an error, a missing page, maintenance, a product still being built.\" },\n} as const satisfies Record<TemplatePurpose, { readonly title: string; readonly useWhen: string }>;\n\n/**\n * A named example: the file `registry/examples/<component>/<name>.tsx` (web) or\n * `registry/examples/<component>/native/<name>.tsx` (React Native) — at most 40 lines, imported by the stories and\n * shown (live, with its code) by the documentation.\n */\ninterface ExampleBase {\n /** The file name, kebab-case (`primary`, `icon-button`). */\n readonly name: string;\n /** A short title (`Primary`, `Icon button`). */\n readonly title: string;\n /** One or two sentences: when this variant is the right one. */\n readonly description: string;\n}\n\n/** A plain example, or a template: a whole screen with its intent (`purpose`) and the user goal its layout serves (`why`). */\nexport type Example =\n | (ExampleBase & { readonly template?: false; readonly purpose?: undefined; readonly why?: undefined })\n | (ExampleBase & {\n /**\n * A template: a whole screen of a product composed from the platform (a layout and its blocks, real words, its\n * phone version thought out), copied as a starting point — at most 160 lines, listed on ui.krizaka.com/templates.\n */\n readonly template: true;\n /** The intent that groups it: why you need this block. */\n readonly purpose: TemplatePurpose;\n /** Why this layout: the user goal it serves, in one sentence. */\n readonly why: string;\n });\n\n/** One platform's side of a component. */\nexport interface PlatformDocs {\n /** The names to import (`[\"Button\", \"IconButton\"]`): from `@krizaka/ui/<name>` on the web, `@krizaka/ui/native` in React Native. */\n readonly imports: readonly string[];\n /** The examples, in reading order; the first one is the component's preview. */\n readonly examples: readonly Example[];\n}\n\n/** The React Native side: the same concept, and what differs from the web API. */\nexport interface NativeDocs extends PlatformDocs {\n /**\n * The entry to import from when it is not `@krizaka/ui/native`: a component that needs an Expo module (an optional\n * peer) has its own, `@krizaka/ui/native/<name>`, so an app that does not use it never installs the module.\n */\n readonly entry?: string;\n /** What differs from the web API (`label` instead of children…); empty for a native-only component. */\n readonly differences: readonly string[];\n}\n\nexport interface KeyboardInteraction {\n /** The keys, as written on a keyboard: `Space`, `Enter`, `Arrow keys`, `Esc`, `Tab`. */\n readonly keys: string;\n /** What they do. */\n readonly action: string;\n}\n\nexport interface ComponentMeta {\n /** The display name: `Button`, `Dropdown menu`. */\n readonly title: string;\n /** One sentence: what it is. */\n readonly summary: string;\n readonly status: Status;\n /** Required for, and only for, a `deprecated` component. */\n readonly deprecated?: Deprecation;\n readonly category: Category;\n /** Must match the sides below: `both` ⇔ `web` and `native`. */\n readonly platforms: Platform;\n /** The platforms it is not on, and why; and its desktop form (see `Parity`). */\n readonly parity: Parity;\n /** When to reach for it: concrete situations, one per item. */\n readonly whenToUse: readonly string[];\n /** When not to — and what to use instead (`use`), when another component is the answer. */\n readonly whenNotToUse: readonly { readonly when: string; readonly use?: ComponentName }[];\n /** How to use it well: wording, placement, composition. */\n readonly bestPractices: readonly string[];\n readonly accessibility: {\n /** The keyboard, when it does more than Tab and Enter. */\n readonly keyboard: readonly KeyboardInteraction[];\n /** Roles, names and announcements: what the component does, what the app must provide. */\n readonly notes: readonly string[];\n };\n /** The components a reader of this page should know about. */\n readonly related: readonly ComponentName[];\n readonly web?: PlatformDocs;\n readonly native?: NativeDocs;\n}\n"
|
|
750
|
+
"content": "// The documentation of a component, carried by its code: each primitive has a `meta.ts` beside its source\n// (`src/<name>/meta.ts`; a component that exists only in React Native: `src/native/meta/<name>.ts`). Never built into\n// the package: `scripts/build-registry.mjs` reads them into `registry/<name>.json`, which ui.krizaka.com (apps/site) renders as\n// ui.krizaka.com/components/<name>, and `scripts/registry.test.mjs` fails when one is missing or incomplete.\n//\n// Why a typed module and not JSDoc tags: the fields are lists and links (`related`, `whenNotToUse[].use`, examples),\n// which a type checks and a comment does not; the file is plain data (erasable TypeScript), so Node reads it without a\n// compiler, and an editor completes it.\n\n/** Every documented component: the web primitives (one entry of the package each) and the native-only ones. */\nexport type ComponentName =\n | \"notification-preferences\"\n | \"quota-meter\"\n | \"agenda\"\n | \"account-settings\"\n | \"drop-overlay\"\n | \"upload-queue\"\n | \"achievement\"\n | \"anatomy-plate\"\n | \"bird-plate\"\n | \"taped-photo\"\n | \"stuck\"\n | \"doodle\"\n | \"signature\"\n | \"text-style\"\n | \"flock\"\n | \"elephant\"\n | \"footnote\"\n | \"page-index\"\n | \"chapter\"\n | \"specimen-tag\"\n | \"action-bar\"\n | \"action-sheet\"\n | \"activity-feed\"\n | \"alert\"\n | \"app-header\"\n | \"app-shell\"\n | \"argument-card\"\n | \"audio-player\"\n | \"auth-layout\"\n | \"avatar\"\n | \"badge\"\n | \"button\"\n | \"captcha\"\n | \"card\"\n | \"card-grid\"\n | \"catalogue-page\"\n | \"changelog-page\"\n | \"chat\"\n | \"checkbox\"\n | \"chip\"\n | \"chip-link\"\n | \"chip-row\"\n | \"choice-grid\"\n | \"cn\"\n | \"code-block\"\n | \"combobox\"\n | \"comparison-table\"\n | \"command\"\n | \"component-page\"\n | \"confirm-button\"\n | \"contact-page\"\n | \"continue-prompt\"\n | \"countdown\"\n | \"cover-hero\"\n | \"cta-panel\"\n | \"dashboard-layout\"\n | \"data-table\"\n | \"date-picker\"\n | \"decision-list\"\n | \"description-list\"\n | \"detail-layout\"\n | \"dialog\"\n | \"docs-article-page\"\n | \"docs-hub-page\"\n | \"docs-layout\"\n | \"docs-nav\"\n | \"document-viewer\"\n | \"dropdown-menu\"\n | \"empty-state\"\n | \"engine-flow\"\n | \"error-page\"\n | \"fab\"\n | \"field\"\n | \"file-upload\"\n | \"filter-bar\"\n | \"force-update-screen\"\n | \"form\"\n | \"gallery-page\"\n | \"genui\"\n | \"glossary\"\n | \"glossary-term\"\n | \"glow-card\"\n | \"goal-gauge\"\n | \"header\"\n | \"how-it-works-page\"\n | \"image-cropper\"\n | \"image-viewer\"\n | \"iso\"\n | \"kanban\"\n | \"kbd\"\n | \"kpi-explained\"\n | \"landing-page\"\n | \"landscape\"\n | \"legal-page\"\n | \"link-card\"\n | \"list\"\n | \"loader\"\n | \"longform-page\"\n | \"maintenance-screen\"\n | \"map\"\n | \"markdown\"\n | \"meta-row\"\n | \"waveform\"\n | \"marketing-layout\"\n | \"marquee\"\n | \"mascot-quote\"\n | \"mascot-state\"\n | \"mascots\"\n | \"match-dialog\"\n | \"media-capture\"\n | \"media-grid\"\n | \"language-switch\"\n | \"media-gallery\"\n | \"media-picker\"\n | \"meter\"\n | \"nav-bar\"\n | \"number-ticker\"\n | \"onboarding\"\n | \"opening-hours\"\n | \"otp-input\"\n | \"page\"\n | \"page-header\"\n | \"page-hero\"\n | \"pagination\"\n | \"permission-prompt\"\n | \"photo-grid\"\n | \"pointer-light\"\n | \"popover\"\n | \"pricing-page\"\n | \"product-page\"\n | \"profile-card\"\n | \"progress\"\n | \"promise-card\"\n | \"pull-to-refresh\"\n | \"retro-tv\"\n | \"radio-group\"\n | \"router\"\n | \"rail-nav\"\n | \"recipes\"\n | \"screen\"\n | \"scroll-stage\"\n | \"search-field\"\n | \"section-backdrop\"\n | \"segmented\"\n | \"separator\"\n | \"service-card\"\n | \"settings-layout\"\n | \"account-menu\"\n | \"showcase\"\n | \"step-showcase\"\n | \"site-footer\"\n | \"sites\"\n | \"skeleton\"\n | \"skip-link\"\n | \"slider\"\n | \"slot-picker\"\n | \"slot\"\n | \"spinner\"\n | \"sticky-action\"\n | \"splash-handoff\"\n | \"split-view\"\n | \"stage-banner\"\n | \"stat\"\n | \"status-page\"\n | \"step-progress\"\n | \"stepper\"\n | \"swipe-deck\"\n | \"swipeable-row\"\n | \"switch\"\n | \"tab-bar\"\n | \"tabs\"\n | \"tag-input\"\n | \"tag-selector\"\n | \"terminal-replay\"\n | \"theme\"\n | \"toast\"\n | \"toc\"\n | \"tooltip\"\n | \"tree-view\"\n | \"txt\"\n | \"update-prompt\"\n | \"verified-badge\"\n | \"vertical-video\"\n | \"video-player\"\n | \"video-trimmer\"\n | \"virtual\";\n\n/** Where a component exists: `@krizaka/ui/<name>` (web), `@krizaka/ui/native` (React Native), or both. */\nexport type Platform = \"web\" | \"native\" | \"both\";\n\n/**\n * Why a component is not on a platform — a decision, never a silence (the parity matrix of ui.krizaka.com/platforms):\n * - `gap` (✗): it belongs there and is not built yet; `issue` is the issue that tracks it (`krizaka/krizaka-ui#275`);\n * - `n/a`: it is not meant for that platform (a site's page block on a phone, a swipe on a desktop); `use` names the\n * component that answers there, when one does.\n */\nexport type Absence =\n | { readonly status: \"gap\"; readonly issue: string; readonly reason: string; readonly use?: undefined }\n | { readonly status: \"n/a\"; readonly reason: string; readonly use?: ComponentName; readonly issue?: undefined };\n\n/**\n * Desktop is the Orazaka Mac app (Tauri 2: the system WebView renders the web components — ADR-002). `web`: the web\n * component runs there as it is; `desktop`: it also has a desktop form (a window's title bar, a denser layout…), said in\n * its documentation; otherwise the absence and why.\n */\nexport type DesktopSupport = \"web\" | \"desktop\" | Absence;\n\n/**\n * The three platforms of Krizaka UI — web · native (React Native, Expo) · desktop — for one component. The side a\n * component has is `platforms`; the side it lacks says why here: `native` is required for (and only for) a web-only\n * component, `web` for a native-only one; `desktop` always. `scripts/registry.test.mjs` fails on a missing decision.\n */\nexport interface Parity {\n readonly web?: Absence;\n readonly native?: Absence;\n readonly desktop: DesktopSupport;\n}\n\n/**\n * `stable`: its API only changes in a major. `beta`: it may still change in a minor. `deprecated`: no product uses it and\n * no work plans to — it still works, says what replaces it, and goes in the major named by `deprecated.removedIn`.\n */\nexport type Status = \"stable\" | \"beta\" | \"deprecated\";\n\n/** What a deprecated component says (required when `status` is `deprecated`, absent otherwise). */\nexport interface Deprecation {\n /** The version that deprecated it (`3.6.0`). */\n readonly since: string;\n /** The major that removes it (`4.0.0`). */\n readonly removedIn: string;\n /** What replaces it, when a component does. */\n readonly use?: ComponentName;\n /** Why it goes: no product uses it, no planned work needs it. */\n readonly reason: string;\n}\n\n/**\n * The group of the catalogue. `media`: video, sound, camera, files, cropping. `blocks`: the page blocks — the frame,\n * the hero, sections, cards that lead or argue, decisions, code, calls to act — that every page of a site is made of.\n * `layouts`: the shells of whole screens — an application, a dashboard, settings, sign-in, an object, a list and its\n * item, a site's page, a documentation page — each with its screens. `templates`: the page templates — whole pages of a\n * site (a landing, a product, a docs hub, a long read, a changelog, pricing, status, an error…), each a distinct\n * composition of the blocks, words and content as props.\n */\nexport type Category = \"actions\" | \"forms\" | \"navigation\" | \"overlays\" | \"feedback\" | \"data-display\" | \"media\" | \"blocks\" | \"layouts\" | \"templates\" | \"layout\" | \"foundations\";\n\n/** Why a reader needs a template: the seven intents the templates are grouped by (sidebar sub-headings, catalogue filter). */\nexport type TemplatePurpose = \"convince\" | \"explain\" | \"show\" | \"act\" | \"operate\" | \"converse\" | \"recover\";\n\n/** The groups of templates, in reading order: the title, and the one line that says when to come to that group. */\nexport const templatePurposes = {\n convince: { title: \"Convince\", useWhen: \"You come here when a visitor must decide: a landing, a product, a price, a comparison, a proof.\" },\n explain: { title: \"Explain\", useWhen: \"You come here when a reader must understand: how it works, a documentation hub or article, a changelog, a FAQ, a policy.\" },\n show: { title: \"Show\", useWhen: \"You come here when the work itself is the argument: a gallery, a story, a case study, an object and its details.\" },\n act: { title: \"Act\", useWhen: \"You come here when a person must do something once: reach the team, sign in or up, set up, pay.\" },\n operate: { title: \"Operate\", useWhen: \"You come here when a person works every day: a dashboard, a console with a table, settings, an app shell.\" },\n converse: { title: \"Converse\", useWhen: \"You come here when people talk: an inbox, a chat, comments, a notifications centre.\" },\n recover: { title: \"Recover\", useWhen: \"You come here when the way is blocked: an empty list, an error, a missing page, maintenance, a product still being built.\" },\n} as const satisfies Record<TemplatePurpose, { readonly title: string; readonly useWhen: string }>;\n\n/**\n * A named example: the file `registry/examples/<component>/<name>.tsx` (web) or\n * `registry/examples/<component>/native/<name>.tsx` (React Native) — at most 40 lines, imported by the stories and\n * shown (live, with its code) by the documentation.\n */\ninterface ExampleBase {\n /** The file name, kebab-case (`primary`, `icon-button`). */\n readonly name: string;\n /** A short title (`Primary`, `Icon button`). */\n readonly title: string;\n /** One or two sentences: when this variant is the right one. */\n readonly description: string;\n}\n\n/** A plain example, or a template: a whole screen with its intent (`purpose`) and the user goal its layout serves (`why`). */\nexport type Example =\n | (ExampleBase & { readonly template?: false; readonly purpose?: undefined; readonly why?: undefined })\n | (ExampleBase & {\n /**\n * A template: a whole screen of a product composed from the platform (a layout and its blocks, real words, its\n * phone version thought out), copied as a starting point — at most 160 lines, listed on ui.krizaka.com/templates.\n */\n readonly template: true;\n /** The intent that groups it: why you need this block. */\n readonly purpose: TemplatePurpose;\n /** Why this layout: the user goal it serves, in one sentence. */\n readonly why: string;\n });\n\n/** One platform's side of a component. */\nexport interface PlatformDocs {\n /** The names to import (`[\"Button\", \"IconButton\"]`): from `@krizaka/ui/<name>` on the web, `@krizaka/ui/native` in React Native. */\n readonly imports: readonly string[];\n /** The examples, in reading order; the first one is the component's preview. */\n readonly examples: readonly Example[];\n}\n\n/** The React Native side: the same concept, and what differs from the web API. */\nexport interface NativeDocs extends PlatformDocs {\n /**\n * The entry to import from when it is not `@krizaka/ui/native`: a component that needs an Expo module (an optional\n * peer) has its own, `@krizaka/ui/native/<name>`, so an app that does not use it never installs the module.\n */\n readonly entry?: string;\n /** What differs from the web API (`label` instead of children…); empty for a native-only component. */\n readonly differences: readonly string[];\n /** The React Native entry alone is deprecated while the web one stays (ADR-006 §7); a deprecated component says it once, above. */\n readonly deprecated?: Deprecation;\n}\n\nexport interface KeyboardInteraction {\n /** The keys, as written on a keyboard: `Space`, `Enter`, `Arrow keys`, `Esc`, `Tab`. */\n readonly keys: string;\n /** What they do. */\n readonly action: string;\n}\n\nexport interface ComponentMeta {\n /** The display name: `Button`, `Dropdown menu`. */\n readonly title: string;\n /** One sentence: what it is. */\n readonly summary: string;\n readonly status: Status;\n /** Required for, and only for, a `deprecated` component. */\n readonly deprecated?: Deprecation;\n readonly category: Category;\n /** Must match the sides below: `both` ⇔ `web` and `native`. */\n readonly platforms: Platform;\n /** The platforms it is not on, and why; and its desktop form (see `Parity`). */\n readonly parity: Parity;\n /** When to reach for it: concrete situations, one per item. */\n readonly whenToUse: readonly string[];\n /** When not to — and what to use instead (`use`), when another component is the answer. */\n readonly whenNotToUse: readonly { readonly when: string; readonly use?: ComponentName }[];\n /** How to use it well: wording, placement, composition. */\n readonly bestPractices: readonly string[];\n readonly accessibility: {\n /** The keyboard, when it does more than Tab and Enter. */\n readonly keyboard: readonly KeyboardInteraction[];\n /** Roles, names and announcements: what the component does, what the app must provide. */\n readonly notes: readonly string[];\n };\n /** The components a reader of this page should know about. */\n readonly related: readonly ComponentName[];\n readonly web?: PlatformDocs;\n readonly native?: NativeDocs;\n}\n"
|
|
710
751
|
}
|
|
711
752
|
],
|
|
712
753
|
"dependencies": [
|
|
713
|
-
"@tanstack/react-table@9.
|
|
754
|
+
"@tanstack/react-table@9.3.1",
|
|
714
755
|
"tailwind-variants@^3.3.1"
|
|
715
756
|
],
|
|
716
757
|
"registryDependencies": [
|
|
@@ -721,6 +762,7 @@
|
|
|
721
762
|
"field",
|
|
722
763
|
"filter-bar",
|
|
723
764
|
"pagination",
|
|
765
|
+
"router",
|
|
724
766
|
"search-field",
|
|
725
767
|
"virtual"
|
|
726
768
|
],
|
|
@@ -1006,6 +1048,13 @@
|
|
|
1006
1048
|
"description": "The sizes the pagination offers (`[10, 25, 50]`).",
|
|
1007
1049
|
"required": false
|
|
1008
1050
|
},
|
|
1051
|
+
{
|
|
1052
|
+
"name": "phone",
|
|
1053
|
+
"type": "\"list\" | \"cards\"",
|
|
1054
|
+
"default": "cards",
|
|
1055
|
+
"description": "What a row becomes on a phone (under 36 rem, or always with `layout=\"cards\"`): `cards` (a card per row, its values\nlabelled) or `list` — a list row: the selection's checkbox, the `title` column with its `priority: 2` columns joined as\none line of facts, the `status` / `badge` column at the end; the other columns in the row's detail.",
|
|
1056
|
+
"required": false
|
|
1057
|
+
},
|
|
1009
1058
|
{
|
|
1010
1059
|
"name": "pinning",
|
|
1011
1060
|
"type": "{ start?: readonly string[]; end?: readonly string[]; } | undefined",
|
|
@@ -1027,6 +1076,13 @@
|
|
|
1027
1076
|
"description": "Columns can be resized (pointer drag, or the arrow keys on the focused handle).",
|
|
1028
1077
|
"required": false
|
|
1029
1078
|
},
|
|
1079
|
+
{
|
|
1080
|
+
"name": "routeKey",
|
|
1081
|
+
"type": "string",
|
|
1082
|
+
"default": null,
|
|
1083
|
+
"description": "Keeps the sort, the filters and the page in the address (`?clients.sort=…&clients.page=2`): back and forward, a reload\nand a shared link all land on the same table. Replaces `sort`, `filters` and `page`; needs the router's adapter\n(`RouteStateProvider`) in a framework.",
|
|
1084
|
+
"required": false
|
|
1085
|
+
},
|
|
1030
1086
|
{
|
|
1031
1087
|
"name": "rowCount",
|
|
1032
1088
|
"type": "number",
|