From ceed7456bec95ee148334eca9c8fa83609cf3231 Mon Sep 17 00:00:00 2001
From: aXenDeveloper
Date: Fri, 2 Oct 2026 22:55:25 +0200
Subject: [PATCH 1/3] feat: Add more ui components
---
.nvmrc | 1 -
apps/web/content/docs/dev/passkeys/index.mdx | 2 +-
apps/web/content/docs/dev/passkeys/meta.json | 2 +-
apps/web/content/docs/dev/performance.mdx | 4 +-
apps/web/content/docs/ui/accordion.mdx | 1 +
apps/web/content/docs/ui/alert-dialog.mdx | 7 +-
apps/web/content/docs/ui/alert.mdx | 1 +
apps/web/content/docs/ui/aspect-ratio.mdx | 67 ++
apps/web/content/docs/ui/attachment.mdx | 177 +++++
apps/web/content/docs/ui/auto-form.mdx | 1 +
apps/web/content/docs/ui/avatar.mdx | 127 ++++
apps/web/content/docs/ui/badge.mdx | 1 +
apps/web/content/docs/ui/bubble.mdx | 197 ++++++
apps/web/content/docs/ui/button-group.mdx | 230 ++++++
apps/web/content/docs/ui/button.mdx | 1 +
apps/web/content/docs/ui/card.mdx | 76 +-
apps/web/content/docs/ui/carousel.mdx | 226 ++++++
apps/web/content/docs/ui/cascader.mdx | 207 ++++++
apps/web/content/docs/ui/chart.mdx | 268 +++++++
apps/web/content/docs/ui/checkbox.mdx | 1 +
apps/web/content/docs/ui/color.mdx | 1 +
apps/web/content/docs/ui/colors.mdx | 1 +
apps/web/content/docs/ui/combobox.mdx | 145 +++-
apps/web/content/docs/ui/command.mdx | 303 ++++++++
apps/web/content/docs/ui/comparison.mdx | 102 +++
.../docs/ui/confirm-action-alert-dialog.mdx | 6 +
apps/web/content/docs/ui/context-menu.mdx | 235 +++++++
apps/web/content/docs/ui/copy-button.mdx | 121 ++++
apps/web/content/docs/ui/counter.mdx | 129 ++++
apps/web/content/docs/ui/data-table.mdx | 587 ----------------
.../docs/ui/data-table/bulk-actions.mdx | 115 +++
.../docs/ui/data-table/draggable-rows.mdx | 273 ++++++++
.../docs/ui/data-table/expandable-rows.mdx | 73 ++
.../content/docs/ui/data-table/filters.mdx | 153 ++++
apps/web/content/docs/ui/data-table/index.mdx | 321 +++++++++
apps/web/content/docs/ui/data-table/meta.json | 13 +
.../content/docs/ui/data-table/pagination.mdx | 31 +
.../web/content/docs/ui/data-table/search.mdx | 42 ++
apps/web/content/docs/ui/dialog.mdx | 22 +-
apps/web/content/docs/ui/drawer.mdx | 1 +
apps/web/content/docs/ui/dropdown-menu.mdx | 1 +
apps/web/content/docs/ui/editor.mdx | 1 +
.../web/content/docs/ui/emoji-icon-picker.mdx | 1 +
apps/web/content/docs/ui/empty.mdx | 186 +++++
apps/web/content/docs/ui/field.mdx | 294 ++++++++
.../docs/ui/hooks/use-before-unload.mdx | 1 +
.../ui/hooks/use-intersection-observer.mdx | 98 +++
.../docs/ui/hooks/use-local-storage.mdx | 76 ++
apps/web/content/docs/ui/hooks/use-mobile.mdx | 1 +
apps/web/content/docs/ui/hover-card.mdx | 1 +
apps/web/content/docs/ui/image-zoom.mdx | 88 +++
apps/web/content/docs/ui/input-group.mdx | 1 +
apps/web/content/docs/ui/input-otp.mdx | 220 ++++++
apps/web/content/docs/ui/input.mdx | 1 +
apps/web/content/docs/ui/item.mdx | 212 ++++++
apps/web/content/docs/ui/kbd.mdx | 170 +++++
apps/web/content/docs/ui/label.mdx | 124 ++++
apps/web/content/docs/ui/marker.mdx | 170 +++++
apps/web/content/docs/ui/menubar.mdx | 246 +++++++
apps/web/content/docs/ui/message.mdx | 213 ++++++
apps/web/content/docs/ui/meta.json | 11 +-
apps/web/content/docs/ui/nullable-number.mdx | 26 +-
apps/web/content/docs/ui/number-field.mdx | 270 +++++++
apps/web/content/docs/ui/pagination.mdx | 215 ++++++
apps/web/content/docs/ui/popover.mdx | 1 +
apps/web/content/docs/ui/progress.mdx | 1 +
apps/web/content/docs/ui/radio-group.mdx | 1 +
apps/web/content/docs/ui/resizable.mdx | 290 ++++++++
apps/web/content/docs/ui/roles.mdx | 1 +
apps/web/content/docs/ui/scroll-area.mdx | 1 +
apps/web/content/docs/ui/scroll-fade.mdx | 157 +++++
apps/web/content/docs/ui/select.mdx | 1 +
apps/web/content/docs/ui/separator.mdx | 1 +
apps/web/content/docs/ui/sheet.mdx | 1 +
apps/web/content/docs/ui/shimmer.mdx | 157 +++++
apps/web/content/docs/ui/skeleton.mdx | 1 +
apps/web/content/docs/ui/sonner.mdx | 44 --
apps/web/content/docs/ui/spinner.mdx | 129 ++++
apps/web/content/docs/ui/switch.mdx | 1 +
apps/web/content/docs/ui/tabs.mdx | 1 +
apps/web/content/docs/ui/textarea.mdx | 1 +
apps/web/content/docs/ui/toast.mdx | 165 +++++
apps/web/content/docs/ui/toggle-group.mdx | 1 +
apps/web/content/docs/ui/toggle.mdx | 1 +
apps/web/content/docs/ui/tooltip.mdx | 44 ++
apps/web/content/docs/ui/user.mdx | 1 +
apps/web/package.json | 1 +
apps/web/src/docs/examples/alert-dialog.tsx | 8 +-
apps/web/src/docs/examples/aspect-ratio.tsx | 33 +
.../docs/examples/attachment-auto-form.tsx | 70 ++
apps/web/src/docs/examples/attachment.tsx | 86 +++
apps/web/src/docs/examples/avatar.tsx | 52 ++
apps/web/src/docs/examples/bubble.tsx | 74 ++
apps/web/src/docs/examples/button-group.tsx | 163 +++++
.../docs/examples/card-border-separation.tsx | 33 +
.../src/docs/examples/card-header-border.tsx | 26 +
.../src/docs/examples/card-header-footer.tsx | 29 +
apps/web/src/docs/examples/card-link.tsx | 37 +
apps/web/src/docs/examples/card.tsx | 19 +-
apps/web/src/docs/examples/carousel.tsx | 125 ++++
.../src/docs/examples/cascader-auto-form.tsx | 51 ++
.../web/src/docs/examples/cascader-options.ts | 62 ++
apps/web/src/docs/examples/cascader.tsx | 27 +
apps/web/src/docs/examples/chart-area.tsx | 115 +++
apps/web/src/docs/examples/chart-line.tsx | 80 +++
apps/web/src/docs/examples/chart.tsx | 79 +++
.../web/src/docs/examples/combobox-groups.tsx | 53 ++
.../src/docs/examples/combobox-multiple.tsx | 48 ++
apps/web/src/docs/examples/combobox.tsx | 1 +
apps/web/src/docs/examples/command-dialog.tsx | 116 +++
apps/web/src/docs/examples/command.tsx | 61 ++
apps/web/src/docs/examples/comparison.tsx | 31 +
.../examples/confirm-action-alert-dialog.tsx | 2 +
apps/web/src/docs/examples/context-menu.tsx | 109 +++
apps/web/src/docs/examples/copy-button.tsx | 32 +
.../docs/examples/counter-sliding-number.tsx | 40 ++
apps/web/src/docs/examples/counter.tsx | 27 +
.../docs/examples/data-table-demo-users.ts | 55 ++
.../docs/examples/data-table-expandable.tsx | 182 +++++
.../src/docs/examples/data-table-filters.tsx | 61 ++
.../docs/examples/data-table-pagination.tsx | 24 +
.../docs/examples/data-table-reorderable.tsx | 130 ++++
.../src/docs/examples/data-table-search.tsx | 35 +
apps/web/src/docs/examples/dialog.tsx | 32 +-
apps/web/src/docs/examples/empty-avatar.tsx | 37 +
apps/web/src/docs/examples/empty-outline.tsx | 33 +
apps/web/src/docs/examples/empty-search.tsx | 82 +++
apps/web/src/docs/examples/empty.tsx | 37 +
.../src/docs/examples/field-choice-card.tsx | 62 ++
.../src/docs/examples/field-responsive.tsx | 45 ++
apps/web/src/docs/examples/field.tsx | 89 +++
apps/web/src/docs/examples/image-zoom.tsx | 30 +
apps/web/src/docs/examples/input-otp.tsx | 50 ++
apps/web/src/docs/examples/item-group.tsx | 61 ++
apps/web/src/docs/examples/item-header.tsx | 38 +
apps/web/src/docs/examples/item-link.tsx | 56 ++
apps/web/src/docs/examples/item-sizes.tsx | 32 +
apps/web/src/docs/examples/item-variants.tsx | 46 ++
apps/web/src/docs/examples/item.tsx | 51 ++
apps/web/src/docs/examples/kbd.tsx | 107 +++
apps/web/src/docs/examples/label.tsx | 31 +
apps/web/src/docs/examples/marker.tsx | 86 +++
apps/web/src/docs/examples/menubar.tsx | 142 ++++
apps/web/src/docs/examples/message.tsx | 147 ++++
.../web/src/docs/examples/nullable-number.tsx | 7 +
.../docs/examples/number-field-auto-form.tsx | 51 ++
.../src/docs/examples/number-field-format.tsx | 50 ++
.../src/docs/examples/number-field-sizes.tsx | 25 +
apps/web/src/docs/examples/number-field.tsx | 38 +
apps/web/src/docs/examples/pagination.tsx | 125 ++++
.../docs/examples/resizable-collapsible.tsx | 38 +
.../src/docs/examples/resizable-handle.tsx | 25 +
.../src/docs/examples/resizable-persist.tsx | 86 +++
.../src/docs/examples/resizable-vertical.tsx | 28 +
apps/web/src/docs/examples/resizable.tsx | 37 +
.../docs/examples/scroll-fade-vertical.tsx | 41 ++
apps/web/src/docs/examples/scroll-fade.tsx | 78 +++
.../src/docs/examples/shimmer-variants.tsx | 65 ++
apps/web/src/docs/examples/shimmer.tsx | 21 +
apps/web/src/docs/examples/sonner.tsx | 22 -
.../web/src/docs/examples/spinner-buttons.tsx | 36 +
apps/web/src/docs/examples/spinner-inline.tsx | 38 +
apps/web/src/docs/examples/spinner-states.tsx | 37 +
apps/web/src/docs/examples/spinner.tsx | 25 +
apps/web/src/docs/examples/toast.tsx | 111 +++
apps/web/src/docs/examples/tooltip-group.tsx | 44 ++
apps/web/src/docs/local-table-data.ts | 77 ++
apps/web/src/docs/preview-data-table.tsx | 20 +-
apps/web/src/docs/preview.tsx | 20 +-
apps/web/src/locales/@vitnode/core/pl.json | 24 +-
.../site/home/illustrations/hero-preview.tsx | 4 +-
.../site/home/sections/showcase-carousel.tsx | 4 +-
apps/web/src/site/home/sections/showcase.tsx | 4 +-
apps/web/src/site/marketing/marketing.css | 5 +-
apps/web/src/site/marketing/safari-frame.tsx | 85 +++
apps/web/src/site/marketing/screen-frame.tsx | 44 +-
.../confirm-action-alert-dialog.test.tsx | 44 ++
.../confirm-action-alert-dialog.tsx | 41 +-
.../src/components/form/fields/cascader.tsx | 52 ++
.../form/fields/combobox-groups.test.ts | 48 ++
.../components/form/fields/combobox-groups.ts | 29 +
.../src/components/form/fields/combobox.tsx | 85 ++-
.../components/form/fields/file-gallery.tsx | 2 +-
.../src/components/form/fields/input-otp.tsx | 97 +++
.../form/fields/nullable-number.tsx | 82 +--
.../src/components/form/fields/number.tsx | 70 +-
.../vitnode/src/components/table/content.tsx | 332 ++++++---
.../components/table/data-table-content.tsx | 4 +
.../src/components/table/expansion.test.tsx | 159 +++++
.../src/components/table/expansion.tsx | 126 ++++
.../vitnode/src/components/table/filters.tsx | 7 +-
.../src/components/table/order-table-head.tsx | 4 +-
.../src/components/table/pagination.tsx | 2 +
.../src/components/table/reorder-state.ts | 54 ++
.../src/components/table/reorder.test.tsx | 319 +++++++++
.../vitnode/src/components/table/reorder.tsx | 299 ++++++++
.../src/components/table/selection.tsx | 6 +-
.../src/components/tiptap/tiptap-editor.tsx | 9 +-
.../tiptap/toolbar/actions/audio-action.tsx | 10 +-
.../tiptap/toolbar/actions/bold-action.tsx | 6 +-
.../toolbar/actions/code-block-action.tsx | 6 +-
.../tiptap/toolbar/actions/color-action.tsx | 6 +-
.../tiptap/toolbar/actions/emoji-action.tsx | 13 +-
.../tiptap/toolbar/actions/insert-action.tsx | 6 +-
.../tiptap/toolbar/actions/italic-action.tsx | 6 +-
.../tiptap/toolbar/actions/link-action.tsx | 6 +-
.../tiptap/toolbar/actions/list-action.tsx | 14 +-
.../text-format-more/text-format-more.tsx | 6 +-
.../toolbar/actions/utils/toolbar-tooltip.tsx | 9 +
.../tiptap/toolbar/tiptap-toolbar.tsx | 45 +-
.../src/components/ui/alert-dialog.tsx | 20 +-
.../vitnode/src/components/ui/attachment.tsx | 4 +-
packages/vitnode/src/components/ui/avatar.tsx | 73 +-
packages/vitnode/src/components/ui/bubble.tsx | 127 ++++
.../src/components/ui/button-client.tsx | 4 +-
packages/vitnode/src/components/ui/card.tsx | 4 +-
.../src/components/ui/cascader.test.tsx | 177 +++++
.../vitnode/src/components/ui/cascader.tsx | 661 ++++++++++++++++++
packages/vitnode/src/components/ui/chart.tsx | 2 +-
.../vitnode/src/components/ui/checkbox.tsx | 2 +-
.../src/components/ui/color-picker.tsx | 6 +-
.../vitnode/src/components/ui/combobox.tsx | 130 +++-
.../src/components/ui/comparison.test.ts | 36 +
.../vitnode/src/components/ui/comparison.tsx | 209 ++++++
.../src/components/ui/context-menu.tsx | 10 +-
.../src/components/ui/copy-button.test.tsx | 113 +++
.../vitnode/src/components/ui/copy-button.tsx | 180 +++++
.../src/components/ui/counter.test.tsx | 93 +++
.../vitnode/src/components/ui/counter.tsx | 90 +++
packages/vitnode/src/components/ui/dialog.tsx | 2 +-
packages/vitnode/src/components/ui/editor.tsx | 17 +-
.../src/components/ui/emoji-icon-picker.tsx | 6 +-
packages/vitnode/src/components/ui/field.tsx | 2 +-
.../src/components/ui/image-zoom.test.ts | 28 +
.../vitnode/src/components/ui/image-zoom.tsx | 171 +++++
.../vitnode/src/components/ui/input-otp.tsx | 74 +-
packages/vitnode/src/components/ui/label.tsx | 2 +-
packages/vitnode/src/components/ui/loader.tsx | 20 -
packages/vitnode/src/components/ui/marker.tsx | 73 ++
.../vitnode/src/components/ui/menubar.tsx | 16 +-
.../vitnode/src/components/ui/message.tsx | 91 +++
.../src/components/ui/number-field.test.tsx | 136 ++++
.../src/components/ui/number-field.tsx | 160 +++++
.../src/components/ui/pagination.test.tsx | 89 +++
.../vitnode/src/components/ui/pagination.tsx | 39 +-
.../vitnode/src/components/ui/radio-group.tsx | 2 +-
.../vitnode/src/components/ui/sidebar.tsx | 32 +-
.../src/components/ui/sliding-number.test.ts | 32 +
.../src/components/ui/sliding-number.tsx | 120 ++++
packages/vitnode/src/components/ui/sonner.tsx | 148 +++-
.../src/components/ui/spinner.test.tsx | 26 +
.../vitnode/src/components/ui/spinner.tsx | 56 +-
packages/vitnode/src/components/ui/switch.tsx | 15 +-
.../vitnode/src/components/ui/tooltip.tsx | 102 +++
.../src/editor/properties/area-panel.tsx | 1 +
.../vitnode/src/editor/properties/field.tsx | 4 +-
.../vitnode/src/editor/sidebar/footer.tsx | 3 +-
.../vitnode/src/editor/zones/area-frame.tsx | 1 +
.../src/framework/vite/optimize-deps.test.ts | 46 ++
.../src/framework/vite/optimize-deps.ts | 28 +-
.../hooks/use-intersection-observer.test.ts | 117 ++++
.../src/hooks/use-intersection-observer.ts | 45 ++
.../src/hooks/use-local-storage.test.ts | 108 +++
.../vitnode/src/hooks/use-local-storage.ts | 87 +++
.../vitnode/src/lib/helpers/auto-form.test.ts | 14 +
packages/vitnode/src/lib/helpers/auto-form.ts | 3 +
packages/vitnode/src/locales/en.json | 26 +-
.../tanstack/admin/content/row-actions.tsx | 2 +
.../vitnode/src/tanstack/auth/sso-screen.tsx | 6 +-
.../views/content/actions/content-panel.tsx | 12 +-
.../actions/delivery/delivery-panel.tsx | 9 +-
.../actions/history/revision-history.tsx | 6 +-
.../content/actions/history/revision-row.tsx | 5 +-
.../views/content/actions/preview-action.tsx | 43 +-
.../actions/schedule/schedule-panel.tsx | 9 +-
.../admin/views/content/form/primitives.tsx | 1 +
.../views/content/lib/field-component.tsx | 4 +-
.../search/remove-documents-action.tsx | 1 +
.../core/dashboard/grid/widget-properties.tsx | 10 +-
.../debug/actions/clear-cache/clear-cache.tsx | 1 +
.../debug/system-logs/actions/more/more.tsx | 10 +-
.../navigation/navigation-create-dialog.tsx | 10 +-
.../navigation/navigation-list-content.tsx | 15 +-
.../core/staff/table/staff-table-content.tsx | 1 +
.../system/files/actions/file-row-actions.tsx | 1 +
.../files/actions/files-bulk-actions.tsx | 1 +
.../send-test-email/send-test-email.tsx | 10 +-
.../system/integrations/test-ai/test-ai.tsx | 10 +-
.../test-storage/test-storage.tsx | 10 +-
.../core/users/list/create-user-content.tsx | 10 +-
.../core/users/roles/roles-table-content.tsx | 17 +-
.../settings/devices/revoke-device-button.tsx | 1 +
.../passkeys/delete-passkey-button.tsx | 1 +
.../settings/sso/disconnect-sso-button.tsx | 1 +
.../sso/callback/sso-callback-content.tsx | 6 +-
.../views/files/actions/file-row-actions.tsx | 1 +
.../files/actions/files-bulk-actions.tsx | 1 +
.../vitnode/src/views/layouts/providers.tsx | 6 +-
pnpm-lock.yaml | 5 +-
299 files changed, 17544 insertions(+), 1254 deletions(-)
delete mode 100644 .nvmrc
create mode 100644 apps/web/content/docs/ui/aspect-ratio.mdx
create mode 100644 apps/web/content/docs/ui/attachment.mdx
create mode 100644 apps/web/content/docs/ui/avatar.mdx
create mode 100644 apps/web/content/docs/ui/bubble.mdx
create mode 100644 apps/web/content/docs/ui/button-group.mdx
create mode 100644 apps/web/content/docs/ui/carousel.mdx
create mode 100644 apps/web/content/docs/ui/cascader.mdx
create mode 100644 apps/web/content/docs/ui/chart.mdx
create mode 100644 apps/web/content/docs/ui/command.mdx
create mode 100644 apps/web/content/docs/ui/comparison.mdx
create mode 100644 apps/web/content/docs/ui/context-menu.mdx
create mode 100644 apps/web/content/docs/ui/copy-button.mdx
create mode 100644 apps/web/content/docs/ui/counter.mdx
delete mode 100644 apps/web/content/docs/ui/data-table.mdx
create mode 100644 apps/web/content/docs/ui/data-table/bulk-actions.mdx
create mode 100644 apps/web/content/docs/ui/data-table/draggable-rows.mdx
create mode 100644 apps/web/content/docs/ui/data-table/expandable-rows.mdx
create mode 100644 apps/web/content/docs/ui/data-table/filters.mdx
create mode 100644 apps/web/content/docs/ui/data-table/index.mdx
create mode 100644 apps/web/content/docs/ui/data-table/meta.json
create mode 100644 apps/web/content/docs/ui/data-table/pagination.mdx
create mode 100644 apps/web/content/docs/ui/data-table/search.mdx
create mode 100644 apps/web/content/docs/ui/empty.mdx
create mode 100644 apps/web/content/docs/ui/field.mdx
create mode 100644 apps/web/content/docs/ui/hooks/use-intersection-observer.mdx
create mode 100644 apps/web/content/docs/ui/hooks/use-local-storage.mdx
create mode 100644 apps/web/content/docs/ui/image-zoom.mdx
create mode 100644 apps/web/content/docs/ui/input-otp.mdx
create mode 100644 apps/web/content/docs/ui/item.mdx
create mode 100644 apps/web/content/docs/ui/kbd.mdx
create mode 100644 apps/web/content/docs/ui/label.mdx
create mode 100644 apps/web/content/docs/ui/marker.mdx
create mode 100644 apps/web/content/docs/ui/menubar.mdx
create mode 100644 apps/web/content/docs/ui/message.mdx
create mode 100644 apps/web/content/docs/ui/number-field.mdx
create mode 100644 apps/web/content/docs/ui/pagination.mdx
create mode 100644 apps/web/content/docs/ui/resizable.mdx
create mode 100644 apps/web/content/docs/ui/scroll-fade.mdx
create mode 100644 apps/web/content/docs/ui/shimmer.mdx
delete mode 100644 apps/web/content/docs/ui/sonner.mdx
create mode 100644 apps/web/content/docs/ui/spinner.mdx
create mode 100644 apps/web/content/docs/ui/toast.mdx
create mode 100644 apps/web/src/docs/examples/aspect-ratio.tsx
create mode 100644 apps/web/src/docs/examples/attachment-auto-form.tsx
create mode 100644 apps/web/src/docs/examples/attachment.tsx
create mode 100644 apps/web/src/docs/examples/avatar.tsx
create mode 100644 apps/web/src/docs/examples/bubble.tsx
create mode 100644 apps/web/src/docs/examples/button-group.tsx
create mode 100644 apps/web/src/docs/examples/card-border-separation.tsx
create mode 100644 apps/web/src/docs/examples/card-header-border.tsx
create mode 100644 apps/web/src/docs/examples/card-header-footer.tsx
create mode 100644 apps/web/src/docs/examples/card-link.tsx
create mode 100644 apps/web/src/docs/examples/carousel.tsx
create mode 100644 apps/web/src/docs/examples/cascader-auto-form.tsx
create mode 100644 apps/web/src/docs/examples/cascader-options.ts
create mode 100644 apps/web/src/docs/examples/cascader.tsx
create mode 100644 apps/web/src/docs/examples/chart-area.tsx
create mode 100644 apps/web/src/docs/examples/chart-line.tsx
create mode 100644 apps/web/src/docs/examples/chart.tsx
create mode 100644 apps/web/src/docs/examples/combobox-groups.tsx
create mode 100644 apps/web/src/docs/examples/combobox-multiple.tsx
create mode 100644 apps/web/src/docs/examples/command-dialog.tsx
create mode 100644 apps/web/src/docs/examples/command.tsx
create mode 100644 apps/web/src/docs/examples/comparison.tsx
create mode 100644 apps/web/src/docs/examples/context-menu.tsx
create mode 100644 apps/web/src/docs/examples/copy-button.tsx
create mode 100644 apps/web/src/docs/examples/counter-sliding-number.tsx
create mode 100644 apps/web/src/docs/examples/counter.tsx
create mode 100644 apps/web/src/docs/examples/data-table-demo-users.ts
create mode 100644 apps/web/src/docs/examples/data-table-expandable.tsx
create mode 100644 apps/web/src/docs/examples/data-table-filters.tsx
create mode 100644 apps/web/src/docs/examples/data-table-pagination.tsx
create mode 100644 apps/web/src/docs/examples/data-table-reorderable.tsx
create mode 100644 apps/web/src/docs/examples/data-table-search.tsx
create mode 100644 apps/web/src/docs/examples/empty-avatar.tsx
create mode 100644 apps/web/src/docs/examples/empty-outline.tsx
create mode 100644 apps/web/src/docs/examples/empty-search.tsx
create mode 100644 apps/web/src/docs/examples/empty.tsx
create mode 100644 apps/web/src/docs/examples/field-choice-card.tsx
create mode 100644 apps/web/src/docs/examples/field-responsive.tsx
create mode 100644 apps/web/src/docs/examples/field.tsx
create mode 100644 apps/web/src/docs/examples/image-zoom.tsx
create mode 100644 apps/web/src/docs/examples/input-otp.tsx
create mode 100644 apps/web/src/docs/examples/item-group.tsx
create mode 100644 apps/web/src/docs/examples/item-header.tsx
create mode 100644 apps/web/src/docs/examples/item-link.tsx
create mode 100644 apps/web/src/docs/examples/item-sizes.tsx
create mode 100644 apps/web/src/docs/examples/item-variants.tsx
create mode 100644 apps/web/src/docs/examples/item.tsx
create mode 100644 apps/web/src/docs/examples/kbd.tsx
create mode 100644 apps/web/src/docs/examples/label.tsx
create mode 100644 apps/web/src/docs/examples/marker.tsx
create mode 100644 apps/web/src/docs/examples/menubar.tsx
create mode 100644 apps/web/src/docs/examples/message.tsx
create mode 100644 apps/web/src/docs/examples/number-field-auto-form.tsx
create mode 100644 apps/web/src/docs/examples/number-field-format.tsx
create mode 100644 apps/web/src/docs/examples/number-field-sizes.tsx
create mode 100644 apps/web/src/docs/examples/number-field.tsx
create mode 100644 apps/web/src/docs/examples/pagination.tsx
create mode 100644 apps/web/src/docs/examples/resizable-collapsible.tsx
create mode 100644 apps/web/src/docs/examples/resizable-handle.tsx
create mode 100644 apps/web/src/docs/examples/resizable-persist.tsx
create mode 100644 apps/web/src/docs/examples/resizable-vertical.tsx
create mode 100644 apps/web/src/docs/examples/resizable.tsx
create mode 100644 apps/web/src/docs/examples/scroll-fade-vertical.tsx
create mode 100644 apps/web/src/docs/examples/scroll-fade.tsx
create mode 100644 apps/web/src/docs/examples/shimmer-variants.tsx
create mode 100644 apps/web/src/docs/examples/shimmer.tsx
delete mode 100644 apps/web/src/docs/examples/sonner.tsx
create mode 100644 apps/web/src/docs/examples/spinner-buttons.tsx
create mode 100644 apps/web/src/docs/examples/spinner-inline.tsx
create mode 100644 apps/web/src/docs/examples/spinner-states.tsx
create mode 100644 apps/web/src/docs/examples/spinner.tsx
create mode 100644 apps/web/src/docs/examples/toast.tsx
create mode 100644 apps/web/src/docs/examples/tooltip-group.tsx
create mode 100644 apps/web/src/docs/local-table-data.ts
create mode 100644 apps/web/src/site/marketing/safari-frame.tsx
create mode 100644 packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.test.tsx
create mode 100644 packages/vitnode/src/components/form/fields/cascader.tsx
create mode 100644 packages/vitnode/src/components/form/fields/combobox-groups.test.ts
create mode 100644 packages/vitnode/src/components/form/fields/combobox-groups.ts
create mode 100644 packages/vitnode/src/components/form/fields/input-otp.tsx
create mode 100644 packages/vitnode/src/components/table/expansion.test.tsx
create mode 100644 packages/vitnode/src/components/table/expansion.tsx
create mode 100644 packages/vitnode/src/components/table/reorder-state.ts
create mode 100644 packages/vitnode/src/components/table/reorder.test.tsx
create mode 100644 packages/vitnode/src/components/table/reorder.tsx
create mode 100644 packages/vitnode/src/components/tiptap/toolbar/actions/utils/toolbar-tooltip.tsx
create mode 100644 packages/vitnode/src/components/ui/bubble.tsx
create mode 100644 packages/vitnode/src/components/ui/cascader.test.tsx
create mode 100644 packages/vitnode/src/components/ui/cascader.tsx
create mode 100644 packages/vitnode/src/components/ui/comparison.test.ts
create mode 100644 packages/vitnode/src/components/ui/comparison.tsx
create mode 100644 packages/vitnode/src/components/ui/copy-button.test.tsx
create mode 100644 packages/vitnode/src/components/ui/copy-button.tsx
create mode 100644 packages/vitnode/src/components/ui/counter.test.tsx
create mode 100644 packages/vitnode/src/components/ui/counter.tsx
create mode 100644 packages/vitnode/src/components/ui/image-zoom.test.ts
create mode 100644 packages/vitnode/src/components/ui/image-zoom.tsx
delete mode 100644 packages/vitnode/src/components/ui/loader.tsx
create mode 100644 packages/vitnode/src/components/ui/marker.tsx
create mode 100644 packages/vitnode/src/components/ui/message.tsx
create mode 100644 packages/vitnode/src/components/ui/number-field.test.tsx
create mode 100644 packages/vitnode/src/components/ui/number-field.tsx
create mode 100644 packages/vitnode/src/components/ui/pagination.test.tsx
create mode 100644 packages/vitnode/src/components/ui/sliding-number.test.ts
create mode 100644 packages/vitnode/src/components/ui/sliding-number.tsx
create mode 100644 packages/vitnode/src/components/ui/spinner.test.tsx
create mode 100644 packages/vitnode/src/framework/vite/optimize-deps.test.ts
create mode 100644 packages/vitnode/src/hooks/use-intersection-observer.test.ts
create mode 100644 packages/vitnode/src/hooks/use-intersection-observer.ts
create mode 100644 packages/vitnode/src/hooks/use-local-storage.test.ts
create mode 100644 packages/vitnode/src/hooks/use-local-storage.ts
diff --git a/.nvmrc b/.nvmrc
deleted file mode 100644
index 8fdd954df..000000000
--- a/.nvmrc
+++ /dev/null
@@ -1 +0,0 @@
-22
\ No newline at end of file
diff --git a/apps/web/content/docs/dev/passkeys/index.mdx b/apps/web/content/docs/dev/passkeys/index.mdx
index 4108bf0d9..547d133c3 100644
--- a/apps/web/content/docs/dev/passkeys/index.mdx
+++ b/apps/web/content/docs/dev/passkeys/index.mdx
@@ -1,7 +1,7 @@
---
title: Passkeys (WebAuthn)
description: Let members sign in with Face ID, Touch ID, Windows Hello or a security key. Configure the RP ID and origins, run the migration, and learn how the WebAuthn ceremonies work in VitNode.
-icon: Fingerprint
+icon: FingerprintPattern
---
import { Tab, Tabs } from "fumadocs-ui/components/tabs"
diff --git a/apps/web/content/docs/dev/passkeys/meta.json b/apps/web/content/docs/dev/passkeys/meta.json
index f6761fcec..f350bca0e 100644
--- a/apps/web/content/docs/dev/passkeys/meta.json
+++ b/apps/web/content/docs/dev/passkeys/meta.json
@@ -1,6 +1,6 @@
{
"title": "Passkeys",
"description": "Passwordless sign-in with WebAuthn passkeys - Face ID, Touch ID, Windows Hello or a security key",
- "icon": "Fingerprint",
+ "icon": "FingerprintPattern",
"pages": ["index", "using-passkeys", "admincp"]
}
diff --git a/apps/web/content/docs/dev/performance.mdx b/apps/web/content/docs/dev/performance.mdx
index fdb65e648..92728897d 100644
--- a/apps/web/content/docs/dev/performance.mdx
+++ b/apps/web/content/docs/dev/performance.mdx
@@ -65,7 +65,7 @@ Heavy editors (like Tiptap) or complex modals should be lazy-loaded with `React.
```tsx title="plugins/blog/src/views/admin/article-editor.tsx"
import React, { Suspense } from 'react'
-import { Loader } from '@vitnode/core/components/ui/loader'
+import { Spinner } from '@vitnode/core/components/ui/spinner'
// [!code ++:6]
const RichEditor = React.lazy(async () =>
@@ -75,7 +75,7 @@ const RichEditor = React.lazy(async () =>
)
export const ArticleEditor = (props) => (
- }>
+ }>
)
diff --git a/apps/web/content/docs/ui/accordion.mdx b/apps/web/content/docs/ui/accordion.mdx
index 75d60cf12..3050bf2e0 100644
--- a/apps/web/content/docs/ui/accordion.mdx
+++ b/apps/web/content/docs/ui/accordion.mdx
@@ -1,6 +1,7 @@
---
title: Accordion
description: A component that allows users to expand and collapse sections of content.
+icon: Rows3
---
## Preview
diff --git a/apps/web/content/docs/ui/alert-dialog.mdx b/apps/web/content/docs/ui/alert-dialog.mdx
index 0b70f474d..69d6535ca 100644
--- a/apps/web/content/docs/ui/alert-dialog.mdx
+++ b/apps/web/content/docs/ui/alert-dialog.mdx
@@ -1,6 +1,7 @@
---
title: Alert Dialog
description: Display important messages to users in a modal dialog.
+icon: MessageSquareWarning
---
A modal dialog that interrupts the user with important content and expects a
@@ -30,10 +31,10 @@ import { Button } from '@vitnode/core/components/ui/button';
```tsx
- Show Dialog} />
+ Delete account} />
- Are you absolutely sure?
+ Delete your account?
This action cannot be undone. This will permanently delete your account
and remove your data from our servers.
@@ -41,7 +42,7 @@ import { Button } from '@vitnode/core/components/ui/button';
Cancel
- Continue
+ Delete account
diff --git a/apps/web/content/docs/ui/alert.mdx b/apps/web/content/docs/ui/alert.mdx
index 9cf761fb7..7cd4b260c 100644
--- a/apps/web/content/docs/ui/alert.mdx
+++ b/apps/web/content/docs/ui/alert.mdx
@@ -1,6 +1,7 @@
---
title: Alert
description: Display a short, important message to users.
+icon: CircleAlert
---
## Preview
diff --git a/apps/web/content/docs/ui/aspect-ratio.mdx b/apps/web/content/docs/ui/aspect-ratio.mdx
new file mode 100644
index 000000000..d1a9f4ef9
--- /dev/null
+++ b/apps/web/content/docs/ui/aspect-ratio.mdx
@@ -0,0 +1,67 @@
+---
+title: Aspect Ratio
+description: Keep images, videos and embeds at the right shape, whatever the width.
+icon: Ratio
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import { AspectRatio } from '@vitnode/core/components/ui/aspect-ratio'
+```
+
+```tsx
+
+
+
+```
+
+The box takes the full width it is given and works out its height from
+`ratio`, so the page doesn't jump around while the image loads. Give the child
+`size-full` and `object-cover` to fill the box without stretching.
+
+## Common ratios
+
+| Ratio | Good for |
+| -------- | ------------------------------ |
+| `16 / 9` | Videos, hero images, banners |
+| `4 / 3` | Photos, product shots |
+| `1` | Avatars, thumbnails, galleries |
+| `9 / 16` | Phone screenshots, stories |
+
+## Video embed
+
+```tsx
+
+
+
+```
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+
+
+Every other prop goes straight to the underlying `div`.
diff --git a/apps/web/content/docs/ui/attachment.mdx b/apps/web/content/docs/ui/attachment.mdx
new file mode 100644
index 000000000..8b9390888
--- /dev/null
+++ b/apps/web/content/docs/ui/attachment.mdx
@@ -0,0 +1,177 @@
+---
+title: Attachment
+description: File uploads with drag and drop, progress, previews and errors - from a single avatar to a sortable gallery.
+icon: Paperclip
+---
+
+## Preview
+
+
+
+## Usage
+
+import { Tab, Tabs } from "fumadocs-ui/components/tabs";
+
+
+
+
+Two fields cover every upload: `AutoFormFile` for one file and `AutoFormFiles`
+for many.
+
+```ts
+import { z } from 'zod'
+import { AutoForm } from '@vitnode/core/components/form/auto-form'
+import { AutoFormFile } from '@vitnode/core/components/form/fields/file'
+import { AutoFormFiles } from '@vitnode/core/components/form/fields/files'
+```
+
+The form stores **file ids**, not the files themselves - a number for a single
+file and an array of numbers for many:
+
+```ts
+const formSchema = z.object({
+ avatar: z.number().nullable().default(null),
+ gallery: z.array(z.number()).max(4).default([]),
+})
+```
+
+```tsx
+ (
+
+ ),
+ },
+ {
+ id: 'gallery',
+ component: props => (
+
+ ),
+ },
+ ]}
+/>
+```
+
+
+
+
+
+
+
+```ts
+import {
+ Attachment,
+ AttachmentAction,
+ AttachmentActions,
+ AttachmentContent,
+ AttachmentDescription,
+ AttachmentMedia,
+ AttachmentTitle,
+} from '@vitnode/core/components/ui/attachment'
+```
+
+```tsx
+
+
+
+
+
+ {file.name}
+ 48 KB
+
+
+
+
+
+
+
+```
+
+
+
+
+## Uploading files
+
+`onUpload` receives the `File` the user picked and returns the stored file. The
+field shows a spinner while it runs and an error if it throws, then saves the
+returned `id` into the form.
+
+```ts
+import type { AutoFormFileValue } from '@vitnode/core/components/form/fields/file'
+
+const uploadFile = async (file: File): Promise => {
+ const body = new FormData()
+ body.append('file', file)
+
+ const res = await fetch('/api/my-plugin/uploads', { body, method: 'POST' })
+ if (!res.ok) throw new Error('Upload failed')
+
+ return await res.json() // { id, name, size, url, mimeType? }
+}
+```
+
+When editing something that already has files, pass them in so the field can
+show their names and previews: `file={existing}` on `AutoFormFile`, or
+`files={existing}` on `AutoFormFiles`.
+
+## Limits
+
+Both fields check files **before** uploading, so nobody waits for a 2 GB video
+just to hear it was too big:
+
+- `maxBytes` - the largest accepted file, required.
+- `allowedExtensions` - lowercase with a leading dot, like `['.pdf', '.png']`.
+- `allowedMimeTypes` - for example `['image/png', 'application/pdf']`.
+
+`AutoFormFiles` also takes `maxItems` and `minItems`. Files can be reordered by
+dragging or with the keyboard; pass `ordered={false}` when the order doesn't
+matter.
+
+Your server should check the same limits again - the browser only saves people
+a wasted upload, it can't stop someone determined.
+
+## States
+
+Set `state` on `Attachment` to match what the file is doing:
+
+| State | Looks like |
+| ------------ | ---------------------------------------------- |
+| `done` | The default - a finished file |
+| `idle` | Dashed border, waiting for a file |
+| `uploading` | Shimmering title, dimmed preview |
+| `processing` | Same as uploading, for server-side work |
+| `error` | Red border and description - say what went wrong |
+
+## Layouts
+
+- `orientation="vertical"` turns the attachment into a card with a big preview,
+ great for galleries.
+- `size="sm"` or `size="xs"` shrink it for chat composers and tight lists.
+- Wrap several in `AttachmentGroup` for a horizontally scrolling row that fades
+ out at the edges.
+- `AttachmentTrigger` makes the whole attachment clickable - render it as a link
+ to open the file.
+
+## Accessibility
+
+- Give every `AttachmentAction` an `aria-label` that names the file, like
+ "Remove report.pdf" - "Remove" alone is a riddle when there are five files.
+- Previews in `AttachmentMedia` are decorative next to the file name, so
+ `alt=""` is right.
+- The AutoForm fields announce reordering to screen readers and show every
+ rejected file with the reason.
diff --git a/apps/web/content/docs/ui/auto-form.mdx b/apps/web/content/docs/ui/auto-form.mdx
index d43496634..ec219892f 100644
--- a/apps/web/content/docs/ui/auto-form.mdx
+++ b/apps/web/content/docs/ui/auto-form.mdx
@@ -1,6 +1,7 @@
---
title: Auto Form
description: Component creates form based on Zod schemas & TanStack Form with validation
+icon: ClipboardPen
---
## Preview
diff --git a/apps/web/content/docs/ui/avatar.mdx b/apps/web/content/docs/ui/avatar.mdx
new file mode 100644
index 000000000..e4b273f7b
--- /dev/null
+++ b/apps/web/content/docs/ui/avatar.mdx
@@ -0,0 +1,127 @@
+---
+title: Avatar
+description: Profile pictures with a graceful fallback, status badges and groups that lift on hover.
+icon: CircleUserRound
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from '@vitnode/core/components/ui/avatar'
+```
+
+```tsx
+
+
+ AL
+
+```
+
+The fallback shows while the image loads and stays if it never arrives - so a
+broken link turns into tidy initials instead of a sad empty circle.
+
+## Sizes
+
+```tsx
+
+
+
+```
+
+## Badge
+
+Add `AvatarBadge` for a status dot. It takes any background colour, so pick a
+semantic one:
+
+```tsx
+
+
+ AL
+
+
+```
+
+A coloured dot alone doesn't tell everyone that someone is online - say it in
+text somewhere too, or give the badge an `aria-label`.
+
+## Group
+
+Stack avatars with `AvatarGroup`. Wrap each one in `AvatarGroupItem` to make it
+lift on hover and show the person's name in a tooltip, and finish with
+`AvatarGroupCount` for everyone who didn't fit:
+
+```tsx
+import {
+ AvatarGroup,
+ AvatarGroupCount,
+ AvatarGroupItem,
+} from '@vitnode/core/components/ui/avatar'
+```
+
+```tsx
+
+ {users.map(user => (
+
+
+
+ {user.initials}
+
+
+ ))}
+ +3
+
+```
+
+All items in a group share one tooltip, so moving along the stack glides it
+from face to face instead of blinking a new one open each time.
+
+Plain `Avatar`s work inside `AvatarGroup` too, when you want a quiet stack with
+no hover effect.
+
+## Accessibility
+
+- Always pass `alt` to `AvatarImage` - the person's name is usually perfect.
+- An `AvatarGroupItem` with a `label` can be focused with the keyboard, and the
+ tooltip appears on focus as well as on hover.
+- With reduced motion turned on, avatars in a group stay put and only the
+ tooltip appears.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+### Avatar
+
+
+
+### AvatarGroupItem
+
+
+
+## API Reference
+
+[Base UI - Avatar](https://base-ui.com/react/components/avatar)
diff --git a/apps/web/content/docs/ui/badge.mdx b/apps/web/content/docs/ui/badge.mdx
index 5857aa34d..88a54d043 100644
--- a/apps/web/content/docs/ui/badge.mdx
+++ b/apps/web/content/docs/ui/badge.mdx
@@ -1,6 +1,7 @@
---
title: Badge
description: Display small labels or indicators.
+icon: Tag
---
## Preview
diff --git a/apps/web/content/docs/ui/bubble.mdx b/apps/web/content/docs/ui/bubble.mdx
new file mode 100644
index 000000000..a08fac019
--- /dev/null
+++ b/apps/web/content/docs/ui/bubble.mdx
@@ -0,0 +1,197 @@
+---
+title: Bubble
+description: Chat-style message bubbles with variants, alignment, grouping, reactions and clickable suggestions.
+icon: MessageCircleMore
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import {
+ Bubble,
+ BubbleContent,
+ BubbleGroup,
+ BubbleReactions,
+} from '@vitnode/core/components/ui/bubble'
+```
+
+```tsx
+
+ I removed the stale route. You are welcome.
+
+```
+
+A bubble shrinks to fit its text and never grows past 80% of the row, so short
+replies stay short and long ones wrap politely.
+
+## Composition
+
+```text
+BubbleGroup
+├── Bubble
+│ ├── BubbleContent
+│ └── BubbleReactions
+└── Bubble
+ └── BubbleContent
+```
+
+## Variants
+
+```tsx
+
+ Quiet, supporting content.
+
+```
+
+| Variant | Use it for |
+| ------------- | -------------------------------------------------------- |
+| `default` | A strong primary bubble, usually the current user. |
+| `secondary` | The standard neutral bubble. |
+| `muted` | Lower-emphasis replies. |
+| `tinted` | A soft bubble derived from your primary color. |
+| `outline` | A bordered bubble for suggestions or rich content. |
+| `ghost` | Unframed, full-width content such as assistant answers. |
+| `destructive` | Errors and failed actions. |
+
+## Alignment
+
+Messages you sent go on the right, everything else on the left - just like
+every chat app you have ever used.
+
+```tsx
+
+ Sent by me.
+
+```
+
+## Grouping
+
+Wrap consecutive bubbles from the same sender in `BubbleGroup` to tighten the
+spacing between them. Set `align` on each `Bubble`, not on the group.
+
+```tsx
+
+
+ First thought.
+
+
+ Second thought, arriving fashionably late.
+
+
+```
+
+## Reactions
+
+`BubbleReactions` pins a small pill to the bubble's edge. Move it with `side`
+(`top` or `bottom`) and `align` (`start` or `end`). It overlaps the bubble, so
+leave a bit more `gap` between rows.
+
+```tsx
+
+ Tests passed on the first try.
+
+ 🚀
+ 👀
+
+
+```
+
+## Links and buttons
+
+Use `render` on `BubbleContent` to turn a bubble into a real link or button -
+perfect for quick reply suggestions.
+
+```tsx
+
+ }>
+ Show me the plugin docs
+
+
+```
+
+## Use with Message
+
+Need an avatar, a sender name or a read receipt? Wrap bubbles in a
+[Message](/docs/ui/message). Set `align="end"` on the `Message` once and every
+bubble inside follows it to the end side.
+
+```tsx
+
+
+
+ No need to align me twice.
+
+ Read
+
+
+```
+
+## Accessibility
+
+- `Bubble` is just the visual surface. Put conversation semantics such as
+ `role="log"` and an `aria-label` on the surrounding container.
+- A row of emoji reads poorly on a screen reader ("plus eight", anyone?). Give
+ `BubbleReactions` `role="img"` and a descriptive `aria-label` so it is
+ announced once. When reactions are interactive, render buttons with labels
+ instead.
+- Clickable bubbles should be a real `` or `` via `render`. They get
+ a visible focus ring and take their accessible name from the text.
+- Variants signal tone with color - keep the meaning in the text too, especially
+ for `destructive` bubbles.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+### Bubble
+
+
+
+### BubbleContent
+
+
+
+### BubbleReactions
+
+
+
+## API Reference
+
+[Base UI - useRender](https://base-ui.com/react/utils/use-render)
diff --git a/apps/web/content/docs/ui/button-group.mdx b/apps/web/content/docs/ui/button-group.mdx
new file mode 100644
index 000000000..ea4392091
--- /dev/null
+++ b/apps/web/content/docs/ui/button-group.mdx
@@ -0,0 +1,230 @@
+---
+title: Button Group
+description: Glue related buttons, inputs and menus together into one tidy row (or column) with shared borders and rounded ends.
+icon: Group
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+} from '@vitnode/core/components/ui/button-group'
+```
+
+```tsx
+
+ Archive
+ Report
+ Snooze
+
+```
+
+The group squashes the inner borders and rounds only the outer corners, so a
+handful of buttons reads as one control. No extra props on the buttons - they
+just know they are in a group.
+
+## Orientation
+
+Set `orientation="vertical"` to stack the buttons. Great for zoom controls on a
+map, less great for a full sentence.
+
+```tsx
+
+
+
+
+
+
+
+
+```
+
+## Nested groups
+
+Put groups inside a group and they get a small gap between them - the classic
+toolbar look.
+
+```tsx
+
+
+
+
+
+
+
+ Archive
+ Report
+
+
+```
+
+## Separator
+
+Outline buttons already have borders to divide them. Variants without a border,
+like `secondary`, look like one big blob - add a `ButtonGroupSeparator` between
+them.
+
+```tsx
+
+
+
+
+
+
+
+
+
+```
+
+## With text
+
+`ButtonGroupText` renders a non-interactive segment, perfect for a counter or a
+prefix. Use `render` to swap the element, for example to a ``.
+
+```tsx
+
+
+
+
+ {quantity}
+
+
+
+
+```
+
+## With input
+
+An `Input` inside a group stretches to fill the free space, so the button stays
+snug on the end.
+
+```tsx
+
+
+
+
+
+
+```
+
+## With dropdown menu
+
+Pair a button with a [Dropdown Menu](/docs/ui/dropdown-menu) trigger to build a
+split button: the main action on the left, the "but also..." options on the
+right.
+
+```tsx
+
+ Snooze
+
+
+ }
+ >
+
+
+
+ Later today
+ Next week
+
+
+
+```
+
+## Sizes
+
+The group follows the size of its buttons - use the same `size` on every button
+in it so the heights match.
+
+```tsx
+
+
+ Small
+
+
+
+
+
+```
+
+## Accessibility
+
+- The group renders with `role="group"`. Give it an `aria-label` (or
+ `aria-labelledby`) so screen readers announce what the buttons are for.
+- Icon-only buttons need their own `aria-label` - the `Button` types will remind
+ you if you forget.
+- A focused button is lifted above its neighbours, so the focus ring is never
+ hidden behind the next segment.
+- Borders and corners use logical properties, so groups flip correctly in
+ right-to-left languages.
+- Need a single selected option, like a segmented control? Reach for
+ [Toggle Group](/docs/ui/toggle-group) instead - it handles the pressed state
+ for you.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+### ButtonGroup
+
+
+
+### ButtonGroupText
+
+`, for example with a ``.',
+ type: 'ReactElement | (props, state) => ReactElement',
+ },
+ className: {
+ description: 'Extra classes for the text segment.',
+ type: 'string',
+ },
+ }}
+/>
+
+### ButtonGroupSeparator
+
+
+
+## API Reference
+
+[shadcn/ui - Button Group](https://ui.shadcn.com/docs/components/base/button-group)
diff --git a/apps/web/content/docs/ui/button.mdx b/apps/web/content/docs/ui/button.mdx
index e16aeb96a..adc769b80 100644
--- a/apps/web/content/docs/ui/button.mdx
+++ b/apps/web/content/docs/ui/button.mdx
@@ -1,6 +1,7 @@
---
title: Button
description: A button component for triggering actions in your application.
+icon: MousePointerClick
---
## Preview
diff --git a/apps/web/content/docs/ui/card.mdx b/apps/web/content/docs/ui/card.mdx
index dd7077b23..2c67793f7 100644
--- a/apps/web/content/docs/ui/card.mdx
+++ b/apps/web/content/docs/ui/card.mdx
@@ -1,6 +1,7 @@
---
title: Card
description: Display content in a card layout.
+icon: RectangleHorizontal
---
## Preview
@@ -31,7 +32,80 @@ import {
This is the content of the card. You can put any content here.
- Action Button
+ Action
```
+
+`CardFooter` comes with a top border and a soft background out of the box, so it always reads as the "what now?" part of the card.
+
+## Header with border
+
+Add `border-b` to `CardHeader` and it picks up matching bottom padding on its own.
+
+
+
+```tsx
+
+ Header with Border
+ This is a card with a header that has a bottom border.
+
+```
+
+## Header and footer
+
+Combine a bordered header with the footer for a card split into three clear parts.
+
+
+
+```tsx
+
+
+ Header with Border
+
+ ...
+
+
+ Action
+
+
+
+```
+
+## Border separation
+
+Drop the card padding and let the content draw the lines. `CardAction` keeps the header button on the right.
+
+
+
+```tsx
+
+
+ Header
+
+ Action
+
+
+ ...
+
+
+ Action
+
+
+
+```
+
+## With a link
+
+A slim footer is a nice home for a "read more" style link.
+
+
+
+```tsx
+
+
+ See our guideline
+
+
+
+```
diff --git a/apps/web/content/docs/ui/carousel.mdx b/apps/web/content/docs/ui/carousel.mdx
new file mode 100644
index 000000000..6425b24e7
--- /dev/null
+++ b/apps/web/content/docs/ui/carousel.mdx
@@ -0,0 +1,226 @@
+---
+title: Carousel
+description: A swipeable carousel with motion and keyboard support, built on Embla Carousel.
+icon: GalleryHorizontal
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselNext,
+ CarouselPrevious,
+} from '@vitnode/core/components/ui/carousel'
+```
+
+```tsx
+
+
+ ...
+ ...
+ ...
+
+
+
+
+```
+
+Swipe it, drag it, or click the arrows - [Embla Carousel](https://www.embla-carousel.com)
+does the heavy lifting, so the motion feels like a native app and not a slideshow
+from 2009.
+
+By default the arrows sit just outside the slides, so leave some room around
+the carousel (for example `px-12`). On narrow screens, move them into a row of
+their own with `static translate-y-0`, like the preview does.
+
+## Sizes
+
+Each `CarouselItem` takes the full width. Change it with a `basis-*` class -
+responsive ones included.
+
+```tsx
+
+
+ ...
+ ...
+ ...
+
+
+```
+
+## Spacing
+
+The gap between slides comes from a padding on `CarouselItem` and a matching
+negative margin on `CarouselContent`. Change both together. They use logical
+properties, so right-to-left layouts get the same gap on the correct side.
+
+```tsx
+
+
+ ...
+ ...
+ ...
+
+
+```
+
+## Orientation
+
+Set `orientation="vertical"` to scroll up and down. Give `CarouselContent` a
+fixed height, and use `-mt-*` / `pt-*` for spacing instead.
+
+```tsx
+
+
+ ...
+ ...
+ ...
+
+
+
+
+```
+
+## Options
+
+`opts` goes straight to Embla. A few favourites: `loop` for an endless ride,
+`align` to snap slides to the `start`, `center` or `end`, and `dragFree` for a
+free-scrolling feel.
+
+```tsx
+...
+```
+
+For a right-to-left page, tell Embla too - the layout flips on its own, but the
+scroll math needs the hint:
+
+```tsx
+
+ ...
+
+```
+
+## API
+
+Grab the Embla instance with `setApi` to build your own controls, such as the
+dots and the "slide x of y" counter.
+
+```tsx
+import type { CarouselApi } from '@vitnode/core/components/ui/carousel'
+
+const [api, setApi] = React.useState()
+const [current, setCurrent] = React.useState(0)
+
+React.useEffect(() => {
+ if (!api) return
+
+ const onSelect = () => {
+ setCurrent(api.selectedScrollSnap())
+ }
+
+ api.on('select', onSelect)
+
+ return () => {
+ api.off('select', onSelect)
+ }
+}, [api])
+
+
+ ...
+
+
+ Slide {current + 1} of {api?.scrollSnapList().length ?? 0}
+
+```
+
+`api.scrollTo(index)` jumps to a slide, which is all a dot button needs.
+Building a control inside the carousel? `useCarousel()` hands you `api`,
+`scrollPrev`, `scrollNext`, `canScrollPrev` and `canScrollNext` without any
+prop drilling.
+
+## Plugins
+
+Pass Embla plugins through `plugins`. Autoplay, for example:
+
+import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
+
+
+
+```bash tab="bun"
+bun i embla-carousel-autoplay
+```
+
+```bash tab="pnpm"
+pnpm i embla-carousel-autoplay
+```
+
+```bash tab="npm"
+npm i embla-carousel-autoplay
+```
+
+
+
+```tsx
+import Autoplay from 'embla-carousel-autoplay'
+
+export const Slideshow = () => (
+
+ ...
+
+)
+```
+
+Keep `stopOnInteraction` on, and skip autoplay for people who prefer reduced
+motion - nobody likes chasing a slide that runs away.
+
+## Accessibility
+
+- The root is a `region` with `aria-roledescription="carousel"`; give it an
+ `aria-label` that says what is inside.
+- Every slide is a `group` with `aria-roledescription="slide"`. Labels like
+ `aria-label="2 of 5"` help screen readers keep count.
+- The arrow buttons are labelled "Previous" and "Next" in the current language
+ and turn disabled when there is nowhere left to go.
+- With focus inside the carousel, the left and right arrow keys move between
+ slides.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+ void',
+ },
+ }}
+/>
+
+`CarouselPrevious` and `CarouselNext` accept every [Button](/docs/ui/button)
+prop and default to `variant="outline"` with `size="icon-sm"`.
+
+## API Reference
+
+[Embla Carousel - API](https://www.embla-carousel.com/api/)
diff --git a/apps/web/content/docs/ui/cascader.mdx b/apps/web/content/docs/ui/cascader.mdx
new file mode 100644
index 000000000..359edfb77
--- /dev/null
+++ b/apps/web/content/docs/ui/cascader.mdx
@@ -0,0 +1,207 @@
+---
+title: Cascader
+description: Pick one option from a tree - continent, then country, then city - drilling in one level at a time.
+icon: ListTree
+---
+
+## Preview
+
+
+
+## Usage
+
+import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
+
+
+
+
+
+
+```ts
+import { z } from 'zod'
+import { AutoForm } from '@vitnode/core/components/form/auto-form'
+import { AutoFormCascader } from '@vitnode/core/components/form/fields/cascader'
+```
+
+```ts
+const formSchema = z.object({
+ office: z.string('Pick an office to continue'),
+ backupOffice: z.string().optional(),
+})
+```
+
+```tsx
+ (
+
+ ),
+ },
+ ]}
+/>
+```
+
+The form stores the **value of the picked leaf** (`"krakow"`), not the whole
+path. Optional fields get a clear button automatically.
+
+
+
+
+
+```ts
+import { Cascader } from '@vitnode/core/components/ui/cascader'
+```
+
+```tsx
+const [office, setOffice] = React.useState(null)
+
+
+```
+
+
+
+
+## Options
+
+Options are a plain tree. Anything with `children` is a branch that opens the
+next column; anything without is a leaf you can pick.
+
+```ts
+import type { CascaderOption } from '@vitnode/core/components/ui/cascader'
+
+const locations: CascaderOption[] = [
+ {
+ label: 'Europe',
+ value: 'europe',
+ children: [
+ {
+ label: 'Poland',
+ value: 'poland',
+ children: [
+ { label: 'Warsaw', value: 'warsaw' },
+ { label: 'Kraków', value: 'krakow' },
+ ],
+ },
+ { label: 'Iceland', value: 'iceland', disabled: true },
+ ],
+ },
+]
+```
+
+`onValueChange` hands you the full path too, for when you need "Europe / Poland"
+as well as the city:
+
+```tsx
+ {
+ console.log(value, path.map(option => option.label))
+ }}
+ options={locations}
+/>
+```
+
+## Search
+
+`searchable` adds a search box that looks through every leaf at once - type
+"krak" and "Europe / Poland / Kraków" is one click away. Accents don't matter,
+so "sao" finds São Paulo.
+
+## Drill down or columns
+
+By default, clicking a branch **replaces the list** with its children - the
+next level slides in, and a back button at the top takes you up again. It keeps
+the popup the width of the field, wherever it sits.
+
+Prefer to see every level at once? `layout="columns"` opens each level in a new
+column beside the last one, and the popup grows smoothly to make room:
+
+```tsx
+
+```
+
+In the columns layout you can also open branches on hover with
+`expandTrigger="hover"`. On phones the cascader always drills down - there's no
+room for columns on a 375px screen.
+
+## Transitions
+
+Each level slides in from the side it came from - forward when you go deeper,
+back when you return - and in the columns layout the popup's width animates as
+columns open. With reduced motion turned on, everything switches instantly.
+
+## Accessibility
+
+- Every level is a listbox you can drive with the keyboard: arrow up and down
+ to move, arrow right (left in RTL) or Enter to open a branch,
+ arrow left to go back, Home / End to jump, and
+ Escape to close.
+- Branches tell screen readers whether they're open, and the picked path is
+ marked as selected.
+- Give the cascader a label - `AutoFormCascader` does it for you; on its own,
+ use `aria-labelledby`.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+ void',
+ },
+ searchable: {
+ description: 'Adds a search box that finds leaves across the whole tree.',
+ type: 'boolean',
+ default: 'false',
+ },
+ layout: {
+ description:
+ 'Drill into one level at a time, or show every level side by side. Phones always drill down.',
+ type: '"drill" | "columns"',
+ default: '"drill"',
+ },
+ expandTrigger: {
+ description:
+ 'In the columns layout, open branches on click or as soon as the pointer is over them.',
+ type: '"click" | "hover"',
+ default: '"click"',
+ },
+ showClear: {
+ description: 'Shows a button that clears the value.',
+ type: 'boolean',
+ default: 'false',
+ },
+ separator: {
+ description: 'Placed between labels in the trigger and in search results.',
+ type: 'string',
+ default: '" / "',
+ },
+ placeholder: {
+ description: 'Shown while nothing is picked.',
+ type: 'string',
+ },
+ }}
+/>
diff --git a/apps/web/content/docs/ui/chart.mdx b/apps/web/content/docs/ui/chart.mdx
new file mode 100644
index 000000000..9f34ee637
--- /dev/null
+++ b/apps/web/content/docs/ui/chart.mdx
@@ -0,0 +1,268 @@
+---
+title: Chart
+description: Beautiful, themeable charts built on Recharts - bar, area, line and friends, with tooltips and legends that match the rest of your UI.
+icon: ChartColumn
+---
+
+## Preview
+
+
+
+## Installation
+
+The chart components wrap [Recharts](https://recharts.github.io/), and you
+build the chart itself with Recharts components. Add it to your app next to
+`@vitnode/core`:
+
+import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
+
+
+
+```bash tab="bun"
+bun i recharts
+```
+
+```bash tab="pnpm"
+pnpm i recharts
+```
+
+```bash tab="npm"
+npm i recharts
+```
+
+
+
+## Usage
+
+```ts
+import type { ChartConfig } from '@vitnode/core/components/ui/chart'
+
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from '@vitnode/core/components/ui/chart'
+import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts'
+```
+
+```tsx
+const chartData = [
+ { month: 'January', desktop: 186, mobile: 80 },
+ { month: 'February', desktop: 305, mobile: 200 },
+ { month: 'March', desktop: 237, mobile: 120 },
+]
+
+const chartConfig = {
+ desktop: { label: 'Desktop', color: 'var(--chart-1)' },
+ mobile: { label: 'Mobile', color: 'var(--chart-2)' },
+} satisfies ChartConfig
+
+
+
+
+
+ } />
+
+
+
+
+```
+
+We don't hide Recharts behind a wrapper of our own. You write plain Recharts and
+borrow only what you need: the container, the tooltip and the legend. When
+Recharts ships a new chart type, you can use it the same day.
+
+`ChartContainer` sizes the chart responsively and defaults to a 16:9 box
+(`aspect-video`). Pass a `className` such as `aspect-square` or `h-64 w-full`
+to change it.
+
+## Chart config
+
+The config is where the chart's words and colours live, kept apart from your
+data. Each key matches a `dataKey` in your data:
+
+```tsx
+import { Monitor, Smartphone } from 'lucide-react'
+
+const chartConfig = {
+ desktop: {
+ label: 'Desktop',
+ icon: Monitor,
+ color: 'var(--chart-1)',
+ },
+ mobile: {
+ label: 'Mobile',
+ icon: Smartphone,
+ theme: {
+ light: 'var(--chart-2)',
+ dark: 'var(--chart-4)',
+ },
+ },
+} satisfies ChartConfig
+```
+
+- `label` is what the tooltip and legend show, so `desktop` reads as "Desktop".
+- `icon` swaps the colour swatch in the tooltip and legend for an icon.
+- `color` sets one colour for both themes. Use `theme` instead when light and
+ dark need different colours.
+
+## Theming
+
+Your theme ships five chart colours, `--chart-1` to `--chart-5`, with values
+for light and dark mode. Use them in the config and the charts follow the
+theme on their own:
+
+```css
+:root {
+ --chart-1: oklch(0.52 0.2 264);
+ --chart-2: oklch(0.52 0.128 155);
+}
+
+.dark {
+ --chart-1: oklch(0.7 0.14 264);
+ --chart-2: oklch(0.758 0.153 155);
+}
+```
+
+For each config key, `ChartContainer` creates a `--color-` variable, scoped
+to that one chart. Use it anywhere Recharts takes a colour:
+
+```tsx
+
+```
+
+It works in Tailwind classes too, for example `fill-(--color-desktop)`. Two or
+three colours per chart is plenty - your readers have eyes, not a rainbow
+decoder ring.
+
+## Area chart
+
+
+
+Stack areas with the same `stackId`, and add a fade with a `linearGradient`
+that uses the chart's colour variables:
+
+```tsx
+
+
+
+
+
+
+
+
+
+ } cursor={false} />
+
+
+```
+
+## Line chart
+
+
+
+```tsx
+
+
+
+ } />
+
+
+```
+
+## Tooltip
+
+`ChartTooltip` is the Recharts `Tooltip`, and `ChartTooltipContent` gives it a
+look that matches the rest of VitNode. It reads labels, icons and colours from
+your chart config.
+
+```tsx
+ } />
+```
+
+Hover over the previews above to see all three indicators: `dashed` in the bar
+chart, `dot` in the area chart and `line` in the line chart.
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+ ReactNode',
+ },
+ formatter: {
+ description: 'Renders each row yourself, replacing the default layout.',
+ type: '(value, name, item, index, payload) => ReactNode',
+ },
+ }}
+/>
+
+## Legend
+
+`ChartLegend` is the Recharts `Legend`, and `ChartLegendContent` renders the
+labels and colours (or icons) from your config:
+
+```tsx
+import {
+ ChartLegend,
+ ChartLegendContent,
+} from '@vitnode/core/components/ui/chart'
+```
+
+```tsx
+ } />
+```
+
+Use `nameKey` to read legend labels from another key - handy for pie charts,
+where each slice is a row in your data rather than a series.
+
+## Accessibility
+
+- `accessibilityLayer` adds ARIA attributes and keyboard support. Tab to a
+ chart, then use the arrow keys to move between points - the tooltip follows
+ along. It is on by default in Recharts 3, but writing it out makes the
+ intent clear.
+- Give each chart a `title` and a `desc`. Recharts renders them as the SVG's
+ `` and ``, so screen readers get more than "graphic".
+- Keep a visible heading next to the chart (a `CardTitle` works well), and
+ never rely on colour alone - labels in the tooltip and legend tell series
+ apart for everyone.
+
+## API Reference
+
+[Recharts - API](https://recharts.github.io/en-US/api/)
diff --git a/apps/web/content/docs/ui/checkbox.mdx b/apps/web/content/docs/ui/checkbox.mdx
index 8f00b7bb2..a174389cf 100644
--- a/apps/web/content/docs/ui/checkbox.mdx
+++ b/apps/web/content/docs/ui/checkbox.mdx
@@ -1,6 +1,7 @@
---
title: Checkbox
description: Toggle between checked and unchecked states.
+icon: SquareCheck
---
## Preview
diff --git a/apps/web/content/docs/ui/color.mdx b/apps/web/content/docs/ui/color.mdx
index e81f63c98..50e2dec30 100644
--- a/apps/web/content/docs/ui/color.mdx
+++ b/apps/web/content/docs/ui/color.mdx
@@ -1,6 +1,7 @@
---
title: Color
description: Color picker field with a preset palette and a free-form picker, both storing HSL.
+icon: Pipette
---
## Preview
diff --git a/apps/web/content/docs/ui/colors.mdx b/apps/web/content/docs/ui/colors.mdx
index 2fd73b43d..82ea17b37 100644
--- a/apps/web/content/docs/ui/colors.mdx
+++ b/apps/web/content/docs/ui/colors.mdx
@@ -1,6 +1,7 @@
---
title: Colors
description: A collection of color utilities and components for consistent theming across your application.
+icon: Palette
---
diff --git a/apps/web/content/docs/ui/combobox.mdx b/apps/web/content/docs/ui/combobox.mdx
index fe6c208ec..db167896e 100644
--- a/apps/web/content/docs/ui/combobox.mdx
+++ b/apps/web/content/docs/ui/combobox.mdx
@@ -1,6 +1,7 @@
---
title: Combobox
description: Select from a list of options with a search input.
+icon: ChevronsUpDown
---
## Preview
@@ -157,6 +158,135 @@ export function ComboboxBasic() {
+## Groups and clear button
+
+
+
+Give options a `group` and they're sorted under headings - handy once a list
+gets long enough to scroll. `showClear` adds an × that empties the field in one
+click.
+
+```tsx
+
+```
+
+Groups appear in the order they first show up in `labels`, and options without
+a `group` gather at the top with no heading.
+
+## Multiple
+
+
+
+Pass `multiple` and use an array in the schema. Picked options pop in as
+chips, and removing one shrinks it away while the rest slide over to close the
+gap - the box keeps its width and simply wraps onto a new line when it's full.
+With reduced motion turned on, chips just appear and disappear.
+
+### Limit visible chips
+
+Picked a dozen tags? Set `maxVisibleChips` and only the first few show, followed
+by a "+N more" chip that counts the rest. Every pick is still saved - the
+limit is purely visual.
+
+```tsx
+
+```
+
+Keyboard tips: Backspace in an empty search removes the last chip,
+and Escape with the list closed clears every pick - that's the
+standard combobox pattern, so it's worth knowing before you press it twice.
+
+
+
+
+```ts
+const formSchema = z.object({
+ topics: z
+ .array(z.enum(["news", "guides", "releases"]))
+ .min(1, "Pick at least one topic")
+ .max(3, "Three topics is plenty"),
+});
+```
+
+```tsx
+
+```
+
+`.min()` and `.max()` on the array become the validation messages, and
+`fetchData` works with `multiple` too - the value is then an array of
+`{ value, label }` objects.
+
+
+
+
+
+```tsx
+import {
+ Combobox,
+ ComboboxChip,
+ ComboboxChips,
+ ComboboxChipsInput,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxItem,
+ ComboboxList,
+ ComboboxValue,
+ useComboboxAnchor,
+} from "@vitnode/core/components/ui/combobox";
+
+export function ComboboxMultiple() {
+ const anchor = useComboboxAnchor();
+
+ return (
+
+
+
+ {(values: string[]) => (
+ <>
+ {values.map(value => (
+ {value}
+ ))}
+
+ >
+ )}
+
+
+
+ No items found.
+
+ {item => (
+
+ {item}
+
+ )}
+
+
+
+ );
+}
+```
+
+
+
+
## Props
import { TypeTable } from "fumadocs-ui/components/type-table";
@@ -187,8 +317,8 @@ import { TypeTable } from "fumadocs-ui/components/type-table";
},
labels: {
description:
- "An array of options for the combobox, each with a value and label. Used for static options.",
- type: "Array<{ value: string; label: string }>",
+ "An array of options for the combobox, each with a value, label and optional group heading. Used for static options.",
+ type: "Array<{ value: string; label: string; group?: string }>",
default: "[]",
},
placeholder: {
@@ -206,6 +336,17 @@ import { TypeTable } from "fumadocs-ui/components/type-table";
type: "boolean",
default: "false",
},
+ showClear: {
+ description: "Shows a button that clears the selection.",
+ type: "boolean",
+ default: "false",
+ },
+ maxVisibleChips: {
+ description:
+ "With `multiple`, shows only this many chips plus a \"+N more\" chip for the rest.",
+ type: "number",
+ default: "",
+ },
multiple: {
description:
"Whether multiple selections are allowed. Not used when fetchData is provided.",
diff --git a/apps/web/content/docs/ui/command.mdx b/apps/web/content/docs/ui/command.mdx
new file mode 100644
index 000000000..2607aa655
--- /dev/null
+++ b/apps/web/content/docs/ui/command.mdx
@@ -0,0 +1,303 @@
+---
+title: Command
+description: A fast, composable command menu with search, groups and keyboard navigation - the "just type it" escape hatch for your app.
+icon: Command
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+ CommandShortcut,
+} from '@vitnode/core/components/ui/command'
+```
+
+```tsx
+
+
+
+ No results found.
+
+ Calendar
+ Search Emoji
+
+
+
+```
+
+Start typing and the list filters itself, ranking the best matches first.
+Groups with nothing left to show politely step aside. It is built on
+[cmdk](https://cmdk.paco.me), so every cmdk prop works here too.
+
+## Groups and separators
+
+Wrap related items in a `CommandGroup` with a `heading`, and drop a
+`CommandSeparator` between groups when they need some breathing room.
+
+```tsx
+
+ Calendar
+
+
+
+ Profile
+
+```
+
+## Icons and shortcuts
+
+Icons go first and are sized for you. `CommandShortcut` pushes a hint to the
+end of the row. It is just a label - wiring the actual shortcut is up to you.
+
+```tsx
+
+
+ Profile
+ ⌘P
+
+```
+
+## Empty state
+
+`CommandEmpty` only shows up when the search matches nothing. Make it helpful,
+or at least make it kind.
+
+```tsx
+No results found.
+```
+
+## Selecting and disabling items
+
+`onSelect` fires on click and on Enter . Add `disabled` to keep an
+item visible but out of reach - the keyboard skips it too.
+
+```tsx
+ router.navigate({ to: '/settings' })}>
+ Settings
+
+Calculator
+```
+
+Items are matched by their text. Give an item a `value` when the text is not
+unique, and `keywords` when people might search for it by another name:
+
+```tsx
+
+ Toggle dark mode
+
+```
+
+## Dialog
+
+
+
+`CommandDialog` puts the whole thing in a [Dialog](/docs/ui/dialog), with a
+screen-reader-only title and description baked in. Pass a `Command` as its
+child and control it with `open` and `onOpenChange`.
+
+```tsx
+const [open, setOpen] = React.useState(false)
+
+React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key.toLowerCase() !== 'j') return
+ if (!event.metaKey && !event.ctrlKey) return
+ if (event.altKey || event.shiftKey || event.defaultPrevented) return
+
+ event.preventDefault()
+ setOpen((prev) => !prev)
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+
+ return () => window.removeEventListener('keydown', handleKeyDown)
+}, [])
+
+
+
+
+
+ Nothing here. Try fewer letters?
+
+ setOpen(false)}>Sign out
+
+
+
+
+```
+
+
+ This docs site already uses ⌘K / Ctrl+K for search, and
+ so does the AdminCP. Two dialogs fighting over one shortcut is nobody's idea
+ of fun, so the demo listens for ⌘J / Ctrl+J instead.
+ Pick whatever is free in your app, and always remove the listener on unmount.
+ Heads-up: once the palette is open, Ctrl+J moves down a row
+ instead of closing it - Escape is the way out.
+
+
+Show the shortcut next to the trigger with the `Kbd` component:
+
+```tsx
+import { Kbd, KbdGroup } from '@vitnode/core/components/ui/kbd'
+
+
+ ⌘
+ J
+
+```
+
+## Your own filtering
+
+Fetching results from an API? Turn the built-in filter off with
+`shouldFilter={false}` and render whatever your query returns. The AdminCP
+search works exactly like that.
+
+```tsx
+
+
+
+ {users.map(user => (
+
+ {user.name}
+
+ ))}
+
+
+```
+
+## Accessibility
+
+- The input is a `combobox` and the list a `listbox`, so screen readers announce
+ the highlighted option as you move through it.
+- ↑ / ↓ move between items, Home /{' '}
+ End jump to the ends and Enter selects. Set `loop` to
+ wrap around at the edges.
+- Ctrl+N / Ctrl+J and Ctrl+P /{' '}
+ Ctrl+K also move up and down while the menu has focus. Pass
+ `vimBindings={false}` if those keys mean something else in your app.
+- `CommandDialog` traps focus, closes on Escape and gives the dialog
+ an accessible name through its `title` and `description`.
+- Give a standalone `Command` a `label` so the input has a name too.
+
+## Props
+
+### Command
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+ number',
+ },
+ value: {
+ description: 'Controls the highlighted item.',
+ type: 'string',
+ },
+ onValueChange: {
+ description: 'Called when the highlighted item changes.',
+ type: '(value: string) => void',
+ },
+ loop: {
+ description: 'Wrap the keyboard selection around at the top and bottom.',
+ type: 'boolean',
+ default: 'false',
+ },
+ vimBindings: {
+ description: 'Enable the `Ctrl+N/J/P/K` navigation shortcuts.',
+ type: 'boolean',
+ default: 'true',
+ },
+ }}
+/>
+
+### CommandDialog
+
+ void',
+ },
+ title: {
+ description: 'Screen-reader-only dialog title.',
+ type: 'string',
+ default: '"Command Palette"',
+ },
+ description: {
+ description: 'Screen-reader-only dialog description.',
+ type: 'string',
+ default: '"Search for a command to run..."',
+ },
+ showCloseButton: {
+ description: 'Show the close button in the corner.',
+ type: 'boolean',
+ default: 'false',
+ },
+ className: {
+ description: 'Classes for the dialog content.',
+ type: 'string',
+ },
+ }}
+/>
+
+### CommandItem
+
+ void',
+ },
+ value: {
+ description:
+ 'A unique value used for filtering and selection. Defaults to the text content.',
+ type: 'string',
+ },
+ keywords: {
+ description: 'Extra words the item should match.',
+ type: 'string[]',
+ },
+ disabled: {
+ description: 'Keep the item visible but not selectable.',
+ type: 'boolean',
+ default: 'false',
+ },
+ forceMount: {
+ description: 'Always render the item, even when it does not match.',
+ type: 'boolean',
+ default: 'false',
+ },
+ }}
+/>
+
+## API Reference
+
+[cmdk](https://github.com/pacocoursey/cmdk#parts-and-styling)
diff --git a/apps/web/content/docs/ui/comparison.mdx b/apps/web/content/docs/ui/comparison.mdx
new file mode 100644
index 000000000..c3f78a65f
--- /dev/null
+++ b/apps/web/content/docs/ui/comparison.mdx
@@ -0,0 +1,102 @@
+---
+title: Comparison
+description: A before/after slider - drag the handle to reveal one image over another.
+icon: Columns2
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import {
+ Comparison,
+ ComparisonHandle,
+ ComparisonItem,
+} from '@vitnode/core/components/ui/comparison'
+```
+
+```tsx
+
+
+
+
+
+
+
+
+
+```
+
+`position="left"` is the side shown to the left of the handle, `right` the side
+shown to its right. Images inside an item fill it with `object-cover`, so give
+the `Comparison` a size or an aspect ratio and both pictures line up.
+
+## Hover mode
+
+With `mode="hover"` the handle follows the pointer - no clicking needed. Nice
+for quick, playful comparisons; for anything precise, stick with dragging.
+
+```tsx
+...
+```
+
+## Starting point and changes
+
+```tsx
+ console.log(position)}
+>
+ ...
+
+```
+
+`position` goes from 0 (all right image) to 100 (all left image).
+
+## Custom handle
+
+`ComparisonHandle` draws a line with a grip by default. Pass children to draw
+your own - it already follows the slider for you.
+
+```tsx
+
+
+
+```
+
+## Accessibility
+
+- The whole comparison is a slider: focus it with Tab , then use the
+ arrow keys (5% a step, 10% with Shift ), Home and
+ End .
+- Screen readers hear the position as a percentage. Name the slider with
+ `aria-label` - "Light and dark theme" says more than the default.
+- The images are decorative inside the slider (`alt=""`); describe what's being
+ compared in the label or a caption.
+- Dragging sideways never blocks scrolling the page up and down on phones.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+ void',
+ },
+ }}
+/>
diff --git a/apps/web/content/docs/ui/confirm-action-alert-dialog.mdx b/apps/web/content/docs/ui/confirm-action-alert-dialog.mdx
index 785e9d8e3..1c65df295 100644
--- a/apps/web/content/docs/ui/confirm-action-alert-dialog.mdx
+++ b/apps/web/content/docs/ui/confirm-action-alert-dialog.mdx
@@ -1,6 +1,7 @@
---
title: Confirm Action Alert Dialog
description: A dialog component for confirming destructive actions like delete operations, built on top of the Alert Dialog.
+icon: ShieldAlert
---
The Confirm Action Alert Dialog is a specialized dialog component built on top of the [Alert Dialog](/docs/ui/alert-dialog) that provides a consistent way to confirm potentially destructive or irreversible actions before they're executed.
@@ -75,6 +76,11 @@ import { TypeTable } from 'fumadocs-ui/components/type-table';
type: 'ReactNode',
default: 'This action cannot be undone.',
},
+ icon: {
+ description:
+ 'Icon shown above the title. Switches the dialog to the compact, centered layout.',
+ type: 'ReactNode',
+ },
textSubmit: {
description:
'Custom text for the submit button. Defaults to translated "Confirm".',
diff --git a/apps/web/content/docs/ui/context-menu.mdx b/apps/web/content/docs/ui/context-menu.mdx
new file mode 100644
index 000000000..9d0eff30d
--- /dev/null
+++ b/apps/web/content/docs/ui/context-menu.mdx
@@ -0,0 +1,235 @@
+---
+title: Context Menu
+description: A menu that opens on right-click or long-press, with shortcuts, submenus, checkbox and radio items.
+icon: MousePointerClick
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuShortcut,
+ ContextMenuTrigger,
+} from '@vitnode/core/components/ui/context-menu'
+```
+
+```tsx
+
+
+ Right-click here
+
+
+
+ Reload
+ ⌘R
+
+ Rename
+
+ Delete
+
+
+```
+
+Right-click the trigger with a mouse, or press and hold it on a phone or
+tablet. The menu opens right where your cursor (or finger) is - no button
+required, no hunting around.
+
+## Submenus
+
+Nest a `ContextMenuSub` to tuck the rarely used stuff one level deeper. It opens
+on hover, on click, or with the arrow keys.
+
+```tsx
+import {
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+} from '@vitnode/core/components/ui/context-menu'
+
+
+ More tools
+
+ Save page as...
+ Developer tools
+
+
+```
+
+## Checkbox and radio items
+
+Use `ContextMenuCheckboxItem` for independent toggles and a
+`ContextMenuRadioGroup` when exactly one option can win. Both work controlled or
+uncontrolled, just like their form siblings.
+
+```tsx
+import {
+ ContextMenuCheckboxItem,
+ ContextMenuLabel,
+ ContextMenuRadioGroup,
+ ContextMenuRadioItem,
+} from '@vitnode/core/components/ui/context-menu'
+
+const [showBookmarks, setShowBookmarks] = React.useState(true)
+const [person, setPerson] = React.useState('pedro')
+
+
+ Show bookmarks bar
+
+
+People
+
+ Pedro Duarte
+ Colm Tuite
+
+```
+
+The check mark sits at the end of the row. Add `inset` to items, labels and
+sub-triggers without an icon so their text lines up with the ones that have
+one.
+
+## Shortcuts
+
+`ContextMenuShortcut` shows a keyboard hint at the end of an item. It is only a
+label - wire the actual key binding yourself, otherwise it is a promise you are
+not keeping.
+
+```tsx
+
+ Save page as...
+ ⇧⌘S
+
+```
+
+## Destructive items
+
+Set `variant="destructive"` for actions you cannot take back, so they look the
+part.
+
+```tsx
+
+
+ Delete
+
+```
+
+## Accessibility
+
+- Context menus are hidden by nature: nothing on screen says "right-click me".
+ Every action inside one must also be reachable another way, such as a
+ toolbar, a [Dropdown Menu](/docs/ui/dropdown-menu) or a keyboard shortcut.
+ Treat the context menu as a shortcut, never as the only door.
+- The trigger is a plain, non-focusable area, so keyboard users cannot open
+ the menu at all - one more reason for the rule above. Once it is open,
+ arrow keys move between items, the right arrow opens a submenu, typing a
+ letter jumps to a matching item and `Escape` closes it.
+- On touch devices a long-press opens the menu, and the trigger sets
+ `select-none` so holding it does not start a text selection.
+- Disabled items stay visible and are announced as disabled, so nobody wonders
+ where "Forward" went.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+### ContextMenu
+
+ void',
+ },
+ disabled: {
+ description: 'Stops the menu from opening.',
+ type: 'boolean',
+ default: 'false',
+ },
+ }}
+/>
+
+### ContextMenuContent
+
+
+
+### ContextMenuItem
+
+ void',
+ },
+ }}
+/>
+
+`ContextMenuCheckboxItem` takes `checked`, `defaultChecked` and
+`onCheckedChange`; `ContextMenuRadioGroup` takes `value`, `defaultValue` and
+`onValueChange`. Both item types accept `inset` and `disabled` too.
+
+## API Reference
+
+[Base UI - Context Menu](https://base-ui.com/react/components/context-menu#api-reference)
diff --git a/apps/web/content/docs/ui/copy-button.mdx b/apps/web/content/docs/ui/copy-button.mdx
new file mode 100644
index 000000000..011089295
--- /dev/null
+++ b/apps/web/content/docs/ui/copy-button.mdx
@@ -0,0 +1,121 @@
+---
+title: Copy Button
+description: A button that copies text to the clipboard, with a smooth icon swap and a check mark that draws itself.
+icon: Copy
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import { CopyButton } from '@vitnode/core/components/ui/copy-button'
+```
+
+```tsx
+
+```
+
+Click it and the copy icon blurs away while a check mark draws itself in - the
+same stroke animation as the [Checkbox](/docs/ui/checkbox). After `delay`
+milliseconds it quietly goes back to the copy icon, ready for round two.
+
+## With a label
+
+Pass children to show text next to the icon. The text becomes the button's
+accessible name, so screen readers hear what you see.
+
+```tsx
+
+ Copy invite link
+
+```
+
+## Icon only
+
+Without children the button renders as an icon. It labels itself "Copy" and
+"Copied" out of the box, but a specific label is friendlier when there are a few
+copy buttons on one screen:
+
+```tsx
+
+```
+
+## Controlled
+
+Own the copied state with `copied` and `onCopiedChange` - handy when something
+else on the page should react to the copy.
+
+```tsx
+const [copied, setCopied] = React.useState(false)
+
+
+```
+
+## Accessibility
+
+- A polite live region announces "Copied" after a successful copy, so the
+ change is not just a visual one.
+- With reduced motion turned on, the icons simply fade and the check mark shows
+ up fully drawn.
+- If the browser blocks clipboard access (for example on a non-HTTPS page), an
+ error toast explains what happened instead of failing silently.
+- Calling `event.preventDefault()` inside your own `onClick` skips the copy.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+ void',
+ },
+ delay: {
+ description: 'How long the check mark stays, in milliseconds.',
+ type: 'number',
+ default: '3000',
+ },
+ variant: {
+ description: 'The variant of the button.',
+ type: 'default | secondary | outline | ghost | link | success | warning | destructive',
+ default: 'outline',
+ },
+ size: {
+ description:
+ 'The size of the button. Defaults to `icon` without children and `default` with them.',
+ type: 'default | xs | sm | lg | icon | icon-xs | icon-sm | icon-lg',
+ },
+ 'aria-label': {
+ description:
+ 'Overrides the accessible name. Without children it defaults to "Copy" / "Copied".',
+ type: 'string',
+ },
+ }}
+/>
+
+## API Reference
+
+[Base UI - Button](https://base-ui.com/react/components/button#api-reference)
diff --git a/apps/web/content/docs/ui/counter.mdx b/apps/web/content/docs/ui/counter.mdx
new file mode 100644
index 000000000..9361cf4d7
--- /dev/null
+++ b/apps/web/content/docs/ui/counter.mdx
@@ -0,0 +1,129 @@
+---
+title: Counter
+description: A plus/minus number picker whose digits roll into place, plus the SlidingNumber behind it.
+icon: Calculator
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import { Counter } from '@vitnode/core/components/ui/counter'
+```
+
+```tsx
+const [quantity, setQuantity] = React.useState(1)
+
+
+```
+
+Each digit rolls on its own, the short way round: going from 9 to 10 rolls the
+last digit forward to 0 while a new 1 slides in, instead of spinning back
+through every number. The buttons give a tiny press, and a button is disabled
+once you hit `min` or `max`, so nobody wonders why clicking does nothing.
+
+Leave out `value` and use `defaultValue` if you only care about changes:
+
+```tsx
+ save(value)} />
+```
+
+## Steps and limits
+
+`step` sets how far one click goes. A step that would jump past a limit stops
+right at it - with `max={10}` and `step={5}`, 8 goes to 10, not 13.
+
+```tsx
+
+```
+
+## Sliding number
+
+
+
+The rolling digits are their own component, so you can animate any number -
+stats, prices, likes - without the buttons:
+
+```ts
+import { SlidingNumber } from '@vitnode/core/components/ui/sliding-number'
+```
+
+```tsx
+
+```
+
+It takes the font size and colour of its surroundings and always uses
+tabular figures, so digits don't wobble as they change. It handles whole
+numbers, positive or negative.
+
+## In a form
+
+Counter is a standalone control. In an AutoForm, the
+[number field](/docs/ui/auto-form) is still the way to collect a number that
+gets saved.
+
+## Accessibility
+
+- The buttons are labelled "Decrease" and "Increase" in the user's language.
+- Screen readers hear the new value after every click, without the rolling
+ digits - those are hidden from assistive tech.
+- Give the counter a name with `aria-label` or `aria-labelledby`, so "Increase"
+ has context: increase _what_?
+- With reduced motion turned on, digits change instantly.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+### Counter
+
+ void',
+ },
+ min: {
+ description: 'The lowest allowed value.',
+ type: 'number',
+ },
+ max: {
+ description: 'The highest allowed value.',
+ type: 'number',
+ },
+ step: {
+ description: 'How much one click adds or removes.',
+ type: 'number',
+ default: '1',
+ },
+ disabled: {
+ description: 'Disables both buttons.',
+ type: 'boolean',
+ default: 'false',
+ },
+ }}
+/>
+
+### SlidingNumber
+
+
diff --git a/apps/web/content/docs/ui/data-table.mdx b/apps/web/content/docs/ui/data-table.mdx
deleted file mode 100644
index 13aac9105..000000000
--- a/apps/web/content/docs/ui/data-table.mdx
+++ /dev/null
@@ -1,587 +0,0 @@
----
-title: Data Table
-description: A table component with sorting, filtering, and pagination compatible with VitNode API.
----
-
-## Preview
-
-
-
-## Usage
-
-```ts
-import {
- type ColumnDef,
- DataTable,
-} from '@vitnode/core/components/table/data-table'
-```
-
-Columns are typed with `ColumnDef`, where `T` is the shape of a single row. An **accessor column** binds to the data through `accessorKey`, which is type-safe - it only accepts a `keyof T`, just like TanStack Table. A **display column** (for actions, selection, etc.) has no `accessorKey` and instead requires a string `id`.
-
-```tsx
-const columns: ColumnDef[] = [
- { accessorKey: 'name', header: 'Name' },
- { accessorKey: 'email', header: 'Email' },
- { accessorKey: 'role', header: 'Role' },
- { accessorKey: 'status', header: 'Status', align: 'center' },
- {
- id: 'actions',
- header: '',
- align: 'right',
- cell: () => (
-
- Edit
-
- ),
- },
-]
-
-;
-```
-
-
- `accessorKey` autocompletes and is checked against the row type `T` - passing
- a key that doesn't exist on `T` is a compile-time error. Use `id` (a plain
- `string`) for display columns that aren't backed by a data field.
-
-
-import { TypeTable } from 'fumadocs-ui/components/type-table'
-
-[] where ColumnDef is:
- // accessor column
- {
- accessorKey: keyof T;
- header: ReactNode;
- id?: string;
- align?: 'left' | 'center' | 'right';
- className?: string;
- cell?: (props: { row: T; allData: T[] }) => ReactNode;
- }
- // or display column
- {
- id: string;
- header: ReactNode;
- align?: 'left' | 'center' | 'right';
- className?: string;
- cell?: (props: { row: T; allData: T[] }) => ReactNode;
- }`,
- },
- edges: {
- description: 'Data to be displayed in the table',
- required: true,
- type: 'Array',
- },
- order: {
- description: 'Column sorting configuration',
- required: true,
- type: `{
- columns?: string[];
- defaultOrder: {
- column: string;
- order: 'asc' | 'desc';
- };
- }`,
- },
- pageInfo: {
- description:
- 'Pagination information from the API. Pass it straight through — the table renders numbered pages and a "Showing 21–30 of 380" range from it.',
- required: true,
- type: `{
- count: number;
- totalCount: number;
- totalPages: number;
- currentPage: number | null;
- hasNextPage: boolean;
- hasPreviousPage: boolean;
- startCursor: string | null;
- endCursor: string | null;
- }`,
- },
- search: {
- description:
- 'Renders a search input above the table. Requires the API route to define the searchable columns.',
- required: false,
- type: 'boolean',
- },
- searchPlaceholder: {
- description: 'Custom placeholder text for the search input',
- required: false,
- type: 'string',
- },
- bulkActions: {
- description:
- 'Actions for the rows a person ticked, rendered in a bar that floats at the bottom centre of the viewport. Passing this turns row selection on: the table grows a leading checkbox column, and the nodes here read the ticked ids with useDataTableSelection().',
- required: false,
- type: 'ReactNode',
- },
- filters: {
- description:
- 'Renders one or more faceted multi-select dropdowns above the table. Each filter controls its own URL query parameter.',
- required: false,
- type: `{
- id: string;
- label: string;
- options?: {
- value: string;
- label: ReactNode;
- keywords?: string[];
- }[];
- onSearch?: (search: string) => Promise<{
- value: string;
- label: ReactNode;
- keywords?: string[];
- }[]>;
- }[]`,
- },
- }}
-/>
-
-## Cell Renderer
-
-You can customize how each cell is rendered using the `cell` property. The renderer function receives the current row data and all table data as parameters.
-
-```tsx
- (
- // [!code ++]
-
- // [!code ++]
- {row.id} - all data {allData.length}
- // [!code ++]
-
- // [!code ++]
- ),
- },
- { accessorKey: 'createdAt', header: 'Created at' },
- ]}
- edges={data.edges}
- pageInfo={data.pageInfo}
- order={{
- columns: ['createdAt', 'id'],
- defaultOrder: {
- order: 'desc',
- },
- }}
-/>
-```
-
-### Order Configuration
-
-If you want to enable sorting on specific columns, you can specify them in the `columns` property.
-
-```tsx
-order={{
- // [!code ++]
- columns: ['createdAt', 'id', 'name'],
- defaultOrder: {
- column: 'createdAt',
- order: 'desc',
- }
-}}
-```
-
-## Column Alignment
-
-Use the `align` property to control the horizontal alignment of a column's header and cells. It accepts `"left"` (default), `"center"`, or `"right"`. This is handy for numeric values and action buttons.
-
-```tsx
-columns={[
- { accessorKey: "name", header: "Name" },
- // [!code ++]
- { accessorKey: "status", header: "Status", align: "center" },
- {
- id: "actions",
- header: "",
- // [!code ++]
- align: "right",
- cell: ({ row }) => ,
- },
-]}
-```
-
-## Search
-
-Set the `search` prop to `true` to render a search input above the table. You can optionally customize the placeholder with `searchPlaceholder`.
-
-```tsx
-
-```
-
-The input writes the term to the `?search=` query parameter (debounced) and reloads the page, so it works out of the box with server-side data fetching. The columns that are actually searched are defined on the API route.
-
-
- Enabling `search` only renders the input. You must also tell the backend which
- columns to search across - see the [Search](/docs/dev/database/search) guide.
-
-
-## Filters
-
-Pass the `filters` prop to render one or more faceted, multi-select dropdowns above the table (next to the search input). Each filter controls its own URL query parameter, so it works out of the box with server-side data fetching. Multiple selected values are stored as a comma-separated list, e.g. `?roleId=1,3`, and changing a filter returns you to the first page.
-
- Promise',
- },
- }}
-/>
-
-### Static filters
-
-When the set of options is small and known ahead of time, pass them directly via `options`. The dropdown list is filtered on the client.
-
-```tsx
-
-```
-
-### Async filters
-
-When the options come from the API (for example a large or searchable list), provide an `onSearch` callback instead of `options`. It runs - debounced - as the user types and should return results already filtered and capped by the server. The universal fetcher answers from the browser here, with the visitor's own cookies:
-
-```tsx title="search-roles.tsx"
-import type { FilterOption } from '@vitnode/core/components/table/filters'
-
-import { RoleFormat } from '@vitnode/core/components/role-format'
-import { fetcher } from '@vitnode/core/tanstack/fetcher'
-
-export const searchRoles = async (search: string): Promise => {
- const res = await fetcher({
- plugin: '@vitnode/core',
- path: '/list',
- method: 'get',
- module: 'admin/roles',
- args: {
- query: { search, first: '20' },
- },
- withPagination: true,
- })
-
- if (res.status !== 200) {
- return []
- }
-
- const data = await res.json()
-
- return data.edges.map((role) => ({
- value: String(role.id),
- label: ,
- keywords: role.name.map((item) => item.name),
- }))
-}
-```
-
-```tsx
-filters={[
- {
- id: "roleId",
- label: "Group",
- // [!code ++]
- onSearch: searchRoles,
- },
-]}
-```
-
-
- A filter only writes its values to the URL - the backend must read the query
- parameter and apply the matching `where` clause. For a comma-separated
- multi-select, split the value and use `inArray`:
-
-```ts
-const roleIds = (query.roleId?.split(",") ?? [])
- .filter(Boolean)
- .map(Number)
- .filter((id) => !Number.isNaN(id));
-
-// pass to withPagination:
-where: roleIds.length ? inArray(core_users.roleId, roleIds) : undefined,
-```
-
-
-
-## Pagination
-
-The table renders its own pager from `pageInfo` — numbered pages with a first/last
-anchor and an ellipsis in between, a rows-per-page select, and a
-"Showing 21–30 of 380" range. You wire up nothing: pass `pageInfo` through and it
-appears.
-
-Each page is a real link (`?page=3`), so middle-click and "open in new tab" work,
-while a plain click stays a client-side navigation. On phones the numbers collapse
-to a "Page 3 of 38" label between the arrows, because forty tap targets do not fit
-on a 390px screen.
-
-Two details worth knowing:
-
-- **Changing the page size or a filter returns you to page one.** A filter changes
- which rows exist, so the page you were on no longer means what it meant.
-- **A page past the end lands on the last page**, not on an empty table. Stale
- links and hand-typed numbers stay useful.
-
-The API does the counting. See [Pagination](/docs/dev/database/pagination) for the
-route side, including the index your ordered column needs.
-
-## Bulk Actions
-
-Pass `bulkActions` to let a person act on several rows at once. That single prop is what turns selection on: the table grows a leading checkbox column - one per row, plus a header checkbox that ticks the whole page - and while anything is ticked, a bar floats at the bottom centre of the viewport with the count, your actions, and a button to clear the selection.
-
-
-
-Your actions read the ticked ids with the `useDataTableSelection` hook. They are rendered inside the bar, which lives inside the table's selection provider, so nothing has to be threaded through props:
-
-```tsx title="delete-bulk-action.tsx"
-'use client'
-
-import { useDataTableSelection } from '@vitnode/core/components/table/selection'
-import { Button } from '@vitnode/core/components/ui/button'
-
-export const DeleteBulkAction = () => {
- // [!code ++]
- const { clear, selected } = useDataTableSelection()
-
- return (
- deleteUsersAction({ ids: selected })}
- variant="destructive"
- >
-
- Delete
-
- )
-}
-```
-
-```tsx
- }
- columns={[
- { accessorKey: 'name', header: 'Name' },
- { accessorKey: 'email', header: 'Email' },
- ]}
- edges={data.edges}
- pageInfo={data.pageInfo}
- order={{
- defaultOrder: {
- column: 'name',
- order: 'asc',
- },
- }}
-/>
-```
-
-### What the hook returns
-
- boolean',
- },
- allSelected: {
- description: 'Every row on the page is ticked.',
- required: true,
- type: 'boolean',
- },
- someSelected: {
- description:
- "Some but not all rows are ticked - the header checkbox's indeterminate state.",
- required: true,
- type: 'boolean',
- },
- toggle: {
- description: 'Ticks or unticks one row.',
- required: true,
- type: '(id: number) => void',
- },
- toggleAll: {
- description: 'Ticks or unticks every row on the page.',
- required: true,
- type: '(next: boolean) => void',
- },
- clear: {
- description: 'Unticks everything, which hides the bar.',
- required: true,
- type: '() => void',
- },
- }}
-/>
-
-### Selection is per page
-
-The selection only ever covers the rows currently on screen. Whenever the server sends a different set of row ids - paging, searching, changing a filter - the selection is pruned to what is still there, so paging away empties it on its own and a bulk action can never touch a row nobody can see.
-
-That pruning is also what makes a partly-successful action readable: revalidate after deleting 3 of 5 rows and the 2 that were refused stay on screen _and_ stay ticked, so the bar is still pointed at exactly the work that is left.
-
-
- There is no bulk endpoint behind `bulkActions` - it hands you ids, and what
- you do with them is yours. When your action loops the single-row endpoint, cap
- the fan-out and report the outcomes per reason rather than as one pass/fail,
- so a run that partly succeeded can say so.
-
-
-## Complete Example
-
-Here's a complete example showing how to use the `DataTable` component in a page:
-
-```tsx
-import {
- DataTable,
- SearchParamsDataTable,
-} from '@vitnode/core/components/table/data-table'
-import { fetcher } from '@vitnode/core/tanstack/fetcher'
-
-export const UsersView = async ({
- searchParams,
-}: {
- searchParams: Promise
-}) => {
- const query = await searchParams
- const res = await fetcher({
- plugin: '@vitnode/core',
- path: '/list',
- method: 'get',
- module: 'admin/users',
- args: {
- query,
- },
- withPagination: true,
- })
- const data = await res.json()
-
- return (
- (
- {row.username}
- ),
- },
- { accessorKey: 'email', header: 'Email' },
- { accessorKey: 'createdAt', header: 'Created at' },
- ]}
- edges={data.edges}
- order={{
- columns: ['id', 'username', 'email', 'createdAt'],
- defaultOrder: {
- column: 'createdAt',
- order: 'desc',
- },
- }}
- pageInfo={data.pageInfo}
- />
- )
-}
-```
diff --git a/apps/web/content/docs/ui/data-table/bulk-actions.mdx b/apps/web/content/docs/ui/data-table/bulk-actions.mdx
new file mode 100644
index 000000000..50010a5e4
--- /dev/null
+++ b/apps/web/content/docs/ui/data-table/bulk-actions.mdx
@@ -0,0 +1,115 @@
+---
+title: Bulk Actions
+description: Tick a few rows and act on all of them at once from a floating bar.
+icon: CheckCheck
+---
+
+## Preview
+
+
+
+## Usage
+
+Pass `bulkActions` to let a person act on several rows at once. That single prop is what turns selection on: the table grows a leading checkbox column - one per row, plus a header checkbox that ticks the whole page - and while anything is ticked, a bar floats at the bottom centre of the viewport with the count, your actions, and a button to clear the selection.
+
+Your actions read the ticked ids with the `useDataTableSelection` hook. They are rendered inside the bar, which lives inside the table's selection provider, so nothing has to be threaded through props:
+
+```tsx title="delete-bulk-action.tsx"
+'use client'
+
+import { useDataTableSelection } from '@vitnode/core/components/table/selection'
+import { Button } from '@vitnode/core/components/ui/button'
+
+export const DeleteBulkAction = () => {
+ // [!code ++]
+ const { clear, selected } = useDataTableSelection()
+
+ return (
+ deleteUsersAction({ ids: selected })}
+ variant="destructive"
+ >
+
+ Delete
+
+ )
+}
+```
+
+```tsx
+ }
+ columns={[
+ { accessorKey: 'name', header: 'Name' },
+ { accessorKey: 'email', header: 'Email' },
+ ]}
+ edges={data.edges}
+ pageInfo={data.pageInfo}
+ order={{
+ defaultOrder: {
+ column: 'name',
+ order: 'asc',
+ },
+ }}
+/>
+```
+
+## What the hook returns
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+ boolean',
+ },
+ allSelected: {
+ description: 'Every row on the page is ticked.',
+ required: true,
+ type: 'boolean',
+ },
+ someSelected: {
+ description:
+ "Some but not all rows are ticked - the header checkbox's indeterminate state.",
+ required: true,
+ type: 'boolean',
+ },
+ toggle: {
+ description: 'Ticks or unticks one row.',
+ required: true,
+ type: '(id: number) => void',
+ },
+ toggleAll: {
+ description: 'Ticks or unticks every row on the page.',
+ required: true,
+ type: '(next: boolean) => void',
+ },
+ clear: {
+ description: 'Unticks everything, which hides the bar.',
+ required: true,
+ type: '() => void',
+ },
+ }}
+/>
+
+## Selection is per page
+
+The selection only ever covers the rows currently on screen. Whenever the server sends a different set of row ids - paging, searching, changing a filter - the selection is pruned to what is still there, so paging away empties it on its own and a bulk action can never touch a row nobody can see.
+
+That pruning is also what makes a partly-successful action readable: revalidate after deleting 3 of 5 rows and the 2 that were refused stay on screen _and_ stay ticked, so the bar is still pointed at exactly the work that is left.
+
+
+ There is no bulk endpoint behind `bulkActions` - it hands you ids, and what
+ you do with them is yours. When your action loops the single-row endpoint, cap
+ the fan-out and report the outcomes per reason rather than as one pass/fail,
+ so a run that partly succeeded can say so.
+
diff --git a/apps/web/content/docs/ui/data-table/draggable-rows.mdx b/apps/web/content/docs/ui/data-table/draggable-rows.mdx
new file mode 100644
index 000000000..d2fbecb0d
--- /dev/null
+++ b/apps/web/content/docs/ui/data-table/draggable-rows.mdx
@@ -0,0 +1,273 @@
+---
+title: Draggable Rows
+description: Drag rows into a new order with mouse, touch or keyboard - and save it on your backend.
+icon: GripVertical
+---
+
+## Preview
+
+
+
+## Usage
+
+When the order of rows _is_ the data - a backlog, homepage sections, FAQ entries - pass `reorderable`. Each row grows a grip handle; grab it and drag the row where it belongs. The other rows slide out of the way, and the dropped row glides into its new spot.
+
+```tsx
+ task.title,
+ // [!code ++]
+ onReorder: async (ids) => await saveOrder(ids),
+ // [!code ++]
+ }}
+ columns={columns}
+ edges={data.edges}
+ pageInfo={data.pageInfo}
+ order={{
+ defaultOrder: {
+ column: 'position',
+ order: 'asc',
+ },
+ }}
+/>
+```
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+ void | Promise`,
+ },
+ getRowLabel: {
+ description:
+ 'What the handle is called out loud - "Reorder Fix the login redirect loop" beats "Reorder row 1". Defaults to the row position.',
+ required: false,
+ type: '(row: T) => string',
+ },
+ disabled: {
+ description:
+ 'Shows the handles but locks them, for example while a person lacks the permission to reorder.',
+ required: false,
+ type: 'boolean',
+ },
+ }}
+/>
+
+What you get for free:
+
+- **Optimistic order.** The table moves the row the moment you drop it and calls `onReorder` in the background. If the promise rejects, it rolls back and shows an error toast - no stale "did it save?" moment.
+- **Only the handle drags.** Links, buttons and `rowOpens` clicks in the rest of the row keep working exactly as before.
+- **Mouse, touch and keyboard.** On touch, press and hold the handle for a moment so scrolling the page still scrolls the page. With a keyboard, focus a handle, press Space, move with the arrow keys and press Space again (Escape cancels). Screen readers hear where the row is the whole time.
+- **Expandable rows travel along.** An open detail panel belongs to its row, so it moves with it during the drag.
+- **Selection is untouched.** With `bulkActions` the handle sits first, then the checkbox.
+- **Reduced motion is respected.** Rows swap places without sliding when the system asks for less motion.
+
+
+ The handles are disabled while a search, a filter or a sort other than
+ `order.defaultOrder` is active - dragging a row in a filtered view would
+ shuffle rows nobody can see. Hovering a disabled handle says so. Make the
+ table's `defaultOrder` your stored order (for example `position` ascending) so
+ the default view is the one that can be rearranged.
+
+
+## Saving the order on the backend
+
+The table hands you ids; making them stick is three small pieces. VitNode's own navigation admin does the same thing in `packages/vitnode/src/api/modules/admin/navigation/routes/reorder.route.ts` - peek there for a production version.
+
+
+
+
+#### Add a `position` column and sort by it
+
+```ts title="plugins/tasks/src/database/tasks.ts"
+import { camelCase, index } from 'drizzle-orm/pg-core'
+
+export const tasks = camelCase.table.withRLS(
+ 'tasks',
+ (t) => ({
+ id: t.serial().primaryKey(),
+ title: t.varchar({ length: 255 }).notNull(),
+ // [!code ++]
+ position: t.integer().notNull().default(0),
+ createdAt: t.timestamp().notNull().defaultNow(),
+ }),
+ // [!code ++]
+ (t) => [index('tasks_position_idx').on(t.position)],
+)
+```
+
+The list route orders by it, so the default view is the stored order:
+
+```ts
+withPagination({
+ // ...
+ // [!code ++]
+ orderBy: { column: tasks.position, order: 'asc' },
+})
+```
+
+
+
+
+#### Accept the new order in an admin route
+
+The route takes the ids of one page, in their new order, and hands those rows the positions they already had between them. That keeps pagination honest: reordering page 3 never touches page 1. New admin APIs always need a staff permission:
+
+```ts title="plugins/tasks/src/api/modules/admin/tasks/routes/reorder.route.ts"
+import { z } from '@hono/zod-openapi'
+import { buildRoute } from '@vitnode/core/api/lib/route'
+import { eq, inArray } from 'drizzle-orm'
+
+import { CONFIG_PLUGIN } from '@/const'
+import { tasks } from '@/database/tasks'
+
+const zodReorderTasksSchema = z.object({
+ ids: z.array(z.number().int()).min(1).max(100),
+})
+
+export const reorderTasksRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ // [!code ++]
+ adminStaffPermission: { module: 'tasks', permission: 'can_reorder' },
+ route: {
+ method: 'post',
+ path: '/reorder',
+ description: 'Save the order of one page of tasks (Admin only)',
+ request: {
+ body: {
+ required: true,
+ content: { 'application/json': { schema: zodReorderTasksSchema } },
+ },
+ },
+ responses: {
+ 200: { description: 'Tasks reordered' },
+ 400: {
+ content: {
+ 'application/json': { schema: z.object({ error: z.string() }) },
+ },
+ description: 'The ids are duplicated or unknown',
+ },
+ 403: { description: 'Access Denied' },
+ },
+ },
+ handler: async (c) => {
+ const { ids } = c.req.valid('json')
+ const db = c.get('db')
+
+ if (new Set(ids).size !== ids.length) {
+ return c.json({ error: 'A task appears twice in the new order' }, 400)
+ }
+
+ const current = await db
+ .select({ id: tasks.id, position: tasks.position })
+ .from(tasks)
+ .where(inArray(tasks.id, ids))
+
+ if (current.length !== ids.length) {
+ return c.json({ error: 'The new order names an unknown task' }, 400)
+ }
+
+ const slots = current.map((row) => row.position).sort((a, b) => a - b)
+
+ // [!code ++:8]
+ await db.transaction(async (tx) => {
+ for (const [index, id] of ids.entries()) {
+ await tx
+ .update(tasks)
+ .set({ position: slots[index] })
+ .where(eq(tasks.id, id))
+ }
+ })
+
+ return c.body(null, 200)
+ },
+})
+```
+
+Register the route in its module and declare the permission in the plugin's API config:
+
+```ts title="plugins/tasks/src/config.api.ts"
+export const tasksApiPlugin = () =>
+ buildApiPlugin({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ // [!code ++:8]
+ permissionStaff: {
+ admin: {
+ tasks: [
+ 'can_view',
+ { permission: 'can_reorder', dependsOn: ['can_view'] },
+ ],
+ },
+ },
+ modules: [adminModule],
+ })
+```
+
+
+ Rows that all share `position: 0` sort unpredictably. When you insert a task,
+ set its `position` to the current maximum plus one.
+
+
+
+
+
+#### Call it from `onReorder`
+
+Throwing from `onReorder` is all the table needs to roll back and show its error toast. On success, invalidate the list so every other view of it catches up:
+
+```tsx title="plugins/tasks/src/views/admin/tasks-table.tsx"
+import { useQueryClient } from '@tanstack/react-query'
+import { DataTable } from '@vitnode/core/components/table/data-table'
+import { fetcher } from '@vitnode/core/tanstack/fetcher'
+
+import { tasksQueryKey } from './tasks-query'
+
+export const TasksTable = ({ data }: { data: TasksPage }) => {
+ const queryClient = useQueryClient()
+
+ return (
+ task.title,
+ onReorder: async (ids) => {
+ // [!code ++:7]
+ const res = await fetcher({
+ plugin: '@acme/tasks',
+ method: 'post',
+ module: 'admin/tasks',
+ path: '/reorder',
+ args: { body: { ids } },
+ })
+
+ if (!res.ok) {
+ throw new Error('Saving the order failed')
+ }
+
+ // [!code ++]
+ await queryClient.invalidateQueries({ queryKey: tasksQueryKey })
+ },
+ }}
+ />
+ )
+}
+```
+
+
+
diff --git a/apps/web/content/docs/ui/data-table/expandable-rows.mdx b/apps/web/content/docs/ui/data-table/expandable-rows.mdx
new file mode 100644
index 000000000..681208751
--- /dev/null
+++ b/apps/web/content/docs/ui/data-table/expandable-rows.mdx
@@ -0,0 +1,73 @@
+---
+title: Expandable Rows
+description: Rows that open a detail panel underneath, sliding smoothly open and closed.
+icon: UnfoldVertical
+---
+
+## Preview
+
+
+
+## Usage
+
+Some rows have more to say than a cell can hold - an order's line items, a job's error log, a user's last logins. Pass `expandable` and every row grows a chevron that slides a detail panel open right underneath it. No modal, no new page, no losing your place.
+
+```tsx
+ ,
+ // [!code ++]
+ canExpand: (order) => order.items.length > 0,
+ // [!code ++]
+ }}
+ columns={columns}
+ edges={data.edges}
+ pageInfo={data.pageInfo}
+ order={{
+ defaultOrder: {
+ column: 'number',
+ order: 'asc',
+ },
+ }}
+/>
+```
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+ ReactNode',
+ },
+ canExpand: {
+ description:
+ 'Return false to give a row no chevron - for example an order with nothing in it. Every row can expand when omitted.',
+ required: false,
+ type: '(row: T) => boolean',
+ },
+ defaultExpanded: {
+ description: 'Row ids that start open on the first render.',
+ required: false,
+ type: 'number[]',
+ },
+ }}
+/>
+
+A few things the table handles for you:
+
+- **Several rows can be open at once.** Open state lives in the component, not the URL, and it is dropped for rows that leave the page when you paginate, search or filter.
+- **It is a real button.** The chevron is a `` with `aria-expanded` and `aria-controls`, so Enter and Space work and screen readers announce "Expand row" / "Collapse row". Row clicks from `rowOpens` keep working, because a click on a button never counts as a row click.
+- **It plays nice with `bulkActions`.** The chevron column sits right after the selection checkbox.
+- **The motion is polite.** The panel eases its height and opacity open in about 300 ms, and with "reduce motion" turned on it simply fades - no sliding around.
+
+
+ `render` runs on the client as soon as a row opens. If the detail needs data
+ the list did not fetch, fetch it inside the panel component (TanStack Query
+ caches it), so collapsing and re-opening a row is instant.
+
diff --git a/apps/web/content/docs/ui/data-table/filters.mdx b/apps/web/content/docs/ui/data-table/filters.mdx
new file mode 100644
index 000000000..fb5d6e699
--- /dev/null
+++ b/apps/web/content/docs/ui/data-table/filters.mdx
@@ -0,0 +1,153 @@
+---
+title: Filters
+description: Faceted multi-select filters above the table, with static or async options from your API.
+icon: ListFilter
+---
+
+## Preview
+
+
+
+## Usage
+
+Pass the `filters` prop to render one or more faceted, multi-select dropdowns above the table (next to the search input). Each filter controls its own URL query parameter, so it works out of the box with server-side data fetching. Multiple selected values are stored as a comma-separated list, e.g. `?roleId=1,3`, and changing a filter returns you to the first page.
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+ Promise',
+ },
+ }}
+/>
+
+## Static filters
+
+When the set of options is small and known ahead of time, pass them directly via `options`. The dropdown list is filtered on the client.
+
+```tsx
+
+```
+
+## Async filters
+
+When the options come from the API (for example a large or searchable list), provide an `onSearch` callback instead of `options`. It runs - debounced - as the user types and should return results already filtered and capped by the server. The universal fetcher answers from the browser here, with the visitor's own cookies:
+
+```tsx title="search-roles.tsx"
+import type { FilterOption } from '@vitnode/core/components/table/filters'
+
+import { RoleFormat } from '@vitnode/core/components/role-format'
+import { fetcher } from '@vitnode/core/tanstack/fetcher'
+
+export const searchRoles = async (search: string): Promise => {
+ const res = await fetcher({
+ plugin: '@vitnode/core',
+ path: '/list',
+ method: 'get',
+ module: 'admin/roles',
+ args: {
+ query: { search, first: '20' },
+ },
+ withPagination: true,
+ })
+
+ if (res.status !== 200) {
+ return []
+ }
+
+ const data = await res.json()
+
+ return data.edges.map((role) => ({
+ value: String(role.id),
+ label: ,
+ keywords: role.name.map((item) => item.name),
+ }))
+}
+```
+
+```tsx
+filters={[
+ {
+ id: "roleId",
+ label: "Group",
+ // [!code ++]
+ onSearch: searchRoles,
+ },
+]}
+```
+
+
+ A filter only writes its values to the URL - the backend must read the query
+ parameter and apply the matching `where` clause. For a comma-separated
+ multi-select, split the value and use `inArray`:
+
+```ts
+const roleIds = (query.roleId?.split(",") ?? [])
+ .filter(Boolean)
+ .map(Number)
+ .filter((id) => !Number.isNaN(id));
+
+// pass to withPagination:
+where: roleIds.length ? inArray(core_users.roleId, roleIds) : undefined,
+```
+
+
diff --git a/apps/web/content/docs/ui/data-table/index.mdx b/apps/web/content/docs/ui/data-table/index.mdx
new file mode 100644
index 000000000..5a67fd3c9
--- /dev/null
+++ b/apps/web/content/docs/ui/data-table/index.mdx
@@ -0,0 +1,321 @@
+---
+title: Data Table
+description: A table component with sorting, filtering, and pagination compatible with VitNode API.
+icon: Table
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import {
+ type ColumnDef,
+ DataTable,
+} from '@vitnode/core/components/table/data-table'
+```
+
+Columns are typed with `ColumnDef`, where `T` is the shape of a single row. An **accessor column** binds to the data through `accessorKey`, which is type-safe - it only accepts a `keyof T`, just like TanStack Table. A **display column** (for actions, selection, etc.) has no `accessorKey` and instead requires a string `id`.
+
+```tsx
+const columns: ColumnDef[] = [
+ { accessorKey: 'name', header: 'Name' },
+ { accessorKey: 'email', header: 'Email' },
+ { accessorKey: 'role', header: 'Role' },
+ { accessorKey: 'status', header: 'Status', align: 'center' },
+ {
+ id: 'actions',
+ header: '',
+ align: 'right',
+ cell: () => (
+
+ Edit
+
+ ),
+ },
+]
+
+;
+```
+
+
+ `accessorKey` autocompletes and is checked against the row type `T` - passing
+ a key that doesn't exist on `T` is a compile-time error. Use `id` (a plain
+ `string`) for display columns that aren't backed by a data field.
+
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+[] where ColumnDef is:
+ // accessor column
+ {
+ accessorKey: keyof T;
+ header: ReactNode;
+ id?: string;
+ align?: 'left' | 'center' | 'right';
+ className?: string;
+ cell?: (props: { row: T; allData: T[] }) => ReactNode;
+ }
+ // or display column
+ {
+ id: string;
+ header: ReactNode;
+ align?: 'left' | 'center' | 'right';
+ className?: string;
+ cell?: (props: { row: T; allData: T[] }) => ReactNode;
+ }`,
+ },
+ edges: {
+ description: 'Data to be displayed in the table',
+ required: true,
+ type: 'Array',
+ },
+ order: {
+ description: 'Column sorting configuration',
+ required: true,
+ type: `{
+ columns?: string[];
+ defaultOrder: {
+ column: string;
+ order: 'asc' | 'desc';
+ };
+ }`,
+ },
+ pageInfo: {
+ description:
+ 'Pagination information from the API. Pass it straight through — the table renders numbered pages and a "Showing 21–30 of 380" range from it.',
+ required: true,
+ type: `{
+ count: number;
+ totalCount: number;
+ totalPages: number;
+ currentPage: number | null;
+ hasNextPage: boolean;
+ hasPreviousPage: boolean;
+ startCursor: string | null;
+ endCursor: string | null;
+ }`,
+ },
+ search: {
+ description:
+ 'Renders a search input above the table. Requires the API route to define the searchable columns.',
+ required: false,
+ type: 'boolean',
+ },
+ searchPlaceholder: {
+ description: 'Custom placeholder text for the search input',
+ required: false,
+ type: 'string',
+ },
+ bulkActions: {
+ description:
+ 'Actions for the rows a person ticked, rendered in a bar that floats at the bottom centre of the viewport. Passing this turns row selection on: the table grows a leading checkbox column, and the nodes here read the ticked ids with useDataTableSelection().',
+ required: false,
+ type: 'ReactNode',
+ },
+ expandable: {
+ description:
+ 'Turns on expandable rows: a chevron column that opens a detail panel under its row. See Expandable rows below.',
+ required: false,
+ type: `{
+ render: (row: T) => ReactNode;
+ canExpand?: (row: T) => boolean;
+ defaultExpanded?: number[];
+ }`,
+ },
+ reorderable: {
+ description:
+ 'Turns on drag-and-drop row ordering with a grip handle per row. See Draggable rows below.',
+ required: false,
+ type: `{
+ onReorder: (ids: number[], details: {
+ activeId: number;
+ overId: number;
+ from: number;
+ to: number;
+ }) => void | Promise;
+ getRowLabel?: (row: T) => string;
+ disabled?: boolean;
+ }`,
+ },
+ filters: {
+ description:
+ 'Renders one or more faceted multi-select dropdowns above the table. Each filter controls its own URL query parameter.',
+ required: false,
+ type: `{
+ id: string;
+ label: string;
+ options?: {
+ value: string;
+ label: ReactNode;
+ keywords?: string[];
+ }[];
+ onSearch?: (search: string) => Promise<{
+ value: string;
+ label: ReactNode;
+ keywords?: string[];
+ }[]>;
+ }[]`,
+ },
+ }}
+/>
+
+## Cell Renderer
+
+You can customize how each cell is rendered using the `cell` property. The renderer function receives the current row data and all table data as parameters.
+
+```tsx
+ (
+ // [!code ++]
+
+ // [!code ++]
+ {row.id} - all data {allData.length}
+ // [!code ++]
+
+ // [!code ++]
+ ),
+ },
+ { accessorKey: 'createdAt', header: 'Created at' },
+ ]}
+ edges={data.edges}
+ pageInfo={data.pageInfo}
+ order={{
+ columns: ['createdAt', 'id'],
+ defaultOrder: {
+ order: 'desc',
+ },
+ }}
+/>
+```
+
+### Order Configuration
+
+If you want to enable sorting on specific columns, you can specify them in the `columns` property.
+
+```tsx
+order={{
+ // [!code ++]
+ columns: ['createdAt', 'id', 'name'],
+ defaultOrder: {
+ column: 'createdAt',
+ order: 'desc',
+ }
+}}
+```
+
+## Column Alignment
+
+Use the `align` property to control the horizontal alignment of a column's header and cells. It accepts `"left"` (default), `"center"`, or `"right"`. This is handy for numeric values and action buttons.
+
+```tsx
+columns={[
+ { accessorKey: "name", header: "Name" },
+ // [!code ++]
+ { accessorKey: "status", header: "Status", align: "center" },
+ {
+ id: "actions",
+ header: "",
+ // [!code ++]
+ align: "right",
+ cell: ({ row }) => ,
+ },
+]}
+```
+
+## Features
+
+Each feature has its own page with a live preview:
+
+- [Search](/docs/ui/data-table/search) - a debounced search box that writes to the URL.
+- [Filters](/docs/ui/data-table/filters) - faceted multi-select filters, static or async.
+- [Pagination](/docs/ui/data-table/pagination) - numbered pages and a rows-per-page select.
+- [Bulk Actions](/docs/ui/data-table/bulk-actions) - select rows and act on all of them at once.
+- [Expandable Rows](/docs/ui/data-table/expandable-rows) - a detail panel under each row.
+- [Draggable Rows](/docs/ui/data-table/draggable-rows) - reorder rows and save the order on your backend.
+
+## Complete Example
+
+Here's a complete example showing how to use the `DataTable` component in a page:
+
+```tsx
+import {
+ DataTable,
+ SearchParamsDataTable,
+} from '@vitnode/core/components/table/data-table'
+import { fetcher } from '@vitnode/core/tanstack/fetcher'
+
+export const UsersView = async ({
+ searchParams,
+}: {
+ searchParams: Promise
+}) => {
+ const query = await searchParams
+ const res = await fetcher({
+ plugin: '@vitnode/core',
+ path: '/list',
+ method: 'get',
+ module: 'admin/users',
+ args: {
+ query,
+ },
+ withPagination: true,
+ })
+ const data = await res.json()
+
+ return (
+ (
+ {row.username}
+ ),
+ },
+ { accessorKey: 'email', header: 'Email' },
+ { accessorKey: 'createdAt', header: 'Created at' },
+ ]}
+ edges={data.edges}
+ order={{
+ columns: ['id', 'username', 'email', 'createdAt'],
+ defaultOrder: {
+ column: 'createdAt',
+ order: 'desc',
+ },
+ }}
+ pageInfo={data.pageInfo}
+ />
+ )
+}
+```
diff --git a/apps/web/content/docs/ui/data-table/meta.json b/apps/web/content/docs/ui/data-table/meta.json
new file mode 100644
index 000000000..37a2436d1
--- /dev/null
+++ b/apps/web/content/docs/ui/data-table/meta.json
@@ -0,0 +1,13 @@
+{
+ "title": "Data Table",
+ "icon": "Table",
+ "pages": [
+ "index",
+ "search",
+ "filters",
+ "pagination",
+ "bulk-actions",
+ "expandable-rows",
+ "draggable-rows"
+ ]
+}
diff --git a/apps/web/content/docs/ui/data-table/pagination.mdx b/apps/web/content/docs/ui/data-table/pagination.mdx
new file mode 100644
index 000000000..dcb54843c
--- /dev/null
+++ b/apps/web/content/docs/ui/data-table/pagination.mdx
@@ -0,0 +1,31 @@
+---
+title: Pagination
+description: Numbered pages, a rows-per-page select and a "Showing 21–30 of 380" range - all from pageInfo.
+icon: BookCopy
+---
+
+## Preview
+
+
+
+## Usage
+
+The table renders its own pager from `pageInfo` — numbered pages with a first/last
+anchor and an ellipsis in between, a rows-per-page select, and a
+"Showing 21–30 of 380" range. You wire up nothing: pass `pageInfo` through and it
+appears.
+
+Each page is a real link (`?page=3`), so middle-click and "open in new tab" work,
+while a plain click stays a client-side navigation. On phones the numbers collapse
+to a "Page 3 of 38" label between the arrows, because forty tap targets do not fit
+on a 390px screen.
+
+Two details worth knowing:
+
+- **Changing the page size or a filter returns you to page one.** A filter changes
+ which rows exist, so the page you were on no longer means what it meant.
+- **A page past the end lands on the last page**, not on an empty table. Stale
+ links and hand-typed numbers stay useful.
+
+The API does the counting. See [Pagination](/docs/dev/database/pagination) for the
+route side, including the index your ordered column needs.
diff --git a/apps/web/content/docs/ui/data-table/search.mdx b/apps/web/content/docs/ui/data-table/search.mdx
new file mode 100644
index 000000000..510e3fe14
--- /dev/null
+++ b/apps/web/content/docs/ui/data-table/search.mdx
@@ -0,0 +1,42 @@
+---
+title: Search
+description: Let people search the table - the term lives in the URL, so the API does the heavy lifting.
+icon: TextSearch
+---
+
+## Preview
+
+
+
+## Usage
+
+Set the `search` prop to `true` to render a search input above the table. You can optionally customize the placeholder with `searchPlaceholder`.
+
+```tsx
+
+```
+
+The input writes the term to the `?search=` query parameter (debounced) and reloads the page, so it works out of the box with server-side data fetching. The columns that are actually searched are defined on the API route.
+
+
+ Enabling `search` only renders the input. You must also tell the backend which
+ columns to search across - see the [Search](/docs/dev/database/search) guide.
+
diff --git a/apps/web/content/docs/ui/dialog.mdx b/apps/web/content/docs/ui/dialog.mdx
index 810d3cbd5..a0bf34b91 100644
--- a/apps/web/content/docs/ui/dialog.mdx
+++ b/apps/web/content/docs/ui/dialog.mdx
@@ -1,6 +1,7 @@
---
title: Dialog
description: Display content in a modal dialog.
+icon: AppWindow
---
## Preview
@@ -25,23 +26,32 @@ import {
```tsx
- Open } />
+ Share } />
- Are you absolutely sure?
+ Share this page
- This action cannot be undone. This will permanently delete your account
- and remove your data from our servers.
+ Anyone with the link can read it - no account needed.
+
+
+
+
- Cancel} />
- Yes, delete account
+ Done} />
```
+## Dialog or Alert Dialog?
+
+Use a Dialog for friendly, low-stakes tasks - sharing, quick edits, previews.
+Asking someone to confirm something they can't undo? Reach for the
+[Alert Dialog](/docs/ui/alert-dialog) instead: it can't be dismissed by clicking
+outside, so nobody deletes anything by accident.
+
## API Reference
[Base UI - Dialog](https://base-ui.com/react/components/dialog#api-reference)
diff --git a/apps/web/content/docs/ui/drawer.mdx b/apps/web/content/docs/ui/drawer.mdx
index d31e8c9f0..9b37fb2ab 100644
--- a/apps/web/content/docs/ui/drawer.mdx
+++ b/apps/web/content/docs/ui/drawer.mdx
@@ -1,6 +1,7 @@
---
title: Drawer
description: A component for displaying content in a sliding panel.
+icon: PanelBottom
---
## Preview
diff --git a/apps/web/content/docs/ui/dropdown-menu.mdx b/apps/web/content/docs/ui/dropdown-menu.mdx
index 1120a29ef..b0c41d817 100644
--- a/apps/web/content/docs/ui/dropdown-menu.mdx
+++ b/apps/web/content/docs/ui/dropdown-menu.mdx
@@ -1,6 +1,7 @@
---
title: Dropdown Menu
description: A dropdown menu component for building interactive menus in your application.
+icon: EllipsisVertical
---
## Preview
diff --git a/apps/web/content/docs/ui/editor.mdx b/apps/web/content/docs/ui/editor.mdx
index 3b3ca9e85..62453a8e0 100644
--- a/apps/web/content/docs/ui/editor.mdx
+++ b/apps/web/content/docs/ui/editor.mdx
@@ -1,6 +1,7 @@
---
title: Editor
description: Rich text editor built on TipTap for editing and rendering HTML content.
+icon: PenLine
---
## Preview
diff --git a/apps/web/content/docs/ui/emoji-icon-picker.mdx b/apps/web/content/docs/ui/emoji-icon-picker.mdx
index 501f9014b..4fb53ea0d 100644
--- a/apps/web/content/docs/ui/emoji-icon-picker.mdx
+++ b/apps/web/content/docs/ui/emoji-icon-picker.mdx
@@ -1,6 +1,7 @@
---
title: Emoji & Icon Picker
description: One picker with segmented emoji and icon modes that returns a discriminated value you can store in a single column.
+icon: FaceSlightlySmiling
---
## Preview
diff --git a/apps/web/content/docs/ui/empty.mdx b/apps/web/content/docs/ui/empty.mdx
new file mode 100644
index 000000000..d4fd01485
--- /dev/null
+++ b/apps/web/content/docs/ui/empty.mdx
@@ -0,0 +1,186 @@
+---
+title: Empty
+description: Friendly empty states for lists, tables and searches that have nothing to show yet - with an icon, a clear message and a next step.
+icon: PackageOpen
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import {
+ Empty,
+ EmptyContent,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from '@vitnode/core/components/ui/empty'
+```
+
+```tsx
+
+
+
+
+
+ No projects yet
+
+ You haven't created any projects yet. Start a fresh one or import an
+ existing repository.
+
+
+
+ Create project
+
+
+```
+
+Reach for `Empty` whenever a screen would otherwise show... nothing. A blank
+table, a fresh dashboard, a search with zero hits. The AdminCP uses it for an
+empty dashboard (with an "Edit" button to add widgets) and for navigation lists
+that have no items yet.
+
+## Anatomy
+
+- `Empty` - the centered wrapper. It grows to fill its parent (`flex-1`).
+- `EmptyHeader` - groups the media, title and description.
+- `EmptyMedia` - an icon, avatar or small illustration.
+- `EmptyTitle` - one short line that says what's missing.
+- `EmptyDescription` - why it's empty and what to do about it. Links inside it
+ are underlined automatically.
+- `EmptyContent` - actions, a search box or anything else the user can act on.
+
+## Media
+
+`EmptyMedia` has two variants. `icon` wraps the icon in a soft muted tile and
+sizes it for you. `default` adds no styling, so it's the one for avatars and
+illustrations.
+
+```tsx
+
+
+
+```
+
+
+
+```tsx
+
+
+
+ AL
+
+
+```
+
+## Outline
+
+`Empty` already has a dashed border style - it just needs a width. Add `border`
+for a drop-zone look, or `border-2` when it sits alone on a page, like the
+AdminCP dashboard.
+
+
+
+```tsx
+...
+```
+
+## No results
+
+A search that finds nothing deserves its own empty state - not the same one as
+"you have no data". Echo the query back and offer a way out, like clearing the
+search.
+
+
+
+```tsx
+{results.length === 0 ? (
+
+
+
+
+
+ No integrations found
+
+ Nothing matches "{query}". Check the spelling or try a shorter search.
+
+
+
+ setQuery('')} size="sm" variant="outline">
+ Clear search
+
+
+
+) : (
+
+)}
+```
+
+## Inside a card or sidebar
+
+The default `p-12` is generous on purpose. On phones, `p-6 md:p-12` gives the
+copy room to breathe. In tight spots - a card, a sidebar, a popover - shrink the
+padding and the text:
+
+```tsx
+
+
+ No widgets yet
+
+ Widgets from your plugins will show up here.
+
+
+
+```
+
+## Writing good empty states
+
+An empty state is a tiny piece of onboarding, so make every word count:
+
+- **Say what's missing.** "No projects yet" beats "Nothing here".
+- **Say why.** Is it brand new, filtered out, or did the search miss?
+- **Give the next step.** One primary action, and maybe a secondary one. If the
+ user can't do anything about it, a clear description is enough.
+- **Keep it short.** One title line, one or two sentences. Nobody reads an essay
+ about an empty table.
+- **Skip the blame.** "No results for 'xylphone'" is fine. "You typed it wrong"
+ is not.
+
+## Accessibility
+
+- `EmptyTitle` renders a `div`. If the empty state replaces a whole page
+ section, give the title a heading role so it shows up in the outline:
+ ``.
+- Icons in `EmptyMedia` are decorative - the title already says it all. Use
+ `alt=""` on avatar images for the same reason.
+- When results change while the user types, announce the count in a
+ `role="status"` region (a `sr-only` paragraph works great) so screen reader
+ users know the list just went empty.
+- Use real `Button`s for actions, so they're reachable by keyboard.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+All parts accept the regular `div` props, including `className`.
+
+### EmptyMedia
+
+
+
+## API Reference
+
+[shadcn/ui - Empty](https://ui.shadcn.com/docs/components/base/empty)
diff --git a/apps/web/content/docs/ui/field.mdx b/apps/web/content/docs/ui/field.mdx
new file mode 100644
index 000000000..20d082a12
--- /dev/null
+++ b/apps/web/content/docs/ui/field.mdx
@@ -0,0 +1,294 @@
+---
+title: Field
+description: Layout primitives for labels, descriptions, errors and groups of controls - the building blocks behind every AutoForm field.
+icon: ListChecks
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import {
+ Field,
+ FieldContent,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLabel,
+ FieldLegend,
+ FieldSeparator,
+ FieldSet,
+ FieldTitle,
+} from '@vitnode/core/components/ui/field'
+```
+
+```tsx
+
+ Username
+
+ Pick something your friends can type.
+
+```
+
+## Should I use this for my form?
+
+Probably not directly. In VitNode, forms are built with
+[AutoForm](/docs/ui/auto-form), and AutoForm already lays every field out with
+these primitives - labels, descriptions and error messages included. You get
+validation, ids and ARIA wiring for free.
+
+Reach for `Field` when you are:
+
+- writing a [custom AutoForm field](#inside-a-custom-autoform-field), so it
+ looks like its built-in neighbours,
+- building a settings-style screen with controls that save instantly or are not
+ part of a form at all (toggles, filters, preferences).
+
+## Anatomy
+
+| Component | What it does |
+| ------------------ | -------------------------------------------------------------------------------------- |
+| `FieldSet` | A semantic `` that groups related fields. |
+| `FieldLegend` | The `` of a `FieldSet`. Use `variant="label"` for a smaller, label-sized title. |
+| `FieldGroup` | Stacks fields with consistent spacing and enables the `responsive` orientation. |
+| `Field` | One control with its label, description and error. |
+| `FieldContent` | Keeps the label and description together next to a control. |
+| `FieldLabel` | The `` of a control. Wrap a `Field` in it to make a choice card. |
+| `FieldTitle` | A label-looking title for content that is already inside a `FieldLabel`. |
+| `FieldDescription` | Helper text in a muted colour. |
+| `FieldError` | An error message announced to screen readers. |
+| `FieldSeparator` | A divider between fields, optionally with text in the middle. |
+
+## Orientation
+
+`Field` stacks everything vertically by default. Switch it to `horizontal` for
+checkboxes, switches and other compact controls that sit next to their label:
+
+```tsx
+
+
+ Weekly digest
+ A short recap, every Monday.
+
+
+
+```
+
+Put the control first for a checkbox on the left, or last for a switch on the
+right - the layout follows the markup.
+
+### Responsive
+
+`responsive` is vertical on narrow screens and horizontal once its
+`FieldGroup` is wide enough. It uses a container query, so it reacts to the
+space the group has, not to the whole window. Resize your browser to watch it
+fold:
+
+
+
+```tsx
+
+
+
+ Display name
+ Shown next to your posts.
+
+
+
+
+```
+
+## Fieldset and legend
+
+Wrap related fields in a `FieldSet` with a `FieldLegend`. Screen readers read
+the legend before each field inside, so "Mentions" becomes "Notifications,
+Mentions" - context without extra words on screen.
+
+```tsx
+
+ Notifications
+ Pick how loudly we should knock on your door.
+
+
+
+ Mentions
+
+
+
+ Backup email
+
+
+
+
+```
+
+Need a divider with words? Pass children: `or `.
+
+## Choice cards
+
+Wrap a whole `Field` in a `FieldLabel` and the label becomes a card - the full
+card is clickable, it highlights when its control is checked, and it shows a
+focus ring when you tab into it. Use `FieldTitle` for the card's title, since
+it already lives inside a label.
+
+
+
+```tsx
+
+
+
+
+ Team
+ Shared workspaces for up to 20 people.
+
+
+
+
+
+```
+
+Add `data-disabled` to a `Field` to fade it out, and pass `disabled` to its
+control so it cannot be picked. AutoForm's
+[Radio Group](/docs/ui/radio-group) uses exactly this for `variant="blocks"`.
+
+## Errors
+
+Mark the field with `data-invalid` and the control with `aria-invalid`, then
+render a `FieldError`:
+
+```tsx
+
+ Email
+
+ {isInvalid && That is not an email. }
+
+```
+
+`FieldError` also takes an `errors` array - duplicates are removed, a single
+message renders as text and several render as a list. It renders nothing when
+there is nothing to say.
+
+```tsx
+
+```
+
+## Inside a custom AutoForm field
+
+A custom AutoForm field is just a `component` function. Combine `Field` for the
+layout with AutoForm's own helpers and your field gets the same ids, ARIA and
+error handling as the built-in ones:
+
+- `AutoFormLabel` - a `FieldLabel` already pointed at the control, with the
+ "optional" hint.
+- `AutoFormDesc` - the description text.
+- `FormControl` - passes `id`, `aria-invalid` and `aria-describedby` to the
+ control inside it.
+- `FormMessage` - a `FieldError` showing the field's validation errors.
+
+```tsx
+import type { ItemAutoFormComponentProps } from '@vitnode/core/components/form/auto-form'
+import { AutoFormDesc } from '@vitnode/core/components/form/common/desc'
+import { AutoFormLabel } from '@vitnode/core/components/form/common/label'
+import { Field, FieldContent } from '@vitnode/core/components/ui/field'
+import { FormControl, FormMessage } from '@vitnode/core/components/ui/form'
+import { Switch } from '@vitnode/core/components/ui/switch'
+
+export const AutoFormSettingSwitch = ({
+ label,
+ description,
+ field,
+ otherProps: { isOptional },
+}: ItemAutoFormComponentProps) => (
+ <>
+
+
+ {label && {label} }
+ {description && {description} }
+
+
+
+
+
+
+ >
+)
+```
+
+```tsx
+ (
+
+ ),
+ },
+ ]}
+/>
+```
+
+## Accessibility
+
+- Every control needs a label: give the control an `id` and the `FieldLabel` a
+ matching `htmlFor`. Clicking the label then focuses or toggles the control.
+- `Field` renders `role="group"`, so the control, its label and its help text
+ are announced together.
+- `FieldSet` and `FieldLegend` render a real `` and ``, the
+ native way to name a group of controls.
+- For errors, set `aria-invalid` on the control and point `aria-describedby` at
+ the description and the `FieldError`. `FieldError` has `role="alert"`, so new
+ messages are announced as they appear. AutoForm does all of this for you.
+- Choice cards keep the radio or checkbox inside them, so keyboard users can
+ tab in and use the arrow keys or Space as usual.
+
+## Props
+
+All components accept the props of the element they render. These are the
+extras:
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+
+
+## API Reference
+
+[shadcn/ui - Field](https://ui.shadcn.com/docs/components/base/field)
diff --git a/apps/web/content/docs/ui/hooks/use-before-unload.mdx b/apps/web/content/docs/ui/hooks/use-before-unload.mdx
index cc2d57c5d..a89c756f8 100644
--- a/apps/web/content/docs/ui/hooks/use-before-unload.mdx
+++ b/apps/web/content/docs/ui/hooks/use-before-unload.mdx
@@ -1,6 +1,7 @@
---
title: useBeforeUnload
description: A custom hook to handle the beforeunload event in React applications
+icon: DoorOpen
---
## Example
diff --git a/apps/web/content/docs/ui/hooks/use-intersection-observer.mdx b/apps/web/content/docs/ui/hooks/use-intersection-observer.mdx
new file mode 100644
index 000000000..ca41daa73
--- /dev/null
+++ b/apps/web/content/docs/ui/hooks/use-intersection-observer.mdx
@@ -0,0 +1,98 @@
+---
+title: useIntersectionObserver
+description: Find out when an element scrolls into view - perfect for lazy loading, infinite feeds and "seen" tracking.
+icon: ScanEye
+---
+
+## Usage
+
+```ts
+import { useIntersectionObserver } from '@vitnode/core/hooks/use-intersection-observer'
+```
+
+```tsx
+export const ExampleComponent = () => {
+ const ref = React.useRef(null)
+ const entry = useIntersectionObserver(ref, { threshold: 0.5 }) // [!code ++]
+
+ return (
+
+ {entry?.isIntersecting ? 'I can see you!' : 'Nobody is looking...'}
+
+ )
+}
+```
+
+The hook returns the latest
+[`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry),
+or `undefined` until the browser reports the first one. Besides
+`isIntersecting` you also get `intersectionRatio`, `boundingClientRect` and
+friends.
+
+## Load once, then stop watching
+
+Set `freezeOnceVisible` and the hook stops observing the first time the element
+shows up. Handy for lazy loading or a one-off entrance animation - no point
+paying for an observer that has nothing left to do.
+
+```tsx
+const entry = useIntersectionObserver(ref, {
+ freezeOnceVisible: true,
+ rootMargin: '200px',
+})
+
+return {entry?.isIntersecting ? : null}
+```
+
+A positive `rootMargin` starts loading a little before the element is on
+screen, so people rarely catch it loading.
+
+## Inside a scroll container
+
+By default the hook watches the viewport. To watch a scrollable element
+instead, pass it as `root` - store it with a callback ref so the hook picks it
+up once it mounts:
+
+```tsx
+const [root, setRoot] = React.useState(null)
+const entry = useIntersectionObserver(targetRef, { root })
+
+return (
+
+ ...
+
+)
+```
+
+## Options
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+
+
+On the server, and in the rare browser without `IntersectionObserver`, the hook
+simply returns `undefined`.
diff --git a/apps/web/content/docs/ui/hooks/use-local-storage.mdx b/apps/web/content/docs/ui/hooks/use-local-storage.mdx
new file mode 100644
index 000000000..99a70e820
--- /dev/null
+++ b/apps/web/content/docs/ui/hooks/use-local-storage.mdx
@@ -0,0 +1,76 @@
+---
+title: useLocalStorage
+description: Like useState, but the value survives page reloads and stays in sync across tabs.
+icon: HardDrive
+---
+
+## Usage
+
+```ts
+import { useLocalStorage } from '@vitnode/core/hooks/use-local-storage'
+```
+
+```tsx
+export const ExampleComponent = () => {
+ const [name, setName] = useLocalStorage('name', '') // [!code ++]
+
+ return setName(e.target.value)} value={name} />
+}
+```
+
+It works just like `useState`: pass a key and a default value, get the value and
+a setter back. Values are saved as JSON, so numbers, booleans, arrays and plain
+objects all come back exactly as you stored them.
+
+## Update from the previous value
+
+The setter also takes a function, which always receives the freshest value:
+
+```ts
+const [count, setCount] = useLocalStorage('count', 0)
+
+setCount(previous => previous + 1)
+```
+
+## Reset
+
+The third item removes the key from storage, and the hook goes back to the
+default value:
+
+```ts
+const [filters, setFilters, resetFilters] = useLocalStorage('filters', [])
+```
+
+## Good to know
+
+- **Synced everywhere.** Every component using the same key updates together -
+ in this tab and in any other tab of your site.
+- **Server-safe.** During server rendering the hook returns the default value,
+ then switches to the stored one in the browser without a hydration mismatch.
+- **No crashes.** A broken stored value falls back to the default. When storage
+ is blocked (some private windows) or full, the value lives in memory for the
+ session instead.
+- **Only for nice-to-haves.** Local storage lives in one browser on one device.
+ Use it for things like a collapsed panel or a draft, never for data that has
+ to reach the server or other people.
+
+## Returns
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+ T)) => void',
+ },
+ '[2] removeValue': {
+ description: 'Removes the key and goes back to the default value.',
+ type: '() => void',
+ },
+ }}
+/>
diff --git a/apps/web/content/docs/ui/hooks/use-mobile.mdx b/apps/web/content/docs/ui/hooks/use-mobile.mdx
index 94f866bf2..600c1f6dd 100644
--- a/apps/web/content/docs/ui/hooks/use-mobile.mdx
+++ b/apps/web/content/docs/ui/hooks/use-mobile.mdx
@@ -1,6 +1,7 @@
---
title: useMobile
description: A custom hook to determine if the user is on a mobile device
+icon: Smartphone
---
## Example
diff --git a/apps/web/content/docs/ui/hover-card.mdx b/apps/web/content/docs/ui/hover-card.mdx
index 3ba044f21..8dccb795b 100644
--- a/apps/web/content/docs/ui/hover-card.mdx
+++ b/apps/web/content/docs/ui/hover-card.mdx
@@ -1,6 +1,7 @@
---
title: Hover Card
description: A component for displaying additional information on hover.
+icon: MousePointer2
---
## Preview
diff --git a/apps/web/content/docs/ui/image-zoom.mdx b/apps/web/content/docs/ui/image-zoom.mdx
new file mode 100644
index 000000000..17d3a950e
--- /dev/null
+++ b/apps/web/content/docs/ui/image-zoom.mdx
@@ -0,0 +1,88 @@
+---
+title: Image Zoom
+description: Click an image and it grows smoothly from its spot on the page to fill the screen.
+icon: ZoomIn
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import { ImageZoom } from '@vitnode/core/components/ui/image-zoom'
+```
+
+```tsx
+
+```
+
+Click the image (or focus it and press Enter ) and it glides from
+where it sits to the middle of the screen over a blurred backdrop. Click
+anywhere, press Escape or hit the × to send it back home.
+
+## Sharper zoom
+
+Show a light thumbnail on the page and load the big version only when someone
+zooms in:
+
+```tsx
+
+```
+
+## Styling
+
+- `className` styles the clickable wrapper - use it for layout and visibility
+ (`hidden dark:block` for theme-specific screenshots).
+- `imageClassName` styles the image on the page: size, border, rounded corners.
+
+The zoomed image always fits the screen without cropping.
+
+## Accessibility
+
+- The image is a real button, so it works with the keyboard and screen readers
+ announce that it opens a dialog.
+- `alt` is required - it labels the image on the page and names the zoomed view.
+- Focus is trapped while zoomed and returns to the image when you close it.
+- With reduced motion turned on, the image appears zoomed instantly instead of
+ gliding.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+
diff --git a/apps/web/content/docs/ui/input-group.mdx b/apps/web/content/docs/ui/input-group.mdx
index 868f0aff7..b0993e3f0 100644
--- a/apps/web/content/docs/ui/input-group.mdx
+++ b/apps/web/content/docs/ui/input-group.mdx
@@ -1,6 +1,7 @@
---
title: Input Group
description: Component used for grouping related input fields together
+icon: RectangleEllipsis
---
## Preview
diff --git a/apps/web/content/docs/ui/input-otp.mdx b/apps/web/content/docs/ui/input-otp.mdx
new file mode 100644
index 000000000..055333134
--- /dev/null
+++ b/apps/web/content/docs/ui/input-otp.mdx
@@ -0,0 +1,220 @@
+---
+title: Input OTP
+description: A one-time password input with separate slots, a blinking caret and digits that pop into place as you type.
+icon: KeyRound
+---
+
+## Preview
+
+
+
+## Usage
+
+import { Tab, Tabs } from "fumadocs-ui/components/tabs";
+
+
+
+
+```ts
+import { z } from 'zod'
+import { AutoForm } from '@vitnode/core/components/form/auto-form'
+import { AutoFormInputOTP } from '@vitnode/core/components/form/fields/input-otp'
+```
+
+```ts
+const formSchema = z.object({
+ code: z
+ .string()
+ .length(6, 'Enter all 6 digits')
+ .regex(/^\d+$/, 'Only digits, please'),
+})
+```
+
+```tsx
+ (
+
+ ),
+ },
+ ]}
+/>
+```
+
+The schema does the setup for you: `.length(6)` decides how many slots there
+are, and `.regex()` becomes the input's `pattern`, so letters never even make it
+into a digits-only code.
+
+
+
+
+
+```ts
+import {
+ InputOTP,
+ InputOTPGroup,
+ InputOTPSeparator,
+ InputOTPSlot,
+} from '@vitnode/core/components/ui/input-otp'
+```
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+
+
+
+Each new character rises into its slot with a quick fade and scale. Values that
+are already there when the input mounts (like a code restored from state) show
+up instantly, and with reduced motion turned on nothing moves at all.
+
+Under the hood there is a single real ` `, so pasting a whole code,
+password managers and the "fill code from SMS" suggestion on phones
+(`autocomplete="one-time-code"`) all work out of the box.
+
+## Groups
+
+`AutoFormInputOTP` builds the slots for you. Codes of 6 or more characters with
+an even length are split in half with a separator (`123-456`), anything else
+stays in one group. Pass `groups` to pick your own split:
+
+```tsx
+
+
+```
+
+## Submit when complete
+
+`onComplete` fires once every slot is filled - perfect for verifying the code
+without making people hunt for a button. It works on both `AutoFormInputOTP`
+and `InputOTP`.
+
+```tsx
+ verify(code)} />
+```
+
+## Wrong code
+
+A wrong code usually comes back from your API, not from the schema. Set it on
+the field with `setFormFieldError` and every slot turns red, with the message
+underneath:
+
+```ts
+import { setFormFieldError } from '@vitnode/core/components/ui/form'
+```
+
+```tsx
+ {
+ const res = await verifyCode(values.code)
+
+ if (!res.ok) {
+ setFormFieldError(form, 'code', "That code doesn't match.")
+ }
+ }}
+ fields={[...]}
+/>
+```
+
+Using `InputOTP` on its own? Set `aria-invalid` on it and link your message
+with `aria-describedby` - colour alone never tells the whole story.
+
+## Only digits (manual)
+
+Without a schema, restrict what can be typed with `pattern`. The common patterns
+are re-exported, so you don't need to install `input-otp` yourself:
+
+```ts
+import {
+ REGEXP_ONLY_CHARS,
+ REGEXP_ONLY_DIGITS,
+ REGEXP_ONLY_DIGITS_AND_CHARS,
+} from '@vitnode/core/components/ui/input-otp'
+```
+
+```tsx
+
+ ...
+
+```
+
+## Accessibility
+
+- `AutoFormInputOTP` wires the label, description and error message to the
+ input for you.
+- Using `InputOTP` directly? Pass it an `id`, point a `` at it
+ and describe where the code was sent with `aria-describedby`.
+- Slots and separators are hidden from screen readers - the real input already
+ announces the value, so nobody hears every digit twice.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+`AutoFormInputOTP` accepts every `InputOTP` prop (except `value` and
+`children`, which the form owns) plus:
+
+ void',
+ },
+ onComplete: {
+ description: 'Called once every slot is filled.',
+ type: '(value: string) => void',
+ },
+ pattern: {
+ description:
+ 'A regular expression the value must match. Defaults to the schema `.regex()`.',
+ type: 'string',
+ },
+ disabled: {
+ description: 'Disables the input.',
+ type: 'boolean',
+ },
+ }}
+/>
+
+## API Reference
+
+[input-otp](https://github.com/guilhermerodz/input-otp)
diff --git a/apps/web/content/docs/ui/input.mdx b/apps/web/content/docs/ui/input.mdx
index 86fb2ea3e..98c9c3e84 100644
--- a/apps/web/content/docs/ui/input.mdx
+++ b/apps/web/content/docs/ui/input.mdx
@@ -1,6 +1,7 @@
---
title: Input
description: Component used for collecting data from users
+icon: TextCursorInput
---
## Preview
diff --git a/apps/web/content/docs/ui/item.mdx b/apps/web/content/docs/ui/item.mdx
new file mode 100644
index 000000000..c1bd40a4c
--- /dev/null
+++ b/apps/web/content/docs/ui/item.mdx
@@ -0,0 +1,212 @@
+---
+title: Item
+description: A flexible row for lists, settings and menus - media, a title, a description and actions, all lined up without the CSS headache.
+icon: LayoutList
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemMedia,
+ ItemTitle,
+} from '@vitnode/core/components/ui/item'
+```
+
+```tsx
+-
+
+
+
+
+ Two-factor authentication
+ Add a second lock to your account.
+
+
+
+ Enable
+
+
+
+```
+
+Every app ends up with a dozen slightly different "icon, text, button on the
+right" rows. `Item` is that row, built once, so you can stop rebuilding it.
+
+## Anatomy
+
+- `Item` - the row itself. Flexbox, wraps when it has to.
+- `ItemMedia` - an icon, avatar or image on the start side. With a description
+ present it aligns itself to the top of the text.
+- `ItemContent` - stacks the title and description and takes the free space.
+- `ItemTitle` - one line, truncated if it gets chatty.
+- `ItemDescription` - up to two lines of muted text. Links inside are
+ underlined automatically.
+- `ItemActions` - buttons, badges or a chevron on the end side.
+- `ItemHeader` / `ItemFooter` - full-width rows above and below the content.
+- `ItemGroup` / `ItemSeparator` - a list of items and a line between them.
+
+## Variants
+
+`default` has no border or background, `outline` adds a border, and `muted`
+adds a soft background.
+
+
+
+```tsx
+
+
+
+```
+
+## Sizes
+
+`default`, `sm` and `xs` shrink the padding and the gaps. `ItemMedia` with the
+`image` variant shrinks along with them, and `xs` also uses smaller description
+text.
+
+
+
+```tsx
+-
+ ...
+
+```
+
+## Media
+
+`ItemMedia` comes in three flavours:
+
+- `default` - no styling at all, perfect for an `Avatar`.
+- `icon` - sizes a bare SVG icon to `size-4`.
+- `image` - a rounded square that crops an `img` to fit.
+
+```tsx
+
+
+
+```
+
+## As a link
+
+Pass an element to `render` and the whole row becomes that element - an
+``, a router `Link`, a ``, whatever you need. Rendered as a link it
+picks up a hover background for free.
+
+
+
+```tsx
+-
+
+ Read the Avatar docs
+
+
+
+
+
+ }
+/>
+```
+
+Put the row's content inside the rendered element, as above, so the link wraps
+everything you see.
+
+## Groups
+
+Wrap items in `ItemGroup` to stack them, and drop an `ItemSeparator` between
+them when they need some breathing room.
+
+
+
+```tsx
+
+ - ...
+
+ - ...
+
+```
+
+## Header and footer
+
+`ItemHeader` and `ItemFooter` take the full width of the row, so a status badge
+or a timestamp can sit above or below the main content.
+
+
+
+```tsx
+-
+
+ Active
+
+ ...
+
+ Edit
+
+
+```
+
+## Accessibility
+
+- `ItemGroup` renders with `role="list"`. Give every `Item` inside it
+ `role="listitem"`, and an `aria-label` on the group helps screen readers
+ announce what the list is about.
+- Keep one interactive element per row. Either the whole row is a link or
+ button (via `render`), or it holds buttons in `ItemActions` - never a button
+ inside a link.
+- Icon-only buttons in `ItemActions` need an `aria-label` that names the row,
+ for example "Invite Ada Lovelace" rather than just "Invite".
+- Decorative icons such as a trailing chevron get `aria-hidden`. Links that
+ open a new tab say so with `sr-only` text.
+- Rows rendered as links or buttons show the focus ring on keyboard focus.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+### Item
+
+ ReactElement)',
+ },
+ }}
+/>
+
+### ItemMedia
+
+
+
+## API Reference
+
+[Base UI - useRender](https://base-ui.com/react/utils/use-render)
diff --git a/apps/web/content/docs/ui/kbd.mdx b/apps/web/content/docs/ui/kbd.mdx
new file mode 100644
index 000000000..ea6b91b51
--- /dev/null
+++ b/apps/web/content/docs/ui/kbd.mdx
@@ -0,0 +1,170 @@
+---
+title: Kbd
+description: Show keyboard keys and shortcuts like ⌘ K in a way both eyes and screen readers understand.
+icon: Keyboard
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import { Kbd, KbdGroup } from '@vitnode/core/components/ui/kbd'
+```
+
+```tsx
+Esc
+```
+
+`Kbd` renders a real `` element, so browsers, search engines and assistive
+tech all know it is a key - not just a tiny gray box that happens to look like
+one.
+
+## Shortcuts
+
+Wrap the keys of a combination in `KbdGroup`. It lines them up with a small gap
+and stays a `` too, which is exactly how HTML describes a key combo.
+
+```tsx
+
+ Ctrl
+ K
+
+```
+
+Prefer a visible plus sign? Drop it between the keys and hide it from screen
+readers, they already hear two keys:
+
+```tsx
+
+ Ctrl
+ +
+ C
+
+```
+
+## Inside a button
+
+Hint at the shortcut right on the action. `Kbd` sizes itself to the text, so it
+fits small buttons without extra classes.
+
+```tsx
+
+ Accept
+ Enter
+
+```
+
+## Inside a tooltip
+
+In a [Tooltip](/docs/ui/tooltip) the keys switch to a translucent style that
+stays readable on the dark tooltip background, in both themes. No props needed.
+
+```tsx
+
+ Save changes
+
+ Ctrl
+ S
+
+ >
+ }
+>
+
+
+
+
+```
+
+## Inside an input
+
+Pair it with an [Input Group](/docs/ui/input-group) addon for the classic
+"press ⌘ K to search" field.
+
+```tsx
+
+
+
+
+
+
+
+ Ctrl
+ K
+
+
+
+```
+
+## ⌘ on Mac, Ctrl everywhere else
+
+Showing ⌘ to a Windows user is a small betrayal. Detect the platform on the
+client, but render the same thing on the server and during hydration, or React
+will complain about mismatched HTML. `useSyncExternalStore` does exactly that:
+the third argument is used for the server render and hydration, then React
+switches to the real value.
+
+```tsx
+const subscribeToNothing = () => () => {}
+
+const useIsApple = () =>
+ React.useSyncExternalStore(
+ subscribeToNothing,
+ () => /Mac|iPhone|iPad|iPod/.test(navigator.userAgent),
+ () => true,
+ )
+
+const ModifierKey = () => (useIsApple() ? ⌘ : Ctrl )
+```
+
+No `useEffect` plus `setState` dance required.
+
+## Accessibility
+
+- Keep the `` element. It tells assistive tech "this is a key", which a
+ styled `` never will.
+- Do not rely on symbols alone. Screen readers announce ⌘ or ⇧ inconsistently,
+ if at all, so pair each symbol with a readable name:
+
+ ```tsx
+
+ ⌘
+ Command
+
+ ```
+
+ An `aria-label` on a plain `` is not reliably read out, so visually
+ hidden text is the safer bet. `title` adds a hover hint for sighted users.
+- `Kbd` has `pointer-events-none` and `select-none`. It is a hint, not a
+ control - if pressing the key should do something, listen for the key
+ itself (and put the action on a real button).
+- Do not make a shortcut the only way to do something. Mouse, touch and
+ keyboard users should all get there.
+
+## Props
+
+Both components accept every prop of the native element they render.
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+`.',
+ type: "React.ComponentProps<'kbd'>",
+ },
+ KbdGroup: {
+ description:
+ 'A key combination. Renders `` around the keys.',
+ type: "React.ComponentProps<'div'>",
+ },
+ }}
+/>
+
+## API Reference
+
+[MDN - The Keyboard Input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/kbd)
diff --git a/apps/web/content/docs/ui/label.mdx b/apps/web/content/docs/ui/label.mdx
new file mode 100644
index 000000000..20d9966d0
--- /dev/null
+++ b/apps/web/content/docs/ui/label.mdx
@@ -0,0 +1,124 @@
+---
+title: Label
+description: An accessible text label for form controls - click it and the control it names responds.
+icon: TextInitial
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import { Label } from '@vitnode/core/components/ui/label'
+```
+
+```tsx
+Email
+
+```
+
+`Label` is a styled native ``. Point `htmlFor` at the control's `id` and
+the browser handles the rest: the whole label becomes a click target, and
+screen readers announce the text when the control gets focus.
+
+## Using AutoForm? You already have labels
+
+[AutoForm](/docs/ui/auto-form) fields render their own label (`AutoFormLabel`)
+from the `label` prop, wired to the right `id`, with an "Optional" hint when the
+schema says so. Don't add a `Label` next to an AutoForm field - you'd just get
+two.
+
+```tsx
+
+```
+
+Reach for `Label` when you build a control by hand: a settings toggle outside a
+form, a custom picker, a filter bar. For a full field layout with description
+and error message, see [Field](/docs/ui/field) - `FieldLabel` is built on top
+of this component.
+
+## With a checkbox
+
+Put the control first and the label after it. The gap and alignment are built
+in, so clicking anywhere on the text toggles the checkbox.
+
+```tsx
+
+
+ I promise I read the terms
+
+```
+
+## Wrapping the control
+
+No `id` handy? Wrap the control instead - a `` is associated with the
+control inside it automatically.
+
+```tsx
+
+
+ Remember me
+
+```
+
+## Disabled controls
+
+Disable the control and the label follows along: it dims and shows a
+`not-allowed` cursor, so nobody wonders why clicking it does nothing.
+
+```tsx
+
+
+ Weekly newsletter (coming soon)
+
+```
+
+This works when the control comes **before** the label as a sibling and has the
+`peer` class - VitNode's `Checkbox`, `Switch` and `RadioGroupItem` already do.
+`Label` reacts to both a native `disabled` attribute and the `data-disabled`
+attribute Base UI puts on its controls.
+
+Label sits somewhere else? Put `group` and `data-disabled="true"` on a shared
+parent instead - that is what [Field](/docs/ui/field) does for you.
+
+## Accessibility
+
+- Always associate the label with its control, either with `htmlFor` + `id` or
+ by wrapping the control. A label floating next to an input is just text to a
+ screen reader.
+- Keep the visible text meaningful. "Email" beats "Field 1", and placeholders
+ are not labels - they vanish the moment someone starts typing.
+- The label is part of the hit area. That makes small controls like checkboxes
+ much easier to tap on a phone, so keep the text next to the control rather
+ than far away.
+- `select-none` stops accidental text selection when someone clicks the label
+ quickly a few times.
+
+## Props
+
+Accepts every prop of the native `` element.
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+
+
+## API Reference
+
+[MDN - The Label element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/label)
diff --git a/apps/web/content/docs/ui/marker.mdx b/apps/web/content/docs/ui/marker.mdx
new file mode 100644
index 000000000..80c0e225a
--- /dev/null
+++ b/apps/web/content/docs/ui/marker.mdx
@@ -0,0 +1,170 @@
+---
+title: Marker
+description: Inline status lines, system notes, bordered rows and labeled separators for conversations and activity feeds.
+icon: Flag
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import {
+ Marker,
+ MarkerContent,
+ MarkerIcon,
+} from '@vitnode/core/components/ui/marker'
+```
+
+```tsx
+
+
+
+
+ Explored 4 files
+
+```
+
+A marker is the quiet line between messages - "Switched branch", "Today",
+"Thinking...". It pairs nicely with [Bubble](/docs/ui/bubble) when you build a
+chat thread, and it is just as happy in an activity feed.
+
+```text
+Marker
+├── MarkerIcon
+└── MarkerContent
+```
+
+## Variants
+
+Pick a `variant` to change how the marker sits in the flow.
+
+```tsx
+
+ A default marker for inline notes.
+
+
+
+ Today
+
+
+
+ A border marker for row boundaries.
+
+```
+
+| Variant | What it looks like |
+| ----------- | ---------------------------------------------------- |
+| `default` | An inline line of muted text for notes and actions. |
+| `separator` | A centered label with a divider line on each side. |
+| `border` | A default marker with a bottom border under the row. |
+
+Keep separator labels short - a date or a few words. The divider lines take the
+leftover space, so a long label leaves them nothing to work with.
+
+## With an icon
+
+Wrap any icon in `MarkerIcon`. It is sized to match the text and hidden from
+screen readers, so the content does the talking. Add `flex-col` to stack the
+icon above the text:
+
+```tsx
+
+
+
+
+ Syncing completed
+
+```
+
+## Status and shimmer
+
+For something that is still happening, set `role="status"` so the update gets
+announced, drop a `Spinner` in the icon slot and give the
+text the `shimmer` utility for that "working on it" glow.
+
+```tsx
+import { Spinner } from '@vitnode/core/components/ui/spinner'
+
+;
+
+
+
+ Thinking...
+
+```
+
+## Links and buttons
+
+The root is polymorphic. Pass `render` to turn a marker into a link or a
+button - links get an underline for free.
+
+```tsx
+ }>
+ View installed plugins
+
+
+
+ }
+>
+
+
+
+ Revert this change
+
+```
+
+## Accessibility
+
+- A marker is a plain `div` by default. Choose the semantics from what it does:
+ `role="status"` for live progress, `render` with an `a` or `button` when it is
+ clickable, nothing at all for a simple note.
+- A labeled separator needs no role. The lines are decorative pseudo-elements and
+ the label is read as ordinary text. Do not add `role="separator"` - it would
+ hide the label from assistive tech.
+- `MarkerIcon` is `aria-hidden`, so the meaning lives in `MarkerContent`. For an
+ icon-only marker, give the root an `aria-label`.
+- Wrap a whole thread in an element with `role="log"` so new messages and
+ markers are announced in order.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+### Marker
+
+ ReactElement)',
+ },
+ role: {
+ description: 'Use `status` for markers that announce live progress.',
+ type: 'AriaRole',
+ },
+ }}
+/>
+
+### MarkerIcon and MarkerContent
+
+Both render a `span` and accept every standard `span` prop, including
+`className`.
+
+## API Reference
+
+[Base UI - useRender](https://base-ui.com/react/utils/use-render)
diff --git a/apps/web/content/docs/ui/menubar.mdx b/apps/web/content/docs/ui/menubar.mdx
new file mode 100644
index 000000000..67b198ada
--- /dev/null
+++ b/apps/web/content/docs/ui/menubar.mdx
@@ -0,0 +1,246 @@
+---
+title: Menubar
+description: A desktop-style menu bar with File, Edit and View menus, shortcuts, submenus, checkbox and radio items.
+icon: PanelTopOpen
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import {
+ Menubar,
+ MenubarContent,
+ MenubarItem,
+ MenubarMenu,
+ MenubarSeparator,
+ MenubarShortcut,
+ MenubarTrigger,
+} from '@vitnode/core/components/ui/menubar'
+```
+
+```tsx
+
+
+ File
+
+
+ New tab
+ ⌘T
+
+ New window
+
+ Print...
+
+
+
+ Edit
+
+ Undo
+ Redo
+
+
+
+```
+
+Every `MenubarMenu` is one menu in the bar: a trigger plus its content. Open one
+and the bar remembers - hover the next trigger and it opens straight away, just
+like the menu bar at the top of your desktop. Handy for editors, dashboards and
+anything else with more commands than buttons.
+
+## Submenus
+
+Nest a `MenubarSub` to tuck related actions one level deeper. It opens on hover,
+on click, or with the arrow keys.
+
+```tsx
+import {
+ MenubarSub,
+ MenubarSubContent,
+ MenubarSubTrigger,
+} from '@vitnode/core/components/ui/menubar'
+
+
+ Share
+
+ Email link
+ Messages
+
+
+```
+
+## Checkbox and radio items
+
+Use `MenubarCheckboxItem` for independent toggles and a `MenubarRadioGroup`
+when exactly one option can win. Both work controlled or uncontrolled.
+
+```tsx
+import {
+ MenubarCheckboxItem,
+ MenubarLabel,
+ MenubarRadioGroup,
+ MenubarRadioItem,
+} from '@vitnode/core/components/ui/menubar'
+
+const [showBookmarks, setShowBookmarks] = React.useState(true)
+const [profile, setProfile] = React.useState('benoit')
+
+
+ Always show bookmarks bar
+
+
+Switch profile
+
+ Andy
+ Benoit
+
+```
+
+The check mark sits at the start of the row. Add `inset` to plain items, labels
+and sub-triggers in the same menu so their text lines up with the checked ones.
+
+## Shortcuts
+
+`MenubarShortcut` shows a keyboard hint at the end of an item. It is only a
+label - wire the actual key binding yourself, otherwise it is a promise you are
+not keeping.
+
+```tsx
+
+ Undo
+ ⌘Z
+
+```
+
+## Destructive items
+
+Set `variant="destructive"` for actions you cannot take back, so they look the
+part.
+
+```tsx
+Close all windows
+```
+
+## Accessibility
+
+- The bar is a single tab stop. Once a trigger has focus, the left and right
+ arrows move between menus, and when a menu is open they move the open menu
+ with you.
+- Inside a menu, the up and down arrows move between items, the right arrow
+ opens a submenu, typing a letter jumps to a matching item and `Escape`
+ closes the menu and returns focus to its trigger.
+- Shortcuts are labels only. Screen readers read them as part of the item, but
+ pressing the keys does nothing until you bind them.
+- Disabled items stay visible and are announced as disabled, so nobody wonders
+ where "Force reload" went.
+- A menu bar is a desktop pattern. On small screens keep it short, or offer the
+ same actions in a [Dropdown Menu](/docs/ui/dropdown-menu).
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+### Menubar
+
+
+
+### MenubarContent
+
+
+
+### MenubarItem
+
+ void',
+ },
+ }}
+/>
+
+`MenubarCheckboxItem` takes `checked`, `defaultChecked` and `onCheckedChange`;
+`MenubarRadioGroup` takes `value`, `defaultValue` and `onValueChange`. Both item
+types accept `inset` and `disabled` too. `MenubarMenu` takes `open`,
+`defaultOpen` and `onOpenChange` when you want to drive a single menu yourself.
+
+## API Reference
+
+[Base UI - Menubar](https://base-ui.com/react/components/menubar#api-reference)
diff --git a/apps/web/content/docs/ui/message.mdx b/apps/web/content/docs/ui/message.mdx
new file mode 100644
index 000000000..20aa62198
--- /dev/null
+++ b/apps/web/content/docs/ui/message.mdx
@@ -0,0 +1,213 @@
+---
+title: Message
+description: Lay out a chat message with an avatar, sender name, timestamp, footer actions and start or end alignment.
+icon: MessagesSquare
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from '@vitnode/core/components/ui/avatar'
+import { Bubble, BubbleContent } from '@vitnode/core/components/ui/bubble'
+import {
+ Message,
+ MessageAvatar,
+ MessageContent,
+} from '@vitnode/core/components/ui/message'
+```
+
+```tsx
+
+
+
+
+ VN
+
+
+
+
+ How can I help you today?
+
+
+
+```
+
+`Message` owns the row: who is talking, which side they sit on, and what goes
+above or below. The actual speech bubble is a [Bubble](/docs/ui/bubble) - the
+two are designed to be best friends.
+
+## Composition
+
+```text
+MessageGroup
+└── Message
+ ├── MessageAvatar
+ └── MessageContent
+ ├── MessageHeader
+ ├── Bubble / Attachment
+ └── MessageFooter
+```
+
+## Alignment
+
+Set `align="end"` for messages the current user sent. The avatar swaps sides,
+and every bubble, attachment, header and footer inside `MessageContent` moves to
+the end - no need to set `align` on each `Bubble`.
+
+```tsx
+
+
+
+ Sent by me, obviously.
+
+
+
+```
+
+`start` and `end` are logical, so in right-to-left languages the sides flip on
+their own.
+
+## Avatar
+
+`MessageAvatar` sticks to the bottom of the message, next to the last bubble.
+When the message has a `MessageFooter`, the avatar lifts up so it lines up with
+the bubble instead of the footer.
+
+## Group
+
+Wrap consecutive messages from the same sender in `MessageGroup` to tighten the
+spacing. Give the earlier messages an empty `MessageAvatar` so their bubbles line
+up with the one that shows the avatar.
+
+```tsx
+
+
+
+
+
+ First thought.
+
+
+
+
+
+
+ AV
+
+
+
+
+ Second thought, now with a face.
+
+
+
+
+```
+
+Need several bubbles inside a single message instead? Use
+[`BubbleGroup`](/docs/ui/bubble#grouping) inside `MessageContent`.
+
+## Header and footer
+
+`MessageHeader` is the spot for a sender name and timestamp. `MessageFooter`
+holds metadata such as a read receipt and follows the message to its side.
+
+```tsx
+
+
+
+ Ava
+ 9:41 AM
+
+
+ Did the plugin build pass?
+
+ Read
+
+
+```
+
+## Actions
+
+Put message-level actions such as copy, retry or feedback in the footer.
+
+```tsx
+
+
+
+
+
+```
+
+## Attachments
+
+Drop an [Attachment](/docs/ui/attachment) next to the bubble inside
+`MessageContent`. It aligns with the message like everything else.
+
+```tsx
+
+
+
+
+
+
+
+ build-log.txt
+ TXT · 12 KB
+
+
+
+ Log attached.
+
+
+
+```
+
+## Accessibility
+
+- `Message` is a layout wrapper with no role of its own. Put `role="log"` and an
+ `aria-label` on the conversation container so new messages are announced
+ politely.
+- Alignment only tells sighted users who is speaking. Name the sender in text
+ too: a visible `MessageHeader`, or one with `className="sr-only"` for your own
+ messages.
+- Wrap timestamps in `` so they stay machine-readable
+ while showing a friendly format.
+- Give `AvatarImage` an `alt` with the sender's name. If a `MessageHeader`
+ already names them, mark the `MessageAvatar` `aria-hidden="true"` so screen
+ readers do not hear the name twice.
+- Icon-only footer actions need an `aria-label`.
+- For "someone is typing" hints, use a [Marker](/docs/ui/marker) with
+ `role="status"`.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+### Message
+
+
+
+`MessageGroup`, `MessageAvatar`, `MessageContent`, `MessageHeader` and
+`MessageFooter` accept every `div` prop, including `className`.
+
+## API Reference
+
+[shadcn/ui - Message](https://ui.shadcn.com/docs/components/base/message)
diff --git a/apps/web/content/docs/ui/meta.json b/apps/web/content/docs/ui/meta.json
index 71129e393..5be55f704 100644
--- a/apps/web/content/docs/ui/meta.json
+++ b/apps/web/content/docs/ui/meta.json
@@ -14,13 +14,18 @@
"confirm-action-alert-dialog",
"---Forms---",
"auto-form",
+ "cascader",
"checkbox",
"color",
"combobox",
"editor",
+ "field",
"input",
"input-group",
+ "input-otp",
+ "label",
"nullable-number",
+ "number-field",
"radio-group",
"roles",
"select",
@@ -28,6 +33,10 @@
"textarea",
"user",
"---UI---",
- "..."
+ "...",
+ "---Utilities---",
+ "counter",
+ "scroll-fade",
+ "shimmer"
]
}
diff --git a/apps/web/content/docs/ui/nullable-number.mdx b/apps/web/content/docs/ui/nullable-number.mdx
index 5cb5b8ca9..46f09945a 100644
--- a/apps/web/content/docs/ui/nullable-number.mdx
+++ b/apps/web/content/docs/ui/nullable-number.mdx
@@ -1,12 +1,17 @@
---
title: Nullable Number
description: Numeric field paired with a checkbox that toggles the value to null - for "unlimited", "never", "no limit", and similar.
+icon: Hash
---
## Preview
+It builds on [Number Field](/docs/ui/number-field), so you get the plus and
+minus buttons, arrow-key stepping and min/max clamping for free - plus one
+checkbox for when the honest answer is "no limit".
+
## Usage
```ts
@@ -41,7 +46,8 @@ const formSchema = z.object({
The field value is `number | null`. A number is whatever is typed in the
- input; `null` means the checkbox is checked and the input is disabled. Back it
+ input (clearing it gives `0`); `null` means the checkbox is checked and the
+ number field is disabled. Back it
with a `z.number().nullable()` schema, and keep it optional or give it a
`default` when the field can be
[hidden](/docs/ui/auto-form#conditional-fields) so it never blocks submission.
@@ -68,8 +74,22 @@ and so on:
/>
```
-Any other props (`min`, `max`, `step`, `placeholder`, …) are forwarded to the
-underlying number input; validation constraints come from the Zod schema.
+Any other props (`min`, `max`, `step`, `largeStep`, `format`, `placeholder`,
+…) are forwarded to the underlying
+[Number Field](/docs/ui/number-field#props). Validation comes from the Zod
+schema, while `min` / `max` on the field clamp the buttons and arrow keys - so
+pass them in both places.
+
+```tsx
+
+```
## Props
diff --git a/apps/web/content/docs/ui/number-field.mdx b/apps/web/content/docs/ui/number-field.mdx
new file mode 100644
index 000000000..f2e7edd5e
--- /dev/null
+++ b/apps/web/content/docs/ui/number-field.mdx
@@ -0,0 +1,270 @@
+---
+title: Number Field
+description: A number input with plus and minus buttons, keyboard stepping, min/max clamping, Intl formatting and drag-to-scrub labels.
+icon: Diff
+---
+
+## Preview
+
+
+
+## Usage
+
+import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
+
+
+
+
+
+
+```ts
+import { z } from 'zod'
+import { AutoForm } from '@vitnode/core/components/form/auto-form'
+import { AutoFormNumber } from '@vitnode/core/components/form/fields/number'
+```
+
+```ts
+const formSchema = z.object({
+ guests: z.number().int().min(1).max(12).default(2),
+})
+```
+
+```tsx
+ (
+
+ ),
+ },
+ ]}
+/>
+```
+
+The form stores a real `number`, not a string. Clearing the input stores
+`null`, so pair it with `.optional()` or a `default` when empty is allowed.
+Pass `min`/`max` to the field as well as the schema - the schema validates,
+the props clamp the buttons and the arrow keys.
+
+Need an "unlimited" checkbox next to it? That's
+[Nullable Number](/docs/ui/nullable-number).
+
+
+
+
+
+```ts
+import {
+ NumberField,
+ NumberFieldDecrement,
+ NumberFieldGroup,
+ NumberFieldIncrement,
+ NumberFieldInput,
+} from '@vitnode/core/components/ui/number-field'
+```
+
+```tsx
+const [value, setValue] = React.useState(2)
+
+
+
+
+
+
+
+
+```
+
+Prefer uncontrolled? Swap `value` for `defaultValue` and listen with
+`onValueCommitted`, which fires once the user is done (on blur, or when the
+button is released) instead of on every keystroke.
+
+
+
+
+## Min, max and step
+
+- `min` / `max` - the buttons, arrow keys and scrubbing stop at the limits, and
+ the button that would cross one is disabled. A typed value is clamped on blur.
+- `step` - how much one click or arrow press moves the value (default `1`).
+- `largeStep` - used with Shift held (default `10`).
+- `smallStep` - used with Alt (default `0.1`).
+- `snapOnStep` - snap to the nearest multiple of the step instead of adding it
+ to whatever was typed.
+
+```tsx
+
+ …
+
+```
+
+## Formatting
+
+`format` takes [`Intl.NumberFormatOptions`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#options),
+so currencies, percentages and units come for free. The value stays a plain
+number - `0.15` shows as `15%`, and typing `20%` gives you `0.2` back.
+
+
+
+```tsx
+
+ …
+
+
+
+ …
+
+```
+
+Numbers are formatted in the current app locale, so Polish users see `12 345`
+and English users see `12,345`. Pass `locale` to override it.
+
+## Scrub area
+
+Wrap the label in `NumberFieldScrubArea` and the label becomes a slider: drag
+it left or right to change the value, like in a design tool. Give it the same
+`htmlFor` as the field's `id` so clicking the label still focuses the input.
+
+```tsx
+const id = React.useId()
+
+
+
+
+
+
+
+
+
+```
+
+Want the mouse wheel to change the value while the input is focused? Add
+`allowWheelScrub`.
+
+## Sizes
+
+
+
+```tsx
+…
+…
+…
+```
+
+## Disabled and read-only
+
+`disabled` dims the whole field and ignores every interaction. `readOnly`
+keeps the value readable and focusable but locks the buttons and the keys.
+
+## Accessibility
+
+- The input stays a text field (so formatted values like `$19.99` work) with
+ `inputMode` set, so phones open the right keyboard.
+- Arrow Up / Arrow Down step by `step`, hold
+ Shift for `largeStep` or Alt for `smallStep`.
+ Home / End jump to `min` / `max` when set.
+- The plus and minus buttons are labelled "Increase" and "Decrease" in the
+ user's language and are skipped by Tab - the keyboard already has
+ the arrow keys. On touch screens their hit area grows to 44px.
+- Name the input with a label (`htmlFor` + `id`), a scrub area, or
+ `aria-label`. `AutoFormNumber` does it for you.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+ void',
+ },
+ onValueCommitted: {
+ description:
+ 'Called once the change is final - on blur, or when a button or scrub is released.',
+ type: '(value: number | null) => void',
+ },
+ min: {
+ description: 'The lowest allowed value.',
+ type: 'number',
+ },
+ max: {
+ description: 'The highest allowed value.',
+ type: 'number',
+ },
+ step: {
+ description: 'Amount for one click or arrow press.',
+ type: 'number | "any"',
+ default: '1',
+ },
+ largeStep: {
+ description: 'Amount with Shift held.',
+ type: 'number',
+ default: '10',
+ },
+ smallStep: {
+ description: 'Amount with Alt held.',
+ type: 'number',
+ default: '0.1',
+ },
+ format: {
+ description: 'How the value is displayed.',
+ type: 'Intl.NumberFormatOptions',
+ },
+ locale: {
+ description: 'Locale used for formatting and parsing.',
+ type: 'Intl.LocalesArgument',
+ default: 'the app locale',
+ },
+ size: {
+ description: 'Height of the field.',
+ type: '"sm" | "default" | "lg"',
+ default: '"default"',
+ },
+ allowWheelScrub: {
+ description: 'Change the value with the mouse wheel while focused.',
+ type: 'boolean',
+ default: 'false',
+ },
+ disabled: {
+ description: 'Ignores every interaction.',
+ type: 'boolean',
+ default: 'false',
+ },
+ readOnly: {
+ description: 'Shows the value but blocks changes.',
+ type: 'boolean',
+ default: 'false',
+ },
+ }}
+/>
+
+`AutoFormNumber` takes all of the props above except `value`,
+`defaultValue` and `onValueChange` (the form owns those), plus `label`,
+`description`, `placeholder` and `unitLabel`.
+
+## API Reference
+
+[Base UI - Number Field](https://base-ui.com/react/components/number-field)
diff --git a/apps/web/content/docs/ui/pagination.mdx b/apps/web/content/docs/ui/pagination.mdx
new file mode 100644
index 000000000..7c422113c
--- /dev/null
+++ b/apps/web/content/docs/ui/pagination.mdx
@@ -0,0 +1,215 @@
+---
+title: Pagination
+description: Page numbers, previous and next links, and an ellipsis for when there are more pages than anyone wants to click through.
+icon: ChevronsLeftRightEllipsis
+---
+
+## Preview
+
+
+
+
+ The [Data Table](/docs/ui/data-table) already paginates for you - page
+ numbers, page size and URL state included. Reach for `Pagination` directly
+ when you render your own list, feed or gallery.
+
+
+## Usage
+
+```ts
+import {
+ Pagination,
+ PaginationContent,
+ PaginationEllipsis,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from '@vitnode/core/components/ui/pagination'
+```
+
+```tsx
+
+
+
+
+
+
+ 1
+
+
+
+ 2
+
+
+
+ 3
+
+
+
+
+
+
+
+
+
+```
+
+Every link renders a real ``, so pages stay crawlable and middle-click still
+opens a new tab. `PaginationPrevious` and `PaginationNext` hide their text below
+the `sm` breakpoint and keep just the chevron, which also flips in RTL
+languages.
+
+## With router links
+
+`PaginationLink` takes any anchor prop. Keep a real `href` for crawlers and new
+tabs, then let TanStack Router handle the click so the page does not reload:
+
+```tsx
+import { useNavigate } from '@tanstack/react-router'
+
+const navigate = useNavigate()
+
+const goTo = (page: number) => (event: React.MouseEvent) => {
+ event.preventDefault()
+ void navigate({ to: '.', search: prev => ({ ...prev, page }) })
+}
+
+
+ {page}
+
+```
+
+## Ellipsis
+
+Twelve pages do not need twelve buttons. Show the first page, the last page and
+the one you are on, and let `PaginationEllipsis` stand in for the rest. A tiny
+helper keeps the window at five slots, so the row never jumps around:
+
+```tsx
+type PageSlot = 'ellipsis-end' | 'ellipsis-start' | number
+
+const range = (from: number, to: number) =>
+ Array.from({ length: to - from + 1 }, (_, step) => from + step)
+
+const pageWindow = (current: number, total: number): PageSlot[] => {
+ if (total <= 5) return range(1, total)
+ if (current <= 3) return [1, 2, 3, 'ellipsis-end', total]
+ if (current >= total - 2) {
+ return [1, 'ellipsis-start', ...range(total - 2, total)]
+ }
+
+ return [1, 'ellipsis-start', current, 'ellipsis-end', total]
+}
+```
+
+```tsx
+{pageWindow(page, total).map(slot => (
+
+ {typeof slot === 'number' ? (
+
+ {slot}
+
+ ) : (
+
+ )}
+
+))}
+```
+
+## Small screens
+
+Even five numbers get crowded on a phone. Give every number's `PaginationItem`
+`className="hidden sm:block"` and show a "Page 2 of 12" line between the arrows
+instead - the same trick the [Data Table](/docs/ui/data-table) uses:
+
+```tsx
+
+
+ Page {page} of {total}
+
+
+```
+
+## Disabled ends
+
+There is no page zero (we checked). On the first and last page, drop the `href`
+so the keyboard skips the link, and add `role="link"` with `aria-disabled` so
+screen readers still hear it as an unavailable link:
+
+```tsx
+ 1
+ ? { href: `?page=${page - 1}` }
+ : { 'aria-disabled': true, className: 'pointer-events-none', role: 'link' })}
+/>
+```
+
+## Custom labels
+
+The previous and next labels are translated out of the box. Pass `text` when
+your feed deserves something more specific:
+
+```tsx
+
+
+```
+
+## Accessibility
+
+- `Pagination` is a `nav` landmark labelled "pagination", so screen reader users
+ can jump straight to it.
+- The active link gets `aria-current="page"`, which is announced as the current
+ page.
+- `PaginationPrevious` and `PaginationNext` keep a translated label ("Previous
+ page", "Next page") even when only the chevron is visible.
+- Page links are plain anchors named by their number, so "2" is read as
+ "link, 2" rather than a generic "Go to page".
+- `PaginationEllipsis` is hidden from assistive technology - it is decoration,
+ not a link.
+- Announce page changes that do not reload the page, for example with an
+ `aria-live="polite"` "Page 2 of 12" line like in the preview.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+### PaginationLink
+
+
+
+### PaginationPrevious / PaginationNext
+
+Accept every `PaginationLink` prop, plus:
+
+
+
+## API Reference
+
+[shadcn/ui - Pagination](https://ui.shadcn.com/docs/components/base/pagination)
diff --git a/apps/web/content/docs/ui/popover.mdx b/apps/web/content/docs/ui/popover.mdx
index ef4e8e5a2..00d478bba 100644
--- a/apps/web/content/docs/ui/popover.mdx
+++ b/apps/web/content/docs/ui/popover.mdx
@@ -1,6 +1,7 @@
---
title: Popover
description: A popover component for displaying additional content on user interaction.
+icon: MessageSquare
---
## Preview
diff --git a/apps/web/content/docs/ui/progress.mdx b/apps/web/content/docs/ui/progress.mdx
index 45072044a..770bf8df0 100644
--- a/apps/web/content/docs/ui/progress.mdx
+++ b/apps/web/content/docs/ui/progress.mdx
@@ -1,6 +1,7 @@
---
title: Progress
description: A progress component for indicating the completion of a task.
+icon: Hourglass
---
## Preview
diff --git a/apps/web/content/docs/ui/radio-group.mdx b/apps/web/content/docs/ui/radio-group.mdx
index 07f31c261..650184f41 100644
--- a/apps/web/content/docs/ui/radio-group.mdx
+++ b/apps/web/content/docs/ui/radio-group.mdx
@@ -1,6 +1,7 @@
---
title: Radio Group
description: Pick one option from a list.
+icon: CircleDot
---
## Preview
diff --git a/apps/web/content/docs/ui/resizable.mdx b/apps/web/content/docs/ui/resizable.mdx
new file mode 100644
index 000000000..02c6d94ca
--- /dev/null
+++ b/apps/web/content/docs/ui/resizable.mdx
@@ -0,0 +1,290 @@
+---
+title: Resizable
+description: Resizable panel groups and layouts with draggable, keyboard-friendly handles.
+icon: SquareSplitHorizontal
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import {
+ ResizableHandle,
+ ResizablePanel,
+ ResizablePanelGroup,
+} from '@vitnode/core/components/ui/resizable'
+```
+
+```tsx
+
+
+ Sidebar
+
+ Content
+
+
+```
+
+A group always fills its parent with an inline `height: 100%`, so a height
+class on the group itself is ignored - give the **parent** a height instead.
+Otherwise your beautiful layout is only as tall as its text, which is very
+minimalist but not very useful.
+
+Groups nest: drop a `ResizablePanelGroup` inside a `ResizablePanel` to split it
+again, like the sidebar + content + terminal combo in the preview.
+
+## Vertical
+
+Set `orientation="vertical"` to stack panels on top of each other.
+
+
+
+```tsx
+
+ Header
+
+ Content
+
+```
+
+## With handle
+
+Pass `withHandle` to show a small grip on the divider, so people know it is
+meant to be grabbed.
+
+
+
+```tsx
+
+```
+
+## Sizes
+
+`defaultSize`, `minSize` and `maxSize` accept a few units. Mind the type -
+it changes the meaning:
+
+- a **string** without a unit is a percentage: `"30"` and `"30%"` are the same;
+- a **number** is pixels: `minSize={180}` means 180px, not 180%;
+- other CSS units work too: `"12rem"`, `"20vw"`, `"200px"`.
+
+```tsx
+
+
+ Sidebar
+
+
+ Content
+
+```
+
+Panels without a `defaultSize` share whatever space is left. Double-click a
+handle to snap its panel back to its `defaultSize`.
+
+## Collapsible
+
+Mark a panel `collapsible` and it snaps to `collapsedSize` (default `0%`) once
+it is dragged below its `minSize`. Drag it back out to expand it again.
+
+
+
+```tsx
+
+ Sidebar
+
+```
+
+Need a toggle button? Pass `panelRef` and call `collapse()`, `expand()` or
+`isCollapsed()` on it.
+
+## Persisting the layout
+
+The group takes a `defaultLayout` - a map of panel `id` to percentage - and
+reports every finished resize through `onLayoutChanged`. Wire both to storage
+and the layout survives a reload. Give every panel a stable `id`, since that is
+what the saved layout is keyed by.
+
+
+
+```tsx
+const storageKey = 'my-layout'
+
+const readLayout = () => {
+ try {
+ return localStorage.getItem(storageKey)
+ } catch {
+ return null
+ }
+}
+
+export const RememberedLayout = () => {
+ const stored = React.useSyncExternalStore(
+ () => () => {},
+ readLayout,
+ () => null,
+ )
+
+ return (
+ {
+ if (!meta.isUserInteraction) return
+
+ localStorage.setItem(
+ storageKey,
+ JSON.stringify(meta.requestedLayout ?? layout),
+ )
+ }}
+ >
+
+
+ Content
+
+ )
+}
+```
+
+`useSyncExternalStore` with a server snapshot of `null` keeps server rendering
+happy: the server renders the default sizes and the browser swaps in the saved
+ones. Prefer `meta.requestedLayout` when saving - it is the layout the user
+asked for, before a small screen squeezed it to fit the constraints.
+
+## Accessibility
+
+- Every handle is a focusable `role="separator"` with `aria-valuenow`,
+ `aria-valuemin`, `aria-valuemax` and `aria-controls` pointing at the panel it
+ resizes.
+- Arrow keys resize by 5% - left and right in a horizontal group, up and down in
+ a vertical one.
+- `Home` and `End` push the panel to its minimum or maximum size.
+- `Enter` collapses or expands a `collapsible` panel.
+- `F6` jumps to the next handle in the group (`Shift + F6` goes back).
+- Touch dragging only claims the resize direction, so content inside a panel
+ still scrolls the other way.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+### ResizablePanelGroup
+
+',
+ },
+ onLayoutChange: {
+ description:
+ 'Called on every layout change, including each pointer move while dragging.',
+ type: '(layout: Layout) => void',
+ },
+ onLayoutChanged: {
+ description:
+ 'Called once a resize is finished (pointer released or key pressed). The best place to save a layout.',
+ type: '(layout: Layout, meta: { isUserInteraction: boolean; requestedLayout?: Layout }) => void',
+ },
+ disabled: {
+ description: 'Turns resizing off for the whole group.',
+ type: 'boolean',
+ default: 'false',
+ },
+ groupRef: {
+ description: 'Imperative API with `getLayout()` and `setLayout(layout)`.',
+ type: 'Ref',
+ },
+ id: {
+ description: 'Unique id of the group. Falls back to `useId`.',
+ type: 'string | number',
+ },
+ }}
+/>
+
+### ResizablePanel
+
+ void',
+ },
+ panelRef: {
+ description:
+ 'Imperative API with `collapse()`, `expand()`, `getSize()`, `isCollapsed()` and `resize(size)`.',
+ type: 'Ref',
+ },
+ }}
+/>
+
+### ResizableHandle
+
+
+
+## API Reference
+
+[react-resizable-panels](https://react-resizable-panels.vercel.app/)
diff --git a/apps/web/content/docs/ui/roles.mdx b/apps/web/content/docs/ui/roles.mdx
index eea4c0318..7189147ff 100644
--- a/apps/web/content/docs/ui/roles.mdx
+++ b/apps/web/content/docs/ui/roles.mdx
@@ -1,6 +1,7 @@
---
title: Roles
description: Search and pick roles for an Auto Form field - one, or as many as you like.
+icon: ShieldUser
---
## Preview
diff --git a/apps/web/content/docs/ui/scroll-area.mdx b/apps/web/content/docs/ui/scroll-area.mdx
index c8032cace..09394c51a 100644
--- a/apps/web/content/docs/ui/scroll-area.mdx
+++ b/apps/web/content/docs/ui/scroll-area.mdx
@@ -1,6 +1,7 @@
---
title: Scroll Area
description: Container for scrollable content with custom scrollbars.
+icon: ScrollText
---
## Preview
diff --git a/apps/web/content/docs/ui/scroll-fade.mdx b/apps/web/content/docs/ui/scroll-fade.mdx
new file mode 100644
index 000000000..c8714d145
--- /dev/null
+++ b/apps/web/content/docs/ui/scroll-fade.mdx
@@ -0,0 +1,157 @@
+---
+title: Scroll Fade
+description: A Tailwind CSS utility that fades the edges of a scroll container, but only on the side where more content is waiting.
+icon: Blend
+---
+
+## Preview
+
+
+
+## Usage
+
+Scroll Fade is a Tailwind CSS utility, not a React component - there is nothing
+to import. It ships with the `shadcn` package, and VitNode apps already pull it
+in from `src/styles.css`:
+
+```css
+@import 'shadcn/tailwind.css';
+```
+
+Add the class to the element that actually scrolls - the one with
+`overflow-y-auto` or `overflow-x-auto`:
+
+```tsx
+{/* ... */}
+```
+
+The fade follows the scroll position:
+
+- At rest, the start edge is crisp and the end edge fades to hint that there is
+ more below.
+- Mid-scroll, both edges fade.
+- At the end, the end edge sharpens - you have reached the bottom, congrats.
+- If the content does not overflow, nothing fades, so you can sprinkle it on any
+ list without checking first.
+
+It works with `mask-image`, so it dissolves the content itself instead of
+painting a gradient on top. No colors to match, it just works on any
+background.
+
+| Class | Fades |
+| ------------------ | ---------------------------------------------------------- |
+| `scroll-fade` | Top and bottom (same as `scroll-fade-y`) |
+| `scroll-fade-y` | Top and bottom |
+| `scroll-fade-x` | Start and end of a horizontal scroller, mirrors in RTL |
+| `scroll-fade-t` | Top only |
+| `scroll-fade-b` | Bottom only |
+| `scroll-fade-s` | Inline start only, mirrors in RTL |
+| `scroll-fade-e` | Inline end only, mirrors in RTL |
+| `scroll-fade-l` | Left only, ignores the reading direction |
+| `scroll-fade-r` | Right only, ignores the reading direction |
+| `scroll-fade-none` | Nothing - turns the fade off |
+
+## Vertical
+
+
+
+Keep the background and border on a wrapper and put `scroll-fade` on the inner
+scroller, so the fade dissolves the list and not your card:
+
+```tsx
+
+```
+
+## Horizontal
+
+Use `scroll-fade-x` for rows of chips, cards or breadcrumbs. Pair it with
+`no-scrollbar` (from the same package) when the scrollbar would only fade along
+with the content anyway:
+
+```tsx
+
+```
+
+VitNode uses exactly this in the [Attachment](/docs/ui/attachment) list and in
+the breadcrumb trail.
+
+## One edge only
+
+Reach for the edge utilities when only one side should react. `scroll-fade-b`
+is great for a chat log that grows downward, `scroll-fade-s` / `scroll-fade-e`
+for horizontal rows that need to respect RTL:
+
+```tsx
+{/* ... */}
+```
+
+## Fade size
+
+By default the fade is 12% of the container, capped at `40px`, so tall lists
+stay subtle. Set a size on the spacing scale with `scroll-fade-`, or use
+any length or percentage:
+
+```tsx
+
+
+```
+
+Need opposite edges to disagree? `scroll-fade-t-`,
+`scroll-fade-b-`, `scroll-fade-s-` and `scroll-fade-e-`
+override the size for a single edge (arbitrary values work here too):
+
+```tsx
+
+```
+
+The fade eases in over `96px` of scrolling rather than popping in. Tweak that
+distance with the `--scroll-fade-reveal` variable:
+
+```tsx
+
+```
+
+## Turning it off
+
+`scroll-fade-none` removes the mask, which is handy with a breakpoint or a
+state variant:
+
+```tsx
+
+```
+
+## Browser support
+
+The scroll-aware part is built on CSS scroll-driven animations
+(`animation-timeline: scroll()`) - no JavaScript, no scroll listeners. Browsers
+without scroll-driven animations get a static fade on every edge the class
+covers, all the time, even at the very top. Content is never hidden, it just
+loses the "you're at the end" polish.
+
+## Accessibility
+
+- The fade is purely visual. Every item is still in the DOM and reachable - it
+ only looks softer near the edges.
+- A scroll container without focusable children cannot be scrolled with a
+ keyboard unless it can take focus. Give it `tabIndex={0}`, `role="region"`
+ and an `aria-label`, like the examples above.
+- The mask also clips focus rings drawn on the scroller itself, so show focus
+ on a wrapper instead, for example `has-focus-visible:ring-3` on the parent.
+- Don't fade so much that the first or last item becomes hard to read; the
+ default size is a safe bet.
+
+## Reference
+
+[shadcn/ui - scroll-fade](https://ui.shadcn.com/docs/utils/scroll-fade)
diff --git a/apps/web/content/docs/ui/select.mdx b/apps/web/content/docs/ui/select.mdx
index 860b868df..1ead2102c 100644
--- a/apps/web/content/docs/ui/select.mdx
+++ b/apps/web/content/docs/ui/select.mdx
@@ -1,6 +1,7 @@
---
title: Select
description: Choose an option from a list of options.
+icon: SquareChevronDown
---
## Preview
diff --git a/apps/web/content/docs/ui/separator.mdx b/apps/web/content/docs/ui/separator.mdx
index 1d0ed556d..ee971fd9a 100644
--- a/apps/web/content/docs/ui/separator.mdx
+++ b/apps/web/content/docs/ui/separator.mdx
@@ -1,6 +1,7 @@
---
title: Separator
description: A simple horizontal line to separate content.
+icon: SeparatorHorizontal
---
## Preview
diff --git a/apps/web/content/docs/ui/sheet.mdx b/apps/web/content/docs/ui/sheet.mdx
index 03103f2f6..96e8c883a 100644
--- a/apps/web/content/docs/ui/sheet.mdx
+++ b/apps/web/content/docs/ui/sheet.mdx
@@ -1,6 +1,7 @@
---
title: Sheet
description: A sheet component for displaying additional content in a modal-like overlay.
+icon: PanelRight
---
## Preview
diff --git a/apps/web/content/docs/ui/shimmer.mdx b/apps/web/content/docs/ui/shimmer.mdx
new file mode 100644
index 000000000..35990e9e6
--- /dev/null
+++ b/apps/web/content/docs/ui/shimmer.mdx
@@ -0,0 +1,157 @@
+---
+title: Shimmer
+description: A Tailwind CSS utility that sweeps a soft highlight across text - the universal sign for "hang on, I'm working on it".
+icon: Sparkles
+---
+
+## Preview
+
+
+
+## Usage
+
+Shimmer is a Tailwind CSS utility, not a React component - there is nothing to
+import. It ships with the `shadcn` package, and VitNode apps already pull it in
+from `src/styles.css`:
+
+```css
+@import 'shadcn/tailwind.css';
+```
+
+Add it to any text element:
+
+```tsx
+Generating response...
+```
+
+The highlight is derived from `currentColor`, so it works on muted text, brand
+colors and everything in between. In dark mode it brightens on its own to stay
+visible. It's pure CSS: the text is painted with `background-clip: text` and
+the highlight loops across it every two seconds.
+
+| Class | What it does |
+| ------------------------- | -------------------------------------------------- |
+| `shimmer` | Turns the effect on |
+| `shimmer-color-` | Sets the highlight color, opacity modifier allowed |
+| `shimmer-duration-` | Length of one sweep in milliseconds (default 2000) |
+| `shimmer-spread-` | Width of the highlight band on the spacing scale |
+| `shimmer-angle-` | Tilt of the band in degrees (default 20) |
+| `shimmer-reverse` | Sweeps the other way |
+| `shimmer-once` | Plays a single sweep instead of looping |
+| `shimmer-none` | Renders the text normally |
+
+## With a status line
+
+Shimmer pairs nicely with a `Spinner` and the [Marker](/docs/ui/marker)
+component for "the assistant is busy" moments:
+
+```tsx
+import {
+ Marker,
+ MarkerContent,
+ MarkerIcon,
+} from '@vitnode/core/components/ui/marker'
+import { Spinner } from '@vitnode/core/components/ui/spinner'
+
+
+
+
+
+ Thinking...
+
+```
+
+## Variants
+
+
+
+### Color
+
+Use any theme color, with an optional opacity modifier, or an arbitrary value:
+
+```tsx
+Generating response...
+Generating response...
+Generating response...
+```
+
+### Duration
+
+```tsx
+In a hurry...
+```
+
+### Spread
+
+The band is `calc(3ch + 40px)` wide by default, so it scales with the font
+size. Set it on the spacing scale or with any length or percentage:
+
+```tsx
+Generating response...
+Generating response...
+```
+
+### Angle
+
+```tsx
+Generating response...
+```
+
+### Reverse
+
+The sweep follows the reading direction out of the box (right to left in RTL).
+`shimmer-reverse` flips it manually:
+
+```tsx
+Generating response...
+```
+
+### Play once
+
+`shimmer-once` plays a single sweep - a nice little "done!" when streaming
+finishes. Combine it with `shimmer-duration-` to set how long it takes, and
+change the element's `key` to play it again:
+
+```tsx
+
+ Response generated.
+
+```
+
+### Turning it off
+
+`shimmer-none` works in any class order, so it plays well with breakpoints and
+state variants:
+
+```tsx
+Generating response...
+```
+
+## Browser support
+
+Shimmer uses relative color syntax and `color-mix()`, which every current
+browser supports. In older browsers the highlight gradient can be dropped and
+the text may render transparent. If you need to support them, apply the class
+only where it works:
+
+```tsx
+
+ Generating response...
+
+```
+
+## Accessibility
+
+- With reduced motion turned on, the animation stops and the text renders
+ normally - no setup needed. That rule targets the plain `shimmer` class, so
+ if you apply it through a variant (`group-hover:shimmer`), add
+ `motion-reduce:shimmer-none` yourself.
+- Shimmer is decoration, not a loading signal. Screen readers and reduced-motion
+ users never see it, so pair it with real text inside `role="status"` - like
+ "Thinking..." in the examples above.
+- Keep it on short status text. A whole paragraph sparkling away is more disco
+ than progress.
+
+## Reference
+
+[shadcn/ui - shimmer](https://ui.shadcn.com/docs/utils/shimmer)
diff --git a/apps/web/content/docs/ui/skeleton.mdx b/apps/web/content/docs/ui/skeleton.mdx
index a3818a8f9..70a4461e6 100644
--- a/apps/web/content/docs/ui/skeleton.mdx
+++ b/apps/web/content/docs/ui/skeleton.mdx
@@ -1,6 +1,7 @@
---
title: Skeleton
description: A placeholder component for loading states.
+icon: SquareDashed
---
## Preview
diff --git a/apps/web/content/docs/ui/sonner.mdx b/apps/web/content/docs/ui/sonner.mdx
deleted file mode 100644
index 102b459bf..000000000
--- a/apps/web/content/docs/ui/sonner.mdx
+++ /dev/null
@@ -1,44 +0,0 @@
----
-title: Sonner
-description: A toast notification component for displaying messages to users.
----
-
-## Preview
-
-
-
-## Usage
-
-```ts
-import { toast } from 'sonner';
-```
-
-```ts
-toast('Event has been created', {
- description: 'Sunday, December 03, 2023 at 9:00 AM',
- action: {
- label: 'Undo',
- onClick: () => console.log('Undo'),
- },
-});
-```
-
-```ts
-toast.error('An error occurred.');
-```
-
-```ts
-toast.success('Operation was successful.');
-```
-
-```ts
-toast.info('Here is some information.');
-```
-
-```ts
-toast.warning('This is a warning.');
-```
-
-## Documentation
-
-[Sonner Documentation](https://sonner.emilkowal.ski/)
diff --git a/apps/web/content/docs/ui/spinner.mdx b/apps/web/content/docs/ui/spinner.mdx
new file mode 100644
index 000000000..253aed72a
--- /dev/null
+++ b/apps/web/content/docs/ui/spinner.mdx
@@ -0,0 +1,129 @@
+---
+title: Spinner
+description: A little spinning circle that says "hold on, it's coming" - in four sizes and any colour.
+icon: Loader
+---
+
+## Preview
+
+
+
+## Usage
+
+```ts
+import { Spinner } from '@vitnode/core/components/ui/spinner'
+```
+
+```tsx
+
+```
+
+## Sizes
+
+| Size | Pixels | Good for |
+| --------- | ------ | --------------------------------- |
+| `sm` | 12 | Badges, dense rows |
+| `default` | 16 | Buttons, inputs, inline with text |
+| `lg` | 24 | Cards and panels |
+| `xl` | 40 | A whole page or section loading |
+
+```tsx
+
+```
+
+It takes the text colour of its surroundings, so `className="text-primary"`
+(or `text-success`, `text-destructive`...) is all a colour change needs.
+
+## In buttons
+
+
+
+For a button that's busy, prefer `isLoading` on the [Button](/docs/ui/button) -
+it swaps the label for a spinner, disables the button and announces "Loading".
+Put a `Spinner` in by hand when you want to keep the label visible:
+
+```tsx
+
+
+ Uploading...
+
+```
+
+## In badges, inputs and text
+
+
+
+```tsx
+
+
+ Syncing
+
+
+
+
+
+
+
+
+```
+
+## Loading states
+
+
+
+Inside an [Empty](/docs/ui/empty) state, or laid over a card that's fetching
+fresh data:
+
+```tsx
+
+ ...
+
+
+
+
+```
+
+Showing a whole section while it loads? Center an `xl` spinner - that's what
+VitNode does for Suspense fallbacks across the AdminCP:
+
+```tsx
+
+
+
+ }
+>
+
+
+```
+
+If you know roughly what's coming, a [Skeleton](/docs/ui/skeleton) of that
+shape usually feels faster than a spinner.
+
+## Accessibility
+
+- A spinner is a `status` and is labelled "Loading" in the user's language.
+ Pass `aria-label` for something more specific, like "Searching".
+- When text right next to it already says what's happening ("Uploading..."), or
+ the button announces it, hide the spinner with `aria-hidden="true"` so screen
+ readers don't hear it twice.
+- Mark the container that's loading with `aria-busy="true"`.
+
+## Props
+
+import { TypeTable } from 'fumadocs-ui/components/type-table'
+
+
diff --git a/apps/web/content/docs/ui/switch.mdx b/apps/web/content/docs/ui/switch.mdx
index 9ef16fc14..98e354fe9 100644
--- a/apps/web/content/docs/ui/switch.mdx
+++ b/apps/web/content/docs/ui/switch.mdx
@@ -1,6 +1,7 @@
---
title: Switch
description: Toggle between checked and unchecked states.
+icon: ToggleRight
---
## Preview
diff --git a/apps/web/content/docs/ui/tabs.mdx b/apps/web/content/docs/ui/tabs.mdx
index 39f834db9..afcac9d6f 100644
--- a/apps/web/content/docs/ui/tabs.mdx
+++ b/apps/web/content/docs/ui/tabs.mdx
@@ -1,6 +1,7 @@
---
title: Tabs
description: Switch between panels of content with an indicator that slides and panels that follow.
+icon: PanelsTopLeft
---
## Preview
diff --git a/apps/web/content/docs/ui/textarea.mdx b/apps/web/content/docs/ui/textarea.mdx
index 41ebf0345..c8bd2ad18 100644
--- a/apps/web/content/docs/ui/textarea.mdx
+++ b/apps/web/content/docs/ui/textarea.mdx
@@ -1,6 +1,7 @@
---
title: Textarea
description: Input for multi-line text input.
+icon: NotepadText
---
## Preview
diff --git a/apps/web/content/docs/ui/toast.mdx b/apps/web/content/docs/ui/toast.mdx
new file mode 100644
index 000000000..e97bc7df5
--- /dev/null
+++ b/apps/web/content/docs/ui/toast.mdx
@@ -0,0 +1,165 @@
+---
+title: Toast
+description: Short, friendly notifications that pop into the top-right corner - in five colours, with icons, avatars and actions.
+icon: BellRing
+---
+
+## Preview
+
+
+
+## Usage
+
+VitNode already mounts the toaster for you, so all you need is the `toast`
+function from [Sonner](https://sonner.emilkowal.ski/):
+
+```ts
+import { toast } from 'sonner'
+```
+
+```ts
+toast('Event has been created', {
+ description: 'Sunday, December 03, 2023 at 9:00 AM',
+})
+```
+
+## Variants
+
+Each variant tints the icon, the title and the action button, while the card
+itself stays neutral - so a toast never shouts louder than it has to.
+
+```ts
+toast('Event has been created') // default
+toast.info('You have 2 credits left') // primary colour
+toast.success('Your plan has been upgraded')
+toast.warning('You are running low on storage')
+toast.error('Storage is full')
+```
+
+Pick the variant by what happened, not by the colour you fancy:
+
+- `success` - something finished the way the user hoped.
+- `info` - worth knowing, nothing to worry about.
+- `warning` - nothing broke yet, but the user should take a look.
+- `error` - it failed, and the description should say what to do next.
+
+After a create, edit or delete, show a toast with a `description` so people
+know exactly what changed.
+
+## With an action
+
+```ts
+toast.error('Storage is full', {
+ description: 'Remove some files to free up space.',
+ action: {
+ label: 'Remove',
+ onClick: () => openFileManager(),
+ },
+})
+```
+
+The action button takes the variant's colour too. Add `cancel` for a second,
+quieter button.
+
+## With an avatar
+
+Swap the icon for a picture with `ToastAvatar` - great for anything a person
+did, like an invite, a mention or a new message. Without a `src`, or while the
+image is still loading, it shows the first letter of `alt`.
+
+```ts
+import { ToastAvatar } from '@vitnode/core/components/ui/sonner'
+```
+
+```tsx
+toast('Ada Lovelace invited you', {
+ description: 'Join the "Docs writers" group to start editing.',
+ icon: ,
+})
+```
+
+Any other element works as `icon` as well - a custom Lucide icon, an emoji or
+your own badge.
+
+## Message with actions
+
+For anything that reads like a message - a reply, a mention, an invite - use
+`ToastMessage` with `toast.custom`. It lays out an avatar, the sender, a
+timestamp, the text and a row of buttons underneath.
+
+```ts
+import { ToastMessage } from '@vitnode/core/components/ui/sonner'
+```
+
+```tsx
+toast.custom(
+ id => (
+ toast.dismiss(id), variant: 'outline' },
+ { label: 'Reply', onClick: () => openReply(message) },
+ ]}
+ avatar={{ alt: 'Alex Johnson', src: user.avatarUrl }}
+ description="Hey! I've finished the design review."
+ time="2m ago"
+ title="Alex Johnson"
+ />
+ ),
+ { duration: 10000 },
+)
+```
+
+Give message toasts a longer `duration` than the usual few seconds - people
+need time to read, and to decide whether to reply.
+
+## Loading and promises
+
+`toast.promise` shows a spinner while the promise runs, then swaps to the
+success or error variant on its own:
+
+```ts
+toast.promise(publishArticle(), {
+ loading: 'Publishing your article...',
+ success: 'Article published',
+ error: 'Could not publish the article',
+})
+```
+
+## Placement
+
+Toasts show up in the **top-right** corner. To move them, pass `toaster` to
+`VitNodeProviders`:
+
+```tsx
+
+ {children}
+
+```
+
+Available positions: `top-left`, `top-center`, `top-right`, `bottom-left`,
+`bottom-center` and `bottom-right`. On phones the toast stretches across the
+screen, whatever you choose.
+
+## Custom styles
+
+Pass `classNames` to a single toast to restyle it. Your classes are merged with
+the defaults, not swapped for them:
+
+```ts
+toast('Saved', {
+ classNames: { title: 'font-semibold' },
+})
+```
+
+## Accessibility
+
+- Toasts are announced to screen readers through a polite live region.
+- Hovering a toast pauses its timer, so nobody has to race it. Keyboard users
+ can jump to the toasts with Alt + T .
+- The close button appears on hover and keyboard focus, and is always visible on
+ touch screens.
+- Colour is never the only signal - every variant has its own icon too.
+
+## Documentation
+
+[Sonner Documentation](https://sonner.emilkowal.ski/)
diff --git a/apps/web/content/docs/ui/toggle-group.mdx b/apps/web/content/docs/ui/toggle-group.mdx
index f18b8e6e1..c1e9f1d71 100644
--- a/apps/web/content/docs/ui/toggle-group.mdx
+++ b/apps/web/content/docs/ui/toggle-group.mdx
@@ -1,6 +1,7 @@
---
title: Toggle Group
description: A toggle group component for selecting multiple options.
+icon: TextAlignCenter
---
## Preview
diff --git a/apps/web/content/docs/ui/toggle.mdx b/apps/web/content/docs/ui/toggle.mdx
index 979fb9342..503986456 100644
--- a/apps/web/content/docs/ui/toggle.mdx
+++ b/apps/web/content/docs/ui/toggle.mdx
@@ -1,6 +1,7 @@
---
title: Toggle
description: A toggle component for switching between two states.
+icon: Bold
---
## Preview
diff --git a/apps/web/content/docs/ui/tooltip.mdx b/apps/web/content/docs/ui/tooltip.mdx
index 0349c8ba9..bbe31e442 100644
--- a/apps/web/content/docs/ui/tooltip.mdx
+++ b/apps/web/content/docs/ui/tooltip.mdx
@@ -1,6 +1,7 @@
---
title: Tooltip
description: A tooltip component for displaying additional information on hover or focus.
+icon: MessageCircleMore
---
## Preview
@@ -41,6 +42,49 @@ import { PlayIcon } from "lucide-react";
```
+## Group
+
+
+
+Got a toolbar full of icon buttons? Wrap it in `TooltipGroup` and use
+`TooltipGroupTrigger` for each button. They all share one tooltip, so moving
+from button to button glides it over - resizing to fit the new text, which
+slides in from the direction you came from - instead of closing and opening
+again every time.
+
+```ts
+import {
+ TooltipGroup,
+ TooltipGroupTrigger,
+} from "@vitnode/core/components/ui/tooltip";
+```
+
+```tsx
+
+
+ }
+ >
+
+
+ }
+ >
+
+
+
+
+```
+
+`TooltipGroup` takes `side`, `sideOffset` and `align`, just like
+`TooltipContent`. With reduced motion turned on, the tooltip jumps straight to
+the next button instead of gliding.
+
+A tooltip is a hint, not a label - icon buttons still need their own
+`aria-label`, as above.
+
## API Reference
[Base UI - Tooltip](https://base-ui.com/react/components/tooltip)
diff --git a/apps/web/content/docs/ui/user.mdx b/apps/web/content/docs/ui/user.mdx
index 4f17d90cd..7039b1e80 100644
--- a/apps/web/content/docs/ui/user.mdx
+++ b/apps/web/content/docs/ui/user.mdx
@@ -1,6 +1,7 @@
---
title: User
description: Pick a person by name, with their avatar, for an Auto Form field.
+icon: CircleUser
---
## Preview
diff --git a/apps/web/package.json b/apps/web/package.json
index f2e32c251..950168916 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -42,6 +42,7 @@
"nitro": "3.0.260903-beta",
"react": "^19.3.0",
"react-dom": "^19.3.0",
+ "recharts": "^3.10.1",
"shadcn": "^4.21.0",
"sonner": "^2.0.8",
"tailwindcss": "^4.3.3",
diff --git a/apps/web/src/docs/examples/alert-dialog.tsx b/apps/web/src/docs/examples/alert-dialog.tsx
index cdd2190a5..5c89d87b8 100644
--- a/apps/web/src/docs/examples/alert-dialog.tsx
+++ b/apps/web/src/docs/examples/alert-dialog.tsx
@@ -15,11 +15,11 @@ export default function AlertDialogExample() {
return (
Show Dialog}
+ render={Delete account }
/>
- Are you absolutely sure?
+ Delete your account?
This action cannot be undone. This will permanently delete your
account and remove your data from our servers.
@@ -27,7 +27,9 @@ export default function AlertDialogExample() {
Cancel
- Continue
+
+ Delete account
+
diff --git a/apps/web/src/docs/examples/aspect-ratio.tsx b/apps/web/src/docs/examples/aspect-ratio.tsx
new file mode 100644
index 000000000..4bc40c92e
--- /dev/null
+++ b/apps/web/src/docs/examples/aspect-ratio.tsx
@@ -0,0 +1,33 @@
+import { AspectRatio } from '@vitnode/core/components/ui/aspect-ratio'
+
+import adminDashboardDark from '@/site/home/assets/admin-dashboard-dark-800.webp'
+import adminDashboardLight from '@/site/home/assets/admin-dashboard-light-800.webp'
+
+export default function AspectRatioExample() {
+ return (
+
+
+
+
+
+
+ Always 16:9, however wide the screen gets.
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/attachment-auto-form.tsx b/apps/web/src/docs/examples/attachment-auto-form.tsx
new file mode 100644
index 000000000..a603a2d4e
--- /dev/null
+++ b/apps/web/src/docs/examples/attachment-auto-form.tsx
@@ -0,0 +1,70 @@
+import type { AutoFormFileValue } from '@vitnode/core/components/form/fields/file'
+
+import { AutoForm } from '@vitnode/core/components/form/auto-form'
+import { AutoFormFile } from '@vitnode/core/components/form/fields/file'
+import { AutoFormFiles } from '@vitnode/core/components/form/fields/files'
+import { toast } from 'sonner'
+import { z } from 'zod'
+
+const MAX_BYTES = 5 * 1024 * 1024
+
+let nextFileId = 1
+
+const fakeUpload = async (file: File): Promise => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 1200)
+ })
+
+ return {
+ id: nextFileId++,
+ mimeType: file.type,
+ name: file.name,
+ size: file.size,
+ url: URL.createObjectURL(file),
+ }
+}
+
+export default function AttachmentAutoFormExample() {
+ const formSchema = z.object({
+ avatar: z.number().nullable().default(null),
+ gallery: z.array(z.number()).max(4).default([]),
+ })
+
+ return (
+ (
+
+ ),
+ },
+ {
+ id: 'gallery',
+ component: (props) => (
+
+ ),
+ },
+ ]}
+ formSchema={formSchema}
+ onSubmit={(values) => {
+ toast.success('Files saved', {
+ description: `Avatar #${values.avatar ?? 'none'}, gallery: ${values.gallery.length} file(s).`,
+ })
+ }}
+ />
+ )
+}
diff --git a/apps/web/src/docs/examples/attachment.tsx b/apps/web/src/docs/examples/attachment.tsx
new file mode 100644
index 000000000..7e098524e
--- /dev/null
+++ b/apps/web/src/docs/examples/attachment.tsx
@@ -0,0 +1,86 @@
+import {
+ Attachment,
+ AttachmentAction,
+ AttachmentActions,
+ AttachmentContent,
+ AttachmentDescription,
+ AttachmentGroup,
+ AttachmentMedia,
+ AttachmentTitle,
+} from '@vitnode/core/components/ui/attachment'
+import { Spinner } from '@vitnode/core/components/ui/spinner'
+import {
+ FileTextIcon,
+ RotateCcwIcon,
+ TriangleAlertIcon,
+ XIcon,
+} from 'lucide-react'
+
+import adminDashboardLight from '@/site/home/assets/admin-dashboard-light-800.webp'
+
+export default function AttachmentExample() {
+ return (
+
+
+
+
+
+
+
+ dashboard.webp
+ 48 KB
+
+
+
+
+
+
+
+
+
+
+
+
+
+ release-notes.pdf
+ Uploading... · 1.2 MB
+
+
+
+
+
+
+
+
+
+
+
+ holiday-video.mov
+
+ That file is 2 GB. The maximum is 50 MB.
+
+
+
+
+
+
+
+
+
+
+
+ {['cover.webp', 'gallery-1.webp', 'gallery-2.webp'].map((name) => (
+
+
+
+
+
+ {name}
+ 48 KB
+
+
+ ))}
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/avatar.tsx b/apps/web/src/docs/examples/avatar.tsx
new file mode 100644
index 000000000..8f00e7f3f
--- /dev/null
+++ b/apps/web/src/docs/examples/avatar.tsx
@@ -0,0 +1,52 @@
+import {
+ Avatar,
+ AvatarBadge,
+ AvatarFallback,
+ AvatarGroup,
+ AvatarGroupCount,
+ AvatarGroupItem,
+ AvatarImage,
+} from '@vitnode/core/components/ui/avatar'
+
+const team = [
+ { initials: 'AL', name: 'Ada Lovelace' },
+ { initials: 'GH', name: 'Grace Hopper' },
+ { initials: 'AT', name: 'Alan Turing' },
+ { initials: 'KJ', name: 'Katherine Johnson' },
+]
+
+export default function AvatarExample() {
+ return (
+
+
+
+
+ VN
+
+
+
+ VN
+
+
+
+ VN
+
+
+
+ AL
+
+
+
+
+ {team.map((person) => (
+
+
+ {person.initials}
+
+
+ ))}
+ +3
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/bubble.tsx b/apps/web/src/docs/examples/bubble.tsx
new file mode 100644
index 000000000..d36524381
--- /dev/null
+++ b/apps/web/src/docs/examples/bubble.tsx
@@ -0,0 +1,74 @@
+import {
+ Bubble,
+ BubbleContent,
+ BubbleGroup,
+ BubbleReactions,
+} from '@vitnode/core/components/ui/bubble'
+import { Card } from '@vitnode/core/components/ui/card'
+import { toast } from 'sonner'
+
+const suggestions = ['Show me the plugin docs', 'Surprise me']
+
+export default function BubbleExample() {
+ return (
+
+
+ Hey! Can VitNode do chat bubbles now?
+
+
+
+
+ It sure can.
+
+
+
+ Seven variants, both sides of the conversation, and reactions that
+ hang off the edge like a sticky note.
+
+
+ 🎉
+ 🔥
+
+
+
+
+
+ Okay, I am impressed. What else?
+
+
+
+ {suggestions.map((suggestion) => (
+
+
+ toast('You picked a suggestion', {
+ description: suggestion,
+ })
+ }
+ type="button"
+ />
+ }
+ >
+ {suggestion}
+
+
+ ))}
+
+
+
+
+ Could not load the joke of the day. The joke was too good to send.
+
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/button-group.tsx b/apps/web/src/docs/examples/button-group.tsx
new file mode 100644
index 000000000..2dda8eebf
--- /dev/null
+++ b/apps/web/src/docs/examples/button-group.tsx
@@ -0,0 +1,163 @@
+import { Button } from '@vitnode/core/components/ui/button'
+import {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+} from '@vitnode/core/components/ui/button-group'
+import { Card } from '@vitnode/core/components/ui/card'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@vitnode/core/components/ui/dropdown-menu'
+import { Input } from '@vitnode/core/components/ui/input'
+import {
+ AlignCenter,
+ AlignLeft,
+ AlignRight,
+ Archive,
+ ArrowLeft,
+ CalendarClock,
+ ChevronDown,
+ Clock,
+ Flag,
+ Minus,
+ Plus,
+ Search,
+ Trash2,
+ ZoomIn,
+ ZoomOut,
+} from 'lucide-react'
+import React from 'react'
+
+const QuantityStepper = () => {
+ const [quantity, setQuantity] = React.useState(1)
+
+ return (
+
+ setQuantity((value) => value - 1)}
+ size="icon"
+ variant="outline"
+ >
+
+
+
+ {quantity}
+
+ = 9}
+ onClick={() => setQuantity((value) => value + 1)}
+ size="icon"
+ variant="outline"
+ >
+
+
+
+ )
+}
+
+export default function ButtonGroupExample() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ Archive
+
+
+
+
+
+
+
+
+ Snooze
+
+
+
+ }
+ >
+
+
+
+
+
+
+ Later today
+
+
+
+ Next week
+
+
+
+
+
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/card-border-separation.tsx b/apps/web/src/docs/examples/card-border-separation.tsx
new file mode 100644
index 000000000..3974e69f6
--- /dev/null
+++ b/apps/web/src/docs/examples/card-border-separation.tsx
@@ -0,0 +1,33 @@
+import { Button } from '@vitnode/core/components/ui/button'
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@vitnode/core/components/ui/card'
+
+export default function CardBorderSeparationExample() {
+ return (
+
+
+ Header
+
+ Action
+
+
+
+
+ The content has border-y applied, splitting the card into three clean
+ sections.
+
+
+
+
+ Action
+
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/card-header-border.tsx b/apps/web/src/docs/examples/card-header-border.tsx
new file mode 100644
index 000000000..26e3b77d6
--- /dev/null
+++ b/apps/web/src/docs/examples/card-header-border.tsx
@@ -0,0 +1,26 @@
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@vitnode/core/components/ui/card'
+
+export default function CardHeaderBorderExample() {
+ return (
+
+
+ Header with Border
+
+ This is a card with a header that has a bottom border.
+
+
+
+
+ The header has a border-b class applied, creating a visual separation
+ between the header and content sections.
+
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/card-header-footer.tsx b/apps/web/src/docs/examples/card-header-footer.tsx
new file mode 100644
index 000000000..b285ffde4
--- /dev/null
+++ b/apps/web/src/docs/examples/card-header-footer.tsx
@@ -0,0 +1,29 @@
+import { Button } from '@vitnode/core/components/ui/button'
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@vitnode/core/components/ui/card'
+
+export default function CardHeaderFooterExample() {
+ return (
+
+
+ Header with Border
+
+
+
+ The footer has a top border and a soft background, creating a visual
+ separation between the content and footer sections.
+
+
+
+
+ Action
+
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/card-link.tsx b/apps/web/src/docs/examples/card-link.tsx
new file mode 100644
index 000000000..90a801cb3
--- /dev/null
+++ b/apps/web/src/docs/examples/card-link.tsx
@@ -0,0 +1,37 @@
+import { buttonVariants } from '@vitnode/core/components/ui/button'
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@vitnode/core/components/ui/card'
+import { cn } from 'cn'
+import { ExternalLinkIcon } from 'lucide-react'
+
+export default function CardLinkExample() {
+ return (
+
+
+ Need a help in Claim?
+
+
+
+ Go to this step by step guideline process on how to certify for your
+ weekly benefits:
+
+
+
+
+ See our guideline
+
+
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/card.tsx b/apps/web/src/docs/examples/card.tsx
index 2b1ad5b1c..75de9f481 100644
--- a/apps/web/src/docs/examples/card.tsx
+++ b/apps/web/src/docs/examples/card.tsx
@@ -1,6 +1,6 @@
+import { Button } from '@vitnode/core/components/ui/button'
import {
Card,
- CardAction,
CardContent,
CardDescription,
CardFooter,
@@ -10,16 +10,23 @@ import {
export default function CardExample() {
return (
-
+
- Card Title
- Card Description
+ Default Card
+
+ This card uses the default size variant.
+
- This is the content of the card. You can put any content here.
+
+ The card component supports a size prop that defaults to
+ "default" for standard spacing and sizing.
+
- Action Button
+
+ Action
+
)
diff --git a/apps/web/src/docs/examples/carousel.tsx b/apps/web/src/docs/examples/carousel.tsx
new file mode 100644
index 000000000..15256290f
--- /dev/null
+++ b/apps/web/src/docs/examples/carousel.tsx
@@ -0,0 +1,125 @@
+import type { CarouselApi } from '@vitnode/core/components/ui/carousel'
+
+import {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselNext,
+ CarouselPrevious,
+} from '@vitnode/core/components/ui/carousel'
+import { cn } from 'cn'
+import { useEffect, useState } from 'react'
+
+import adminContentCreateDark from '@/site/home/assets/admin-content-create-dark-800.webp'
+import adminContentCreateLight from '@/site/home/assets/admin-content-create-light-800.webp'
+import adminDashboardDark from '@/site/home/assets/admin-dashboard-dark-800.webp'
+import adminDashboardLight from '@/site/home/assets/admin-dashboard-light-800.webp'
+import adminIntegrationsDark from '@/site/home/assets/admin-integrations-dark-800.webp'
+import adminIntegrationsLight from '@/site/home/assets/admin-integrations-light-800.webp'
+import adminRolesDark from '@/site/home/assets/admin-roles-dark-800.webp'
+import adminRolesLight from '@/site/home/assets/admin-roles-light-800.webp'
+import publicLoginDark from '@/site/home/assets/public-login-dark-800.webp'
+import publicLoginLight from '@/site/home/assets/public-login-light-800.webp'
+
+const SLIDES = [
+ { title: 'Dashboard', light: adminDashboardLight, dark: adminDashboardDark },
+ {
+ title: 'Content editor',
+ light: adminContentCreateLight,
+ dark: adminContentCreateDark,
+ },
+ {
+ title: 'Integrations',
+ light: adminIntegrationsLight,
+ dark: adminIntegrationsDark,
+ },
+ { title: 'Roles', light: adminRolesLight, dark: adminRolesDark },
+ { title: 'Sign in', light: publicLoginLight, dark: publicLoginDark },
+]
+
+export default function CarouselExample() {
+ const [api, setApi] = useState()
+ const [current, setCurrent] = useState(0)
+
+ useEffect(() => {
+ if (!api) return
+
+ const onSelect = () => {
+ setCurrent(api.selectedScrollSnap())
+ }
+
+ api.on('select', onSelect)
+ api.on('reInit', onSelect)
+
+ return () => {
+ api.off('select', onSelect)
+ api.off('reInit', onSelect)
+ }
+ }, [api])
+
+ return (
+
+
+ {SLIDES.map(({ title, light, dark }, index) => (
+
+
+
+
+
+
+
+ {title}
+
+
+
+ ))}
+
+
+
+
+
+ {SLIDES.map(({ title }, index) => (
+ api?.scrollTo(index)}
+ type="button"
+ >
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/cascader-auto-form.tsx b/apps/web/src/docs/examples/cascader-auto-form.tsx
new file mode 100644
index 000000000..07a061bf7
--- /dev/null
+++ b/apps/web/src/docs/examples/cascader-auto-form.tsx
@@ -0,0 +1,51 @@
+import { AutoForm } from '@vitnode/core/components/form/auto-form'
+import { AutoFormCascader } from '@vitnode/core/components/form/fields/cascader'
+import { toast } from 'sonner'
+import { z } from 'zod'
+
+import { locations } from './cascader-options'
+
+export default function CascaderAutoFormExample() {
+ const formSchema = z.object({
+ office: z.string('Pick an office to continue'),
+ backupOffice: z.string().optional(),
+ })
+
+ return (
+ (
+
+ ),
+ },
+ {
+ id: 'backupOffice',
+ component: (props) => (
+
+ ),
+ },
+ ]}
+ formSchema={formSchema}
+ onSubmit={(values) => {
+ toast.success('Offices saved', {
+ description: [values.office, values.backupOffice]
+ .filter(Boolean)
+ .join(' + '),
+ })
+ }}
+ />
+ )
+}
diff --git a/apps/web/src/docs/examples/cascader-options.ts b/apps/web/src/docs/examples/cascader-options.ts
new file mode 100644
index 000000000..3deb27230
--- /dev/null
+++ b/apps/web/src/docs/examples/cascader-options.ts
@@ -0,0 +1,62 @@
+import type { CascaderOption } from '@vitnode/core/components/ui/cascader'
+
+export const locations: CascaderOption[] = [
+ {
+ label: 'Europe',
+ value: 'europe',
+ children: [
+ {
+ label: 'Poland',
+ value: 'poland',
+ children: [
+ { label: 'Warsaw', value: 'warsaw' },
+ { label: 'Kraków', value: 'krakow' },
+ { label: 'Gdańsk', value: 'gdansk' },
+ ],
+ },
+ {
+ label: 'Portugal',
+ value: 'portugal',
+ children: [
+ { label: 'Lisbon', value: 'lisbon' },
+ { label: 'Porto', value: 'porto' },
+ ],
+ },
+ { label: 'Iceland', value: 'iceland', disabled: true },
+ ],
+ },
+ {
+ label: 'Americas',
+ value: 'americas',
+ children: [
+ {
+ label: 'Canada',
+ value: 'canada',
+ children: [
+ { label: 'Vancouver', value: 'vancouver' },
+ { label: 'Montréal', value: 'montreal' },
+ ],
+ },
+ {
+ label: 'Brazil',
+ value: 'brazil',
+ children: [{ label: 'São Paulo', value: 'sao-paulo' }],
+ },
+ ],
+ },
+ {
+ label: 'Asia & Pacific',
+ value: 'apac',
+ children: [
+ {
+ label: 'Japan',
+ value: 'japan',
+ children: [
+ { label: 'Tokyo', value: 'tokyo' },
+ { label: 'Osaka', value: 'osaka' },
+ ],
+ },
+ { label: 'Singapore', value: 'singapore' },
+ ],
+ },
+]
diff --git a/apps/web/src/docs/examples/cascader.tsx b/apps/web/src/docs/examples/cascader.tsx
new file mode 100644
index 000000000..a5e756662
--- /dev/null
+++ b/apps/web/src/docs/examples/cascader.tsx
@@ -0,0 +1,27 @@
+import { Cascader } from '@vitnode/core/components/ui/cascader'
+import { Label } from '@vitnode/core/components/ui/label'
+import React from 'react'
+
+import { locations } from './cascader-options'
+
+export default function CascaderExample() {
+ const [office, setOffice] = React.useState('krakow')
+
+ return (
+
+
Office
+
+
+ Selected value: {office ?? 'nothing yet'}
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/chart-area.tsx b/apps/web/src/docs/examples/chart-area.tsx
new file mode 100644
index 000000000..1d6f2fdad
--- /dev/null
+++ b/apps/web/src/docs/examples/chart-area.tsx
@@ -0,0 +1,115 @@
+import type { ChartConfig } from '@vitnode/core/components/ui/chart'
+
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@vitnode/core/components/ui/card'
+import {
+ ChartContainer,
+ ChartLegend,
+ ChartLegendContent,
+ ChartTooltip,
+ ChartTooltipContent,
+} from '@vitnode/core/components/ui/chart'
+import { Area, AreaChart, CartesianGrid, XAxis } from 'recharts'
+
+const chartData = [
+ { month: 'January', downloads: 420, upgrades: 120 },
+ { month: 'February', downloads: 610, upgrades: 180 },
+ { month: 'March', downloads: 530, upgrades: 260 },
+ { month: 'April', downloads: 780, upgrades: 240 },
+ { month: 'May', downloads: 920, upgrades: 330 },
+ { month: 'June', downloads: 1080, upgrades: 410 },
+]
+
+const chartConfig = {
+ downloads: {
+ label: 'Downloads',
+ color: 'var(--chart-1)',
+ },
+ upgrades: {
+ label: 'Upgrades',
+ color: 'var(--chart-4)',
+ },
+} satisfies ChartConfig
+
+const shortMonth = (month: string) => month.slice(0, 3)
+
+export default function ChartAreaExample() {
+ return (
+
+
+ Plugin installs
+ Fresh downloads and upgrades, stacked
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } cursor={false} />
+ } />
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/chart-line.tsx b/apps/web/src/docs/examples/chart-line.tsx
new file mode 100644
index 000000000..6394f8f39
--- /dev/null
+++ b/apps/web/src/docs/examples/chart-line.tsx
@@ -0,0 +1,80 @@
+import type { ChartConfig } from '@vitnode/core/components/ui/chart'
+
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@vitnode/core/components/ui/card'
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from '@vitnode/core/components/ui/chart'
+import { CartesianGrid, Line, LineChart, XAxis } from 'recharts'
+
+const chartData = [
+ { day: 'Monday', responseTime: 182 },
+ { day: 'Tuesday', responseTime: 164 },
+ { day: 'Wednesday', responseTime: 210 },
+ { day: 'Thursday', responseTime: 148 },
+ { day: 'Friday', responseTime: 126 },
+ { day: 'Saturday', responseTime: 98 },
+ { day: 'Sunday', responseTime: 104 },
+]
+
+const chartConfig = {
+ responseTime: {
+ label: 'Response time',
+ color: 'var(--chart-2)',
+ },
+} satisfies ChartConfig
+
+const shortDay = (day: string) => day.slice(0, 3)
+
+export default function ChartLineExample() {
+ return (
+
+
+ API response time
+ Median in milliseconds, last week
+
+
+
+
+
+
+ }
+ cursor={false}
+ />
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/chart.tsx b/apps/web/src/docs/examples/chart.tsx
new file mode 100644
index 000000000..b439e5f06
--- /dev/null
+++ b/apps/web/src/docs/examples/chart.tsx
@@ -0,0 +1,79 @@
+import type { ChartConfig } from '@vitnode/core/components/ui/chart'
+
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@vitnode/core/components/ui/card'
+import {
+ ChartContainer,
+ ChartLegend,
+ ChartLegendContent,
+ ChartTooltip,
+ ChartTooltipContent,
+} from '@vitnode/core/components/ui/chart'
+import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts'
+
+const chartData = [
+ { month: 'January', desktop: 186, mobile: 80 },
+ { month: 'February', desktop: 305, mobile: 200 },
+ { month: 'March', desktop: 237, mobile: 120 },
+ { month: 'April', desktop: 73, mobile: 190 },
+ { month: 'May', desktop: 209, mobile: 130 },
+ { month: 'June', desktop: 214, mobile: 140 },
+]
+
+const chartConfig = {
+ desktop: {
+ label: 'Desktop',
+ color: 'var(--chart-1)',
+ },
+ mobile: {
+ label: 'Mobile',
+ color: 'var(--chart-2)',
+ },
+} satisfies ChartConfig
+
+const shortMonth = (month: string) => month.slice(0, 3)
+
+export default function ChartExample() {
+ return (
+
+
+ Visitors
+ January - June 2026
+
+
+
+
+
+
+ }
+ cursor={false}
+ />
+ } />
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/combobox-groups.tsx b/apps/web/src/docs/examples/combobox-groups.tsx
new file mode 100644
index 000000000..b8684d5cc
--- /dev/null
+++ b/apps/web/src/docs/examples/combobox-groups.tsx
@@ -0,0 +1,53 @@
+import { AutoForm } from '@vitnode/core/components/form/auto-form'
+import { AutoFormCombobox } from '@vitnode/core/components/form/fields/combobox'
+import { z } from 'zod'
+
+const timezones = [
+ { group: 'Europe', label: 'London', value: 'europe-london' },
+ { group: 'Europe', label: 'Warsaw', value: 'europe-warsaw' },
+ { group: 'Europe', label: 'Lisbon', value: 'europe-lisbon' },
+ { group: 'Americas', label: 'New York', value: 'america-new-york' },
+ { group: 'Americas', label: 'São Paulo', value: 'america-sao-paulo' },
+ { group: 'Americas', label: 'Vancouver', value: 'america-vancouver' },
+ { group: 'Asia & Pacific', label: 'Tokyo', value: 'asia-tokyo' },
+ { group: 'Asia & Pacific', label: 'Singapore', value: 'asia-singapore' },
+ { group: 'Asia & Pacific', label: 'Sydney', value: 'australia-sydney' },
+]
+
+export default function ComboboxGroupsExample() {
+ const formSchema = z.object({
+ timezone: z.enum([
+ 'europe-london',
+ 'europe-warsaw',
+ 'europe-lisbon',
+ 'america-new-york',
+ 'america-sao-paulo',
+ 'america-vancouver',
+ 'asia-tokyo',
+ 'asia-singapore',
+ 'australia-sydney',
+ ]),
+ })
+
+ return (
+ (
+
+ ),
+ },
+ ]}
+ formSchema={formSchema}
+ />
+ )
+}
diff --git a/apps/web/src/docs/examples/combobox-multiple.tsx b/apps/web/src/docs/examples/combobox-multiple.tsx
new file mode 100644
index 000000000..9b132fe06
--- /dev/null
+++ b/apps/web/src/docs/examples/combobox-multiple.tsx
@@ -0,0 +1,48 @@
+import { AutoForm } from '@vitnode/core/components/form/auto-form'
+import { AutoFormCombobox } from '@vitnode/core/components/form/fields/combobox'
+import { toast } from 'sonner'
+import { z } from 'zod'
+
+const topics = [
+ { value: 'news', label: 'News' },
+ { value: 'guides', label: 'Guides' },
+ { value: 'releases', label: 'Releases' },
+ { value: 'plugins', label: 'Plugins' },
+ { value: 'community', label: 'Community' },
+]
+
+export default function ComboboxMultipleExample() {
+ const formSchema = z.object({
+ topics: z
+ .array(z.enum(['news', 'guides', 'releases', 'plugins', 'community']))
+ .min(1, 'Pick at least one topic'),
+ })
+
+ return (
+ (
+
+ ),
+ },
+ ]}
+ formSchema={formSchema}
+ onSubmit={(values) => {
+ toast.success('Topics saved', {
+ description: values.topics.join(', '),
+ })
+ }}
+ />
+ )
+}
diff --git a/apps/web/src/docs/examples/combobox.tsx b/apps/web/src/docs/examples/combobox.tsx
index 81184d1f7..86479ea34 100644
--- a/apps/web/src/docs/examples/combobox.tsx
+++ b/apps/web/src/docs/examples/combobox.tsx
@@ -9,6 +9,7 @@ export default function ComboboxExample() {
return (
() => {}
+
+const useIsApple = () =>
+ React.useSyncExternalStore(
+ subscribeToNothing,
+ () => /Mac|iPhone|iPad|iPod/.test(navigator.userAgent),
+ () => true,
+ )
+
+const actions = [
+ {
+ group: 'Navigation',
+ items: [
+ { label: 'Dashboard', icon: LayoutDashboardIcon },
+ { label: 'Documentation', icon: FileTextIcon },
+ ],
+ },
+ {
+ group: 'Actions',
+ items: [
+ { label: 'Invite a teammate', icon: UserPlusIcon },
+ { label: 'Toggle dark mode', icon: MoonIcon },
+ { label: 'Sign out', icon: LogOutIcon },
+ ],
+ },
+]
+
+export default function CommandDialogExample() {
+ const [open, setOpen] = React.useState(false)
+ const isApple = useIsApple()
+
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key.toLowerCase() !== 'j') return
+ if (!event.metaKey && !event.ctrlKey) return
+ if (event.altKey || event.shiftKey || event.defaultPrevented) return
+
+ event.preventDefault()
+ setOpen((prev) => !prev)
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [])
+
+ const runAction = (label: string) => {
+ setOpen(false)
+ toast(label, { description: 'Pretend something impressive just happened.' })
+ }
+
+ return (
+
+
setOpen(true)}
+ variant="outline"
+ >
+
+ Open command palette
+
+
+ or press
+
+ {isApple ? '⌘' : 'Ctrl'}
+ J
+
+
+
+
+
+
+
+ Nothing here. Try fewer letters?
+ {actions.map(({ group, items }, index) => (
+
+ {index > 0 && }
+
+ {items.map(({ label, icon: Icon }) => (
+ runAction(label)}>
+
+ {label}
+
+ ))}
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/command.tsx b/apps/web/src/docs/examples/command.tsx
new file mode 100644
index 000000000..19d0bc82d
--- /dev/null
+++ b/apps/web/src/docs/examples/command.tsx
@@ -0,0 +1,61 @@
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+ CommandShortcut,
+} from '@vitnode/core/components/ui/command'
+import {
+ CalculatorIcon,
+ CalendarIcon,
+ CreditCardIcon,
+ SettingsIcon,
+ SmileIcon,
+ UserIcon,
+} from 'lucide-react'
+
+export default function CommandExample() {
+ return (
+
+
+
+ No results found.
+
+
+
+ Calendar
+
+
+
+ Search Emoji
+
+
+
+ Calculator
+
+
+
+
+
+
+ Profile
+ ⌘P
+
+
+
+ Billing
+ ⌘B
+
+
+
+ Settings
+ ⌘S
+
+
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/comparison.tsx b/apps/web/src/docs/examples/comparison.tsx
new file mode 100644
index 000000000..8134785ce
--- /dev/null
+++ b/apps/web/src/docs/examples/comparison.tsx
@@ -0,0 +1,31 @@
+import {
+ Comparison,
+ ComparisonHandle,
+ ComparisonItem,
+} from '@vitnode/core/components/ui/comparison'
+
+import adminDashboardDark from '@/site/home/assets/admin-dashboard-dark-800.webp'
+import adminDashboardLight from '@/site/home/assets/admin-dashboard-light-800.webp'
+
+export default function ComparisonExample() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Drag the handle - or focus it and use the arrow keys - to compare the
+ light and dark themes.
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/confirm-action-alert-dialog.tsx b/apps/web/src/docs/examples/confirm-action-alert-dialog.tsx
index 420593716..21200410a 100644
--- a/apps/web/src/docs/examples/confirm-action-alert-dialog.tsx
+++ b/apps/web/src/docs/examples/confirm-action-alert-dialog.tsx
@@ -1,10 +1,12 @@
import { ConfirmActionAlertDialog } from '@vitnode/core/components/confirm-action/confirm-action-alert-dialog'
import { Button } from '@vitnode/core/components/ui/button'
+import { Trash2Icon } from 'lucide-react'
import { toast } from 'sonner'
export default function ConfirmActionAlertDialogExample() {
return (
}
onSubmit={({ onClose }) => {
toast.success('Category deleted successfully!', {
description: 'The category has been removed from your list.',
diff --git a/apps/web/src/docs/examples/context-menu.tsx b/apps/web/src/docs/examples/context-menu.tsx
new file mode 100644
index 000000000..ea0e11e12
--- /dev/null
+++ b/apps/web/src/docs/examples/context-menu.tsx
@@ -0,0 +1,109 @@
+import {
+ ContextMenu,
+ ContextMenuCheckboxItem,
+ ContextMenuContent,
+ ContextMenuGroup,
+ ContextMenuItem,
+ ContextMenuLabel,
+ ContextMenuRadioGroup,
+ ContextMenuRadioItem,
+ ContextMenuSeparator,
+ ContextMenuShortcut,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+ ContextMenuTrigger,
+} from '@vitnode/core/components/ui/context-menu'
+import {
+ ArrowLeftIcon,
+ ArrowRightIcon,
+ MousePointerClickIcon,
+ RotateCwIcon,
+ Trash2Icon,
+} from 'lucide-react'
+import React from 'react'
+
+export default function ContextMenuExample() {
+ const [showBookmarks, setShowBookmarks] = React.useState(true)
+ const [showFullUrls, setShowFullUrls] = React.useState(false)
+ const [person, setPerson] = React.useState('pedro')
+
+ return (
+
+
+
+
+ Right-click here
+
+
+ On a touch device, long-press instead.
+
+
+
+
+
+
+ Back
+ ⌘[
+
+
+
+ Forward
+ ⌘]
+
+
+
+ Reload
+ ⌘R
+
+
+ More tools
+
+
+ Save page as...
+ ⇧⌘S
+
+ Create shortcut...
+ Name window...
+
+ Developer tools
+
+
+
+
+
+
+ Show bookmarks bar
+
+
+ Show full URLs
+
+
+
+ People
+
+
+ Pedro Duarte
+
+
+ Colm Tuite
+
+
+
+
+
+ Delete
+ ⌘⌫
+
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/copy-button.tsx b/apps/web/src/docs/examples/copy-button.tsx
new file mode 100644
index 000000000..237198a96
--- /dev/null
+++ b/apps/web/src/docs/examples/copy-button.tsx
@@ -0,0 +1,32 @@
+import { Card } from '@vitnode/core/components/ui/card'
+import { CopyButton } from '@vitnode/core/components/ui/copy-button'
+
+const installCommand = 'pnpm create vitnode-app@canary'
+
+export default function CopyButtonExample() {
+ return (
+
+
+
+
+
+
+
+ Copy text
+
+
+
+
+
+ {installCommand}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/counter-sliding-number.tsx b/apps/web/src/docs/examples/counter-sliding-number.tsx
new file mode 100644
index 000000000..bdbccce85
--- /dev/null
+++ b/apps/web/src/docs/examples/counter-sliding-number.tsx
@@ -0,0 +1,40 @@
+import { Button } from '@vitnode/core/components/ui/button'
+import { SlidingNumber } from '@vitnode/core/components/ui/sliding-number'
+import React from 'react'
+
+export default function SlidingNumberExample() {
+ const [downloads, setDownloads] = React.useState(1287)
+
+ return (
+
+
Downloads this week
+
+
+ {
+ setDownloads((current) => current + 1)
+ }}
+ variant="outline"
+ >
+ +1
+
+ {
+ setDownloads((current) => current + 125)
+ }}
+ variant="outline"
+ >
+ +125
+
+ {
+ setDownloads(9)
+ }}
+ variant="ghost"
+ >
+ Reset to 9
+
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/counter.tsx b/apps/web/src/docs/examples/counter.tsx
new file mode 100644
index 000000000..f50fecc43
--- /dev/null
+++ b/apps/web/src/docs/examples/counter.tsx
@@ -0,0 +1,27 @@
+import { Card } from '@vitnode/core/components/ui/card'
+import { Counter } from '@vitnode/core/components/ui/counter'
+import React from 'react'
+
+export default function CounterExample() {
+ const [quantity, setQuantity] = React.useState(1)
+
+ return (
+
+
+
+ Conference ticket
+
+
+ Up to 10 per order
+
+
+
+
+ )
+}
diff --git a/apps/web/src/docs/examples/data-table-demo-users.ts b/apps/web/src/docs/examples/data-table-demo-users.ts
new file mode 100644
index 000000000..5d452cd59
--- /dev/null
+++ b/apps/web/src/docs/examples/data-table-demo-users.ts
@@ -0,0 +1,55 @@
+export interface DemoUser {
+ email: string
+ id: number
+ name: string
+ role: 'Admin' | 'Editor' | 'Viewer'
+ status: 'Active' | 'Banned' | 'Invited'
+}
+
+const firstNames = [
+ 'Ada',
+ 'Alan',
+ 'Grace',
+ 'Linus',
+ 'Margaret',
+ 'Dennis',
+ 'Katherine',
+ 'Ken',
+ 'Barbara',
+ 'Guido',
+ 'Hedy',
+ 'Tim',
+]
+const lastNames = [
+ 'Lovelace',
+ 'Turing',
+ 'Hopper',
+ 'Torvalds',
+ 'Hamilton',
+ 'Ritchie',
+ 'Johnson',
+ 'Thompson',
+ 'Liskov',
+ 'van Rossum',
+ 'Lamarr',
+ 'Berners-Lee',
+]
+const roles = ['Admin', 'Editor', 'Viewer', 'Viewer'] as const
+const statuses = ['Active', 'Active', 'Invited', 'Banned'] as const
+
+export const demoUsers: DemoUser[] = Array.from({ length: 36 }, (_, index) => {
+ const first = firstNames[index % firstNames.length] ?? 'Ada'
+ const last =
+ lastNames[
+ (index * 5 + Math.floor(index / firstNames.length)) % lastNames.length
+ ] ?? 'Lovelace'
+
+ return {
+ email:
+ `${first}.${last}`.toLowerCase().replaceAll(' ', '') + '@vitnode.com',
+ id: index + 1,
+ name: `${first} ${last}`,
+ role: roles[index % roles.length] ?? 'Viewer',
+ status: statuses[(index * 3) % statuses.length] ?? 'Active',
+ }
+})
diff --git a/apps/web/src/docs/examples/data-table-expandable.tsx b/apps/web/src/docs/examples/data-table-expandable.tsx
new file mode 100644
index 000000000..21df817ab
--- /dev/null
+++ b/apps/web/src/docs/examples/data-table-expandable.tsx
@@ -0,0 +1,182 @@
+import { Badge } from '@vitnode/core/components/ui/badge'
+
+import { type ColumnDef, DataTable } from '../preview-data-table'
+
+interface LineItem {
+ name: string
+ price: number
+ quantity: number
+}
+
+interface DemoOrder {
+ customer: string
+ id: number
+ items: LineItem[]
+ number: string
+ shipping: number
+ status: 'Delivered' | 'Draft' | 'Processing' | 'Refunded'
+}
+
+const money = new Intl.NumberFormat('en-US', {
+ currency: 'USD',
+ style: 'currency',
+})
+
+const subtotalOf = (order: DemoOrder) =>
+ order.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
+
+const statusVariant = {
+ Delivered: 'success',
+ Draft: 'outline',
+ Processing: 'warning',
+ Refunded: 'destructive',
+} as const
+
+const columns: ColumnDef[] = [
+ {
+ accessorKey: 'number',
+ header: 'Order',
+ cell: ({ row }) => {row.number} ,
+ },
+ { accessorKey: 'customer', header: 'Customer' },
+ {
+ accessorKey: 'status',
+ header: 'Status',
+ cell: ({ row }) => (
+ {row.status}
+ ),
+ },
+ {
+ id: 'total',
+ header: 'Total',
+ align: 'right',
+ cell: ({ row }) => (
+
+ {money.format(subtotalOf(row) + row.shipping)}
+
+ ),
+ },
+]
+
+const edges: DemoOrder[] = [
+ {
+ id: 1,
+ number: '#1042',
+ customer: 'John Doe',
+ status: 'Delivered',
+ shipping: 4.99,
+ items: [
+ { name: 'Mechanical keyboard', price: 89, quantity: 1 },
+ { name: 'Keycap set (Ocean)', price: 24.5, quantity: 2 },
+ ],
+ },
+ {
+ id: 2,
+ number: '#1043',
+ customer: 'Jane Smith',
+ status: 'Processing',
+ shipping: 0,
+ items: [
+ { name: 'Standing desk mat', price: 39, quantity: 1 },
+ { name: 'Cable organizer', price: 12, quantity: 3 },
+ { name: 'Monitor light bar', price: 59, quantity: 1 },
+ ],
+ },
+ {
+ id: 3,
+ number: '#1044',
+ customer: 'Alice Johnson',
+ status: 'Draft',
+ shipping: 0,
+ items: [],
+ },
+ {
+ id: 4,
+ number: '#1045',
+ customer: 'Bob Brown',
+ status: 'Refunded',
+ shipping: 7.5,
+ items: [
+ { name: 'Rubber duck (debugging edition)', price: 9.99, quantity: 4 },
+ ],
+ },
+]
+
+const OrderDetail = ({ order }: { order: DemoOrder }) => {
+ const subtotal = subtotalOf(order)
+
+ return (
+
+
+ {order.items.map((item) => (
+
+
+ {item.name}
+ × {item.quantity}
+
+
+ {money.format(item.price * item.quantity)}
+
+
+ ))}
+
+
+
+
+
Subtotal
+ {money.format(subtotal)}
+
+
+
Shipping
+
+ {order.shipping ? money.format(order.shipping) : 'Free'}
+
+
+
+
Total
+
+ {money.format(subtotal + order.shipping)}
+
+
+
+
+ )
+}
+
+export default function DataTableExpandableExample() {
+ return (
+ order.items.length > 0,
+ defaultExpanded: [1],
+ render: (order) => ,
+ }}
+ id="orders-expandable-table"
+ order={{
+ defaultOrder: {
+ column: 'number',
+ order: 'asc',
+ },
+ }}
+ pageInfo={{
+ count: edges.length,
+ currentPage: 1,
+ endCursor: null,
+ hasNextPage: false,
+ hasPreviousPage: false,
+ pageSize: 10,
+ startCursor: null,
+ totalCount: edges.length,
+ totalPages: 1,
+ }}
+ />
+ )
+}
diff --git a/apps/web/src/docs/examples/data-table-filters.tsx b/apps/web/src/docs/examples/data-table-filters.tsx
new file mode 100644
index 000000000..351b7fabc
--- /dev/null
+++ b/apps/web/src/docs/examples/data-table-filters.tsx
@@ -0,0 +1,61 @@
+import { Badge } from '@vitnode/core/components/ui/badge'
+
+import { resolveLocalTable } from '../local-table-data'
+import { type ColumnDef, DataTable } from '../preview-data-table'
+import { type DemoUser, demoUsers } from './data-table-demo-users'
+
+const statusVariant = {
+ Active: 'success',
+ Banned: 'destructive',
+ Invited: 'secondary',
+} as const
+
+const columns: ColumnDef[] = [
+ { accessorKey: 'name', header: 'Name' },
+ { accessorKey: 'role', header: 'Role' },
+ {
+ accessorKey: 'status',
+ header: 'Status',
+ cell: ({ row }) => (
+ {row.status}
+ ),
+ },
+]
+
+const defaultOrder = { column: 'name', order: 'asc' } as const
+
+export default function DataTableFiltersExample() {
+ return (
+
+ resolveLocalTable(demoUsers, params, {
+ defaultOrder,
+ filters: { role: (row) => row.role, status: (row) => row.status },
+ })
+ }
+ />
+ )
+}
diff --git a/apps/web/src/docs/examples/data-table-pagination.tsx b/apps/web/src/docs/examples/data-table-pagination.tsx
new file mode 100644
index 000000000..8aab585a1
--- /dev/null
+++ b/apps/web/src/docs/examples/data-table-pagination.tsx
@@ -0,0 +1,24 @@
+import { resolveLocalTable } from '../local-table-data'
+import { type ColumnDef, DataTable } from '../preview-data-table'
+import { type DemoUser, demoUsers } from './data-table-demo-users'
+
+const columns: ColumnDef[] = [
+ { accessorKey: 'id', header: '#' },
+ { accessorKey: 'name', header: 'Name' },
+ { accessorKey: 'email', header: 'Email' },
+]
+
+const defaultOrder = { column: 'id', order: 'asc' } as const
+
+export default function DataTablePaginationExample() {
+ return (
+
-
-
-
-
+
{note ? (
diff --git a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.test.tsx b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.test.tsx
new file mode 100644
index 000000000..5760d4b74
--- /dev/null
+++ b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.test.tsx
@@ -0,0 +1,44 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { Trash2Icon } from "lucide-react";
+import { describe, expect, it } from "vitest";
+
+import { ConfirmActionAlertDialog } from "./confirm-action-alert-dialog";
+
+const dialogMedia = () =>
+ screen
+ .getByRole("alertdialog")
+ .querySelector("[data-slot=alert-dialog-media]");
+
+describe("ConfirmActionAlertDialog", () => {
+ it("shows the icon above the title in the compact layout", async () => {
+ render(
+ }
+ onSubmit={() => {}}
+ title="Delete chat?"
+ >
+ Delete
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Delete" }));
+
+ expect(await screen.findByText("Delete chat?")).toBeTruthy();
+ expect(dialogMedia()?.querySelector("svg")?.classList).toContain(
+ "lucide-trash-2",
+ );
+ expect(screen.getByRole("alertdialog").dataset.size).toBe("sm");
+ });
+
+ it("keeps the default layout without an icon", async () => {
+ render(
+ {}} title="Delete chat?">
+ Delete
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Delete" }));
+
+ expect(await screen.findByText("Delete chat?")).toBeTruthy();
+ expect(dialogMedia()).toBeNull();
+ expect(screen.getByRole("alertdialog").dataset.size).toBe("default");
+ });
+});
diff --git a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx
index db83b45a9..d9a94a48c 100644
--- a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx
+++ b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx
@@ -1,3 +1,4 @@
+import { cn } from "cn";
import React from "react";
import { useTranslations } from "use-intl";
@@ -5,24 +6,20 @@ import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
- AlertDialogFooterSkeleton,
AlertDialogHeader,
+ AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "../ui/alert-dialog";
-
-const ContentConfirmAction = React.lazy(async () =>
- import("./content").then(module => ({
- default: module.ContentConfirmAction,
- })),
-);
+import { ContentConfirmAction } from "./content";
export const ConfirmActionAlertDialog = ({
children,
title,
description,
finalFocus,
- submitVariant,
+ icon,
+ submitVariant = "destructive",
textSubmit,
onSubmit,
...props
@@ -31,6 +28,7 @@ export const ConfirmActionAlertDialog = ({
children?: React.ReactElement;
description?: React.ReactNode;
finalFocus?: React.ComponentProps["finalFocus"];
+ icon?: React.ReactNode;
title?: React.ReactNode;
}) => {
const t = useTranslations("core.global.confirm_action");
@@ -39,21 +37,32 @@ export const ConfirmActionAlertDialog = ({
{children ? : null}
-
+
+ {icon ? (
+
+ {icon}
+
+ ) : null}
{title ?? t("title")}
{description ?? t("desc")}
- }>
-
-
+
);
diff --git a/packages/vitnode/src/components/form/fields/cascader.tsx b/packages/vitnode/src/components/form/fields/cascader.tsx
new file mode 100644
index 000000000..e43c2a137
--- /dev/null
+++ b/packages/vitnode/src/components/form/fields/cascader.tsx
@@ -0,0 +1,52 @@
+import type { ItemAutoFormComponentProps } from "../auto-form";
+
+import { Cascader } from "../../ui/cascader";
+import { FormControl, FormMessage } from "../../ui/form";
+import { AutoFormDesc } from "../common/desc";
+import { AutoFormLabel } from "../common/label";
+
+type CascaderProps = React.ComponentProps;
+
+export const AutoFormCascader = ({
+ label,
+ labelRight,
+ description,
+ otherProps: { isOptional },
+ field,
+ options,
+ onValueChange,
+ showClear,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ itemParams,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ multiLang,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ children,
+ ...props
+}: ItemAutoFormComponentProps &
+ Omit) => (
+ <>
+ {!!label && (
+
+ {label}
+
+ )}
+
+
+ {
+ field.onChange(value ?? undefined);
+ field.onBlur();
+ onValueChange?.(value, path);
+ }}
+ options={options}
+ showClear={showClear ?? isOptional}
+ value={field.value ?? null}
+ {...props}
+ />
+
+
+ {!!description && {description} }
+
+ >
+);
diff --git a/packages/vitnode/src/components/form/fields/combobox-groups.test.ts b/packages/vitnode/src/components/form/fields/combobox-groups.test.ts
new file mode 100644
index 000000000..0e075692c
--- /dev/null
+++ b/packages/vitnode/src/components/form/fields/combobox-groups.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest";
+
+import { groupComboboxItems } from "./combobox-groups";
+
+describe("groupComboboxItems", () => {
+ it("returns null when no option names a group", () => {
+ expect(
+ groupComboboxItems(
+ ["a", "b"],
+ [
+ { label: "A", value: "a" },
+ { label: "B", value: "b" },
+ ],
+ ),
+ ).toBeNull();
+ });
+
+ it("groups values in the order their groups first appear", () => {
+ expect(
+ groupComboboxItems(
+ ["apple", "carrot", "banana"],
+ [
+ { group: "Fruits", label: "Apple", value: "apple" },
+ { group: "Vegetables", label: "Carrot", value: "carrot" },
+ { group: "Fruits", label: "Banana", value: "banana" },
+ ],
+ ),
+ ).toEqual([
+ { items: ["apple", "banana"], value: "Fruits" },
+ { items: ["carrot"], value: "Vegetables" },
+ ]);
+ });
+
+ it("keeps values without a group together under an empty heading", () => {
+ expect(
+ groupComboboxItems(
+ ["other", "apple", "unknown"],
+ [
+ { label: "Other", value: "other" },
+ { group: "Fruits", label: "Apple", value: "apple" },
+ ],
+ ),
+ ).toEqual([
+ { items: ["other", "unknown"], value: "" },
+ { items: ["apple"], value: "Fruits" },
+ ]);
+ });
+});
diff --git a/packages/vitnode/src/components/form/fields/combobox-groups.ts b/packages/vitnode/src/components/form/fields/combobox-groups.ts
new file mode 100644
index 000000000..64607c602
--- /dev/null
+++ b/packages/vitnode/src/components/form/fields/combobox-groups.ts
@@ -0,0 +1,29 @@
+export interface ComboboxOption {
+ group?: string;
+ label: string;
+ value: string;
+}
+
+export interface ComboboxItemGroup {
+ items: string[];
+ value: string;
+}
+
+export const groupComboboxItems = (
+ values: readonly string[],
+ options: readonly ComboboxOption[],
+): ComboboxItemGroup[] | null => {
+ if (!options.some(option => option.group)) return null;
+
+ const groupOf = new Map(
+ options.map(option => [option.value, option.group ?? ""]),
+ );
+ const groups = new Map();
+
+ for (const value of values) {
+ const group = groupOf.get(value) ?? "";
+ groups.set(group, [...(groups.get(group) ?? []), value]);
+ }
+
+ return [...groups].map(([value, items]) => ({ items, value }));
+};
diff --git a/packages/vitnode/src/components/form/fields/combobox.tsx b/packages/vitnode/src/components/form/fields/combobox.tsx
index a90d8c085..9b0ef4dd3 100644
--- a/packages/vitnode/src/components/form/fields/combobox.tsx
+++ b/packages/vitnode/src/components/form/fields/combobox.tsx
@@ -6,12 +6,17 @@ import { useTranslations } from "use-intl";
import {
Combobox,
ComboboxChip,
+ ComboboxChipList,
+ ComboboxChipOverflow,
ComboboxChips,
ComboboxChipsInput,
+ ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
+ ComboboxGroup,
ComboboxInput,
ComboboxItem,
+ ComboboxLabel,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
@@ -20,9 +25,11 @@ import { FormMessage } from "@/components/ui/form";
import { Skeleton } from "@/components/ui/skeleton";
import type { ItemAutoFormComponentProps } from "../auto-form";
+import type { ComboboxItemGroup, ComboboxOption } from "./combobox-groups";
import { AutoFormDesc } from "../common/desc";
import { AutoFormLabel } from "../common/label";
+import { groupComboboxItems } from "./combobox-groups";
export interface ComboboxAsyncItem {
label: string;
@@ -37,7 +44,8 @@ export const COMBOBOX_INERT_QUERY_KEY = "combobox:no-fetcher";
type AutoFormComboboxProps = ItemAutoFormComponentProps &
Omit, "items" | "value"> & {
className?: string;
- labels?: { label: string; value: string }[];
+ labels?: ComboboxOption[];
+ maxVisibleChips?: number;
placeholder?: string;
renderChip?: (item: ComboboxAsyncItem) => React.ReactNode;
renderItem?: (item: ComboboxAsyncItem) => React.ReactNode;
@@ -71,6 +79,7 @@ export const AutoFormCombobox = ({
otherProps,
labels = [],
labelRight,
+ maxVisibleChips,
onValueChange,
onInputValueChange,
disabled,
@@ -113,6 +122,11 @@ export const AutoFormCombobox = ({
}, 500);
const items = isAsync ? (data ?? []) : (otherProps?.enum ?? []);
+ const staticItemLabel = (item: string) =>
+ labels.find(l => l.value === item)?.label ?? item;
+ const groupedItems = isAsync
+ ? null
+ : groupComboboxItems(otherProps?.enum ?? [], labels);
const inputPlaceholder = isAsync
? (searchPlaceholder ?? placeholder ?? t("select_option"))
: (placeholder ?? t("select_option"));
@@ -172,15 +186,26 @@ export const AutoFormCombobox = ({
);
}
- return (
-
- {(item: string) => (
-
- {labels.find(l => l.value === item)?.label ?? item}
-
- )}
-
+ const renderStaticItem = (item: string) => (
+
+ {staticItemLabel(item)}
+
);
+
+ if (groupedItems) {
+ return (
+
+ {(group: ComboboxItemGroup) => (
+
+ {group.value && {group.value} }
+ {renderStaticItem}
+
+ )}
+
+ );
+ }
+
+ return {renderStaticItem} ;
};
const renderContent = () => {
@@ -218,7 +243,8 @@ export const AutoFormCombobox = ({
disabled={disabled}
filter={comboboxFilter}
isItemEqualToValue={comboboxItemEqual}
- items={items}
+ items={groupedItems ?? items}
+ itemToStringLabel={isAsync ? undefined : staticItemLabel}
multiple={isMultiple}
onInputValueChange={onComboboxInputValueChange}
onValueChange={onComboboxValueChange}
@@ -231,23 +257,32 @@ export const AutoFormCombobox = ({
{(values: (ComboboxAsyncItem | string)[]) => (
<>
- {values.map(value => {
- const item =
- typeof value === "string"
- ? {
- label:
- labels.find(l => l.value === value)?.label ??
+
+ {values.slice(0, maxVisibleChips).map(value => {
+ const item =
+ typeof value === "string"
+ ? {
+ label:
+ labels.find(l => l.value === value)?.label ??
+ value,
value,
- value,
- }
- : value;
+ }
+ : value;
- return (
-
- {renderChip ? renderChip(item) : item.label}
-
- );
- })}
+ return (
+
+ {renderChip ? renderChip(item) : item.label}
+
+ );
+ })}
+ {maxVisibleChips !== undefined &&
+ values.length > maxVisibleChips && (
+
+ )}
+
+
{rows.map(row => {
const name = rowName(row);
diff --git a/packages/vitnode/src/components/form/fields/input-otp.tsx b/packages/vitnode/src/components/form/fields/input-otp.tsx
new file mode 100644
index 000000000..c1e463322
--- /dev/null
+++ b/packages/vitnode/src/components/form/fields/input-otp.tsx
@@ -0,0 +1,97 @@
+import React from "react";
+
+import type { ItemAutoFormComponentProps } from "../auto-form";
+
+import { FormControl, FormMessage } from "../../ui/form";
+import {
+ InputOTP,
+ InputOTPGroup,
+ InputOTPSeparator,
+ InputOTPSlot,
+} from "../../ui/input-otp";
+import { AutoFormDesc } from "../common/desc";
+import { AutoFormLabel } from "../common/label";
+
+const DEFAULT_CODE_LENGTH = 6;
+
+const splitIntoHalves = (length: number) =>
+ length >= 6 && length % 2 === 0 ? [length / 2, length / 2] : [length];
+
+type AutoFormInputOTPProps = ItemAutoFormComponentProps &
+ Omit<
+ React.ComponentProps,
+ "children" | "maxLength" | "onChange" | "render" | "value"
+ > & {
+ groups?: number[];
+ maxLength?: number;
+ onChange?: (value: string) => void;
+ };
+
+export const AutoFormInputOTP = ({
+ label,
+ labelRight,
+ description,
+ otherProps,
+ field,
+ groups,
+ maxLength,
+ pattern,
+ onChange,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ itemParams,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ multiLang,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ children,
+ ...props
+}: AutoFormInputOTPProps) => {
+ const length = maxLength ?? otherProps.maxLength ?? DEFAULT_CODE_LENGTH;
+ const slotGroups = groups ?? splitIntoHalves(length);
+ const groupStarts = slotGroups.map((_, groupIndex) =>
+ slotGroups.slice(0, groupIndex).reduce((sum, size) => sum + size, 0),
+ );
+
+ return (
+ <>
+ {!!label && (
+
+ {label}
+
+ )}
+
+
+ {
+ field.onChange(value);
+ onChange?.(value);
+ }}
+ pattern={pattern ?? otherProps.pattern}
+ value={field.value ?? ""}
+ {...props}
+ >
+ {slotGroups.map((size, groupIndex) => (
+
+ {groupIndex > 0 && }
+
+ {Array.from({ length: size }, (_, slot) => {
+ const index = groupStarts[groupIndex] + slot;
+
+ return ;
+ })}
+
+
+ ))}
+
+
+
+ {!!description && {description} }
+
+ >
+ );
+};
diff --git a/packages/vitnode/src/components/form/fields/nullable-number.tsx b/packages/vitnode/src/components/form/fields/nullable-number.tsx
index 41ff5eac8..d9f77c557 100644
--- a/packages/vitnode/src/components/form/fields/nullable-number.tsx
+++ b/packages/vitnode/src/components/form/fields/nullable-number.tsx
@@ -2,8 +2,14 @@ import { cn } from "cn";
import React from "react";
import { Checkbox } from "@/components/ui/checkbox";
-import { FormControl, FormMessage } from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
+import { FormControl, FormMessage, useFormField } from "@/components/ui/form";
+import {
+ NumberField,
+ NumberFieldDecrement,
+ NumberFieldGroup,
+ NumberFieldIncrement,
+ NumberFieldInput,
+} from "@/components/ui/number-field";
import type { ItemAutoFormComponentProps } from "../auto-form";
@@ -11,8 +17,12 @@ import { AutoFormDesc } from "../common/desc";
import { AutoFormLabel } from "../common/label";
type AutoFormNullableNumberProps = ItemAutoFormComponentProps &
- Omit, "value"> & {
+ Omit<
+ React.ComponentProps,
+ "children" | "defaultValue" | "id" | "name" | "onValueChange" | "value"
+ > & {
orLabel?: React.ReactNode;
+ placeholder?: string;
toggleLabel: React.ReactNode;
unitLabel?: React.ReactNode;
};
@@ -30,15 +40,15 @@ export const AutoFormNullableNumber = ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
multiLang,
className,
+ disabled,
+ placeholder,
unitLabel,
orLabel,
toggleLabel,
...props
}: AutoFormNullableNumberProps) => {
+ const { formItemId } = useFormField();
const isToggled = field.value === null;
- const [text, setText] = React.useState(
- typeof field.value === "number" ? String(field.value) : "",
- );
const lastNumericRef = React.useRef(
typeof field.value === "number" ? field.value : 0,
);
@@ -52,32 +62,30 @@ export const AutoFormNullableNumber = ({
)}
-
- {
- const raw = event.target.value;
- setText(raw);
-
- if (raw === "") {
- lastNumericRef.current = 0;
- field.onChange(0);
-
- return;
- }
-
- const parsed = Number(raw);
- if (Number.isNaN(parsed)) return;
-
- lastNumericRef.current = parsed;
- field.onChange(parsed);
- }}
- type="number"
- value={isToggled ? "" : text}
- {...props}
- />
-
+
{
+ const next = value ?? 0;
+ lastNumericRef.current = next;
+ field.onChange(next);
+ }}
+ value={typeof field.value === "number" ? field.value : null}
+ {...props}
+ >
+
+
+
+
+
+
+
+
{!!unitLabel && (
{unitLabel}
@@ -89,15 +97,9 @@ export const AutoFormNullableNumber = ({
{
- if (checked) {
- field.onChange(null);
-
- return;
- }
-
- field.onChange(lastNumericRef.current);
- setText(String(lastNumericRef.current));
+ field.onChange(checked ? null : lastNumericRef.current);
}}
/>
{toggleLabel}
diff --git a/packages/vitnode/src/components/form/fields/number.tsx b/packages/vitnode/src/components/form/fields/number.tsx
index ba658b325..11809bef3 100644
--- a/packages/vitnode/src/components/form/fields/number.tsx
+++ b/packages/vitnode/src/components/form/fields/number.tsx
@@ -1,13 +1,14 @@
import { cn } from "cn";
import React from "react";
-import { FormControl, FormMessage } from "@/components/ui/form";
+import { FormControl, FormMessage, useFormField } from "@/components/ui/form";
import {
- InputGroup,
- InputGroupAddon,
- InputGroupInput,
- InputGroupText,
-} from "@/components/ui/input-group";
+ NumberField,
+ NumberFieldDecrement,
+ NumberFieldGroup,
+ NumberFieldIncrement,
+ NumberFieldInput,
+} from "@/components/ui/number-field";
import type { ItemAutoFormComponentProps } from "../auto-form";
@@ -15,7 +16,11 @@ import { AutoFormDesc } from "../common/desc";
import { AutoFormLabel } from "../common/label";
type AutoFormNumberProps = ItemAutoFormComponentProps &
- Omit, "type" | "value"> & {
+ Omit<
+ React.ComponentProps,
+ "children" | "defaultValue" | "id" | "name" | "onValueChange" | "value"
+ > & {
+ placeholder?: string;
unitLabel?: React.ReactNode;
};
@@ -30,12 +35,11 @@ export const AutoFormNumber = ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
multiLang,
otherProps: { isOptional },
+ placeholder,
unitLabel,
...props
}: AutoFormNumberProps) => {
- const [text, setText] = React.useState(
- typeof field.value === "number" ? String(field.value) : "",
- );
+ const { formItemId } = useFormField();
return (
<>
@@ -45,31 +49,33 @@ export const AutoFormNumber = ({
)}
-
-
- {
- const raw = event.target.value;
- setText(raw);
+
+ {
+ field.onChange(value);
+ }}
+ value={typeof field.value === "number" ? field.value : null}
+ {...props}
+ >
+
+
+
+
+
+
+
+
- const parsed = Number(raw);
- field.onChange(
- raw === "" || Number.isNaN(parsed) ? null : parsed,
- );
- }}
- type="number"
- value={text}
- {...props}
- />
-
{!!unitLabel && (
-
- {unitLabel}
-
+ {unitLabel}
)}
-
+
{!!description && {description} }
diff --git a/packages/vitnode/src/components/table/content.tsx b/packages/vitnode/src/components/table/content.tsx
index a8628e28b..cde456b25 100644
--- a/packages/vitnode/src/components/table/content.tsx
+++ b/packages/vitnode/src/components/table/content.tsx
@@ -1,5 +1,7 @@
import { cn } from "cn";
import { SearchXIcon } from "lucide-react";
+import { AnimatePresence } from "motion/react";
+import React from "react";
import type {
AlignDataTable,
@@ -17,11 +19,26 @@ import {
TableHeader,
TableRow,
} from "../ui/table";
+import { TooltipGroup } from "../ui/tooltip";
+import {
+ ExpandedRowDataTable,
+ ExpandHeaderDataTable,
+ ExpandToggleDataTable,
+ useRowExpansionDataTable,
+} from "./expansion";
import { FiltersDataTable } from "./filters";
import { useDataTableUrl } from "./navigation";
import { NoResultsDataTable } from "./no-results";
import { OrderTableHeadDataTable } from "./order-table-head";
import { PaginationDataTable } from "./pagination";
+import {
+ ReorderHandleDataTable,
+ ReorderHeaderDataTable,
+ ReorderProviderDataTable,
+ SortableRowGroupDataTable,
+ useRowOrderDataTable,
+} from "./reorder";
+import { isTableInStoredOrder } from "./reorder-state";
import { SearchDataTable } from "./search";
import {
BulkActionsDataTable,
@@ -49,8 +66,10 @@ export function ContentDataTable({
bulkActions,
columns,
edges,
+ expandable,
pageInfo,
order,
+ reorderable,
customNoResults,
header,
rowOpens,
@@ -60,6 +79,19 @@ export function ContentDataTable({
...props
}: DataTableProps) {
const { searchParams } = useDataTableUrl();
+ const expandedBaseId = React.useId();
+ const { reorder, rows } = useRowOrderDataTable({
+ edges,
+ onReorder: reorderable?.onReorder,
+ });
+ const rowIds = rows.map(row => row.id);
+ const expansion = useRowExpansionDataTable({
+ defaultExpanded: expandable?.defaultExpanded,
+ rowIds,
+ });
+ const canExpand = (row: T) =>
+ expandable !== undefined && (expandable.canExpand?.(row) ?? true);
+ const expandedIdOf = (row: T) => `${expandedBaseId}expanded-${row.id}`;
// WHICH COLUMN THE LIST IS ORDERED BY, SAID AND NOT ONLY DRAWN. The arrow on
// the active header is the whole of what states the order once a surface
// stops carrying an "Ordered" control beside its list, and an arrow is not
@@ -84,17 +116,161 @@ export function ContentDataTable({
return ordered.order === "asc" ? "ascending" : "descending";
};
const hasToolbar = Boolean(search) || Boolean(filters?.length);
- const allColumns: ColumnDef[] = bulkActions
- ? [
- {
- id: "select",
- header: ,
- className: "w-8",
- cell: ({ row }) => ,
- },
- ...columns,
- ]
- : columns;
+ const isReorderDisabled =
+ reorderable?.disabled === true ||
+ !isTableInStoredOrder(searchParams, {
+ defaultOrder: {
+ column: String(order.defaultOrder.column),
+ order: order.defaultOrder.order,
+ },
+ filterIds: filters?.map(filter => filter.id) ?? [],
+ });
+ const rowLabelOf = (id: number) => {
+ const row = rows.find(item => item.id === id);
+
+ return row ? reorderable?.getRowLabel?.(row) : undefined;
+ };
+ const allColumns: ColumnDef[] = [
+ ...(reorderable
+ ? [
+ {
+ id: "reorder",
+ header: ,
+ className: "w-8",
+ cell: () => ,
+ },
+ ]
+ : []),
+ ...(bulkActions
+ ? [
+ {
+ id: "select",
+ header: ,
+ className: "w-8",
+ cell: ({ row }: { row: T }) => ,
+ },
+ ]
+ : []),
+ ...(expandable
+ ? [
+ {
+ id: "expand",
+ header: ,
+ className: "w-8",
+ cell: ({ row }: { row: T }) =>
+ canExpand(row) ? (
+ expansion.toggle(row.id)}
+ />
+ ) : null,
+ },
+ ]
+ : []),
+ ...columns,
+ ];
+
+ const renderRow = (row: T) => {
+ const cells = allColumns.map(column => {
+ const columnKey = column.id ?? String(column.accessorKey);
+ const content = column.cell
+ ? column.cell({ allData: rows, row })
+ : column.accessorKey != null
+ ? String(row[column.accessorKey])
+ : "";
+
+ return (
+
+ {content}
+
+ );
+ });
+
+ const detail = expandable && (
+
+ {canExpand(row) && expansion.isExpanded(row.id) && (
+
+ {expandable.render(row)}
+
+ )}
+
+ );
+
+ if (bulkActions) {
+ return (
+ <>
+ {cells}
+ {detail}
+ >
+ );
+ }
+
+ return (
+ <>
+ {
+ // A control inside the row answers for itself,
+ // and a reader who has selected text in a cell
+ // was reading it rather than asking to leave.
+ if (
+ (event.target as HTMLElement).closest(
+ "a,button,input,select,textarea,[role=checkbox]",
+ )
+ ) {
+ return;
+ }
+ if (window.getSelection()?.toString()) {
+ return;
+ }
+ rowOpens(row);
+ }
+ : undefined
+ }
+ onKeyDown={
+ rowOpens
+ ? event => {
+ if (
+ (event.target as HTMLElement).closest(
+ "a,button,input,select,textarea,[role=checkbox]",
+ )
+ ) {
+ return;
+ }
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ rowOpens(row);
+ }
+ }
+ : undefined
+ }
+ tabIndex={rowOpens ? 0 : undefined}
+ >
+ {cells}
+
+ {detail}
+ >
+ );
+ };
const table = (
@@ -125,7 +301,7 @@ export function ContentDataTable
({
the table's own `overflow-x-auto` then works against. It lives here
because every caller sits in that same ``. */}
-
+
({
"[&_td:first-child]:pl-4 sm:[&_td:first-child]:pl-6 [&_th:first-child]:pl-4 sm:[&_th:first-child]:pl-6",
"[&_td:last-child]:pr-4 sm:[&_td:last-child]:pr-6 [&_th:last-child]:pr-4 sm:[&_th:last-child]:pr-6",
bulkActions &&
+ !reorderable &&
"[&_td:first-child]:pe-4 sm:[&_td:first-child]:pe-5 [&_th:first-child]:pe-4 sm:[&_th:first-child]:pe-5",
props.className,
)}
@@ -179,95 +356,8 @@ export function ContentDataTable({
-
- {edges.length ? (
- edges.map(row => {
- const cells = allColumns.map(column => {
- const columnKey = column.id ?? String(column.accessorKey);
- const content = column.cell
- ? column.cell({ allData: edges, row })
- : column.accessorKey != null
- ? String(row[column.accessorKey])
- : "";
-
- return (
-
- {content}
-
- );
- });
-
- if (bulkActions) {
- return (
-
- {cells}
-
- );
- }
-
- return (
- {
- // A control inside the row answers for itself,
- // and a reader who has selected text in a cell
- // was reading it rather than asking to leave.
- if (
- (event.target as HTMLElement).closest(
- "a,button,input,select,textarea,[role=checkbox]",
- )
- ) {
- return;
- }
- if (window.getSelection()?.toString()) {
- return;
- }
- rowOpens(row);
- }
- : undefined
- }
- onKeyDown={
- rowOpens
- ? event => {
- if (
- (event.target as HTMLElement).closest(
- "a,button,input,select,textarea,[role=checkbox]",
- )
- ) {
- return;
- }
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- rowOpens(row);
- }
- }
- : undefined
- }
- tabIndex={rowOpens ? 0 : undefined}
- >
- {cells}
-
- );
- })
- ) : (
+ {rows.length === 0 ? (
+
({
- )}
-
+
+ ) : reorderable ? (
+
+ {rows.map((row, index) => (
+
+ {renderRow(row)}
+
+ ))}
+
+ ) : (
+
+ {rows.map(row => (
+ {renderRow(row)}
+ ))}
+
+ )}
@@ -300,13 +414,15 @@ export function ContentDataTable
({
);
if (!bulkActions) {
- return table;
+ return {table} ;
}
return (
- row.id)}>
- {table}
-
-
+
+
+ {table}
+
+
+
);
}
diff --git a/packages/vitnode/src/components/table/data-table-content.tsx b/packages/vitnode/src/components/table/data-table-content.tsx
index 887ed3cce..678e0997d 100644
--- a/packages/vitnode/src/components/table/data-table-content.tsx
+++ b/packages/vitnode/src/components/table/data-table-content.tsx
@@ -1,8 +1,10 @@
import { cn } from "cn";
import React from "react";
+import type { ExpandableDataTable } from "./expansion";
import type { FilterDataTable } from "./filters";
import type { PaginationDataTable } from "./pagination";
+import type { ReorderableDataTable } from "./reorder";
import type { SearchDataTable } from "./search";
import { Skeleton } from "../ui/skeleton";
@@ -64,6 +66,7 @@ export type DataTableProps = Omit<
title?: string;
};
edges: T[];
+ expandable?: ExpandableDataTable;
filters?: FilterDataTable[];
header?: React.ReactNode;
id: string;
@@ -74,6 +77,7 @@ export type DataTableProps = Omit<
order: "asc" | "desc";
};
};
+ reorderable?: ReorderableDataTable;
rowOpens?: (row: T) => void;
search?: boolean;
};
diff --git a/packages/vitnode/src/components/table/expansion.test.tsx b/packages/vitnode/src/components/table/expansion.test.tsx
new file mode 100644
index 000000000..a7c0450b6
--- /dev/null
+++ b/packages/vitnode/src/components/table/expansion.test.tsx
@@ -0,0 +1,159 @@
+import {
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import type { ColumnDef, DataTableTMin } from "./data-table-content";
+import type { ExpandableDataTable } from "./expansion";
+
+import { ContentDataTable } from "./content";
+import { DataTableNavigationProvider } from "./navigation";
+
+interface Order extends DataTableTMin {
+ id: number;
+ items: string[];
+ number: string;
+}
+
+const edges: Order[] = [
+ { id: 1, items: ["Coffee beans"], number: "#1001" },
+ { id: 2, items: [], number: "#1002" },
+];
+
+const columns: ColumnDef[] = [
+ { accessorKey: "number", header: "Order" },
+];
+
+const renderTable = ({
+ bulkActions,
+ expandable,
+}: {
+ bulkActions?: React.ReactNode;
+ expandable?: ExpandableDataTable;
+} = {}) =>
+ render(
+
+
+ bulkActions={bulkActions}
+ columns={columns}
+ edges={edges}
+ expandable={expandable}
+ id="orders"
+ order={{ defaultOrder: { column: "number", order: "asc" } }}
+ pageInfo={{
+ count: 2,
+ currentPage: 1,
+ endCursor: null,
+ hasNextPage: false,
+ hasPreviousPage: false,
+ pageSize: 10,
+ startCursor: null,
+ totalCount: 2,
+ totalPages: 1,
+ }}
+ />
+ ,
+ );
+
+const renderItems = (row: Order) => (
+
+ {row.items.map(item => (
+ {item}
+ ))}
+
+);
+
+const expandLabel = "core.global.data_table.expand_row";
+const collapseLabel = "core.global.data_table.collapse_row";
+
+describe("ContentDataTable expandable rows", () => {
+ it("renders no toggle and no extra column without the prop", () => {
+ renderTable();
+
+ expect(screen.queryByRole("button", { name: expandLabel })).toBeNull();
+ expect(screen.getAllByRole("columnheader")).toHaveLength(1);
+ expect(screen.getAllByRole("row")).toHaveLength(3);
+ });
+
+ it("shows and hides the detail when the toggle is pressed", async () => {
+ renderTable({ expandable: { render: renderItems } });
+
+ const [toggle] = screen.getAllByRole("button", { name: expandLabel });
+ expect(toggle.getAttribute("aria-expanded")).toBe("false");
+ expect(screen.queryByText("Coffee beans")).toBeNull();
+
+ fireEvent.click(toggle);
+
+ expect(screen.getByText("Coffee beans")).toBeTruthy();
+ expect(toggle.getAttribute("aria-expanded")).toBe("true");
+ expect(toggle.getAttribute("aria-label")).toBe(collapseLabel);
+ const region = screen.getByTestId("table-expanded-row");
+ expect(toggle.getAttribute("aria-controls")).toBe(region.id);
+ expect(region.closest("td")?.getAttribute("colspan")).toBe("2");
+
+ fireEvent.click(toggle);
+
+ expect(toggle.getAttribute("aria-expanded")).toBe("false");
+ await waitFor(() => {
+ expect(screen.queryByText("Coffee beans")).toBeNull();
+ });
+ });
+
+ it("keeps several rows open at once", () => {
+ renderTable({ expandable: { render: row => `Detail ${row.number}` } });
+
+ for (const toggle of screen.getAllByRole("button", {
+ name: expandLabel,
+ })) {
+ fireEvent.click(toggle);
+ }
+
+ expect(screen.getByText("Detail #1001")).toBeTruthy();
+ expect(screen.getByText("Detail #1002")).toBeTruthy();
+ });
+
+ it("gives rows that cannot expand no toggle", () => {
+ renderTable({
+ expandable: {
+ canExpand: row => row.items.length > 0,
+ render: renderItems,
+ },
+ });
+
+ const rows = screen.getAllByRole("row");
+ expect(
+ within(rows[1]).getByRole("button", { name: expandLabel }),
+ ).toBeTruthy();
+ expect(within(rows[2]).queryByRole("button")).toBeNull();
+ });
+
+ it("opens the rows listed in defaultExpanded", () => {
+ renderTable({ expandable: { defaultExpanded: [1], render: renderItems } });
+
+ expect(screen.getByText("Coffee beans")).toBeTruthy();
+ expect(
+ screen
+ .getByRole("button", { name: collapseLabel })
+ .getAttribute("aria-expanded"),
+ ).toBe("true");
+ });
+
+ it("puts the toggle after the selection checkbox", () => {
+ renderTable({
+ bulkActions: Bulk ,
+ expandable: { render: renderItems },
+ });
+
+ const cells = within(screen.getAllByRole("row")[1]).getAllByRole("cell");
+ expect(within(cells[0]).getByRole("checkbox")).toBeTruthy();
+ expect(
+ within(cells[1]).getByRole("button", { name: expandLabel }),
+ ).toBeTruthy();
+ });
+});
diff --git a/packages/vitnode/src/components/table/expansion.tsx b/packages/vitnode/src/components/table/expansion.tsx
new file mode 100644
index 000000000..637c275d4
--- /dev/null
+++ b/packages/vitnode/src/components/table/expansion.tsx
@@ -0,0 +1,126 @@
+import { cn } from "cn";
+import { ChevronRightIcon } from "lucide-react";
+import { motion, useReducedMotion } from "motion/react";
+import React from "react";
+import { useTranslations } from "use-intl";
+
+import { Button } from "../ui/button";
+import { TableCell, TableRow } from "../ui/table";
+
+export interface ExpandableDataTable {
+ canExpand?: (row: T) => boolean;
+ defaultExpanded?: number[];
+ render: (row: T) => React.ReactNode;
+}
+
+export const useRowExpansionDataTable = ({
+ defaultExpanded,
+ rowIds,
+}: {
+ defaultExpanded?: number[];
+ rowIds: number[];
+}) => {
+ const [expanded, setExpanded] = React.useState(
+ defaultExpanded ?? [],
+ );
+ const pageKey = rowIds.join(",");
+ const [prevPageKey, setPrevPageKey] = React.useState(pageKey);
+
+ if (pageKey !== prevPageKey) {
+ setPrevPageKey(pageKey);
+ setExpanded(current => {
+ const next = current.filter(id => rowIds.includes(id));
+
+ return next.length === current.length ? current : next;
+ });
+ }
+
+ const isExpanded = React.useCallback(
+ (id: number) => expanded.includes(id),
+ [expanded],
+ );
+
+ const toggle = React.useCallback((id: number) => {
+ setExpanded(current =>
+ current.includes(id)
+ ? current.filter(item => item !== id)
+ : [...current, id],
+ );
+ }, []);
+
+ return { isExpanded, toggle };
+};
+
+export function ExpandHeaderDataTable() {
+ const t = useTranslations("core.global.data_table");
+
+ return {t("row_details")} ;
+}
+
+export function ExpandToggleDataTable({
+ controls,
+ expanded,
+ onToggle,
+}: {
+ controls: string;
+ expanded: boolean;
+ onToggle: () => void;
+}) {
+ const t = useTranslations("core.global.data_table");
+
+ return (
+
+
+
+ );
+}
+
+export function ExpandedRowDataTable({
+ children,
+ colSpan,
+ id,
+}: {
+ children: React.ReactNode;
+ colSpan: number;
+ id: string;
+}) {
+ const shouldReduceMotion = useReducedMotion();
+ const collapsed = shouldReduceMotion
+ ? { opacity: 0 }
+ : { height: 0, opacity: 0 };
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/packages/vitnode/src/components/table/filters.tsx b/packages/vitnode/src/components/table/filters.tsx
index b3a9642e2..a307c7fe7 100644
--- a/packages/vitnode/src/components/table/filters.tsx
+++ b/packages/vitnode/src/components/table/filters.tsx
@@ -105,7 +105,12 @@ function FilterItem({ filter }: { filter: FilterDataTable }) {
orientation="vertical"
/>
{isAsync || selectedStaticOptions.length > 2 ? (
- {t("selected_count", { count: selected.length })}
+
+ {t.rich("selected_count", {
+ count: selected.length,
+ number: chunks => <>{chunks}>,
+ })}
+
) : (
selectedStaticOptions.map(option => (
{option.label}
diff --git a/packages/vitnode/src/components/table/order-table-head.tsx b/packages/vitnode/src/components/table/order-table-head.tsx
index 9663e529c..e4357a8e4 100644
--- a/packages/vitnode/src/components/table/order-table-head.tsx
+++ b/packages/vitnode/src/components/table/order-table-head.tsx
@@ -9,7 +9,7 @@ import type {
} from "./data-table-content";
import { Button } from "../ui/button";
-import { Loader } from "../ui/loader";
+import { Spinner } from "../ui/spinner";
import { useDataTableUrl } from "./navigation";
import { readTableOrder, toggleTableOrder } from "./url-state";
@@ -34,7 +34,7 @@ export function OrderTableHeadDataTable({
let icon: React.ReactNode;
if (isPending) {
- icon = ;
+ icon = ;
} else if (isActive) {
icon = current.order === "asc" ? : ;
} else {
diff --git a/packages/vitnode/src/components/table/pagination.tsx b/packages/vitnode/src/components/table/pagination.tsx
index 26ab2b3fe..e975bbb07 100644
--- a/packages/vitnode/src/components/table/pagination.tsx
+++ b/packages/vitnode/src/components/table/pagination.tsx
@@ -85,6 +85,7 @@ export const PaginationDataTable = ({
: {
"aria-disabled": true as const,
className: "pointer-events-none opacity-50",
+ role: "link",
tabIndex: -1,
};
@@ -125,6 +126,7 @@ export const PaginationDataTable = ({
: {
"aria-disabled": true as const,
className: "pointer-events-none opacity-50",
+ role: "link",
tabIndex: -1,
};
diff --git a/packages/vitnode/src/components/table/reorder-state.ts b/packages/vitnode/src/components/table/reorder-state.ts
new file mode 100644
index 000000000..877aaa784
--- /dev/null
+++ b/packages/vitnode/src/components/table/reorder-state.ts
@@ -0,0 +1,54 @@
+import { arrayMove } from "@dnd-kit/sortable";
+
+import { readTableOrder, readTableSearch, type TableOrder } from "./url-state";
+
+export interface ReorderDetailsDataTable {
+ activeId: number;
+ from: number;
+ overId: number;
+ to: number;
+}
+
+export interface RowMoveDataTable extends ReorderDetailsDataTable {
+ ids: number[];
+}
+
+export const moveRowId = (
+ ids: readonly number[],
+ activeId: number,
+ overId: number,
+): null | RowMoveDataTable => {
+ const from = ids.indexOf(activeId);
+ const to = ids.indexOf(overId);
+
+ if (from === -1 || to === -1 || from === to) return null;
+
+ return {
+ activeId,
+ from,
+ ids: arrayMove([...ids], from, to),
+ overId,
+ to,
+ };
+};
+
+export const isTableInStoredOrder = (
+ searchParams: URLSearchParams,
+ {
+ defaultOrder,
+ filterIds,
+ }: {
+ defaultOrder: TableOrder;
+ filterIds: readonly string[];
+ },
+): boolean => {
+ if (readTableSearch(searchParams).trim() !== "") return false;
+ if (filterIds.some(id => searchParams.get(id))) return false;
+
+ const ordered = readTableOrder(searchParams, defaultOrder);
+
+ return (
+ ordered.column === defaultOrder.column &&
+ ordered.order === defaultOrder.order
+ );
+};
diff --git a/packages/vitnode/src/components/table/reorder.test.tsx b/packages/vitnode/src/components/table/reorder.test.tsx
new file mode 100644
index 000000000..07047be2f
--- /dev/null
+++ b/packages/vitnode/src/components/table/reorder.test.tsx
@@ -0,0 +1,319 @@
+import {
+ act,
+ fireEvent,
+ render,
+ renderHook,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/react";
+import { toast } from "sonner";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import type { ColumnDef, DataTableTMin } from "./data-table-content";
+import type { ExpandableDataTable } from "./expansion";
+import type { ReorderableDataTable } from "./reorder";
+
+import { ContentDataTable } from "./content";
+import { DataTableNavigationProvider } from "./navigation";
+import { useRowOrderDataTable } from "./reorder";
+import { isTableInStoredOrder, moveRowId } from "./reorder-state";
+
+interface Task extends DataTableTMin {
+ id: number;
+ title: string;
+}
+
+const edges: Task[] = [
+ { id: 1, title: "Write docs" },
+ { id: 2, title: "Fix bug" },
+ { id: 3, title: "Ship it" },
+];
+
+const columns: ColumnDef[] = [{ accessorKey: "title", header: "Task" }];
+
+const handleLabel = "core.global.data_table.reorder_row";
+
+const renderTable = ({
+ bulkActions,
+ expandable,
+ reorderable,
+ search = "",
+}: {
+ bulkActions?: React.ReactNode;
+ expandable?: ExpandableDataTable;
+ reorderable?: ReorderableDataTable;
+ search?: string;
+} = {}) =>
+ render(
+
+
+ bulkActions={bulkActions}
+ columns={columns}
+ edges={edges}
+ expandable={expandable}
+ filters={[{ id: "status", label: "Status", options: [] }]}
+ id="tasks"
+ order={{
+ columns: ["title"],
+ defaultOrder: { column: "id", order: "asc" },
+ }}
+ pageInfo={{
+ count: 3,
+ currentPage: 1,
+ endCursor: null,
+ hasNextPage: false,
+ hasPreviousPage: false,
+ pageSize: 10,
+ startCursor: null,
+ totalCount: 3,
+ totalPages: 1,
+ }}
+ reorderable={reorderable}
+ />
+ ,
+ );
+
+const titles = () =>
+ screen
+ .getAllByRole("row")
+ .slice(1)
+ .map(row => within(row).getAllByRole("cell").at(-1)?.textContent);
+
+const stubRowGeometry = () => {
+ const rowHeight = 40;
+
+ vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(
+ function (this: Element) {
+ const group = this.closest("[data-slot=table-row-group]");
+ const index = group?.parentElement
+ ? [
+ ...group.parentElement.querySelectorAll(
+ ":scope > [data-slot=table-row-group]",
+ ),
+ ].indexOf(group)
+ : -1;
+ const top = index === -1 ? 0 : 40 + index * rowHeight;
+ const height = index === -1 ? 1000 : rowHeight;
+
+ return DOMRect.fromRect({ height, width: 600, x: 0, y: top });
+ },
+ );
+};
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ Reflect.deleteProperty(Element.prototype, "animate");
+});
+
+describe("moveRowId", () => {
+ it("moves a row down and reports where it came from and went", () => {
+ expect(moveRowId([1, 2, 3, 4], 1, 3)).toEqual({
+ activeId: 1,
+ from: 0,
+ ids: [2, 3, 1, 4],
+ overId: 3,
+ to: 2,
+ });
+ });
+
+ it("moves a row up", () => {
+ expect(moveRowId([1, 2, 3, 4], 4, 2)?.ids).toEqual([1, 4, 2, 3]);
+ });
+
+ it("returns null for a drop in place or an unknown id", () => {
+ expect(moveRowId([1, 2, 3], 2, 2)).toBeNull();
+ expect(moveRowId([1, 2, 3], 9, 2)).toBeNull();
+ expect(moveRowId([1, 2, 3], 2, 9)).toBeNull();
+ });
+
+ it("does not mutate the ids it was given", () => {
+ const ids = [1, 2, 3];
+ moveRowId(ids, 1, 3);
+
+ expect(ids).toEqual([1, 2, 3]);
+ });
+});
+
+describe("isTableInStoredOrder", () => {
+ const options = {
+ defaultOrder: { column: "position", order: "asc" as const },
+ filterIds: ["status"],
+ };
+
+ it("is true for the default view", () => {
+ expect(isTableInStoredOrder(new URLSearchParams(), options)).toBe(true);
+ expect(
+ isTableInStoredOrder(
+ new URLSearchParams("orderBy=position&order=asc&page=2"),
+ options,
+ ),
+ ).toBe(true);
+ });
+
+ it("is false while searching, filtering or sorting by something else", () => {
+ expect(
+ isTableInStoredOrder(new URLSearchParams("search=bug"), options),
+ ).toBe(false);
+ expect(
+ isTableInStoredOrder(new URLSearchParams("status=open"), options),
+ ).toBe(false);
+ expect(
+ isTableInStoredOrder(new URLSearchParams("orderBy=title"), options),
+ ).toBe(false);
+ expect(
+ isTableInStoredOrder(new URLSearchParams("order=desc"), options),
+ ).toBe(false);
+ });
+});
+
+describe("useRowOrderDataTable", () => {
+ it("reorders right away and keeps the order when saving succeeds", async () => {
+ const onReorder = vi.fn().mockResolvedValue(undefined);
+ const { result } = renderHook(() =>
+ useRowOrderDataTable({ edges, onReorder }),
+ );
+
+ act(() => {
+ result.current.reorder(1, 3);
+ });
+
+ expect(result.current.rows.map(row => row.id)).toEqual([2, 3, 1]);
+ expect(onReorder).toHaveBeenCalledWith([2, 3, 1], {
+ activeId: 1,
+ from: 0,
+ overId: 3,
+ to: 2,
+ });
+ await waitFor(() => {
+ expect(result.current.rows.map(row => row.id)).toEqual([2, 3, 1]);
+ });
+ });
+
+ it("rolls back and shows an error toast when saving fails", async () => {
+ const errorToast = vi.spyOn(toast, "error");
+ const onReorder = vi.fn().mockRejectedValue(new Error("offline"));
+ const { result } = renderHook(() =>
+ useRowOrderDataTable({ edges, onReorder }),
+ );
+
+ act(() => {
+ result.current.reorder(3, 1);
+ });
+
+ expect(result.current.rows.map(row => row.id)).toEqual([3, 1, 2]);
+ await waitFor(() => {
+ expect(result.current.rows.map(row => row.id)).toEqual([1, 2, 3]);
+ });
+ expect(errorToast).toHaveBeenCalledWith(
+ "core.global.data_table.reorder_failed",
+ { description: "core.global.data_table.reorder_failed_desc" },
+ );
+ });
+
+ it("follows the server again once it sends a new order", () => {
+ const { rerender, result } = renderHook(
+ ({ rows }) => useRowOrderDataTable({ edges: rows, onReorder: vi.fn() }),
+ { initialProps: { rows: edges } },
+ );
+
+ act(() => {
+ result.current.reorder(1, 2);
+ });
+ rerender({ rows: [edges[2], edges[0], edges[1]] });
+
+ expect(result.current.rows.map(row => row.id)).toEqual([3, 1, 2]);
+ });
+});
+
+describe("ContentDataTable reorderable rows", () => {
+ it("renders no handle and no extra column without the prop", () => {
+ renderTable();
+
+ expect(screen.queryByRole("button", { name: handleLabel })).toBeNull();
+ expect(screen.getAllByRole("columnheader")).toHaveLength(1);
+ });
+
+ it("gives every row a sortable handle in the first column", () => {
+ renderTable({
+ bulkActions: Bulk ,
+ reorderable: { getRowLabel: row => row.title, onReorder: vi.fn() },
+ });
+
+ const handles = screen.getAllByRole("button", { name: handleLabel });
+ expect(handles).toHaveLength(3);
+ expect(handles[0].getAttribute("aria-roledescription")).toBe("sortable");
+
+ const cells = within(screen.getAllByRole("row")[1]).getAllByRole("cell");
+ expect(within(cells[0]).getByRole("button")).toBe(handles[0]);
+ expect(within(cells[1]).getByRole("checkbox")).toBeTruthy();
+ });
+
+ it("keeps an open detail panel in the group that moves with its row", () => {
+ renderTable({
+ expandable: { defaultExpanded: [2], render: row => `About ${row.title}` },
+ reorderable: { onReorder: vi.fn() },
+ });
+
+ const detail = screen.getByText("About Fix bug");
+ const group = detail.closest("[data-slot=table-row-group]");
+
+ expect(group).not.toBeNull();
+ expect(within(group as HTMLElement).getByText("Fix bug")).toBeTruthy();
+ });
+
+ it("locks the handles while the view is searched", () => {
+ renderTable({
+ reorderable: { onReorder: vi.fn() },
+ search: "search=bug",
+ });
+
+ for (const handle of screen.getAllByRole("button", {
+ name: handleLabel,
+ })) {
+ expect(handle.getAttribute("aria-disabled")).toBe("true");
+ }
+ });
+
+ it("moves a row with the keyboard, saves the new order and settles it into place", async () => {
+ stubRowGeometry();
+ const animate = vi.fn();
+ Object.defineProperty(Element.prototype, "animate", {
+ configurable: true,
+ value: animate,
+ });
+ const onReorder = vi.fn();
+ renderTable({ reorderable: { onReorder } });
+
+ const [first] = screen.getAllByRole("button", { name: handleLabel });
+ first.focus();
+ fireEvent.keyDown(first, { code: "Space", key: " " });
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ });
+ fireEvent.keyDown(first, { code: "ArrowDown", key: "ArrowDown" });
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ });
+ fireEvent.keyDown(first, { code: "Space", key: " " });
+
+ await waitFor(() => {
+ expect(onReorder).toHaveBeenCalledWith([2, 1, 3], {
+ activeId: 1,
+ from: 0,
+ overId: 2,
+ to: 1,
+ });
+ });
+ expect(titles()).toEqual(["Fix bug", "Write docs", "Ship it"]);
+ expect(animate).toHaveBeenCalledWith(
+ [
+ { transform: "translate3d(0, -40px, 0)" },
+ { transform: "translate3d(0, 0, 0)" },
+ ],
+ expect.objectContaining({ duration: 200 }),
+ );
+ });
+});
diff --git a/packages/vitnode/src/components/table/reorder.tsx b/packages/vitnode/src/components/table/reorder.tsx
new file mode 100644
index 000000000..798f2731d
--- /dev/null
+++ b/packages/vitnode/src/components/table/reorder.tsx
@@ -0,0 +1,299 @@
+import type { Announcements } from "@dnd-kit/core";
+
+import {
+ closestCenter,
+ DndContext,
+ KeyboardSensor,
+ PointerSensor,
+ TouchSensor,
+ useDndMonitor,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import {
+ restrictToParentElement,
+ restrictToVerticalAxis,
+} from "@dnd-kit/modifiers";
+import {
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import { cn } from "cn";
+import { GripVerticalIcon } from "lucide-react";
+import { useReducedMotion } from "motion/react";
+import React from "react";
+import { toast } from "sonner";
+import { useTranslations } from "use-intl";
+
+import type { DataTableTMin } from "./data-table-content";
+
+import { Button } from "../ui/button";
+import { moveRowId, type ReorderDetailsDataTable } from "./reorder-state";
+
+export interface ReorderableDataTable {
+ disabled?: boolean;
+ getRowLabel?: (row: T) => string;
+ onReorder: (
+ ids: number[],
+ details: ReorderDetailsDataTable,
+ ) => Promise | void;
+}
+
+interface RowOrderOverride {
+ ids: number[];
+ key: string;
+}
+
+export const useRowOrderDataTable = ({
+ edges,
+ onReorder,
+}: {
+ edges: T[];
+ onReorder?: ReorderableDataTable["onReorder"];
+}) => {
+ const t = useTranslations("core.global.data_table");
+ const serverIds = edges.map(row => row.id);
+ const serverKey = serverIds.join(",");
+ const [override, setOverride] = React.useState(null);
+ const ids = override?.key === serverKey ? override.ids : serverIds;
+ const byId = new Map(edges.map(row => [row.id, row]));
+ const rows = ids.flatMap(id => {
+ const row = byId.get(id);
+
+ return row ? [row] : [];
+ });
+
+ const reorder = (activeId: number, overId: number) => {
+ const move = moveRowId(ids, activeId, overId);
+ if (!move || !onReorder) return;
+
+ const attempt: RowOrderOverride = { ids: move.ids, key: serverKey };
+ const previous = override;
+ setOverride(attempt);
+
+ const persist = async () => {
+ try {
+ await onReorder(move.ids, {
+ activeId: move.activeId,
+ from: move.from,
+ overId: move.overId,
+ to: move.to,
+ });
+ } catch {
+ setOverride(current => (current === attempt ? previous : current));
+ toast.error(t("reorder_failed"), {
+ description: t("reorder_failed_desc"),
+ });
+ }
+ };
+
+ void persist();
+ };
+
+ return { reorder, rows };
+};
+
+const SETTLE_DURATION = 200;
+const SETTLE_EASING = "cubic-bezier(0.25, 1, 0.5, 1)";
+
+const subscribeToNothing = () => () => undefined;
+const getAnnouncementContainer = () => document.body;
+const getNoAnnouncementContainer = () => null;
+
+const SortableRowHandleContext = React.createContext(
+ null,
+);
+
+export function ReorderProviderDataTable({
+ children,
+ ids,
+ labelOf,
+ onMove,
+}: {
+ children: React.ReactNode;
+ ids: number[];
+ labelOf: (id: number) => string | undefined;
+ onMove: (activeId: number, overId: number) => void;
+}) {
+ const t = useTranslations("core.global.data_table");
+ const contextId = React.useId();
+ const announcementContainer = React.useSyncExternalStore(
+ subscribeToNothing,
+ getAnnouncementContainer,
+ getNoAnnouncementContainer,
+ );
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
+ useSensor(TouchSensor, {
+ activationConstraint: { delay: 200, tolerance: 8 },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ }),
+ );
+
+ const spoken = (activeId: number, overId: number) => ({
+ name:
+ labelOf(activeId) ??
+ t("row_fallback", { position: ids.indexOf(activeId) + 1 }),
+ position: ids.indexOf(overId) + 1,
+ total: ids.length,
+ });
+
+ const announcements: Announcements = {
+ onDragCancel: ({ active }) =>
+ t("reorder_cancelled", spoken(Number(active.id), Number(active.id))),
+ onDragEnd: ({ active, over }) =>
+ over
+ ? t("reorder_ended", spoken(Number(active.id), Number(over.id)))
+ : undefined,
+ onDragOver: ({ active, over }) =>
+ over
+ ? t("reorder_over", spoken(Number(active.id), Number(over.id)))
+ : undefined,
+ onDragStart: ({ active }) =>
+ t("reorder_started", spoken(Number(active.id), Number(active.id))),
+ };
+
+ return (
+ {
+ if (over) onMove(Number(active.id), Number(over.id));
+ }}
+ sensors={sensors}
+ >
+
+ {children}
+
+
+ );
+}
+
+export function SortableRowGroupDataTable({
+ children,
+ disabled,
+ id,
+ label,
+ position,
+}: {
+ children: React.ReactNode;
+ disabled: boolean;
+ id: number;
+ label?: string;
+ position: number;
+}) {
+ const t = useTranslations("core.global.data_table");
+ const name = label ?? t("row_fallback", { position });
+ const shouldReduceMotion = useReducedMotion();
+ const {
+ attributes,
+ isDragging,
+ listeners,
+ node: nodeRef,
+ setActivatorNodeRef,
+ setNodeRef,
+ transform,
+ transition,
+ } = useSortable({
+ disabled,
+ id,
+ transition: shouldReduceMotion
+ ? null
+ : { duration: SETTLE_DURATION, easing: SETTLE_EASING },
+ });
+ const dropRectRef = React.useRef(null);
+
+ useDndMonitor({
+ onDragEnd: ({ active }) => {
+ if (active.id === id && nodeRef.current) {
+ dropRectRef.current = nodeRef.current.getBoundingClientRect();
+ }
+ },
+ });
+
+ React.useLayoutEffect(() => {
+ const from = dropRectRef.current;
+ const element = nodeRef.current;
+ if (!from || !element) return;
+
+ dropRectRef.current = null;
+ if (shouldReduceMotion) return;
+
+ const deltaY = from.top - element.getBoundingClientRect().top;
+ if (Math.abs(deltaY) < 1) return;
+
+ element.animate(
+ [
+ { transform: `translate3d(0, ${deltaY}px, 0)` },
+ { transform: "translate3d(0, 0, 0)" },
+ ],
+ { duration: SETTLE_DURATION, easing: SETTLE_EASING },
+ );
+ });
+
+ const handleLabel = t("reorder_row", { name });
+ const handle = disabled ? (
+
+
+
+ ) : (
+
+
+
+ );
+
+ return (
+
+ tr:last-child]:border-0 [&:last-child>tr:last-child>td]:border-b-0 [&>tr>td]:border-b",
+ isDragging &&
+ "bg-card ring-foreground/10 relative z-10 shadow-lg ring-1",
+ )}
+ data-dragging={isDragging ? "" : undefined}
+ data-slot="table-row-group"
+ ref={setNodeRef}
+ style={{ transform: CSS.Translate.toString(transform), transition }}
+ >
+ {children}
+
+
+ );
+}
+
+export function ReorderHeaderDataTable() {
+ const t = useTranslations("core.global.data_table");
+
+ return {t("reorder_column")} ;
+}
+
+export function ReorderHandleDataTable() {
+ return React.use(SortableRowHandleContext);
+}
diff --git a/packages/vitnode/src/components/table/selection.tsx b/packages/vitnode/src/components/table/selection.tsx
index c51d27c10..90d82aac8 100644
--- a/packages/vitnode/src/components/table/selection.tsx
+++ b/packages/vitnode/src/components/table/selection.tsx
@@ -7,6 +7,7 @@ import { useTranslations } from "use-intl";
import { Button } from "../ui/button";
import { Checkbox } from "../ui/checkbox";
import { Separator } from "../ui/separator";
+import { SlidingNumber } from "../ui/sliding-number";
import { TableRow } from "../ui/table";
import { TooltipWithContent } from "../ui/tooltip";
@@ -159,7 +160,10 @@ export function BulkActionsDataTable({
>
- {t("selected_count", { count: selected.length })}
+ {t.rich("selected_count", {
+ count: selected.length,
+ number: () => ,
+ })}
;
+ if (!editor)
+ return (
+
+
+
+ );
return (
import("./audio/audio-form").then(module => ({
@@ -37,7 +37,13 @@ export const AudioDialog = ({
{t("desc")}
- }>
+
+
+
+ }
+ >
diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/bold-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/bold-action.tsx
index 62f519318..f4333b26e 100644
--- a/packages/vitnode/src/components/tiptap/toolbar/actions/bold-action.tsx
+++ b/packages/vitnode/src/components/tiptap/toolbar/actions/bold-action.tsx
@@ -3,9 +3,9 @@ import { BoldIcon } from "lucide-react";
import { useTranslations } from "use-intl";
import { Toggle } from "@/components/ui/toggle";
-import { TooltipWithContent } from "@/components/ui/tooltip";
import { useToolbarEditor } from "../use-toolbar-editor";
+import { ToolbarTooltip } from "./utils/toolbar-tooltip";
import { TooltipShortcut } from "./utils/tooltip-shortcut";
export const BoldAction = () => {
@@ -21,7 +21,7 @@ export const BoldAction = () => {
});
return (
-
{t("bold")} +B
@@ -39,6 +39,6 @@ export const BoldAction = () => {
-
+
);
};
diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/code-block-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/code-block-action.tsx
index 0dff9143d..9f8fe1ea8 100644
--- a/packages/vitnode/src/components/tiptap/toolbar/actions/code-block-action.tsx
+++ b/packages/vitnode/src/components/tiptap/toolbar/actions/code-block-action.tsx
@@ -3,9 +3,9 @@ import { CodeSquareIcon } from "lucide-react";
import { useTranslations } from "use-intl";
import { Toggle } from "@/components/ui/toggle";
-import { TooltipWithContent } from "@/components/ui/tooltip";
import { useToolbarEditor } from "../use-toolbar-editor";
+import { ToolbarTooltip } from "./utils/toolbar-tooltip";
import { TooltipShortcut } from "./utils/tooltip-shortcut";
export const CodeBlockAction = () => {
@@ -17,7 +17,7 @@ export const CodeBlockAction = () => {
});
return (
-
{t("code_block")}
@@ -36,6 +36,6 @@ export const CodeBlockAction = () => {
-
+
);
};
diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/color-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/color-action.tsx
index d50baed6c..994ed6f97 100644
--- a/packages/vitnode/src/components/tiptap/toolbar/actions/color-action.tsx
+++ b/packages/vitnode/src/components/tiptap/toolbar/actions/color-action.tsx
@@ -10,9 +10,9 @@ import {
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
-import { TooltipWithContent } from "@/components/ui/tooltip";
import { useToolbarEditor } from "../use-toolbar-editor";
+import { ToolbarTooltip } from "./utils/toolbar-tooltip";
export const ColorAction = () => {
const t = useTranslations("core.global.editor.color");
@@ -38,7 +38,7 @@ export const ColorAction = () => {
return (
-
+
{
style={{ backgroundColor: activeColor || "currentColor" }}
/>
-
+
{t("label")}
diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/emoji-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/emoji-action.tsx
index c59ae4dbc..9f8ecd8b6 100644
--- a/packages/vitnode/src/components/tiptap/toolbar/actions/emoji-action.tsx
+++ b/packages/vitnode/src/components/tiptap/toolbar/actions/emoji-action.tsx
@@ -3,13 +3,14 @@ import React from "react";
import { useTranslations } from "use-intl";
import { Button } from "@/components/ui/button";
-import { Loader } from "@/components/ui/loader";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
-import { TooltipWithContent } from "@/components/ui/tooltip";
+import { Spinner } from "@/components/ui/spinner";
+
+import { ToolbarTooltip } from "./utils/toolbar-tooltip";
const EmojiPicker = React.lazy(async () =>
import("./emoji/emoji-picker").then(module => ({
@@ -23,7 +24,7 @@ export const EmojiAction = () => {
return (
-
+
@@ -31,13 +32,15 @@ export const EmojiAction = () => {
>
-
+
-
+
+
+
}
>
diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/insert-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/insert-action.tsx
index 69aaca677..535d7d371 100644
--- a/packages/vitnode/src/components/tiptap/toolbar/actions/insert-action.tsx
+++ b/packages/vitnode/src/components/tiptap/toolbar/actions/insert-action.tsx
@@ -13,11 +13,11 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Kbd } from "@/components/ui/kbd";
-import { TooltipWithContent } from "@/components/ui/tooltip";
import { BLOCK_COMMANDS } from "../../block-commands";
import { useToolbarEditor } from "../use-toolbar-editor";
import { AudioDialog } from "./audio-action";
+import { ToolbarTooltip } from "./utils/toolbar-tooltip";
const INSERT_COMMANDS = BLOCK_COMMANDS.filter(
command => command.group === "insert" || command.id === "quote",
@@ -31,7 +31,7 @@ export const InsertAction = () => {
return (
<>
-
+
{
-
+
diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/italic-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/italic-action.tsx
index 89ebb6391..98f794541 100644
--- a/packages/vitnode/src/components/tiptap/toolbar/actions/italic-action.tsx
+++ b/packages/vitnode/src/components/tiptap/toolbar/actions/italic-action.tsx
@@ -3,9 +3,9 @@ import { ItalicIcon } from "lucide-react";
import { useTranslations } from "use-intl";
import { Toggle } from "@/components/ui/toggle";
-import { TooltipWithContent } from "@/components/ui/tooltip";
import { useToolbarEditor } from "../use-toolbar-editor";
+import { ToolbarTooltip } from "./utils/toolbar-tooltip";
import { TooltipShortcut } from "./utils/tooltip-shortcut";
export const ItalicAction = () => {
@@ -21,7 +21,7 @@ export const ItalicAction = () => {
});
return (
-
{t("italic")}
@@ -40,6 +40,6 @@ export const ItalicAction = () => {
-
+
);
};
diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/link-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/link-action.tsx
index 228fe3a10..2cdfeb1a3 100644
--- a/packages/vitnode/src/components/tiptap/toolbar/actions/link-action.tsx
+++ b/packages/vitnode/src/components/tiptap/toolbar/actions/link-action.tsx
@@ -11,9 +11,9 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Toggle } from "@/components/ui/toggle";
-import { TooltipWithContent } from "@/components/ui/tooltip";
import { useToolbarEditor } from "../use-toolbar-editor";
+import { ToolbarTooltip } from "./utils/toolbar-tooltip";
export const LinkAction = () => {
const t = useTranslations("core.global.editor.link");
@@ -49,7 +49,7 @@ export const LinkAction = () => {
return (
-
+
{
>
-
+
) : (
-
+
)}
diff --git a/packages/vitnode/src/views/admin/views/content/actions/preview-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/preview-action.tsx
index 72e2ef3e1..85acc0c6a 100644
--- a/packages/vitnode/src/views/admin/views/content/actions/preview-action.tsx
+++ b/packages/vitnode/src/views/admin/views/content/actions/preview-action.tsx
@@ -1,10 +1,11 @@
-import { CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react";
+import { ExternalLinkIcon } from "lucide-react";
import React from "react";
import { toast } from "sonner";
import { useTranslations } from "use-intl";
import { DateFormat } from "@/components/date-format";
import { Button } from "@/components/ui/button";
+import { CopyButton } from "@/components/ui/copy-button";
import type { ContentPanelProps } from "./content-panel";
import type { ContentPreviewLink } from "./editorial-api";
@@ -13,44 +14,6 @@ import { contentErrorKey } from "../lib/mutation-feedback";
import { ContentPanel } from "./content-panel";
import { useContentEditorialTransport } from "./editorial-transport";
-const COPIED_FEEDBACK_MS = 2000;
-
-const CopyButton = ({ label, url }: { label: string; url: string }) => {
- const [copied, setCopied] = React.useState(false);
-
- React.useEffect(() => {
- if (!copied) return;
-
- const timer = setTimeout(() => {
- setCopied(false);
- }, COPIED_FEEDBACK_MS);
-
- return () => {
- clearTimeout(timer);
- };
- }, [copied]);
-
- return (
- {
- void navigator.clipboard.writeText(url).then(() => {
- setCopied(true);
- });
- }}
- size="icon"
- type="button"
- variant="outline"
- >
- {copied ? (
-
- ) : (
-
- )}
-
- );
-};
-
const PreviewLink = ({
contentTypeId,
id,
@@ -108,7 +71,7 @@ const PreviewLink = ({
readOnly
value={preview.url}
/>
-
+
diff --git a/packages/vitnode/src/views/admin/views/content/actions/schedule/schedule-panel.tsx b/packages/vitnode/src/views/admin/views/content/actions/schedule/schedule-panel.tsx
index 428bb6925..c872677a5 100644
--- a/packages/vitnode/src/views/admin/views/content/actions/schedule/schedule-panel.tsx
+++ b/packages/vitnode/src/views/admin/views/content/actions/schedule/schedule-panel.tsx
@@ -15,7 +15,7 @@ import { AutoFormSelect } from "@/components/form/fields/select";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Loader } from "@/components/ui/loader";
+import { Spinner } from "@/components/ui/spinner";
import { contentScheduleTimingError } from "@/content/schedules";
import { contentErrorKey } from "../../lib/mutation-feedback";
@@ -136,7 +136,12 @@ export const SchedulePanel = ({
});
};
- if (schedules.isPending) return
;
+ if (schedules.isPending)
+ return (
+
+
+
+ );
const edges = schedules.data?.edges ?? [];
const pending = edges.filter(entry => entry.status === "pending");
diff --git a/packages/vitnode/src/views/admin/views/content/form/primitives.tsx b/packages/vitnode/src/views/admin/views/content/form/primitives.tsx
index 5d00831a0..9214d7b66 100644
--- a/packages/vitnode/src/views/admin/views/content/form/primitives.tsx
+++ b/packages/vitnode/src/views/admin/views/content/form/primitives.tsx
@@ -78,6 +78,7 @@ const ContentFormPublicationToggle = () => {
{title ?? singular}
),
})}
+ icon={
}
onSubmit={async ({ onClose }) => {
if (await transition(action)) onClose();
}}
diff --git a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx
index 28f3cf65a..c01b01504 100644
--- a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx
+++ b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx
@@ -13,6 +13,7 @@ import { AutoFormFile } from "@/components/form/fields/file";
import { AutoFormFiles } from "@/components/form/fields/files";
import { AutoFormInput } from "@/components/form/fields/input";
import { AutoFormNullableNumber } from "@/components/form/fields/nullable-number";
+import { AutoFormNumber } from "@/components/form/fields/number";
import { AutoFormRadioGroup } from "@/components/form/fields/radio-group";
import { AutoFormSelect } from "@/components/form/fields/select";
import { AutoFormSwitch } from "@/components/form/fields/switch";
@@ -160,12 +161,11 @@ export const ContentField = ({
{...props}
/>
) : (
-
);
diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/remove-documents-action.tsx b/packages/vitnode/src/views/admin/views/core/advanced/search/remove-documents-action.tsx
index a51a6dc97..39575b5ad 100644
--- a/packages/vitnode/src/views/admin/views/core/advanced/search/remove-documents-action.tsx
+++ b/packages/vitnode/src/views/admin/views/core/advanced/search/remove-documents-action.tsx
@@ -21,6 +21,7 @@ export const RemoveCollectionDocumentsAction = ({
return (
}
onSubmit={async ({ onClose }) => {
const result = await onClear(itemType);
diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-properties.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-properties.tsx
index f6bcc7d34..c7f0ce167 100644
--- a/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-properties.tsx
+++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-properties.tsx
@@ -5,7 +5,7 @@ import { useTranslations } from "use-intl";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
-import { Loader } from "@/components/ui/loader";
+import { Spinner } from "@/components/ui/spinner";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { TooltipWithContent } from "@/components/ui/tooltip";
@@ -148,7 +148,13 @@ const WidgetSettingsSection = ({ widget }: { widget: DashboardWidgetView }) => {
return (
- }>
+
+
+
+ }
+ >
diff --git a/packages/vitnode/src/views/admin/views/core/debug/actions/clear-cache/clear-cache.tsx b/packages/vitnode/src/views/admin/views/core/debug/actions/clear-cache/clear-cache.tsx
index 24c0c99db..473cc5a58 100644
--- a/packages/vitnode/src/views/admin/views/core/debug/actions/clear-cache/clear-cache.tsx
+++ b/packages/vitnode/src/views/admin/views/core/debug/actions/clear-cache/clear-cache.tsx
@@ -16,6 +16,7 @@ export const ClearCacheAction = ({
return (
}
onSubmit={async ({ onClose }) => {
try {
await onClearCache();
diff --git a/packages/vitnode/src/views/admin/views/core/debug/system-logs/actions/more/more.tsx b/packages/vitnode/src/views/admin/views/core/debug/system-logs/actions/more/more.tsx
index ec20e4c85..398c3d097 100644
--- a/packages/vitnode/src/views/admin/views/core/debug/system-logs/actions/more/more.tsx
+++ b/packages/vitnode/src/views/admin/views/core/debug/system-logs/actions/more/more.tsx
@@ -11,7 +11,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
-import { Loader } from "@/components/ui/loader";
+import { Spinner } from "@/components/ui/spinner";
import { TooltipWithContent } from "@/components/ui/tooltip";
const ContentMoreActionSystemLogs = React.lazy(async () =>
@@ -50,7 +50,13 @@ export const MoreActionSystemLogs = (
- }>
+
+
+
+ }
+ >
diff --git a/packages/vitnode/src/views/admin/views/core/navigation/navigation-create-dialog.tsx b/packages/vitnode/src/views/admin/views/core/navigation/navigation-create-dialog.tsx
index 36173bd88..929edff2f 100644
--- a/packages/vitnode/src/views/admin/views/core/navigation/navigation-create-dialog.tsx
+++ b/packages/vitnode/src/views/admin/views/core/navigation/navigation-create-dialog.tsx
@@ -24,7 +24,7 @@ import {
useDialog,
} from "@/components/ui/dialog";
import { EmojiIcon } from "@/components/ui/emoji-icon";
-import { Loader } from "@/components/ui/loader";
+import { Spinner } from "@/components/ui/spinner";
import { TooltipWithContent } from "@/components/ui/tooltip";
import { parseEmojiIcon } from "@/lib/emoji-icon";
import {
@@ -263,7 +263,13 @@ const NavigationCreateDetailsStep = ({
- }>
+
+
+
+ }
+ >
{isCollapsed && childCount > 0 ? (
-
- {childCount}
+
+
) : null}
{item.isOpenInNewTab && !isMissing ? (
@@ -632,7 +633,13 @@ const NavigationEditSheetBody = ({
{name}
- }>
+
+
+
+ }
+ >
}
onSubmit={async ({ onClose }) => {
const result = await onDelete({ id: row.id, type });
if ("error" in result) {
diff --git a/packages/vitnode/src/views/admin/views/core/system/files/actions/file-row-actions.tsx b/packages/vitnode/src/views/admin/views/core/system/files/actions/file-row-actions.tsx
index 7d436ec1c..01275d589 100644
--- a/packages/vitnode/src/views/admin/views/core/system/files/actions/file-row-actions.tsx
+++ b/packages/vitnode/src/views/admin/views/core/system/files/actions/file-row-actions.tsx
@@ -96,6 +96,7 @@ export const FileRowActions = ({
})
: t("delete.desc")
}
+ icon={ }
onOpenChange={open => {
if (!open) setHeldByRevisions(null);
}}
diff --git a/packages/vitnode/src/views/admin/views/core/system/files/actions/files-bulk-actions.tsx b/packages/vitnode/src/views/admin/views/core/system/files/actions/files-bulk-actions.tsx
index 75357abb6..9b3d67a6a 100644
--- a/packages/vitnode/src/views/admin/views/core/system/files/actions/files-bulk-actions.tsx
+++ b/packages/vitnode/src/views/admin/views/core/system/files/actions/files-bulk-actions.tsx
@@ -50,6 +50,7 @@ export const FilesBulkActions = ({
? t("bulk_delete.in_use.revisions.desc", { count: ids.length })
: t("bulk_delete.desc", { count: ids.length })
}
+ icon={ }
onOpenChange={open => {
if (!open) setHeldByRevisions([]);
}}
diff --git a/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/send-test-email.tsx b/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/send-test-email.tsx
index 908c8c909..6222df580 100644
--- a/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/send-test-email.tsx
+++ b/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/send-test-email.tsx
@@ -11,7 +11,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
-import { Loader } from "@/components/ui/loader";
+import { Spinner } from "@/components/ui/spinner";
import type { SendTestEmail } from "./send-test-email-mutation";
@@ -40,7 +40,13 @@ export const SendTestEmailAction = ({ onSend }: { onSend: SendTestEmail }) => {
{t("desc")}
- }>
+
+
+
+ }
+ >
diff --git a/packages/vitnode/src/views/admin/views/core/system/integrations/test-ai/test-ai.tsx b/packages/vitnode/src/views/admin/views/core/system/integrations/test-ai/test-ai.tsx
index 1e94a1323..f160711c6 100644
--- a/packages/vitnode/src/views/admin/views/core/system/integrations/test-ai/test-ai.tsx
+++ b/packages/vitnode/src/views/admin/views/core/system/integrations/test-ai/test-ai.tsx
@@ -11,7 +11,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
-import { Loader } from "@/components/ui/loader";
+import { Spinner } from "@/components/ui/spinner";
const ContentTestAI = React.lazy(async () =>
import("./content").then(module => ({
@@ -43,7 +43,13 @@ export const TestAIAction = ({ models }: { models: TestAIModel[] }) => {
{t("desc")}
- }>
+
+
+
+ }
+ >
diff --git a/packages/vitnode/src/views/admin/views/core/system/integrations/test-storage/test-storage.tsx b/packages/vitnode/src/views/admin/views/core/system/integrations/test-storage/test-storage.tsx
index 03c78d192..8a55c5cd2 100644
--- a/packages/vitnode/src/views/admin/views/core/system/integrations/test-storage/test-storage.tsx
+++ b/packages/vitnode/src/views/admin/views/core/system/integrations/test-storage/test-storage.tsx
@@ -11,7 +11,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
-import { Loader } from "@/components/ui/loader";
+import { Spinner } from "@/components/ui/spinner";
const ContentTestStorage = React.lazy(async () =>
import("./content").then(module => ({
@@ -38,7 +38,13 @@ export const TestStorageAction = () => {
{t("desc")}
- }>
+
+
+
+ }
+ >
diff --git a/packages/vitnode/src/views/admin/views/core/users/list/create-user-content.tsx b/packages/vitnode/src/views/admin/views/core/users/list/create-user-content.tsx
index 11dfdca1b..f5dc871df 100644
--- a/packages/vitnode/src/views/admin/views/core/users/list/create-user-content.tsx
+++ b/packages/vitnode/src/views/admin/views/core/users/list/create-user-content.tsx
@@ -17,7 +17,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
-import { Loader } from "@/components/ui/loader";
+import { Spinner } from "@/components/ui/spinner";
export type CreateAdminUser = (
input: AdminUserCreateInput,
@@ -52,7 +52,13 @@ export const CreateUserAdminContent = ({
{t("desc")}
- }>
+
+
+
+ }
+ >
diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/roles-table-content.tsx b/packages/vitnode/src/views/admin/views/core/users/roles/roles-table-content.tsx
index a5049af47..edd8e9f0d 100644
--- a/packages/vitnode/src/views/admin/views/core/users/roles/roles-table-content.tsx
+++ b/packages/vitnode/src/views/admin/views/core/users/roles/roles-table-content.tsx
@@ -47,7 +47,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
-import { Loader } from "@/components/ui/loader";
import {
Popover,
PopoverContent,
@@ -319,7 +318,13 @@ const EditRoleAction = ({
{t("title")}
- }>
+
+
+
+ }
+ >
@@ -396,7 +401,13 @@ export const CreateRoleAction = ({
{t("desc")}
- }>
+
+
+
+ }
+ >
diff --git a/packages/vitnode/src/views/auth/settings/devices/revoke-device-button.tsx b/packages/vitnode/src/views/auth/settings/devices/revoke-device-button.tsx
index 682a1203e..a3be2487e 100644
--- a/packages/vitnode/src/views/auth/settings/devices/revoke-device-button.tsx
+++ b/packages/vitnode/src/views/auth/settings/devices/revoke-device-button.tsx
@@ -30,6 +30,7 @@ export const RevokeDeviceButton = ({
}
onSubmit={async ({ onClose }) => {
const result = await onRevoke({ publicId });
diff --git a/packages/vitnode/src/views/auth/settings/passkeys/delete-passkey-button.tsx b/packages/vitnode/src/views/auth/settings/passkeys/delete-passkey-button.tsx
index 3b2a93aa2..f54fed5c6 100644
--- a/packages/vitnode/src/views/auth/settings/passkeys/delete-passkey-button.tsx
+++ b/packages/vitnode/src/views/auth/settings/passkeys/delete-passkey-button.tsx
@@ -32,6 +32,7 @@ export const DeletePasskeyButton = ({
}
onSubmit={async ({ onClose }) => {
const result = await onDelete({ id });
diff --git a/packages/vitnode/src/views/auth/settings/sso/disconnect-sso-button.tsx b/packages/vitnode/src/views/auth/settings/sso/disconnect-sso-button.tsx
index e63655a4a..6a534fbb9 100644
--- a/packages/vitnode/src/views/auth/settings/sso/disconnect-sso-button.tsx
+++ b/packages/vitnode/src/views/auth/settings/sso/disconnect-sso-button.tsx
@@ -32,6 +32,7 @@ export const DisconnectSsoButton = ({
}
+ icon={ }
onSubmit={async ({ onClose }) => {
const result = await onDisconnect({ providerId });
diff --git a/packages/vitnode/src/views/auth/sso/callback/sso-callback-content.tsx b/packages/vitnode/src/views/auth/sso/callback/sso-callback-content.tsx
index f60edc353..ca63b96f6 100644
--- a/packages/vitnode/src/views/auth/sso/callback/sso-callback-content.tsx
+++ b/packages/vitnode/src/views/auth/sso/callback/sso-callback-content.tsx
@@ -3,7 +3,7 @@ import { useTranslations } from "use-intl";
import { Button } from "@/components/ui/button";
import { Card, CardDescription } from "@/components/ui/card";
-import { Loader } from "@/components/ui/loader";
+import { Spinner } from "@/components/ui/spinner";
import { ErrorContent } from "@/views/error/error-content";
import type { SSOLinkSubmit } from "../link/sso-link-form-content";
@@ -123,7 +123,9 @@ export const SSOCallbackContent = ({
return (
);
};
diff --git a/packages/vitnode/src/views/files/actions/file-row-actions.tsx b/packages/vitnode/src/views/files/actions/file-row-actions.tsx
index 666dc8394..864579912 100644
--- a/packages/vitnode/src/views/files/actions/file-row-actions.tsx
+++ b/packages/vitnode/src/views/files/actions/file-row-actions.tsx
@@ -85,6 +85,7 @@ export const MyFileRowActions = ({
})
: t("delete.desc")
}
+ icon={ }
onOpenChange={open => {
if (!open) setHeldByRevisions(null);
}}
diff --git a/packages/vitnode/src/views/files/actions/files-bulk-actions.tsx b/packages/vitnode/src/views/files/actions/files-bulk-actions.tsx
index 53a14f9e0..4ccce5c54 100644
--- a/packages/vitnode/src/views/files/actions/files-bulk-actions.tsx
+++ b/packages/vitnode/src/views/files/actions/files-bulk-actions.tsx
@@ -50,6 +50,7 @@ export const MyFilesBulkActions = ({
? t("bulk_delete.in_use.revisions.desc", { count: ids.length })
: t("bulk_delete.desc", { count: ids.length })
}
+ icon={ }
onOpenChange={open => {
if (!open) setHeldByRevisions([]);
}}
diff --git a/packages/vitnode/src/views/layouts/providers.tsx b/packages/vitnode/src/views/layouts/providers.tsx
index 3eac33675..467dbfc38 100644
--- a/packages/vitnode/src/views/layouts/providers.tsx
+++ b/packages/vitnode/src/views/layouts/providers.tsx
@@ -47,11 +47,7 @@ export const VitNodeProviders = ({
enableSystem
{...theme}
>
-
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 66bbb32c7..0ba94dc37 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -212,6 +212,9 @@ importers:
react-dom:
specifier: ^19.3.0
version: 19.3.0(react@19.3.0)
+ recharts:
+ specifier: ^3.10.1
+ version: 3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@17.0.2)(react@19.3.0)(redux@5.0.1)
shadcn:
specifier: ^4.21.0
version: 4.21.0(typescript@6.0.3)
@@ -12902,7 +12905,7 @@ snapshots:
immer: 11.1.18
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
- reselect: 5.2.0
+ reselect: 5.3.0
optionalDependencies:
react: 19.3.0
react-redux: 9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1)
From c965f85d71b7209cdca52905ae11b77b6081bc8d Mon Sep 17 00:00:00 2001
From: aXenDeveloper
Date: Fri, 2 Oct 2026 23:09:43 +0200
Subject: [PATCH 2/3] =?UTF-8?q?docs:=20=E2=9C=8F=EF=B8=8F=20Improve=20docu?=
=?UTF-8?q?mentation=20clarity=20and=20consistency=20across=20UI=20compone?=
=?UTF-8?q?nts?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/web/content/docs/ui/attachment.mdx | 48 ++++++++++---------
apps/web/content/docs/ui/avatar.mdx | 8 ++--
apps/web/content/docs/ui/bubble.mdx | 12 ++---
apps/web/content/docs/ui/button-group.mdx | 24 +++++-----
apps/web/content/docs/ui/carousel.mdx | 11 ++---
apps/web/content/docs/ui/cascader.mdx | 24 +++++-----
apps/web/content/docs/ui/chart.mdx | 12 ++---
apps/web/content/docs/ui/combobox.mdx | 16 +++----
apps/web/content/docs/ui/command.mdx | 8 ++--
apps/web/content/docs/ui/comparison.mdx | 13 ++---
apps/web/content/docs/ui/context-menu.mdx | 14 +++---
apps/web/content/docs/ui/copy-button.mdx | 18 +++----
apps/web/content/docs/ui/counter.mdx | 12 ++---
.../docs/ui/data-table/bulk-actions.mdx | 6 +--
.../docs/ui/data-table/draggable-rows.mdx | 8 ++--
.../docs/ui/data-table/expandable-rows.mdx | 6 +--
.../content/docs/ui/data-table/filters.mdx | 2 +-
apps/web/content/docs/ui/data-table/index.mdx | 30 ++++++------
.../content/docs/ui/data-table/pagination.mdx | 10 ++--
.../web/content/docs/ui/data-table/search.mdx | 6 +--
apps/web/content/docs/ui/dialog.mdx | 4 +-
apps/web/content/docs/ui/empty.mdx | 32 +++++++------
apps/web/content/docs/ui/field.mdx | 34 ++++++-------
.../ui/hooks/use-intersection-observer.mdx | 8 ++--
.../docs/ui/hooks/use-local-storage.mdx | 4 +-
apps/web/content/docs/ui/image-zoom.mdx | 6 +--
apps/web/content/docs/ui/input-otp.mdx | 12 ++---
apps/web/content/docs/ui/item.mdx | 43 +++++++++--------
apps/web/content/docs/ui/kbd.mdx | 15 +++---
apps/web/content/docs/ui/label.mdx | 22 ++++-----
apps/web/content/docs/ui/marker.mdx | 12 ++---
apps/web/content/docs/ui/menubar.mdx | 14 +++---
apps/web/content/docs/ui/message.mdx | 4 +-
apps/web/content/docs/ui/nullable-number.mdx | 17 ++++---
apps/web/content/docs/ui/number-field.mdx | 24 +++++-----
apps/web/content/docs/ui/pagination.mdx | 4 +-
apps/web/content/docs/ui/resizable.mdx | 18 +++----
apps/web/content/docs/ui/scroll-fade.mdx | 16 +++----
apps/web/content/docs/ui/shimmer.mdx | 14 +++---
apps/web/content/docs/ui/spinner.mdx | 10 ++--
apps/web/content/docs/ui/toast.mdx | 24 +++++-----
apps/web/content/docs/ui/tooltip.mdx | 14 +++---
.../src/docs/examples/data-table-filters.tsx | 1 +
.../docs/examples/data-table-pagination.tsx | 5 +-
.../src/docs/examples/data-table-search.tsx | 1 +
apps/web/src/docs/local-table-data.ts | 22 +++++----
apps/web/src/docs/preview-data-table.tsx | 24 +++++-----
.../tiptap/toolbar/actions/bold-action.tsx | 6 +--
.../toolbar/actions/code-block-action.tsx | 6 +--
.../tiptap/toolbar/actions/color-action.tsx | 6 +--
.../tiptap/toolbar/actions/emoji-action.tsx | 7 ++-
.../tiptap/toolbar/actions/insert-action.tsx | 6 +--
.../tiptap/toolbar/actions/italic-action.tsx | 6 +--
.../tiptap/toolbar/actions/link-action.tsx | 6 +--
.../tiptap/toolbar/actions/list-action.tsx | 14 +++---
.../text-format-more/text-format-more.tsx | 6 +--
.../toolbar/actions/utils/toolbar-tooltip.tsx | 9 ----
.../src/components/ui/alert-dialog.tsx | 21 --------
.../vitnode/src/components/ui/cascader.tsx | 31 +++++-------
.../vitnode/src/components/ui/comparison.tsx | 3 +-
.../vitnode/src/components/ui/counter.tsx | 2 +-
.../vitnode/src/components/ui/spinner.tsx | 2 +-
62 files changed, 399 insertions(+), 424 deletions(-)
delete mode 100644 packages/vitnode/src/components/tiptap/toolbar/actions/utils/toolbar-tooltip.tsx
diff --git a/apps/web/content/docs/ui/attachment.mdx b/apps/web/content/docs/ui/attachment.mdx
index 8b9390888..a48d82e41 100644
--- a/apps/web/content/docs/ui/attachment.mdx
+++ b/apps/web/content/docs/ui/attachment.mdx
@@ -1,6 +1,6 @@
---
title: Attachment
-description: File uploads with drag and drop, progress, previews and errors - from a single avatar to a sortable gallery.
+description: Upload one file or a sortable gallery, with drag and drop, progress, previews and errors.
icon: Paperclip
---
@@ -25,8 +25,8 @@ import { AutoFormFile } from '@vitnode/core/components/form/fields/file'
import { AutoFormFiles } from '@vitnode/core/components/form/fields/files'
```
-The form stores **file ids**, not the files themselves - a number for a single
-file and an array of numbers for many:
+The form stores **file ids**, not the files themselves. One file is a number,
+many files are an array of numbers:
```ts
const formSchema = z.object({
@@ -125,37 +125,39 @@ const uploadFile = async (file: File): Promise => {
}
```
-When editing something that already has files, pass them in so the field can
-show their names and previews: `file={existing}` on `AutoFormFile`, or
-`files={existing}` on `AutoFormFiles`.
+Editing something that already has files? Pass them in with `file={existing}`
+on `AutoFormFile` or `files={existing}` on `AutoFormFiles`, and the field shows
+their names and previews.
## Limits
Both fields check files **before** uploading, so nobody waits for a 2 GB video
just to hear it was too big:
-- `maxBytes` - the largest accepted file, required.
-- `allowedExtensions` - lowercase with a leading dot, like `['.pdf', '.png']`.
-- `allowedMimeTypes` - for example `['image/png', 'application/pdf']`.
+- `maxBytes` sets the largest accepted file. It is required.
+- `allowedExtensions` takes lowercase extensions with a leading dot, like
+ `['.pdf', '.png']`.
+- `allowedMimeTypes` takes MIME types, for example
+ `['image/png', 'application/pdf']`.
-`AutoFormFiles` also takes `maxItems` and `minItems`. Files can be reordered by
-dragging or with the keyboard; pass `ordered={false}` when the order doesn't
+`AutoFormFiles` also takes `maxItems` and `minItems`. People can reorder files
+by dragging or with the keyboard. Pass `ordered={false}` when the order doesn't
matter.
-Your server should check the same limits again - the browser only saves people
-a wasted upload, it can't stop someone determined.
+Check the same limits again on your server. The browser check saves people a
+wasted upload, but it can't stop someone determined.
## States
Set `state` on `Attachment` to match what the file is doing:
-| State | Looks like |
-| ------------ | ---------------------------------------------- |
-| `done` | The default - a finished file |
-| `idle` | Dashed border, waiting for a file |
-| `uploading` | Shimmering title, dimmed preview |
-| `processing` | Same as uploading, for server-side work |
-| `error` | Red border and description - say what went wrong |
+| State | Looks like |
+| ------------ | ------------------------------------------------ |
+| `done` | The default, a finished file |
+| `idle` | Dashed border, waiting for a file |
+| `uploading` | Shimmering title, dimmed preview |
+| `processing` | Same as uploading, for server-side work |
+| `error` | Red border and description. Say what went wrong. |
## Layouts
@@ -164,13 +166,13 @@ Set `state` on `Attachment` to match what the file is doing:
- `size="sm"` or `size="xs"` shrink it for chat composers and tight lists.
- Wrap several in `AttachmentGroup` for a horizontally scrolling row that fades
out at the edges.
-- `AttachmentTrigger` makes the whole attachment clickable - render it as a link
- to open the file.
+- `AttachmentTrigger` makes the whole attachment clickable. Render it as a link to
+ open the file.
## Accessibility
- Give every `AttachmentAction` an `aria-label` that names the file, like
- "Remove report.pdf" - "Remove" alone is a riddle when there are five files.
+ "Remove report.pdf". "Remove" alone is a riddle when there are five files.
- Previews in `AttachmentMedia` are decorative next to the file name, so
`alt=""` is right.
- The AutoForm fields announce reordering to screen readers and show every
diff --git a/apps/web/content/docs/ui/avatar.mdx b/apps/web/content/docs/ui/avatar.mdx
index e4b273f7b..82b7e8f92 100644
--- a/apps/web/content/docs/ui/avatar.mdx
+++ b/apps/web/content/docs/ui/avatar.mdx
@@ -1,6 +1,6 @@
---
title: Avatar
-description: Profile pictures with a graceful fallback, status badges and groups that lift on hover.
+description: Profile pictures with a fallback for missing images, status badges and groups that lift on hover.
icon: CircleUserRound
---
@@ -25,7 +25,7 @@ import {
```
-The fallback shows while the image loads and stays if it never arrives - so a
+The fallback shows while the image loads and stays if it never arrives. A
broken link turns into tidy initials instead of a sad empty circle.
## Sizes
@@ -49,7 +49,7 @@ semantic one:
```
-A coloured dot alone doesn't tell everyone that someone is online - say it in
+A coloured dot alone doesn't tell everyone that someone is online. Say it in
text somewhere too, or give the badge an `aria-label`.
## Group
@@ -88,7 +88,7 @@ no hover effect.
## Accessibility
-- Always pass `alt` to `AvatarImage` - the person's name is usually perfect.
+- Always pass `alt` to `AvatarImage`. The person's name is usually perfect.
- An `AvatarGroupItem` with a `label` can be focused with the keyboard, and the
tooltip appears on focus as well as on hover.
- With reduced motion turned on, avatars in a group stay put and only the
diff --git a/apps/web/content/docs/ui/bubble.mdx b/apps/web/content/docs/ui/bubble.mdx
index a08fac019..dcb7921a3 100644
--- a/apps/web/content/docs/ui/bubble.mdx
+++ b/apps/web/content/docs/ui/bubble.mdx
@@ -59,8 +59,8 @@ BubbleGroup
## Alignment
-Messages you sent go on the right, everything else on the left - just like
-every chat app you have ever used.
+Messages you sent go on the right, everything else on the left, like every
+chat app you have ever used.
```tsx
@@ -102,8 +102,8 @@ leave a bit more `gap` between rows.
## Links and buttons
-Use `render` on `BubbleContent` to turn a bubble into a real link or button -
-perfect for quick reply suggestions.
+Use `render` on `BubbleContent` to turn a bubble into a real link or button,
+such as a quick reply suggestion.
```tsx
@@ -140,8 +140,8 @@ bubble inside follows it to the end side.
instead.
- Clickable bubbles should be a real `` or `` via `render`. They get
a visible focus ring and take their accessible name from the text.
-- Variants signal tone with color - keep the meaning in the text too, especially
- for `destructive` bubbles.
+- Variants signal tone with color, so keep the meaning in the text too,
+ especially for `destructive` bubbles.
## Props
diff --git a/apps/web/content/docs/ui/button-group.mdx b/apps/web/content/docs/ui/button-group.mdx
index ea4392091..e1fbee6d5 100644
--- a/apps/web/content/docs/ui/button-group.mdx
+++ b/apps/web/content/docs/ui/button-group.mdx
@@ -27,7 +27,7 @@ import {
```
The group squashes the inner borders and rounds only the outer corners, so a
-handful of buttons reads as one control. No extra props on the buttons - they
+handful of buttons reads as one control. The buttons need no extra props. They
just know they are in a group.
## Orientation
@@ -48,8 +48,8 @@ map, less great for a full sentence.
## Nested groups
-Put groups inside a group and they get a small gap between them - the classic
-toolbar look.
+Put groups inside a group and they get a small gap between them. That's the
+classic toolbar look.
```tsx
@@ -68,8 +68,8 @@ toolbar look.
## Separator
Outline buttons already have borders to divide them. Variants without a border,
-like `secondary`, look like one big blob - add a `ButtonGroupSeparator` between
-them.
+like `secondary`, merge into one big blob, so add a `ButtonGroupSeparator`
+between them.
```tsx
@@ -85,7 +85,7 @@ them.
## With text
-`ButtonGroupText` renders a non-interactive segment, perfect for a counter or a
+`ButtonGroupText` renders a non-interactive segment, like a counter or a
prefix. Use `render` to swap the element, for example to a ``.
```tsx
@@ -117,8 +117,8 @@ snug on the end.
## With dropdown menu
Pair a button with a [Dropdown Menu](/docs/ui/dropdown-menu) trigger to build a
-split button: the main action on the left, the "but also..." options on the
-right.
+split button, with the main action on the left and the "but also..." options on
+the right.
```tsx
@@ -141,7 +141,7 @@ right.
## Sizes
-The group follows the size of its buttons - use the same `size` on every button
+The group follows the size of its buttons. Use the same `size` on every button
in it so the heights match.
```tsx
@@ -159,14 +159,14 @@ in it so the heights match.
- The group renders with `role="group"`. Give it an `aria-label` (or
`aria-labelledby`) so screen readers announce what the buttons are for.
-- Icon-only buttons need their own `aria-label` - the `Button` types will remind
+- Icon-only buttons need their own `aria-label`. The `Button` types will remind
you if you forget.
- A focused button is lifted above its neighbours, so the focus ring is never
hidden behind the next segment.
- Borders and corners use logical properties, so groups flip correctly in
right-to-left languages.
-- Need a single selected option, like a segmented control? Reach for
- [Toggle Group](/docs/ui/toggle-group) instead - it handles the pressed state
+- Need a single selected option, like a segmented control? Use
+ [Toggle Group](/docs/ui/toggle-group) instead. It handles the pressed state
for you.
## Props
diff --git a/apps/web/content/docs/ui/carousel.mdx b/apps/web/content/docs/ui/carousel.mdx
index 6425b24e7..106eb035a 100644
--- a/apps/web/content/docs/ui/carousel.mdx
+++ b/apps/web/content/docs/ui/carousel.mdx
@@ -32,9 +32,8 @@ import {
```
-Swipe it, drag it, or click the arrows - [Embla Carousel](https://www.embla-carousel.com)
-does the heavy lifting, so the motion feels like a native app and not a slideshow
-from 2009.
+Swipe it, drag it or click the arrows. [Embla Carousel](https://www.embla-carousel.com)
+does the heavy lifting, so it moves like a native app and not a slideshow from 2009.
By default the arrows sit just outside the slides, so leave some room around
the carousel (for example `px-12`). On narrow screens, move them into a row of
@@ -42,7 +41,7 @@ their own with `static translate-y-0`, like the preview does.
## Sizes
-Each `CarouselItem` takes the full width. Change it with a `basis-*` class -
+Each `CarouselItem` takes the full width. Change it with a `basis-*` class,
responsive ones included.
```tsx
@@ -98,7 +97,7 @@ free-scrolling feel.
...
```
-For a right-to-left page, tell Embla too - the layout flips on its own, but the
+For a right-to-left page, tell Embla too. The layout flips on its own, but the
scroll math needs the hint:
```tsx
@@ -178,7 +177,7 @@ export const Slideshow = () => (
```
Keep `stopOnInteraction` on, and skip autoplay for people who prefer reduced
-motion - nobody likes chasing a slide that runs away.
+motion. Nobody likes chasing a slide that runs away.
## Accessibility
diff --git a/apps/web/content/docs/ui/cascader.mdx b/apps/web/content/docs/ui/cascader.mdx
index 359edfb77..ac79d5d16 100644
--- a/apps/web/content/docs/ui/cascader.mdx
+++ b/apps/web/content/docs/ui/cascader.mdx
@@ -1,6 +1,6 @@
---
title: Cascader
-description: Pick one option from a tree - continent, then country, then city - drilling in one level at a time.
+description: Pick one option from a tree, one level at a time. Think continent, then country, then city.
icon: ListTree
---
@@ -110,31 +110,31 @@ as well as the city:
## Search
-`searchable` adds a search box that looks through every leaf at once - type
+`searchable` adds a search box that looks through every leaf at once. Type
"krak" and "Europe / Poland / Kraków" is one click away. Accents don't matter,
so "sao" finds São Paulo.
## Drill down or columns
-By default, clicking a branch **replaces the list** with its children - the
-next level slides in, and a back button at the top takes you up again. It keeps
-the popup the width of the field, wherever it sits.
+By default, clicking a branch **replaces the list** with its children. The
+next level slides in, and a back button at the top takes you up again. The popup
+stays the width of the field, wherever it sits.
Prefer to see every level at once? `layout="columns"` opens each level in a new
-column beside the last one, and the popup grows smoothly to make room:
+column beside the last one, and the popup widens to make room:
```tsx
```
In the columns layout you can also open branches on hover with
-`expandTrigger="hover"`. On phones the cascader always drills down - there's no
-room for columns on a 375px screen.
+`expandTrigger="hover"`. On phones the cascader always drills down, because there's
+no room for columns on a 375px screen.
## Transitions
-Each level slides in from the side it came from - forward when you go deeper,
-back when you return - and in the columns layout the popup's width animates as
+Each level slides in from the direction you moved, forward when you go deeper
+and back when you return. In the columns layout, the popup's width animates as
columns open. With reduced motion turned on, everything switches instantly.
## Accessibility
@@ -145,8 +145,8 @@ columns open. With reduced motion turned on, everything switches instantly.
Escape to close.
- Branches tell screen readers whether they're open, and the picked path is
marked as selected.
-- Give the cascader a label - `AutoFormCascader` does it for you; on its own,
- use `aria-labelledby`.
+- Give the cascader a label. `AutoFormCascader` does it for you, and a
+ standalone `Cascader` needs `aria-labelledby`.
## Props
diff --git a/apps/web/content/docs/ui/chart.mdx b/apps/web/content/docs/ui/chart.mdx
index 9f34ee637..0858781f9 100644
--- a/apps/web/content/docs/ui/chart.mdx
+++ b/apps/web/content/docs/ui/chart.mdx
@@ -1,6 +1,6 @@
---
title: Chart
-description: Beautiful, themeable charts built on Recharts - bar, area, line and friends, with tooltips and legends that match the rest of your UI.
+description: Bar, area, line and any other Recharts chart, with tooltips, legends and colours that follow your theme.
icon: ChartColumn
---
@@ -132,7 +132,7 @@ to that one chart. Use it anywhere Recharts takes a colour:
```
It works in Tailwind classes too, for example `fill-(--color-desktop)`. Two or
-three colours per chart is plenty - your readers have eyes, not a rainbow
+three colours per chart is plenty. Your readers have eyes, not a rainbow
decoder ring.
## Area chart
@@ -248,19 +248,19 @@ import {
} />
```
-Use `nameKey` to read legend labels from another key - handy for pie charts,
-where each slice is a row in your data rather than a series.
+Use `nameKey` to read legend labels from another key. That's handy for pie
+charts, where each slice is a row in your data rather than a series.
## Accessibility
- `accessibilityLayer` adds ARIA attributes and keyboard support. Tab to a
- chart, then use the arrow keys to move between points - the tooltip follows
+ chart and use the arrow keys to move between points. The tooltip follows
along. It is on by default in Recharts 3, but writing it out makes the
intent clear.
- Give each chart a `title` and a `desc`. Recharts renders them as the SVG's
`` and ``, so screen readers get more than "graphic".
- Keep a visible heading next to the chart (a `CardTitle` works well), and
- never rely on colour alone - labels in the tooltip and legend tell series
+ never rely on colour alone. Labels in the tooltip and legend tell series
apart for everyone.
## API Reference
diff --git a/apps/web/content/docs/ui/combobox.mdx b/apps/web/content/docs/ui/combobox.mdx
index db167896e..699b8a2c1 100644
--- a/apps/web/content/docs/ui/combobox.mdx
+++ b/apps/web/content/docs/ui/combobox.mdx
@@ -162,7 +162,7 @@ export function ComboboxBasic() {
-Give options a `group` and they're sorted under headings - handy once a list
+Give options a `group` and they're sorted under headings. Handy once a list
gets long enough to scroll. `showClear` adds an × that empties the field in one
click.
@@ -188,22 +188,22 @@ a `group` gather at the top with no heading.
Pass `multiple` and use an array in the schema. Picked options pop in as
chips, and removing one shrinks it away while the rest slide over to close the
-gap - the box keeps its width and simply wraps onto a new line when it's full.
+gap. The box keeps its width and wraps onto a new line when it's full.
With reduced motion turned on, chips just appear and disappear.
### Limit visible chips
Picked a dozen tags? Set `maxVisibleChips` and only the first few show, followed
-by a "+N more" chip that counts the rest. Every pick is still saved - the
-limit is purely visual.
+by a "+N more" chip that counts the rest. Every pick is still saved. The
+limit is only visual.
```tsx
```
-Keyboard tips: Backspace in an empty search removes the last chip,
-and Escape with the list closed clears every pick - that's the
-standard combobox pattern, so it's worth knowing before you press it twice.
+Press Backspace in an empty search to remove the last chip, or
+Escape with the list closed to clear every pick. That's the
+standard combobox pattern, and worth knowing before you hit Escape twice.
@@ -231,7 +231,7 @@ const formSchema = z.object({
```
`.min()` and `.max()` on the array become the validation messages, and
-`fetchData` works with `multiple` too - the value is then an array of
+`fetchData` works with `multiple` too. The value is then an array of
`{ value, label }` objects.
diff --git a/apps/web/content/docs/ui/command.mdx b/apps/web/content/docs/ui/command.mdx
index 2607aa655..c419f0342 100644
--- a/apps/web/content/docs/ui/command.mdx
+++ b/apps/web/content/docs/ui/command.mdx
@@ -1,6 +1,6 @@
---
title: Command
-description: A fast, composable command menu with search, groups and keyboard navigation - the "just type it" escape hatch for your app.
+description: A command menu with search, groups and keyboard navigation. It's the "just type it" escape hatch for your app.
icon: Command
---
@@ -36,7 +36,7 @@ import {
```
-Start typing and the list filters itself, ranking the best matches first.
+Start typing and the list filters itself and ranks the best matches first.
Groups with nothing left to show politely step aside. It is built on
[cmdk](https://cmdk.paco.me), so every cmdk prop works here too.
@@ -58,7 +58,7 @@ Wrap related items in a `CommandGroup` with a `heading`, and drop a
## Icons and shortcuts
Icons go first and are sized for you. `CommandShortcut` pushes a hint to the
-end of the row. It is just a label - wiring the actual shortcut is up to you.
+end of the row. It is just a label. Wiring up the actual shortcut is your job.
```tsx
@@ -80,7 +80,7 @@ or at least make it kind.
## Selecting and disabling items
`onSelect` fires on click and on Enter . Add `disabled` to keep an
-item visible but out of reach - the keyboard skips it too.
+item visible but out of reach. The keyboard skips it too.
```tsx
router.navigate({ to: '/settings' })}>
diff --git a/apps/web/content/docs/ui/comparison.mdx b/apps/web/content/docs/ui/comparison.mdx
index c3f78a65f..52c444c8c 100644
--- a/apps/web/content/docs/ui/comparison.mdx
+++ b/apps/web/content/docs/ui/comparison.mdx
@@ -1,6 +1,6 @@
---
title: Comparison
-description: A before/after slider - drag the handle to reveal one image over another.
+description: A before/after slider. Drag the handle to reveal one image over another.
icon: Columns2
---
@@ -36,8 +36,9 @@ the `Comparison` a size or an aspect ratio and both pictures line up.
## Hover mode
-With `mode="hover"` the handle follows the pointer - no clicking needed. Nice
-for quick, playful comparisons; for anything precise, stick with dragging.
+With `mode="hover"` the handle follows the pointer, no clicking needed. It's
+nice for quick, playful comparisons, but for anything precise, stick with
+dragging.
```tsx
...
@@ -59,7 +60,7 @@ for quick, playful comparisons; for anything precise, stick with dragging.
## Custom handle
`ComparisonHandle` draws a line with a grip by default. Pass children to draw
-your own - it already follows the slider for you.
+your own, and the handle still moves with the slider.
```tsx
@@ -69,11 +70,11 @@ your own - it already follows the slider for you.
## Accessibility
-- The whole comparison is a slider: focus it with Tab , then use the
+- The whole comparison is a slider. Focus it with Tab , then use the
arrow keys (5% a step, 10% with Shift ), Home and
End .
- Screen readers hear the position as a percentage. Name the slider with
- `aria-label` - "Light and dark theme" says more than the default.
+ `aria-label`. "Light and dark theme" says more than the default.
- The images are decorative inside the slider (`alt=""`); describe what's being
compared in the label or a caption.
- Dragging sideways never blocks scrolling the page up and down on phones.
diff --git a/apps/web/content/docs/ui/context-menu.mdx b/apps/web/content/docs/ui/context-menu.mdx
index 9d0eff30d..78c0edfca 100644
--- a/apps/web/content/docs/ui/context-menu.mdx
+++ b/apps/web/content/docs/ui/context-menu.mdx
@@ -39,8 +39,8 @@ import {
```
Right-click the trigger with a mouse, or press and hold it on a phone or
-tablet. The menu opens right where your cursor (or finger) is - no button
-required, no hunting around.
+tablet. The menu opens right where your cursor or finger is, so there's no
+button to find first.
## Submenus
@@ -101,8 +101,8 @@ one.
## Shortcuts
`ContextMenuShortcut` shows a keyboard hint at the end of an item. It is only a
-label - wire the actual key binding yourself, otherwise it is a promise you are
-not keeping.
+label. Wire up the actual key binding yourself, or it's a promise you aren't
+keeping.
```tsx
@@ -125,12 +125,12 @@ part.
## Accessibility
-- Context menus are hidden by nature: nothing on screen says "right-click me".
- Every action inside one must also be reachable another way, such as a
+- Context menus are hidden by nature, since nothing on screen says "right-click
+ me". Every action inside one must also be reachable another way, such as a
toolbar, a [Dropdown Menu](/docs/ui/dropdown-menu) or a keyboard shortcut.
Treat the context menu as a shortcut, never as the only door.
- The trigger is a plain, non-focusable area, so keyboard users cannot open
- the menu at all - one more reason for the rule above. Once it is open,
+ the menu at all. One more reason for the rule above. Once it is open,
arrow keys move between items, the right arrow opens a submenu, typing a
letter jumps to a matching item and `Escape` closes it.
- On touch devices a long-press opens the menu, and the trigger sets
diff --git a/apps/web/content/docs/ui/copy-button.mdx b/apps/web/content/docs/ui/copy-button.mdx
index 011089295..9a97ba568 100644
--- a/apps/web/content/docs/ui/copy-button.mdx
+++ b/apps/web/content/docs/ui/copy-button.mdx
@@ -1,6 +1,6 @@
---
title: Copy Button
-description: A button that copies text to the clipboard, with a smooth icon swap and a check mark that draws itself.
+description: A button that copies text to the clipboard, then swaps its icon for a check mark that draws itself.
icon: Copy
---
@@ -18,9 +18,9 @@ import { CopyButton } from '@vitnode/core/components/ui/copy-button'
```
-Click it and the copy icon blurs away while a check mark draws itself in - the
-same stroke animation as the [Checkbox](/docs/ui/checkbox). After `delay`
-milliseconds it quietly goes back to the copy icon, ready for round two.
+Click it and the copy icon blurs away while a check mark draws itself in, with
+the same stroke animation as the [Checkbox](/docs/ui/checkbox). After `delay`
+milliseconds it goes back to the copy icon, ready for round two.
## With a label
@@ -37,7 +37,7 @@ accessible name, so screen readers hear what you see.
Without children the button renders as an icon. It labels itself "Copy" and
"Copied" out of the box, but a specific label is friendlier when there are a few
-copy buttons on one screen:
+copy buttons on one screen.
```tsx
@@ -44,8 +44,8 @@ right at it - with `max={10}` and `step={5}`, 8 goes to 10, not 13.
-The rolling digits are their own component, so you can animate any number -
-stats, prices, likes - without the buttons:
+The rolling digits are their own component, so you can animate any number,
+like a download count or a price, without the buttons:
```ts
import { SlidingNumber } from '@vitnode/core/components/ui/sliding-number'
@@ -68,10 +68,10 @@ gets saved.
## Accessibility
- The buttons are labelled "Decrease" and "Increase" in the user's language.
-- Screen readers hear the new value after every click, without the rolling
- digits - those are hidden from assistive tech.
+- Screen readers hear the new value after every click. The rolling digits
+ themselves are hidden from assistive tech.
- Give the counter a name with `aria-label` or `aria-labelledby`, so "Increase"
- has context: increase _what_?
+ has context. Increase _what_?
- With reduced motion turned on, digits change instantly.
## Props
diff --git a/apps/web/content/docs/ui/data-table/bulk-actions.mdx b/apps/web/content/docs/ui/data-table/bulk-actions.mdx
index 50010a5e4..3ec4e3084 100644
--- a/apps/web/content/docs/ui/data-table/bulk-actions.mdx
+++ b/apps/web/content/docs/ui/data-table/bulk-actions.mdx
@@ -10,7 +10,7 @@ icon: CheckCheck
## Usage
-Pass `bulkActions` to let a person act on several rows at once. That single prop is what turns selection on: the table grows a leading checkbox column - one per row, plus a header checkbox that ticks the whole page - and while anything is ticked, a bar floats at the bottom centre of the viewport with the count, your actions, and a button to clear the selection.
+Pass `bulkActions` to let a person act on several rows at once. That one prop turns selection on. The table grows a leading checkbox column, with a checkbox per row and one in the header that ticks the whole page. While anything is ticked, a bar floats at the bottom centre of the viewport with the count, your actions and a button to clear the selection.
Your actions read the ticked ids with the `useDataTableSelection` hook. They are rendered inside the bar, which lives inside the table's selection provider, so nothing has to be threaded through props:
@@ -103,9 +103,9 @@ import { TypeTable } from 'fumadocs-ui/components/type-table'
## Selection is per page
-The selection only ever covers the rows currently on screen. Whenever the server sends a different set of row ids - paging, searching, changing a filter - the selection is pruned to what is still there, so paging away empties it on its own and a bulk action can never touch a row nobody can see.
+The selection only ever covers the rows currently on screen. Whenever the server sends a different set of row ids, because someone paged, searched or changed a filter, the selection is pruned to what is still there. Paging away empties it on its own, and a bulk action can never touch a row nobody can see.
-That pruning is also what makes a partly-successful action readable: revalidate after deleting 3 of 5 rows and the 2 that were refused stay on screen _and_ stay ticked, so the bar is still pointed at exactly the work that is left.
+Pruning also keeps a partly successful action easy to read. Revalidate after deleting 3 of 5 rows, and the 2 that were refused stay on screen _and_ stay ticked, so the bar still points at exactly the work that is left.
There is no bulk endpoint behind `bulkActions` - it hands you ids, and what
diff --git a/apps/web/content/docs/ui/data-table/draggable-rows.mdx b/apps/web/content/docs/ui/data-table/draggable-rows.mdx
index d2fbecb0d..4fb307d1a 100644
--- a/apps/web/content/docs/ui/data-table/draggable-rows.mdx
+++ b/apps/web/content/docs/ui/data-table/draggable-rows.mdx
@@ -1,6 +1,6 @@
---
title: Draggable Rows
-description: Drag rows into a new order with mouse, touch or keyboard - and save it on your backend.
+description: Drag rows into a new order with mouse, touch or keyboard, then save it on your backend.
icon: GripVertical
---
@@ -10,7 +10,7 @@ icon: GripVertical
## Usage
-When the order of rows _is_ the data - a backlog, homepage sections, FAQ entries - pass `reorderable`. Each row grows a grip handle; grab it and drag the row where it belongs. The other rows slide out of the way, and the dropped row glides into its new spot.
+When the order of rows _is_ the data, like a backlog or a list of FAQ entries, pass `reorderable`. Each row grows a grip handle. Grab it and drag the row where it belongs. The other rows slide out of the way, and the dropped row glides into its new spot.
```tsx
diff --git a/apps/web/content/docs/ui/data-table/expandable-rows.mdx b/apps/web/content/docs/ui/data-table/expandable-rows.mdx
index 681208751..507d0e885 100644
--- a/apps/web/content/docs/ui/data-table/expandable-rows.mdx
+++ b/apps/web/content/docs/ui/data-table/expandable-rows.mdx
@@ -1,6 +1,6 @@
---
title: Expandable Rows
-description: Rows that open a detail panel underneath, sliding smoothly open and closed.
+description: Rows that slide open a detail panel right underneath them.
icon: UnfoldVertical
---
@@ -10,7 +10,7 @@ icon: UnfoldVertical
## Usage
-Some rows have more to say than a cell can hold - an order's line items, a job's error log, a user's last logins. Pass `expandable` and every row grows a chevron that slides a detail panel open right underneath it. No modal, no new page, no losing your place.
+Some rows have more to say than a cell can hold, like an order's line items or a job's error log. Pass `expandable` and each row gets a chevron that slides a detail panel open right underneath it. Rows that `canExpand` rules out get no chevron. The panel opens in place, so nobody loses their spot in the list.
```tsx
` with `aria-expanded` and `aria-controls`, so Enter and Space work and screen readers announce "Expand row" / "Collapse row". Row clicks from `rowOpens` keep working, because a click on a button never counts as a row click.
- **It plays nice with `bulkActions`.** The chevron column sits right after the selection checkbox.
-- **The motion is polite.** The panel eases its height and opacity open in about 300 ms, and with "reduce motion" turned on it simply fades - no sliding around.
+- **The motion is polite.** The panel eases its height and opacity open in about 300 ms, and with "reduce motion" turned on it only fades.
`render` runs on the client as soon as a row opens. If the detail needs data
diff --git a/apps/web/content/docs/ui/data-table/filters.mdx b/apps/web/content/docs/ui/data-table/filters.mdx
index fb5d6e699..26c595644 100644
--- a/apps/web/content/docs/ui/data-table/filters.mdx
+++ b/apps/web/content/docs/ui/data-table/filters.mdx
@@ -90,7 +90,7 @@ When the set of options is small and known ahead of time, pass them directly via
## Async filters
-When the options come from the API (for example a large or searchable list), provide an `onSearch` callback instead of `options`. It runs - debounced - as the user types and should return results already filtered and capped by the server. The universal fetcher answers from the browser here, with the visitor's own cookies:
+When the options come from the API (for example a large or searchable list), provide an `onSearch` callback instead of `options`. It runs, debounced, as the user types and should return results already filtered and capped by the server. The universal fetcher answers from the browser here, with the visitor's own cookies:
```tsx title="search-roles.tsx"
import type { FilterOption } from '@vitnode/core/components/table/filters'
diff --git a/apps/web/content/docs/ui/data-table/index.mdx b/apps/web/content/docs/ui/data-table/index.mdx
index 5a67fd3c9..41e02fb2f 100644
--- a/apps/web/content/docs/ui/data-table/index.mdx
+++ b/apps/web/content/docs/ui/data-table/index.mdx
@@ -1,6 +1,6 @@
---
title: Data Table
-description: A table component with sorting, filtering, and pagination compatible with VitNode API.
+description: A table with sorting, filtering and pagination that works with the VitNode API.
icon: Table
---
@@ -17,7 +17,7 @@ import {
} from '@vitnode/core/components/table/data-table'
```
-Columns are typed with `ColumnDef`, where `T` is the shape of a single row. An **accessor column** binds to the data through `accessorKey`, which is type-safe - it only accepts a `keyof T`, just like TanStack Table. A **display column** (for actions, selection, etc.) has no `accessorKey` and instead requires a string `id`.
+Columns are typed with `ColumnDef`, where `T` is the shape of a single row. An **accessor column** binds to the data through `accessorKey`, which is type-safe. It only accepts a `keyof T`, just like TanStack Table. A **display column**, for actions or selection, has no `accessorKey` and requires a string `id` instead.
```tsx
const columns: ColumnDef[] = [
@@ -179,9 +179,9 @@ import { TypeTable } from 'fumadocs-ui/components/type-table'
}}
/>
-## Cell Renderer
+## Cell renderer
-You can customize how each cell is rendered using the `cell` property. The renderer function receives the current row data and all table data as parameters.
+The `cell` property controls how each cell renders. Its function receives the current row and all the table data.
```tsx
```
-### Order Configuration
+### Order configuration
-If you want to enable sorting on specific columns, you can specify them in the `columns` property.
+To enable sorting on specific columns, list them in the `columns` property.
```tsx
order={{
@@ -229,9 +229,9 @@ order={{
}}
```
-## Column Alignment
+## Column alignment
-Use the `align` property to control the horizontal alignment of a column's header and cells. It accepts `"left"` (default), `"center"`, or `"right"`. This is handy for numeric values and action buttons.
+Use the `align` property to control the horizontal alignment of a column's header and cells. It accepts `"left"` (default), `"center"`, or `"right"`. Right alignment suits numbers and action buttons.
```tsx
columns={[
@@ -252,16 +252,16 @@ columns={[
Each feature has its own page with a live preview:
-- [Search](/docs/ui/data-table/search) - a debounced search box that writes to the URL.
-- [Filters](/docs/ui/data-table/filters) - faceted multi-select filters, static or async.
-- [Pagination](/docs/ui/data-table/pagination) - numbered pages and a rows-per-page select.
-- [Bulk Actions](/docs/ui/data-table/bulk-actions) - select rows and act on all of them at once.
-- [Expandable Rows](/docs/ui/data-table/expandable-rows) - a detail panel under each row.
-- [Draggable Rows](/docs/ui/data-table/draggable-rows) - reorder rows and save the order on your backend.
+- [Search](/docs/ui/data-table/search): a debounced search box that writes to the URL.
+- [Filters](/docs/ui/data-table/filters): faceted multi-select filters, static or async.
+- [Pagination](/docs/ui/data-table/pagination): numbered pages and a rows-per-page select.
+- [Bulk Actions](/docs/ui/data-table/bulk-actions): select rows and act on all of them at once.
+- [Expandable Rows](/docs/ui/data-table/expandable-rows): a detail panel under each row.
+- [Draggable Rows](/docs/ui/data-table/draggable-rows): reorder rows and save the order on your backend.
## Complete Example
-Here's a complete example showing how to use the `DataTable` component in a page:
+Here's `DataTable` wired into a full page:
```tsx
import {
diff --git a/apps/web/content/docs/ui/data-table/pagination.mdx b/apps/web/content/docs/ui/data-table/pagination.mdx
index dcb54843c..52f1a9ff7 100644
--- a/apps/web/content/docs/ui/data-table/pagination.mdx
+++ b/apps/web/content/docs/ui/data-table/pagination.mdx
@@ -1,6 +1,6 @@
---
title: Pagination
-description: Numbered pages, a rows-per-page select and a "Showing 21–30 of 380" range - all from pageInfo.
+description: Numbered pages, a rows-per-page select and a "Showing 21–30 of 380" range, all built from pageInfo.
icon: BookCopy
---
@@ -10,10 +10,10 @@ icon: BookCopy
## Usage
-The table renders its own pager from `pageInfo` — numbered pages with a first/last
-anchor and an ellipsis in between, a rows-per-page select, and a
-"Showing 21–30 of 380" range. You wire up nothing: pass `pageInfo` through and it
-appears.
+The table renders its own pager from `pageInfo`. You get numbered pages with a
+first/last anchor and an ellipsis in between, a rows-per-page select, and a
+"Showing 21–30 of 380" range. There's nothing to wire up. Pass `pageInfo` through
+and the pager appears.
Each page is a real link (`?page=3`), so middle-click and "open in new tab" work,
while a plain click stays a client-side navigation. On phones the numbers collapse
diff --git a/apps/web/content/docs/ui/data-table/search.mdx b/apps/web/content/docs/ui/data-table/search.mdx
index 510e3fe14..0da4adb91 100644
--- a/apps/web/content/docs/ui/data-table/search.mdx
+++ b/apps/web/content/docs/ui/data-table/search.mdx
@@ -1,6 +1,6 @@
---
title: Search
-description: Let people search the table - the term lives in the URL, so the API does the heavy lifting.
+description: Let people search the table. The term lives in the URL, so the API does the heavy lifting.
icon: TextSearch
---
@@ -10,7 +10,7 @@ icon: TextSearch
## Usage
-Set the `search` prop to `true` to render a search input above the table. You can optionally customize the placeholder with `searchPlaceholder`.
+Set the `search` prop to `true` to render a search input above the table. Change the placeholder with `searchPlaceholder`.
```tsx
```
-The input writes the term to the `?search=` query parameter (debounced) and reloads the page, so it works out of the box with server-side data fetching. The columns that are actually searched are defined on the API route.
+The input writes the term to the `?search=` query parameter (debounced), and the route loads the matching rows from the API. The API route decides which columns are searched.
Enabling `search` only renders the input. You must also tell the backend which
diff --git a/apps/web/content/docs/ui/dialog.mdx b/apps/web/content/docs/ui/dialog.mdx
index a0bf34b91..540d43e1d 100644
--- a/apps/web/content/docs/ui/dialog.mdx
+++ b/apps/web/content/docs/ui/dialog.mdx
@@ -47,9 +47,9 @@ import {
## Dialog or Alert Dialog?
-Use a Dialog for friendly, low-stakes tasks - sharing, quick edits, previews.
+Use a Dialog for friendly, low-stakes tasks like sharing, quick edits or previews.
Asking someone to confirm something they can't undo? Reach for the
-[Alert Dialog](/docs/ui/alert-dialog) instead: it can't be dismissed by clicking
+[Alert Dialog](/docs/ui/alert-dialog) instead. It can't be dismissed by clicking
outside, so nobody deletes anything by accident.
## API Reference
diff --git a/apps/web/content/docs/ui/empty.mdx b/apps/web/content/docs/ui/empty.mdx
index d4fd01485..ec18fb4f2 100644
--- a/apps/web/content/docs/ui/empty.mdx
+++ b/apps/web/content/docs/ui/empty.mdx
@@ -1,6 +1,6 @@
---
title: Empty
-description: Friendly empty states for lists, tables and searches that have nothing to show yet - with an icon, a clear message and a next step.
+description: Empty states for lists, tables and searches that have nothing to show yet, with an icon, a short message and a next step.
icon: PackageOpen
---
@@ -46,13 +46,14 @@ that have no items yet.
## Anatomy
-- `Empty` - the centered wrapper. It grows to fill its parent (`flex-1`).
-- `EmptyHeader` - groups the media, title and description.
-- `EmptyMedia` - an icon, avatar or small illustration.
-- `EmptyTitle` - one short line that says what's missing.
-- `EmptyDescription` - why it's empty and what to do about it. Links inside it
- are underlined automatically.
-- `EmptyContent` - actions, a search box or anything else the user can act on.
+- `Empty` is the centered wrapper. It grows to fill its parent (`flex-1`).
+- `EmptyHeader` groups the media, title and description.
+- `EmptyMedia` holds an icon, avatar or small illustration.
+- `EmptyTitle` is one short line that says what's missing.
+- `EmptyDescription` says why it's empty and what to do about it. Links inside
+ it are underlined automatically.
+- `EmptyContent` holds actions, a search box or anything else the user can act
+ on.
## Media
@@ -79,7 +80,7 @@ illustrations.
## Outline
-`Empty` already has a dashed border style - it just needs a width. Add `border`
+`Empty` already has a dashed border style. It just needs a width. Add `border`
for a drop-zone look, or `border-2` when it sits alone on a page, like the
AdminCP dashboard.
@@ -91,8 +92,8 @@ AdminCP dashboard.
## No results
-A search that finds nothing deserves its own empty state - not the same one as
-"you have no data". Echo the query back and offer a way out, like clearing the
+A search that finds nothing needs its own empty state, not the same one as "you
+have no data". Echo the query back and offer a way out, like clearing the
search.
@@ -123,8 +124,8 @@ search.
## Inside a card or sidebar
The default `p-12` is generous on purpose. On phones, `p-6 md:p-12` gives the
-copy room to breathe. In tight spots - a card, a sidebar, a popover - shrink the
-padding and the text:
+copy room to breathe. In tight spots like a card, a sidebar or a popover, shrink
+the padding and the text:
```tsx
@@ -139,7 +140,7 @@ padding and the text:
## Writing good empty states
-An empty state is a tiny piece of onboarding, so make every word count:
+An empty state is a tiny piece of onboarding. Five rules for writing one:
- **Say what's missing.** "No projects yet" beats "Nothing here".
- **Say why.** Is it brand new, filtered out, or did the search miss?
@@ -155,7 +156,8 @@ An empty state is a tiny piece of onboarding, so make every word count:
- `EmptyTitle` renders a `div`. If the empty state replaces a whole page
section, give the title a heading role so it shows up in the outline:
``.
-- Icons in `EmptyMedia` are decorative - the title already says it all. Use
+- Icons in `EmptyMedia` are decorative, since the title already says what's
+ missing. Use
`alt=""` on avatar images for the same reason.
- When results change while the user types, announce the count in a
`role="status"` region (a `sr-only` paragraph works great) so screen reader
diff --git a/apps/web/content/docs/ui/field.mdx b/apps/web/content/docs/ui/field.mdx
index 20d082a12..930e1e0d8 100644
--- a/apps/web/content/docs/ui/field.mdx
+++ b/apps/web/content/docs/ui/field.mdx
@@ -1,6 +1,6 @@
---
title: Field
-description: Layout primitives for labels, descriptions, errors and groups of controls - the building blocks behind every AutoForm field.
+description: Layout components for labels, descriptions, errors and groups of controls. AutoForm builds every field out of them.
icon: ListChecks
---
@@ -36,9 +36,9 @@ import {
## Should I use this for my form?
Probably not directly. In VitNode, forms are built with
-[AutoForm](/docs/ui/auto-form), and AutoForm already lays every field out with
-these primitives - labels, descriptions and error messages included. You get
-validation, ids and ARIA wiring for free.
+[AutoForm](/docs/ui/auto-form), and AutoForm already uses these components for
+every field's label, description and error message. You get validation, ids and
+ARIA wiring for free.
Reach for `Field` when you are:
@@ -78,7 +78,7 @@ checkboxes, switches and other compact controls that sit next to their label:
```
Put the control first for a checkbox on the left, or last for a switch on the
-right - the layout follows the markup.
+right. The layout follows the markup.
### Responsive
@@ -105,7 +105,7 @@ fold:
Wrap related fields in a `FieldSet` with a `FieldLegend`. Screen readers read
the legend before each field inside, so "Mentions" becomes "Notifications,
-Mentions" - context without extra words on screen.
+Mentions". The context costs no extra words on screen.
```tsx
@@ -129,9 +129,9 @@ Need a divider with words? Pass children: `or `.
## Choice cards
-Wrap a whole `Field` in a `FieldLabel` and the label becomes a card - the full
-card is clickable, it highlights when its control is checked, and it shows a
-focus ring when you tab into it. Use `FieldTitle` for the card's title, since
+Wrap a whole `Field` in a `FieldLabel` and the label becomes a card. The full
+card is clickable, highlights when its control is checked and shows a focus
+ring when you tab into it. Use `FieldTitle` for the card's title, since
it already lives inside a label.
@@ -167,9 +167,9 @@ render a `FieldError`:
```
-`FieldError` also takes an `errors` array - duplicates are removed, a single
-message renders as text and several render as a list. It renders nothing when
-there is nothing to say.
+`FieldError` also takes an `errors` array. It drops duplicates, renders a single
+message as text and several as a list. It renders nothing when there is nothing
+to say.
```tsx
@@ -181,12 +181,12 @@ A custom AutoForm field is just a `component` function. Combine `Field` for the
layout with AutoForm's own helpers and your field gets the same ids, ARIA and
error handling as the built-in ones:
-- `AutoFormLabel` - a `FieldLabel` already pointed at the control, with the
+- `AutoFormLabel` is a `FieldLabel` already pointed at the control, with the
"optional" hint.
-- `AutoFormDesc` - the description text.
-- `FormControl` - passes `id`, `aria-invalid` and `aria-describedby` to the
+- `AutoFormDesc` renders the description text.
+- `FormControl` passes `id`, `aria-invalid` and `aria-describedby` to the
control inside it.
-- `FormMessage` - a `FieldError` showing the field's validation errors.
+- `FormMessage` is a `FieldError` showing the field's validation errors.
```tsx
import type { ItemAutoFormComponentProps } from '@vitnode/core/components/form/auto-form'
@@ -237,7 +237,7 @@ export const AutoFormSettingSwitch = ({
## Accessibility
-- Every control needs a label: give the control an `id` and the `FieldLabel` a
+- Every control needs a label. Give the control an `id` and the `FieldLabel` a
matching `htmlFor`. Clicking the label then focuses or toggles the control.
- `Field` renders `role="group"`, so the control, its label and its help text
are announced together.
diff --git a/apps/web/content/docs/ui/hooks/use-intersection-observer.mdx b/apps/web/content/docs/ui/hooks/use-intersection-observer.mdx
index ca41daa73..17fb92f85 100644
--- a/apps/web/content/docs/ui/hooks/use-intersection-observer.mdx
+++ b/apps/web/content/docs/ui/hooks/use-intersection-observer.mdx
@@ -1,6 +1,6 @@
---
title: useIntersectionObserver
-description: Find out when an element scrolls into view - perfect for lazy loading, infinite feeds and "seen" tracking.
+description: Find out when an element scrolls into view. Use it for lazy loading, infinite feeds and "seen" tracking.
icon: ScanEye
---
@@ -32,7 +32,7 @@ friends.
## Load once, then stop watching
Set `freezeOnceVisible` and the hook stops observing the first time the element
-shows up. Handy for lazy loading or a one-off entrance animation - no point
+shows up. Handy for lazy loading or a one-off entrance animation, since there's no point
paying for an observer that has nothing left to do.
```tsx
@@ -50,7 +50,7 @@ screen, so people rarely catch it loading.
## Inside a scroll container
By default the hook watches the viewport. To watch a scrollable element
-instead, pass it as `root` - store it with a callback ref so the hook picks it
+instead, pass it as `root`. Store it with a callback ref so the hook picks it
up once it mounts:
```tsx
@@ -95,4 +95,4 @@ import { TypeTable } from 'fumadocs-ui/components/type-table'
/>
On the server, and in the rare browser without `IntersectionObserver`, the hook
-simply returns `undefined`.
+returns `undefined`.
diff --git a/apps/web/content/docs/ui/hooks/use-local-storage.mdx b/apps/web/content/docs/ui/hooks/use-local-storage.mdx
index 99a70e820..cea1a6db1 100644
--- a/apps/web/content/docs/ui/hooks/use-local-storage.mdx
+++ b/apps/web/content/docs/ui/hooks/use-local-storage.mdx
@@ -18,7 +18,7 @@ export const ExampleComponent = () => {
}
```
-It works just like `useState`: pass a key and a default value, get the value and
+It works like `useState`. Pass a key and a default value, and get the value and
a setter back. Values are saved as JSON, so numbers, booleans, arrays and plain
objects all come back exactly as you stored them.
@@ -43,7 +43,7 @@ const [filters, setFilters, resetFilters] = useLocalStorage('filters', [])
## Good to know
-- **Synced everywhere.** Every component using the same key updates together -
+- **Synced everywhere.** Every component using the same key updates together,
in this tab and in any other tab of your site.
- **Server-safe.** During server rendering the hook returns the default value,
then switches to the stored one in the browser without a hydration mismatch.
diff --git a/apps/web/content/docs/ui/image-zoom.mdx b/apps/web/content/docs/ui/image-zoom.mdx
index 17d3a950e..a7b4c65f7 100644
--- a/apps/web/content/docs/ui/image-zoom.mdx
+++ b/apps/web/content/docs/ui/image-zoom.mdx
@@ -1,6 +1,6 @@
---
title: Image Zoom
-description: Click an image and it grows smoothly from its spot on the page to fill the screen.
+description: Click an image and it grows from its spot on the page to fill the screen.
icon: ZoomIn
---
@@ -41,7 +41,7 @@ zooms in:
## Styling
-- `className` styles the clickable wrapper - use it for layout and visibility
+- `className` styles the clickable wrapper. Use it for layout and visibility
(`hidden dark:block` for theme-specific screenshots).
- `imageClassName` styles the image on the page: size, border, rounded corners.
@@ -51,7 +51,7 @@ The zoomed image always fits the screen without cropping.
- The image is a real button, so it works with the keyboard and screen readers
announce that it opens a dialog.
-- `alt` is required - it labels the image on the page and names the zoomed view.
+- `alt` is required. It labels the image on the page and names the zoomed view.
- Focus is trapped while zoomed and returns to the image when you close it.
- With reduced motion turned on, the image appears zoomed instantly instead of
gliding.
diff --git a/apps/web/content/docs/ui/input-otp.mdx b/apps/web/content/docs/ui/input-otp.mdx
index 055333134..611151150 100644
--- a/apps/web/content/docs/ui/input-otp.mdx
+++ b/apps/web/content/docs/ui/input-otp.mdx
@@ -48,7 +48,7 @@ const formSchema = z.object({
/>
```
-The schema does the setup for you: `.length(6)` decides how many slots there
+The schema does the setup for you. `.length(6)` decides how many slots there
are, and `.regex()` becomes the input's `pattern`, so letters never even make it
into a digits-only code.
@@ -95,7 +95,7 @@ password managers and the "fill code from SMS" suggestion on phones
## Groups
`AutoFormInputOTP` builds the slots for you. Codes of 6 or more characters with
-an even length are split in half with a separator (`123-456`), anything else
+an even length are split in half with a separator (`123-456`). Anything else
stays in one group. Pass `groups` to pick your own split:
```tsx
@@ -105,8 +105,8 @@ stays in one group. Pass `groups` to pick your own split:
## Submit when complete
-`onComplete` fires once every slot is filled - perfect for verifying the code
-without making people hunt for a button. It works on both `AutoFormInputOTP`
+`onComplete` fires once every slot is filled, so you can verify the code without
+making people hunt for a button. It works on both `AutoFormInputOTP`
and `InputOTP`.
```tsx
@@ -138,7 +138,7 @@ import { setFormFieldError } from '@vitnode/core/components/ui/form'
```
Using `InputOTP` on its own? Set `aria-invalid` on it and link your message
-with `aria-describedby` - colour alone never tells the whole story.
+with `aria-describedby`. Red slots mean nothing to a screen reader.
## Only digits (manual)
@@ -165,7 +165,7 @@ import {
input for you.
- Using `InputOTP` directly? Pass it an `id`, point a `` at it
and describe where the code was sent with `aria-describedby`.
-- Slots and separators are hidden from screen readers - the real input already
+- Slots and separators are hidden from screen readers. The real input already
announces the value, so nobody hears every digit twice.
## Props
diff --git a/apps/web/content/docs/ui/item.mdx b/apps/web/content/docs/ui/item.mdx
index c1bd40a4c..7ee3e4cdf 100644
--- a/apps/web/content/docs/ui/item.mdx
+++ b/apps/web/content/docs/ui/item.mdx
@@ -1,6 +1,6 @@
---
title: Item
-description: A flexible row for lists, settings and menus - media, a title, a description and actions, all lined up without the CSS headache.
+description: A row for lists, settings and menus that lines up media, a title, a description and actions without the CSS headache.
icon: LayoutList
---
@@ -43,16 +43,17 @@ right" rows. `Item` is that row, built once, so you can stop rebuilding it.
## Anatomy
-- `Item` - the row itself. Flexbox, wraps when it has to.
-- `ItemMedia` - an icon, avatar or image on the start side. With a description
- present it aligns itself to the top of the text.
-- `ItemContent` - stacks the title and description and takes the free space.
-- `ItemTitle` - one line, truncated if it gets chatty.
-- `ItemDescription` - up to two lines of muted text. Links inside are
+- `Item` is the row itself. Flexbox, wraps when it has to.
+- `ItemMedia` holds an icon, avatar or image on the start side. With a
+ description present it aligns itself to the top of the text.
+- `ItemContent` stacks the title and description and takes the free space.
+- `ItemTitle` is one line, truncated if it gets chatty.
+- `ItemDescription` shows up to two lines of muted text. Links inside are
underlined automatically.
-- `ItemActions` - buttons, badges or a chevron on the end side.
-- `ItemHeader` / `ItemFooter` - full-width rows above and below the content.
-- `ItemGroup` / `ItemSeparator` - a list of items and a line between them.
+- `ItemActions` holds buttons, badges or a chevron on the end side.
+- `ItemHeader` and `ItemFooter` are full-width rows above and below the content.
+- `ItemGroup` wraps a list of items, and `ItemSeparator` draws a line between
+ them.
## Variants
@@ -69,9 +70,9 @@ adds a soft background.
## Sizes
-`default`, `sm` and `xs` shrink the padding and the gaps. `ItemMedia` with the
-`image` variant shrinks along with them, and `xs` also uses smaller description
-text.
+Going from `default` to `sm` to `xs` shrinks the padding and the gaps.
+`ItemMedia` with the `image` variant shrinks along with them, and `xs` also uses
+smaller description text.
@@ -85,9 +86,9 @@ text.
`ItemMedia` comes in three flavours:
-- `default` - no styling at all, perfect for an `Avatar`.
-- `icon` - sizes a bare SVG icon to `size-4`.
-- `image` - a rounded square that crops an `img` to fit.
+- `default` adds no styling at all, which suits an `Avatar`.
+- `icon` sizes a bare SVG icon to `size-4`.
+- `image` is a rounded square that crops an `img` to fit.
```tsx
@@ -97,8 +98,8 @@ text.
## As a link
-Pass an element to `render` and the whole row becomes that element - an
-``, a router `Link`, a ``, whatever you need. Rendered as a link it
+Pass an element to `render` and the whole row becomes that element, whether
+that's an ``, a router `Link` or a ``. Rendered as a link it
picks up a hover background for free.
@@ -124,7 +125,7 @@ everything you see.
## Groups
Wrap items in `ItemGroup` to stack them, and drop an `ItemSeparator` between
-them when they need some breathing room.
+them when you want a line in between.
@@ -161,8 +162,8 @@ or a timestamp can sit above or below the main content.
`role="listitem"`, and an `aria-label` on the group helps screen readers
announce what the list is about.
- Keep one interactive element per row. Either the whole row is a link or
- button (via `render`), or it holds buttons in `ItemActions` - never a button
- inside a link.
+ button (via `render`), or it holds buttons in `ItemActions`. Never put a
+ button inside a link.
- Icon-only buttons in `ItemActions` need an `aria-label` that names the row,
for example "Invite Ada Lovelace" rather than just "Invite".
- Decorative icons such as a trailing chevron get `aria-hidden`. Links that
diff --git a/apps/web/content/docs/ui/kbd.mdx b/apps/web/content/docs/ui/kbd.mdx
index ea6b91b51..0966b30ee 100644
--- a/apps/web/content/docs/ui/kbd.mdx
+++ b/apps/web/content/docs/ui/kbd.mdx
@@ -19,8 +19,7 @@ import { Kbd, KbdGroup } from '@vitnode/core/components/ui/kbd'
```
`Kbd` renders a real `` element, so browsers, search engines and assistive
-tech all know it is a key - not just a tiny gray box that happens to look like
-one.
+tech all know it is a key, not a tiny gray box that happens to look like one.
## Shortcuts
@@ -35,7 +34,7 @@ and stays a `` too, which is exactly how HTML describes a key combo.
```
Prefer a visible plus sign? Drop it between the keys and hide it from screen
-readers, they already hear two keys:
+readers, since they already hear two keys:
```tsx
@@ -104,9 +103,9 @@ Pair it with an [Input Group](/docs/ui/input-group) addon for the classic
Showing ⌘ to a Windows user is a small betrayal. Detect the platform on the
client, but render the same thing on the server and during hydration, or React
-will complain about mismatched HTML. `useSyncExternalStore` does exactly that:
-the third argument is used for the server render and hydration, then React
-switches to the real value.
+will complain about mismatched HTML. `useSyncExternalStore` does exactly that.
+React uses its third argument for the server render and hydration, then switches
+to the real value.
```tsx
const subscribeToNothing = () => () => {}
@@ -140,8 +139,8 @@ No `useEffect` plus `setState` dance required.
An `aria-label` on a plain `` is not reliably read out, so visually
hidden text is the safer bet. `title` adds a hover hint for sighted users.
- `Kbd` has `pointer-events-none` and `select-none`. It is a hint, not a
- control - if pressing the key should do something, listen for the key
- itself (and put the action on a real button).
+ control. If pressing the key should do something, listen for the key itself
+ (and put the action on a real button).
- Do not make a shortcut the only way to do something. Mouse, touch and
keyboard users should all get there.
diff --git a/apps/web/content/docs/ui/label.mdx b/apps/web/content/docs/ui/label.mdx
index 20d9966d0..d98a383f5 100644
--- a/apps/web/content/docs/ui/label.mdx
+++ b/apps/web/content/docs/ui/label.mdx
@@ -1,6 +1,6 @@
---
title: Label
-description: An accessible text label for form controls - click it and the control it names responds.
+description: An accessible text label for form controls. Click it and the control it names responds.
icon: TextInitial
---
@@ -20,15 +20,15 @@ import { Label } from '@vitnode/core/components/ui/label'
```
`Label` is a styled native ``. Point `htmlFor` at the control's `id` and
-the browser handles the rest: the whole label becomes a click target, and
+the browser handles the rest. The whole label becomes a click target, and
screen readers announce the text when the control gets focus.
## Using AutoForm? You already have labels
[AutoForm](/docs/ui/auto-form) fields render their own label (`AutoFormLabel`)
from the `label` prop, wired to the right `id`, with an "Optional" hint when the
-schema says so. Don't add a `Label` next to an AutoForm field - you'd just get
-two.
+schema says so. Don't add a `Label` next to an AutoForm field, or you'll end up
+with two.
```tsx
@@ -36,8 +36,8 @@ two.
Reach for `Label` when you build a control by hand: a settings toggle outside a
form, a custom picker, a filter bar. For a full field layout with description
-and error message, see [Field](/docs/ui/field) - `FieldLabel` is built on top
-of this component.
+and error message, see [Field](/docs/ui/field). Its `FieldLabel` is built on
+top of this component.
## With a checkbox
@@ -53,7 +53,7 @@ in, so clicking anywhere on the text toggles the checkbox.
## Wrapping the control
-No `id` handy? Wrap the control instead - a `` is associated with the
+No `id` handy? Wrap the control instead. A `` is associated with the
control inside it automatically.
```tsx
@@ -65,7 +65,7 @@ control inside it automatically.
## Disabled controls
-Disable the control and the label follows along: it dims and shows a
+Disable the control and the label follows along. It dims and shows a
`not-allowed` cursor, so nobody wonders why clicking it does nothing.
```tsx
@@ -76,12 +76,12 @@ Disable the control and the label follows along: it dims and shows a
```
This works when the control comes **before** the label as a sibling and has the
-`peer` class - VitNode's `Checkbox`, `Switch` and `RadioGroupItem` already do.
+`peer` class. VitNode's `Checkbox`, `Switch` and `RadioGroupItem` already do.
`Label` reacts to both a native `disabled` attribute and the `data-disabled`
attribute Base UI puts on its controls.
Label sits somewhere else? Put `group` and `data-disabled="true"` on a shared
-parent instead - that is what [Field](/docs/ui/field) does for you.
+parent instead. [Field](/docs/ui/field) does exactly that for you.
## Accessibility
@@ -89,7 +89,7 @@ parent instead - that is what [Field](/docs/ui/field) does for you.
by wrapping the control. A label floating next to an input is just text to a
screen reader.
- Keep the visible text meaningful. "Email" beats "Field 1", and placeholders
- are not labels - they vanish the moment someone starts typing.
+ are not labels. They vanish the moment someone starts typing.
- The label is part of the hit area. That makes small controls like checkboxes
much easier to tap on a phone, so keep the text next to the control rather
than far away.
diff --git a/apps/web/content/docs/ui/marker.mdx b/apps/web/content/docs/ui/marker.mdx
index 80c0e225a..fb2422c2c 100644
--- a/apps/web/content/docs/ui/marker.mdx
+++ b/apps/web/content/docs/ui/marker.mdx
@@ -27,8 +27,8 @@ import {
```
-A marker is the quiet line between messages - "Switched branch", "Today",
-"Thinking...". It pairs nicely with [Bubble](/docs/ui/bubble) when you build a
+A marker is the quiet line between messages, like "Switched branch", "Today"
+or "Thinking...". It pairs nicely with [Bubble](/docs/ui/bubble) when you build a
chat thread, and it is just as happy in an activity feed.
```text
@@ -61,7 +61,7 @@ Pick a `variant` to change how the marker sits in the flow.
| `separator` | A centered label with a divider line on each side. |
| `border` | A default marker with a bottom border under the row. |
-Keep separator labels short - a date or a few words. The divider lines take the
+Keep separator labels short, like a date or a few words. The divider lines take the
leftover space, so a long label leaves them nothing to work with.
## With an icon
@@ -99,7 +99,7 @@ import { Spinner } from '@vitnode/core/components/ui/spinner'
## Links and buttons
The root is polymorphic. Pass `render` to turn a marker into a link or a
-button - links get an underline for free.
+button. Links get an underline for free.
```tsx
}>
@@ -128,8 +128,8 @@ button - links get an underline for free.
`role="status"` for live progress, `render` with an `a` or `button` when it is
clickable, nothing at all for a simple note.
- A labeled separator needs no role. The lines are decorative pseudo-elements and
- the label is read as ordinary text. Do not add `role="separator"` - it would
- hide the label from assistive tech.
+ the label is read as ordinary text. Do not add `role="separator"`, because it
+ would hide the label from assistive tech.
- `MarkerIcon` is `aria-hidden`, so the meaning lives in `MarkerContent`. For an
icon-only marker, give the root an `aria-label`.
- Wrap a whole thread in an element with `role="log"` so new messages and
diff --git a/apps/web/content/docs/ui/menubar.mdx b/apps/web/content/docs/ui/menubar.mdx
index 67b198ada..27fd85f3a 100644
--- a/apps/web/content/docs/ui/menubar.mdx
+++ b/apps/web/content/docs/ui/menubar.mdx
@@ -46,9 +46,9 @@ import {
```
-Every `MenubarMenu` is one menu in the bar: a trigger plus its content. Open one
-and the bar remembers - hover the next trigger and it opens straight away, just
-like the menu bar at the top of your desktop. Handy for editors, dashboards and
+Every `MenubarMenu` is one menu in the bar, made of a trigger and its content.
+Open one and the bar remembers. Hover the next trigger and it opens straight
+away, just like the menu bar at the top of your desktop. Handy for editors, dashboards and
anything else with more commands than buttons.
## Submenus
@@ -108,8 +108,8 @@ and sub-triggers in the same menu so their text lines up with the checked ones.
## Shortcuts
`MenubarShortcut` shows a keyboard hint at the end of an item. It is only a
-label - wire the actual key binding yourself, otherwise it is a promise you are
-not keeping.
+label. Wire up the actual key binding yourself, or it is a promise you are not
+keeping.
```tsx
@@ -237,8 +237,8 @@ import { TypeTable } from 'fumadocs-ui/components/type-table'
/>
`MenubarCheckboxItem` takes `checked`, `defaultChecked` and `onCheckedChange`;
-`MenubarRadioGroup` takes `value`, `defaultValue` and `onValueChange`. Both item
-types accept `inset` and `disabled` too. `MenubarMenu` takes `open`,
+`MenubarRadioGroup` takes `value`, `defaultValue` and `onValueChange`.
+`MenubarCheckboxItem` and `MenubarRadioItem` both accept `inset` and `disabled`. `MenubarMenu` takes `open`,
`defaultOpen` and `onOpenChange` when you want to drive a single menu yourself.
## API Reference
diff --git a/apps/web/content/docs/ui/message.mdx b/apps/web/content/docs/ui/message.mdx
index 20aa62198..799a435ce 100644
--- a/apps/web/content/docs/ui/message.mdx
+++ b/apps/web/content/docs/ui/message.mdx
@@ -41,7 +41,7 @@ import {
```
`Message` owns the row: who is talking, which side they sit on, and what goes
-above or below. The actual speech bubble is a [Bubble](/docs/ui/bubble) - the
+above or below. The speech bubble itself is a [Bubble](/docs/ui/bubble). The
two are designed to be best friends.
## Composition
@@ -60,7 +60,7 @@ MessageGroup
Set `align="end"` for messages the current user sent. The avatar swaps sides,
and every bubble, attachment, header and footer inside `MessageContent` moves to
-the end - no need to set `align` on each `Bubble`.
+the end, so you don't need to set `align` on each `Bubble`.
```tsx
diff --git a/apps/web/content/docs/ui/nullable-number.mdx b/apps/web/content/docs/ui/nullable-number.mdx
index 46f09945a..ad6e64576 100644
--- a/apps/web/content/docs/ui/nullable-number.mdx
+++ b/apps/web/content/docs/ui/nullable-number.mdx
@@ -1,6 +1,6 @@
---
title: Nullable Number
-description: Numeric field paired with a checkbox that toggles the value to null - for "unlimited", "never", "no limit", and similar.
+description: A number field with a checkbox that toggles the value to null, for answers like "unlimited", "never" or "no limit".
icon: Hash
---
@@ -9,7 +9,7 @@ icon: Hash
It builds on [Number Field](/docs/ui/number-field), so you get the plus and
-minus buttons, arrow-key stepping and min/max clamping for free - plus one
+minus buttons, arrow-key stepping and min/max clamping for free. On top sits one
checkbox for when the honest answer is "no limit".
## Usage
@@ -56,13 +56,12 @@ const formSchema = z.object({
## Adapting the labels
-The three label props are plain text, so the same field works for any domain -
-an unlimited storage cap, a session that never expires, an uncapped rate limit,
-and so on:
+The three label props are plain text, so the same field fits an unlimited
+storage cap, a session that never expires or an uncapped rate limit:
-- `unitLabel` - shown right after the input (e.g. `kB`, `minutes`, `%`).
-- `orLabel` - an optional connector rendered before the checkbox (e.g. `or`).
-- `toggleLabel` - the checkbox label; checking it sets the value to `null`.
+- `unitLabel` is shown right after the input (e.g. `kB`, `minutes`, `%`).
+- `orLabel` is an optional connector rendered before the checkbox (e.g. `or`).
+- `toggleLabel` is the checkbox label. Checking it sets the value to `null`.
```tsx
Shift held (default `10`).
-- `smallStep` - used with Alt (default `0.1`).
-- `snapOnStep` - snap to the nearest multiple of the step instead of adding it
+- `min` / `max` stop the buttons, arrow keys and scrubbing at the limits, and
+ disable the button that would cross one. A typed value is clamped on blur.
+- `step` is how much one click or arrow press moves the value (default `1`).
+- `largeStep` is used with Shift held (default `10`).
+- `smallStep` is used with Alt (default `0.1`).
+- `snapOnStep` snaps to the nearest multiple of the step instead of adding it
to whatever was typed.
```tsx
@@ -110,7 +110,7 @@ button is released) instead of on every keystroke.
`format` takes [`Intl.NumberFormatOptions`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#options),
so currencies, percentages and units come for free. The value stays a plain
-number - `0.15` shows as `15%`, and typing `20%` gives you `0.2` back.
+number. `0.15` shows as `15%`, and typing `20%` gives you `0.2` back.
@@ -135,7 +135,7 @@ and English users see `12,345`. Pass `locale` to override it.
## Scrub area
-Wrap the label in `NumberFieldScrubArea` and the label becomes a slider: drag
+Wrap the label in `NumberFieldScrubArea` and the label becomes a slider. Drag
it left or right to change the value, like in a design tool. Give it the same
`htmlFor` as the field's `id` so clicking the label still focuses the input.
@@ -178,8 +178,8 @@ keeps the value readable and focusable but locks the buttons and the keys.
Shift for `largeStep` or Alt for `smallStep`.
Home / End jump to `min` / `max` when set.
- The plus and minus buttons are labelled "Increase" and "Decrease" in the
- user's language and are skipped by Tab - the keyboard already has
- the arrow keys. On touch screens their hit area grows to 44px.
+ user's language and are skipped by Tab , since the keyboard already
+ has the arrow keys. On touch screens their hit area grows to 44px.
- Name the input with a label (`htmlFor` + `id`), a scrub area, or
`aria-label`. `AutoFormNumber` does it for you.
diff --git a/apps/web/content/docs/ui/pagination.mdx b/apps/web/content/docs/ui/pagination.mdx
index 7c422113c..8daa2f230 100644
--- a/apps/web/content/docs/ui/pagination.mdx
+++ b/apps/web/content/docs/ui/pagination.mdx
@@ -121,7 +121,7 @@ const pageWindow = (current: number, total: number): PageSlot[] => {
Even five numbers get crowded on a phone. Give every number's `PaginationItem`
`className="hidden sm:block"` and show a "Page 2 of 12" line between the arrows
-instead - the same trick the [Data Table](/docs/ui/data-table) uses:
+instead. The [Data Table](/docs/ui/data-table) uses the same trick:
```tsx
@@ -165,7 +165,7 @@ your feed deserves something more specific:
page", "Next page") even when only the chevron is visible.
- Page links are plain anchors named by their number, so "2" is read as
"link, 2" rather than a generic "Go to page".
-- `PaginationEllipsis` is hidden from assistive technology - it is decoration,
+- `PaginationEllipsis` is hidden from assistive technology. It is decoration,
not a link.
- Announce page changes that do not reload the page, for example with an
`aria-live="polite"` "Page 2 of 12" line like in the preview.
diff --git a/apps/web/content/docs/ui/resizable.mdx b/apps/web/content/docs/ui/resizable.mdx
index 02c6d94ca..eb6e8fb5a 100644
--- a/apps/web/content/docs/ui/resizable.mdx
+++ b/apps/web/content/docs/ui/resizable.mdx
@@ -29,7 +29,7 @@ import {
```
A group always fills its parent with an inline `height: 100%`, so a height
-class on the group itself is ignored - give the **parent** a height instead.
+class on the group itself is ignored. Give the **parent** a height instead.
Otherwise your beautiful layout is only as tall as its text, which is very
minimalist but not very useful.
@@ -63,8 +63,8 @@ meant to be grabbed.
## Sizes
-`defaultSize`, `minSize` and `maxSize` accept a few units. Mind the type -
-it changes the meaning:
+`defaultSize`, `minSize` and `maxSize` accept a few units, and the type
+changes the meaning:
- a **string** without a unit is a percentage: `"30"` and `"30%"` are the same;
- a **number** is pixels: `minSize={180}` means 180px, not 180%;
@@ -101,8 +101,8 @@ Need a toggle button? Pass `panelRef` and call `collapse()`, `expand()` or
## Persisting the layout
-The group takes a `defaultLayout` - a map of panel `id` to percentage - and
-reports every finished resize through `onLayoutChanged`. Wire both to storage
+The group's `defaultLayout` maps each panel `id` to a percentage, and
+`onLayoutChanged` reports every finished resize. Wire both to storage
and the layout survives a reload. Give every panel a stable `id`, since that is
what the saved layout is keyed by.
@@ -149,8 +149,8 @@ export const RememberedLayout = () => {
```
`useSyncExternalStore` with a server snapshot of `null` keeps server rendering
-happy: the server renders the default sizes and the browser swaps in the saved
-ones. Prefer `meta.requestedLayout` when saving - it is the layout the user
+happy. The server renders the default sizes, and the browser swaps in the saved
+ones. Prefer `meta.requestedLayout` when saving. It is the layout the user
asked for, before a small screen squeezed it to fit the constraints.
## Accessibility
@@ -158,8 +158,8 @@ asked for, before a small screen squeezed it to fit the constraints.
- Every handle is a focusable `role="separator"` with `aria-valuenow`,
`aria-valuemin`, `aria-valuemax` and `aria-controls` pointing at the panel it
resizes.
-- Arrow keys resize by 5% - left and right in a horizontal group, up and down in
- a vertical one.
+- Arrow keys resize by 5%, left and right in a horizontal group and up and down
+ in a vertical one.
- `Home` and `End` push the panel to its minimum or maximum size.
- `Enter` collapses or expands a `collapsible` panel.
- `F6` jumps to the next handle in the group (`Shift + F6` goes back).
diff --git a/apps/web/content/docs/ui/scroll-fade.mdx b/apps/web/content/docs/ui/scroll-fade.mdx
index c8714d145..1f2b362da 100644
--- a/apps/web/content/docs/ui/scroll-fade.mdx
+++ b/apps/web/content/docs/ui/scroll-fade.mdx
@@ -10,15 +10,15 @@ icon: Blend
## Usage
-Scroll Fade is a Tailwind CSS utility, not a React component - there is nothing
-to import. It ships with the `shadcn` package, and VitNode apps already pull it
+Scroll Fade is a Tailwind CSS utility, not a React component, so there is
+nothing to import. It ships with the `shadcn` package, and VitNode apps already pull it
in from `src/styles.css`:
```css
@import 'shadcn/tailwind.css';
```
-Add the class to the element that actually scrolls - the one with
+Add the class to the element that actually scrolls, the one with
`overflow-y-auto` or `overflow-x-auto`:
```tsx
@@ -30,12 +30,12 @@ The fade follows the scroll position:
- At rest, the start edge is crisp and the end edge fades to hint that there is
more below.
- Mid-scroll, both edges fade.
-- At the end, the end edge sharpens - you have reached the bottom, congrats.
+- At the end, the end edge sharpens. You have reached the bottom, congrats.
- If the content does not overflow, nothing fades, so you can sprinkle it on any
list without checking first.
It works with `mask-image`, so it dissolves the content itself instead of
-painting a gradient on top. No colors to match, it just works on any
+painting a gradient on top. There are no colors to match, so it works on any
background.
| Class | Fades |
@@ -135,14 +135,14 @@ state variant:
## Browser support
The scroll-aware part is built on CSS scroll-driven animations
-(`animation-timeline: scroll()`) - no JavaScript, no scroll listeners. Browsers
+(`animation-timeline: scroll()`), with no JavaScript and no scroll listeners. Browsers
without scroll-driven animations get a static fade on every edge the class
-covers, all the time, even at the very top. Content is never hidden, it just
+covers, all the time, even at the very top. Content is never hidden. It just
loses the "you're at the end" polish.
## Accessibility
-- The fade is purely visual. Every item is still in the DOM and reachable - it
+- The fade is purely visual. Every item is still in the DOM and reachable. It
only looks softer near the edges.
- A scroll container without focusable children cannot be scrolled with a
keyboard unless it can take focus. Give it `tabIndex={0}`, `role="region"`
diff --git a/apps/web/content/docs/ui/shimmer.mdx b/apps/web/content/docs/ui/shimmer.mdx
index 35990e9e6..856355fb5 100644
--- a/apps/web/content/docs/ui/shimmer.mdx
+++ b/apps/web/content/docs/ui/shimmer.mdx
@@ -1,6 +1,6 @@
---
title: Shimmer
-description: A Tailwind CSS utility that sweeps a soft highlight across text - the universal sign for "hang on, I'm working on it".
+description: A Tailwind CSS utility that sweeps a soft highlight across text, the universal sign for "hang on, I'm working on it".
icon: Sparkles
---
@@ -10,8 +10,8 @@ icon: Sparkles
## Usage
-Shimmer is a Tailwind CSS utility, not a React component - there is nothing to
-import. It ships with the `shadcn` package, and VitNode apps already pull it in
+Shimmer is a Tailwind CSS utility, not a React component, so there is nothing
+to import. It ships with the `shadcn` package, and VitNode apps already pull it in
from `src/styles.css`:
```css
@@ -26,7 +26,7 @@ Add it to any text element:
The highlight is derived from `currentColor`, so it works on muted text, brand
colors and everything in between. In dark mode it brightens on its own to stay
-visible. It's pure CSS: the text is painted with `background-clip: text` and
+visible. It's pure CSS. The text is painted with `background-clip: text` and
the highlight loops across it every two seconds.
| Class | What it does |
@@ -108,7 +108,7 @@ The sweep follows the reading direction out of the box (right to left in RTL).
### Play once
-`shimmer-once` plays a single sweep - a nice little "done!" when streaming
+`shimmer-once` plays a single sweep, a nice little "done!" when streaming
finishes. Combine it with `shimmer-duration-` to set how long it takes, and
change the element's `key` to play it again:
@@ -143,11 +143,11 @@ only where it works:
## Accessibility
- With reduced motion turned on, the animation stops and the text renders
- normally - no setup needed. That rule targets the plain `shimmer` class, so
+ normally, with no setup needed. That rule targets the plain `shimmer` class, so
if you apply it through a variant (`group-hover:shimmer`), add
`motion-reduce:shimmer-none` yourself.
- Shimmer is decoration, not a loading signal. Screen readers and reduced-motion
- users never see it, so pair it with real text inside `role="status"` - like
+ users never see it, so pair it with real text inside `role="status"`, like
"Thinking..." in the examples above.
- Keep it on short status text. A whole paragraph sparkling away is more disco
than progress.
diff --git a/apps/web/content/docs/ui/spinner.mdx b/apps/web/content/docs/ui/spinner.mdx
index 253aed72a..4cbe5502d 100644
--- a/apps/web/content/docs/ui/spinner.mdx
+++ b/apps/web/content/docs/ui/spinner.mdx
@@ -1,6 +1,6 @@
---
title: Spinner
-description: A little spinning circle that says "hold on, it's coming" - in four sizes and any colour.
+description: A little spinning circle that says "hold on, it's coming". It comes in four sizes and any colour.
icon: Loader
---
@@ -38,8 +38,8 @@ It takes the text colour of its surroundings, so `className="text-primary"`
-For a button that's busy, prefer `isLoading` on the [Button](/docs/ui/button) -
-it swaps the label for a spinner, disables the button and announces "Loading".
+For a button that's busy, prefer `isLoading` on the [Button](/docs/ui/button).
+It swaps the label for a spinner, disables the button and announces "Loading".
Put a `Spinner` in by hand when you want to keep the label visible:
```tsx
@@ -83,8 +83,8 @@ fresh data:
```
-Showing a whole section while it loads? Center an `xl` spinner - that's what
-VitNode does for Suspense fallbacks across the AdminCP:
+Showing a whole section while it loads? Center an `xl` spinner. VitNode does
+the same for Suspense fallbacks across the AdminCP:
```tsx
Alt + T .
- The close button appears on hover and keyboard focus, and is always visible on
touch screens.
-- Colour is never the only signal - every variant has its own icon too.
+- Colour is never the only signal, since every variant has its own icon too.
## Documentation
diff --git a/apps/web/content/docs/ui/tooltip.mdx b/apps/web/content/docs/ui/tooltip.mdx
index bbe31e442..297888aca 100644
--- a/apps/web/content/docs/ui/tooltip.mdx
+++ b/apps/web/content/docs/ui/tooltip.mdx
@@ -1,6 +1,6 @@
---
title: Tooltip
-description: A tooltip component for displaying additional information on hover or focus.
+description: A small hint that shows extra information on hover or focus.
icon: MessageCircleMore
---
@@ -24,9 +24,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@vitnode/core/component
```
-### Faster Tooltips
+### Shorter tooltips
-If you don't need custom props for components like `TooltipTrigger` or `TooltipContent`, you can use the `TooltipWithContent` component to enable faster tooltips.
+If you don't need custom props on `TooltipTrigger` or `TooltipContent`, `TooltipWithContent` does the same job in one component.
```ts
import { Button } from "@vitnode/core/components/ui/button";
@@ -48,9 +48,9 @@ import { PlayIcon } from "lucide-react";
Got a toolbar full of icon buttons? Wrap it in `TooltipGroup` and use
`TooltipGroupTrigger` for each button. They all share one tooltip, so moving
-from button to button glides it over - resizing to fit the new text, which
-slides in from the direction you came from - instead of closing and opening
-again every time.
+from button to button glides it over instead of closing and opening it again
+every time. It resizes to fit the new text, and the text slides in from the
+direction you came from.
```ts
import {
@@ -82,7 +82,7 @@ import {
`TooltipContent`. With reduced motion turned on, the tooltip jumps straight to
the next button instead of gliding.
-A tooltip is a hint, not a label - icon buttons still need their own
+A tooltip is a hint, not a label, so icon buttons still need their own
`aria-label`, as above.
## API Reference
diff --git a/apps/web/src/docs/examples/data-table-filters.tsx b/apps/web/src/docs/examples/data-table-filters.tsx
index 351b7fabc..f11f5d6ed 100644
--- a/apps/web/src/docs/examples/data-table-filters.tsx
+++ b/apps/web/src/docs/examples/data-table-filters.tsx
@@ -53,6 +53,7 @@ export default function DataTableFiltersExample() {
resolve={(params) =>
resolveLocalTable(demoUsers, params, {
defaultOrder,
+ sortBy: { name: (row) => row.name },
filters: { role: (row) => row.role, status: (row) => row.status },
})
}
diff --git a/apps/web/src/docs/examples/data-table-pagination.tsx b/apps/web/src/docs/examples/data-table-pagination.tsx
index 8aab585a1..15e23f5d0 100644
--- a/apps/web/src/docs/examples/data-table-pagination.tsx
+++ b/apps/web/src/docs/examples/data-table-pagination.tsx
@@ -17,7 +17,10 @@ export default function DataTablePaginationExample() {
id="docs-pagination-table"
order={{ columns: ['id', 'name'], defaultOrder }}
resolve={(params) =>
- resolveLocalTable(demoUsers, params, { defaultOrder })
+ resolveLocalTable(demoUsers, params, {
+ defaultOrder,
+ sortBy: { id: (row) => row.id, name: (row) => row.name },
+ })
}
/>
)
diff --git a/apps/web/src/docs/examples/data-table-search.tsx b/apps/web/src/docs/examples/data-table-search.tsx
index 00e7c67f9..f7832ea68 100644
--- a/apps/web/src/docs/examples/data-table-search.tsx
+++ b/apps/web/src/docs/examples/data-table-search.tsx
@@ -25,6 +25,7 @@ export default function DataTableSearchExample() {
resolve={(params) =>
resolveLocalTable(demoUsers, params, {
defaultOrder,
+ sortBy: { name: (row) => row.name },
searchIn: (row) => `${row.name} ${row.email}`,
})
}
diff --git a/apps/web/src/docs/local-table-data.ts b/apps/web/src/docs/local-table-data.ts
index 5074eeab9..dcdd561ce 100644
--- a/apps/web/src/docs/local-table-data.ts
+++ b/apps/web/src/docs/local-table-data.ts
@@ -13,9 +13,10 @@ interface LocalTableOptions {
defaultOrder?: TableOrder
filters?: Record string>
searchIn?: (row: T) => string
+ sortBy?: Record number | string>
}
-const compare = (a: unknown, b: unknown) =>
+const compare = (a: number | string, b: number | string) =>
typeof a === 'number' && typeof b === 'number'
? a - b
: String(a).localeCompare(String(b))
@@ -23,7 +24,12 @@ const compare = (a: unknown, b: unknown) =>
export const resolveLocalTable = (
rows: readonly T[],
params: URLSearchParams,
- { defaultOrder, filters = {}, searchIn }: LocalTableOptions = {},
+ {
+ defaultOrder,
+ filters = {},
+ searchIn,
+ sortBy = {},
+ }: LocalTableOptions = {},
) => {
const term = readTableSearch(params).trim().toLowerCase()
let result = [...rows]
@@ -41,14 +47,12 @@ export const resolveLocalTable = (
if (defaultOrder) {
const { column, order } = readTableOrder(params, defaultOrder)
+ const read = sortBy[column]
const direction = order === 'asc' ? 1 : -1
- result.sort(
- (a, b) =>
- compare(
- (a as Record)[column],
- (b as Record)[column],
- ) * direction,
- )
+
+ if (read) {
+ result.sort((a, b) => compare(read(a), read(b)) * direction)
+ }
}
const pageSize = readTablePageSize(params)
diff --git a/apps/web/src/docs/preview-data-table.tsx b/apps/web/src/docs/preview-data-table.tsx
index b5709f32b..61eac91ce 100644
--- a/apps/web/src/docs/preview-data-table.tsx
+++ b/apps/web/src/docs/preview-data-table.tsx
@@ -15,14 +15,13 @@ type LocalData = Pick<
'edges' | 'pageInfo'
>
-export function DataTable({
- resolve,
- ...props
-}:
- | (DataTableProps & { resolve?: undefined })
- | (Omit, 'edges' | 'pageInfo'> & {
- resolve: (params: URLSearchParams) => LocalData
- })) {
+export function DataTable(
+ props:
+ | (DataTableProps & { resolve?: undefined })
+ | (Omit, 'edges' | 'pageInfo'> & {
+ resolve: (params: URLSearchParams) => LocalData
+ }),
+) {
const [search, setSearch] = React.useState('')
const navigation = React.useMemo(
@@ -37,10 +36,13 @@ export function DataTable({
return (
- {resolve ? (
- {...props} {...resolve(navigation.searchParams)} />
+ {props.resolve ? (
+
+ {...props}
+ {...props.resolve(navigation.searchParams)}
+ />
) : (
- {...(props as DataTableProps)} />
+ {...props} />
)}
)
diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/bold-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/bold-action.tsx
index f4333b26e..62f519318 100644
--- a/packages/vitnode/src/components/tiptap/toolbar/actions/bold-action.tsx
+++ b/packages/vitnode/src/components/tiptap/toolbar/actions/bold-action.tsx
@@ -3,9 +3,9 @@ import { BoldIcon } from "lucide-react";
import { useTranslations } from "use-intl";
import { Toggle } from "@/components/ui/toggle";
+import { TooltipWithContent } from "@/components/ui/tooltip";
import { useToolbarEditor } from "../use-toolbar-editor";
-import { ToolbarTooltip } from "./utils/toolbar-tooltip";
import { TooltipShortcut } from "./utils/tooltip-shortcut";
export const BoldAction = () => {
@@ -21,7 +21,7 @@ export const BoldAction = () => {
});
return (
-
{t("bold")} +B
@@ -39,6 +39,6 @@ export const BoldAction = () => {
-
+
);
};
diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/code-block-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/code-block-action.tsx
index 9f8fe1ea8..0dff9143d 100644
--- a/packages/vitnode/src/components/tiptap/toolbar/actions/code-block-action.tsx
+++ b/packages/vitnode/src/components/tiptap/toolbar/actions/code-block-action.tsx
@@ -3,9 +3,9 @@ import { CodeSquareIcon } from "lucide-react";
import { useTranslations } from "use-intl";
import { Toggle } from "@/components/ui/toggle";
+import { TooltipWithContent } from "@/components/ui/tooltip";
import { useToolbarEditor } from "../use-toolbar-editor";
-import { ToolbarTooltip } from "./utils/toolbar-tooltip";
import { TooltipShortcut } from "./utils/tooltip-shortcut";
export const CodeBlockAction = () => {
@@ -17,7 +17,7 @@ export const CodeBlockAction = () => {
});
return (
-
{t("code_block")}
@@ -36,6 +36,6 @@ export const CodeBlockAction = () => {
-
+
);
};
diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/color-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/color-action.tsx
index 994ed6f97..d50baed6c 100644
--- a/packages/vitnode/src/components/tiptap/toolbar/actions/color-action.tsx
+++ b/packages/vitnode/src/components/tiptap/toolbar/actions/color-action.tsx
@@ -10,9 +10,9 @@ import {
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
+import { TooltipWithContent } from "@/components/ui/tooltip";
import { useToolbarEditor } from "../use-toolbar-editor";
-import { ToolbarTooltip } from "./utils/toolbar-tooltip";
export const ColorAction = () => {
const t = useTranslations("core.global.editor.color");
@@ -38,7 +38,7 @@ export const ColorAction = () => {
return (
-
+
{
style={{ backgroundColor: activeColor || "currentColor" }}
/>
-
+
{t("label")}
diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/emoji-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/emoji-action.tsx
index 9f8ecd8b6..72af7e221 100644
--- a/packages/vitnode/src/components/tiptap/toolbar/actions/emoji-action.tsx
+++ b/packages/vitnode/src/components/tiptap/toolbar/actions/emoji-action.tsx
@@ -9,8 +9,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Spinner } from "@/components/ui/spinner";
-
-import { ToolbarTooltip } from "./utils/toolbar-tooltip";
+import { TooltipWithContent } from "@/components/ui/tooltip";
const EmojiPicker = React.lazy(async () =>
import("./emoji/emoji-picker").then(module => ({
@@ -24,7 +23,7 @@ export const EmojiAction = () => {
return (
-
+
@@ -32,7 +31,7 @@ export const EmojiAction = () => {
>
-
+
command.group === "insert" || command.id === "quote",
@@ -31,7 +31,7 @@ export const InsertAction = () => {
return (
<>
-
+
{
-
+
diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/italic-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/italic-action.tsx
index 98f794541..89ebb6391 100644
--- a/packages/vitnode/src/components/tiptap/toolbar/actions/italic-action.tsx
+++ b/packages/vitnode/src/components/tiptap/toolbar/actions/italic-action.tsx
@@ -3,9 +3,9 @@ import { ItalicIcon } from "lucide-react";
import { useTranslations } from "use-intl";
import { Toggle } from "@/components/ui/toggle";
+import { TooltipWithContent } from "@/components/ui/tooltip";
import { useToolbarEditor } from "../use-toolbar-editor";
-import { ToolbarTooltip } from "./utils/toolbar-tooltip";
import { TooltipShortcut } from "./utils/tooltip-shortcut";
export const ItalicAction = () => {
@@ -21,7 +21,7 @@ export const ItalicAction = () => {
});
return (
-
{t("italic")}
@@ -40,6 +40,6 @@ export const ItalicAction = () => {
-
+
);
};
diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/link-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/link-action.tsx
index 2cdfeb1a3..228fe3a10 100644
--- a/packages/vitnode/src/components/tiptap/toolbar/actions/link-action.tsx
+++ b/packages/vitnode/src/components/tiptap/toolbar/actions/link-action.tsx
@@ -11,9 +11,9 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Toggle } from "@/components/ui/toggle";
+import { TooltipWithContent } from "@/components/ui/tooltip";
import { useToolbarEditor } from "../use-toolbar-editor";
-import { ToolbarTooltip } from "./utils/toolbar-tooltip";
export const LinkAction = () => {
const t = useTranslations("core.global.editor.link");
@@ -49,7 +49,7 @@ export const LinkAction = () => {
return (
-
+
{
>
-
+