@startsimpli/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +537 -0
  2. package/package.json +80 -0
  3. package/src/components/index.ts +50 -0
  4. package/src/components/navigation/sidebar.tsx +178 -0
  5. package/src/components/ui/accordion.tsx +58 -0
  6. package/src/components/ui/alert.tsx +59 -0
  7. package/src/components/ui/badge.tsx +36 -0
  8. package/src/components/ui/button.tsx +57 -0
  9. package/src/components/ui/calendar.tsx +70 -0
  10. package/src/components/ui/card.tsx +68 -0
  11. package/src/components/ui/checkbox.tsx +30 -0
  12. package/src/components/ui/collapsible.tsx +12 -0
  13. package/src/components/ui/dialog.tsx +122 -0
  14. package/src/components/ui/dropdown-menu.tsx +200 -0
  15. package/src/components/ui/index.ts +24 -0
  16. package/src/components/ui/input.tsx +25 -0
  17. package/src/components/ui/label.tsx +26 -0
  18. package/src/components/ui/popover.tsx +31 -0
  19. package/src/components/ui/progress.tsx +28 -0
  20. package/src/components/ui/scroll-area.tsx +48 -0
  21. package/src/components/ui/select.tsx +160 -0
  22. package/src/components/ui/separator.tsx +31 -0
  23. package/src/components/ui/skeleton.tsx +15 -0
  24. package/src/components/ui/table.tsx +117 -0
  25. package/src/components/ui/tabs.tsx +55 -0
  26. package/src/components/ui/textarea.tsx +24 -0
  27. package/src/components/ui/tooltip.tsx +30 -0
  28. package/src/components/unified-table/UnifiedTable.tsx +553 -0
  29. package/src/components/unified-table/__tests__/components/BulkActionBar.test.tsx +477 -0
  30. package/src/components/unified-table/__tests__/components/ExportButton.test.tsx +467 -0
  31. package/src/components/unified-table/__tests__/components/InlineEditCell.test.tsx +159 -0
  32. package/src/components/unified-table/__tests__/components/SavedViewsDropdown.test.tsx +128 -0
  33. package/src/components/unified-table/__tests__/components/TablePagination.test.tsx +374 -0
  34. package/src/components/unified-table/__tests__/hooks/useColumnReorder.test.ts +191 -0
  35. package/src/components/unified-table/__tests__/hooks/useColumnResize.test.ts +122 -0
  36. package/src/components/unified-table/__tests__/hooks/useColumnVisibility.test.ts +594 -0
  37. package/src/components/unified-table/__tests__/hooks/useFilters.test.ts +460 -0
  38. package/src/components/unified-table/__tests__/hooks/usePagination.test.ts +439 -0
  39. package/src/components/unified-table/__tests__/hooks/useResponsive.test.ts +421 -0
  40. package/src/components/unified-table/__tests__/hooks/useSelection.test.ts +367 -0
  41. package/src/components/unified-table/__tests__/hooks/useTableKeyboard.test.ts +803 -0
  42. package/src/components/unified-table/__tests__/hooks/useTableState.test.ts +210 -0
  43. package/src/components/unified-table/__tests__/integration/table-with-selection.test.tsx +624 -0
  44. package/src/components/unified-table/__tests__/utils/export.test.ts +427 -0
  45. package/src/components/unified-table/components/BulkActionBar/index.tsx +119 -0
  46. package/src/components/unified-table/components/DataTableCore/index.tsx +473 -0
  47. package/src/components/unified-table/components/InlineEditCell/index.tsx +159 -0
  48. package/src/components/unified-table/components/MobileView/Card.tsx +218 -0
  49. package/src/components/unified-table/components/MobileView/CardActions.tsx +126 -0
  50. package/src/components/unified-table/components/MobileView/README.md +411 -0
  51. package/src/components/unified-table/components/MobileView/index.tsx +77 -0
  52. package/src/components/unified-table/components/MobileView/types.ts +77 -0
  53. package/src/components/unified-table/components/TableFilters/index.tsx +298 -0
  54. package/src/components/unified-table/components/TablePagination/index.tsx +157 -0
  55. package/src/components/unified-table/components/Toolbar/ExportButton.tsx +229 -0
  56. package/src/components/unified-table/components/Toolbar/SavedViewsDropdown.tsx +251 -0
  57. package/src/components/unified-table/components/Toolbar/StandardTableToolbar.tsx +146 -0
  58. package/src/components/unified-table/components/Toolbar/index.tsx +3 -0
  59. package/src/components/unified-table/hooks/index.ts +21 -0
  60. package/src/components/unified-table/hooks/useColumnReorder.ts +90 -0
  61. package/src/components/unified-table/hooks/useColumnResize.ts +123 -0
  62. package/src/components/unified-table/hooks/useColumnVisibility.ts +92 -0
  63. package/src/components/unified-table/hooks/useFilters.ts +53 -0
  64. package/src/components/unified-table/hooks/usePagination.ts +120 -0
  65. package/src/components/unified-table/hooks/useResponsive.ts +50 -0
  66. package/src/components/unified-table/hooks/useSelection.ts +152 -0
  67. package/src/components/unified-table/hooks/useTableKeyboard.ts +206 -0
  68. package/src/components/unified-table/hooks/useTablePreferences.ts +198 -0
  69. package/src/components/unified-table/hooks/useTableState.ts +103 -0
  70. package/src/components/unified-table/hooks/useTableURL.test.tsx +921 -0
  71. package/src/components/unified-table/hooks/useTableURL.ts +301 -0
  72. package/src/components/unified-table/index.ts +16 -0
  73. package/src/components/unified-table/types.ts +393 -0
  74. package/src/components/unified-table/utils/export.ts +236 -0
  75. package/src/components/unified-table/utils/index.ts +4 -0
  76. package/src/components/unified-table/utils/renderers.ts +105 -0
  77. package/src/components/unified-table/utils/themes.ts +87 -0
  78. package/src/components/unified-table/utils/validation.ts +122 -0
  79. package/src/index.ts +6 -0
  80. package/src/lib/utils.ts +1 -0
  81. package/src/theme/contract.ts +46 -0
  82. package/src/theme/index.ts +9 -0
  83. package/src/theme/tailwind.config.js +70 -0
  84. package/src/theme/tailwind.preset.ts +93 -0
  85. package/src/utils/cn.ts +6 -0
  86. package/src/utils/index.ts +91 -0
@@ -0,0 +1,92 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback, useEffect } from 'react'
4
+ import { ColumnVisibilityState } from '../types'
5
+
6
+ export interface UseColumnVisibilityOptions {
7
+ defaultVisibility?: ColumnVisibilityState
8
+ persistKey?: string
9
+ onVisibilityChange?: (visibility: ColumnVisibilityState) => void
10
+ }
11
+
12
+ export function useColumnVisibility({
13
+ defaultVisibility = {},
14
+ persistKey,
15
+ onVisibilityChange
16
+ }: UseColumnVisibilityOptions = {}) {
17
+ const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>(() => {
18
+ if (persistKey && typeof window !== 'undefined') {
19
+ const stored = localStorage.getItem(`column-visibility-${persistKey}`)
20
+ if (stored) {
21
+ try {
22
+ return JSON.parse(stored)
23
+ } catch {
24
+ return defaultVisibility
25
+ }
26
+ }
27
+ }
28
+ return defaultVisibility
29
+ })
30
+
31
+ useEffect(() => {
32
+ if (persistKey && typeof window !== 'undefined') {
33
+ localStorage.setItem(`column-visibility-${persistKey}`, JSON.stringify(columnVisibility))
34
+ }
35
+ onVisibilityChange?.(columnVisibility)
36
+ }, [columnVisibility, persistKey, onVisibilityChange])
37
+
38
+ const toggleColumn = useCallback((columnId: string) => {
39
+ setColumnVisibility((prev) => ({
40
+ ...prev,
41
+ [columnId]: !(prev[columnId] ?? true)
42
+ }))
43
+ }, [])
44
+
45
+ const showColumn = useCallback((columnId: string) => {
46
+ setColumnVisibility((prev) => ({
47
+ ...prev,
48
+ [columnId]: true
49
+ }))
50
+ }, [])
51
+
52
+ const hideColumn = useCallback((columnId: string) => {
53
+ setColumnVisibility((prev) => ({
54
+ ...prev,
55
+ [columnId]: false
56
+ }))
57
+ }, [])
58
+
59
+ const showAllColumns = useCallback(() => {
60
+ setColumnVisibility({})
61
+ }, [])
62
+
63
+ const hideAllColumns = useCallback((exceptColumns: string[] = []) => {
64
+ setColumnVisibility((prev) => {
65
+ const newVisibility: ColumnVisibilityState = {}
66
+ Object.keys(prev).forEach((key) => {
67
+ newVisibility[key] = exceptColumns.includes(key)
68
+ })
69
+ return newVisibility
70
+ })
71
+ }, [])
72
+
73
+ const resetVisibility = useCallback(() => {
74
+ setColumnVisibility(defaultVisibility)
75
+ }, [defaultVisibility])
76
+
77
+ const isColumnVisible = useCallback((columnId: string): boolean => {
78
+ return columnVisibility[columnId] ?? true
79
+ }, [columnVisibility])
80
+
81
+ return {
82
+ columnVisibility,
83
+ setColumnVisibility,
84
+ toggleColumn,
85
+ showColumn,
86
+ hideColumn,
87
+ showAllColumns,
88
+ hideAllColumns,
89
+ resetVisibility,
90
+ isColumnVisible,
91
+ }
92
+ }
@@ -0,0 +1,53 @@
1
+ import { useState, useCallback } from 'react'
2
+ import { UseFiltersReturn, FilterState } from '../types'
3
+
4
+ interface UseFiltersProps {
5
+ initialFilters?: FilterState
6
+ onChange?: (filters: FilterState) => void
7
+ }
8
+
9
+ export function useFilters({
10
+ initialFilters = {},
11
+ onChange,
12
+ }: UseFiltersProps): UseFiltersReturn {
13
+ const [filters, setFilters] = useState<FilterState>(initialFilters)
14
+
15
+ const setFilter = useCallback((key: string, value: any) => {
16
+ setFilters((prev) => {
17
+ const next = { ...prev, [key]: value }
18
+ onChange?.(next)
19
+ return next
20
+ })
21
+ }, [onChange])
22
+
23
+ const clearFilter = useCallback((key: string) => {
24
+ setFilters((prev) => {
25
+ const next = { ...prev }
26
+ delete next[key]
27
+ onChange?.(next)
28
+ return next
29
+ })
30
+ }, [onChange])
31
+
32
+ const clearAllFilters = useCallback(() => {
33
+ setFilters({})
34
+ onChange?.({})
35
+ }, [onChange])
36
+
37
+ const hasActiveFilters = useCallback(() => {
38
+ return Object.keys(filters).length > 0
39
+ }, [filters])
40
+
41
+ const getActiveFilterCount = useCallback(() => {
42
+ return Object.keys(filters).length
43
+ }, [filters])
44
+
45
+ return {
46
+ filters,
47
+ setFilter,
48
+ clearFilter,
49
+ clearAllFilters,
50
+ hasActiveFilters,
51
+ getActiveFilterCount,
52
+ }
53
+ }
@@ -0,0 +1,120 @@
1
+ import { useState, useCallback, useMemo } from 'react'
2
+ import { UsePaginationReturn } from '../types'
3
+
4
+ interface UsePaginationProps {
5
+ totalCount: number
6
+ initialPageSize?: number
7
+ initialPage?: number
8
+ serverSide?: boolean
9
+ onPageChange?: (page: number) => void
10
+ }
11
+
12
+ export function usePagination({
13
+ totalCount,
14
+ initialPageSize = 25,
15
+ initialPage = 1,
16
+ serverSide = false,
17
+ onPageChange,
18
+ }: UsePaginationProps): UsePaginationReturn {
19
+ const [currentPage, setCurrentPage] = useState(initialPage)
20
+ const [pageSize, setPageSize] = useState(initialPageSize)
21
+
22
+ const totalPages = useMemo(() => {
23
+ return Math.max(1, Math.ceil(totalCount / pageSize))
24
+ }, [totalCount, pageSize])
25
+
26
+ const canGoNext = useMemo(() => {
27
+ return currentPage < totalPages
28
+ }, [currentPage, totalPages])
29
+
30
+ const canGoPrevious = useMemo(() => {
31
+ return currentPage > 1
32
+ }, [currentPage])
33
+
34
+ const goToPage = useCallback((page: number) => {
35
+ const validPage = Math.max(1, Math.min(page, totalPages))
36
+ setCurrentPage(validPage)
37
+ onPageChange?.(validPage)
38
+ }, [totalPages, onPageChange])
39
+
40
+ const goToFirstPage = useCallback(() => {
41
+ goToPage(1)
42
+ }, [goToPage])
43
+
44
+ const goToLastPage = useCallback(() => {
45
+ goToPage(totalPages)
46
+ }, [goToPage, totalPages])
47
+
48
+ const goToNextPage = useCallback(() => {
49
+ if (canGoNext) {
50
+ goToPage(currentPage + 1)
51
+ }
52
+ }, [canGoNext, currentPage, goToPage])
53
+
54
+ const goToPreviousPage = useCallback(() => {
55
+ if (canGoPrevious) {
56
+ goToPage(currentPage - 1)
57
+ }
58
+ }, [canGoPrevious, currentPage, goToPage])
59
+
60
+ const getPageNumbers = useCallback((): (number | '...')[] => {
61
+ const maxVisible = 7 // Maximum number of page buttons to show
62
+
63
+ if (totalPages <= maxVisible) {
64
+ // Show all pages if total is small
65
+ return Array.from({ length: totalPages }, (_, i) => i + 1)
66
+ }
67
+
68
+ const pages: (number | '...')[] = []
69
+
70
+ // Always show first page
71
+ pages.push(1)
72
+
73
+ if (currentPage > 3) {
74
+ pages.push('...')
75
+ }
76
+
77
+ // Show pages around current
78
+ const start = Math.max(2, currentPage - 1)
79
+ const end = Math.min(totalPages - 1, currentPage + 1)
80
+
81
+ for (let i = start; i <= end; i++) {
82
+ if (!pages.includes(i)) {
83
+ pages.push(i)
84
+ }
85
+ }
86
+
87
+ if (currentPage < totalPages - 2) {
88
+ pages.push('...')
89
+ }
90
+
91
+ // Always show last page
92
+ if (!pages.includes(totalPages)) {
93
+ pages.push(totalPages)
94
+ }
95
+
96
+ return pages
97
+ }, [currentPage, totalPages])
98
+
99
+ const getDisplayRange = useCallback(() => {
100
+ const start = (currentPage - 1) * pageSize + 1
101
+ const end = Math.min(currentPage * pageSize, totalCount)
102
+ return { start, end }
103
+ }, [currentPage, pageSize, totalCount])
104
+
105
+ return {
106
+ currentPage,
107
+ pageSize,
108
+ totalPages,
109
+ totalCount,
110
+ canGoNext,
111
+ canGoPrevious,
112
+ goToPage,
113
+ goToFirstPage,
114
+ goToLastPage,
115
+ goToNextPage,
116
+ goToPreviousPage,
117
+ getPageNumbers,
118
+ getDisplayRange,
119
+ }
120
+ }
@@ -0,0 +1,50 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+
5
+ export interface ResponsiveBreakpoints {
6
+ mobile: number
7
+ tablet: number
8
+ desktop: number
9
+ }
10
+
11
+ export const DEFAULT_BREAKPOINTS: ResponsiveBreakpoints = {
12
+ mobile: 768,
13
+ tablet: 1024,
14
+ desktop: 1280,
15
+ }
16
+
17
+ export type ViewMode = 'mobile' | 'tablet' | 'desktop'
18
+
19
+ export function useResponsive(breakpoints: ResponsiveBreakpoints = DEFAULT_BREAKPOINTS) {
20
+ const [viewMode, setViewMode] = useState<ViewMode>('desktop')
21
+ const [windowWidth, setWindowWidth] = useState<number>(0)
22
+
23
+ useEffect(() => {
24
+ const handleResize = () => {
25
+ const width = window.innerWidth
26
+ setWindowWidth(width)
27
+
28
+ if (width < breakpoints.mobile) {
29
+ setViewMode('mobile')
30
+ } else if (width < breakpoints.tablet) {
31
+ setViewMode('tablet')
32
+ } else {
33
+ setViewMode('desktop')
34
+ }
35
+ }
36
+
37
+ handleResize()
38
+ window.addEventListener('resize', handleResize)
39
+ return () => window.removeEventListener('resize', handleResize)
40
+ }, [breakpoints])
41
+
42
+ return {
43
+ viewMode,
44
+ windowWidth,
45
+ isMobile: viewMode === 'mobile',
46
+ isTablet: viewMode === 'tablet',
47
+ isDesktop: viewMode === 'desktop',
48
+ isMobileOrTablet: viewMode === 'mobile' || viewMode === 'tablet',
49
+ }
50
+ }
@@ -0,0 +1,152 @@
1
+ import { useState, useCallback, useEffect, useRef } from 'react'
2
+ import { UseSelectionReturn } from '../types'
3
+
4
+ interface UseSelectionProps {
5
+ currentPageData: any[]
6
+ totalCount: number
7
+ getRowId: (row: any) => string
8
+ onSelectionChange?: (ids: Set<string>) => void
9
+ onSelectAllPages?: (enabled: boolean) => void
10
+ // External controlled state
11
+ externalSelectedIds?: Set<string>
12
+ externalSelectAllPages?: boolean
13
+ }
14
+
15
+ export function useSelection({
16
+ currentPageData,
17
+ totalCount,
18
+ getRowId,
19
+ onSelectionChange,
20
+ onSelectAllPages,
21
+ externalSelectedIds,
22
+ externalSelectAllPages,
23
+ }: UseSelectionProps): UseSelectionReturn {
24
+ const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set())
25
+ const [internalSelectAllPages, setInternalSelectAllPages] = useState(false)
26
+
27
+ // Track if we're in controlled mode
28
+ const isControlled = externalSelectedIds !== undefined
29
+
30
+ // Use external state if provided, otherwise internal
31
+ const selectedIds = isControlled ? externalSelectedIds : internalSelectedIds
32
+ const selectAllPages = externalSelectAllPages ?? internalSelectAllPages
33
+
34
+ // Store callbacks in refs to avoid stale closures
35
+ const onSelectionChangeRef = useRef(onSelectionChange)
36
+ const onSelectAllPagesRef = useRef(onSelectAllPages)
37
+ useEffect(() => {
38
+ onSelectionChangeRef.current = onSelectionChange
39
+ onSelectAllPagesRef.current = onSelectAllPages
40
+ }, [onSelectionChange, onSelectAllPages])
41
+
42
+ // Sync external state to internal when in controlled mode
43
+ const prevExternalIdsRef = useRef<Set<string> | undefined>()
44
+ useEffect(() => {
45
+ if (externalSelectedIds !== undefined && externalSelectedIds !== prevExternalIdsRef.current) {
46
+ setInternalSelectedIds(new Set(externalSelectedIds))
47
+ prevExternalIdsRef.current = externalSelectedIds
48
+ }
49
+ }, [externalSelectedIds])
50
+
51
+ useEffect(() => {
52
+ if (externalSelectAllPages !== undefined) {
53
+ setInternalSelectAllPages(externalSelectAllPages)
54
+ }
55
+ }, [externalSelectAllPages])
56
+
57
+ const toggleRow = useCallback((id: string) => {
58
+ setInternalSelectedIds(prev => {
59
+ const next = new Set(prev)
60
+ if (next.has(id)) {
61
+ next.delete(id)
62
+ } else {
63
+ next.add(id)
64
+ }
65
+ // Notify parent after state update
66
+ onSelectionChangeRef.current?.(next)
67
+ return next
68
+ })
69
+
70
+ // If we're toggling individual rows, turn off select all pages
71
+ setInternalSelectAllPages(prev => {
72
+ if (prev) {
73
+ onSelectAllPagesRef.current?.(false)
74
+ return false
75
+ }
76
+ return prev
77
+ })
78
+ }, [])
79
+
80
+ const toggleAll = useCallback(() => {
81
+ const currentPageIds = currentPageData.map(getRowId)
82
+
83
+ setInternalSelectedIds(prev => {
84
+ const allSelected = currentPageIds.every(id => prev.has(id))
85
+
86
+ let next: Set<string>
87
+ if (allSelected) {
88
+ // Deselect all on current page
89
+ next = new Set(prev)
90
+ currentPageIds.forEach(id => next.delete(id))
91
+ } else {
92
+ // Select all on current page
93
+ next = new Set(prev)
94
+ currentPageIds.forEach(id => next.add(id))
95
+ }
96
+
97
+ // Notify parent
98
+ onSelectionChangeRef.current?.(next)
99
+ return next
100
+ })
101
+
102
+ // Turn off select all pages if toggling individual page
103
+ setInternalSelectAllPages(prev => {
104
+ if (prev) {
105
+ onSelectAllPagesRef.current?.(false)
106
+ return false
107
+ }
108
+ return prev
109
+ })
110
+ }, [currentPageData, getRowId])
111
+
112
+ const selectAllPagesToggle = useCallback(() => {
113
+ setInternalSelectAllPages(prev => {
114
+ const next = !prev
115
+ // Notify parent
116
+ onSelectAllPagesRef.current?.(next)
117
+
118
+ if (next) {
119
+ // When selecting all pages, clear individual selections
120
+ setInternalSelectedIds(new Set())
121
+ onSelectionChangeRef.current?.(new Set())
122
+ }
123
+ return next
124
+ })
125
+ }, [])
126
+
127
+ const clearSelection = useCallback(() => {
128
+ // Update internal state
129
+ setInternalSelectedIds(new Set())
130
+ setInternalSelectAllPages(false)
131
+ // Notify parent
132
+ onSelectionChangeRef.current?.(new Set())
133
+ onSelectAllPagesRef.current?.(false)
134
+ }, [])
135
+
136
+ const getSelectedCount = useCallback(() => {
137
+ if (selectAllPages) {
138
+ return totalCount
139
+ }
140
+ return selectedIds.size
141
+ }, [selectAllPages, selectedIds, totalCount])
142
+
143
+ return {
144
+ selectedIds,
145
+ selectAllPages,
146
+ toggleRow,
147
+ toggleAll,
148
+ selectAllPagesToggle,
149
+ clearSelection,
150
+ getSelectedCount,
151
+ }
152
+ }
@@ -0,0 +1,206 @@
1
+ import { useEffect, useCallback, useRef, useState, RefObject } from 'react'
2
+
3
+ export interface UseTableKeyboardProps<TData> {
4
+ data: TData[]
5
+ getRowId: (row: TData) => string
6
+ selectedIds?: Set<string>
7
+ onToggleRow?: (id: string) => void
8
+ onToggleAll?: () => void
9
+ onRowClick?: (row: TData) => void
10
+ onDelete?: (ids: Set<string>) => void
11
+ tableRef: RefObject<HTMLElement>
12
+ enabled?: boolean
13
+ }
14
+
15
+ export interface UseTableKeyboardReturn {
16
+ focusedRowIndex: number
17
+ setFocusedRowIndex: (index: number) => void
18
+ handleKeyDown: (event: React.KeyboardEvent) => void
19
+ }
20
+
21
+ /**
22
+ * Hook for handling keyboard navigation in UnifiedTable
23
+ *
24
+ * Features:
25
+ * - Arrow Up/Down: Navigate between rows
26
+ * - Enter: Expand/click row (trigger onRowClick)
27
+ * - Space: Toggle row selection
28
+ * - Tab: Move between interactive elements (native browser behavior)
29
+ * - Escape: Close menus/dialogs (handled by menu components)
30
+ * - Ctrl/Cmd+A: Select all rows
31
+ * - Delete: Trigger delete action if available
32
+ */
33
+ export function useTableKeyboard<TData>({
34
+ data,
35
+ getRowId,
36
+ selectedIds,
37
+ onToggleRow,
38
+ onToggleAll,
39
+ onRowClick,
40
+ onDelete,
41
+ tableRef,
42
+ enabled = true,
43
+ }: UseTableKeyboardProps<TData>): UseTableKeyboardReturn {
44
+ // Use state for reactive updates so consumers get re-renders
45
+ const [focusedRowIndex, setFocusedRowIndexState] = useState<number>(-1)
46
+ // Keep a ref in sync for use in callbacks to avoid stale closures
47
+ const focusedRowIndexRef = useRef<number>(-1)
48
+
49
+ const setFocusedRowIndex = useCallback((index: number) => {
50
+ focusedRowIndexRef.current = index
51
+ setFocusedRowIndexState(index)
52
+ }, [])
53
+
54
+ const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
55
+ if (!enabled || data.length === 0) return
56
+
57
+ const { key, ctrlKey, metaKey, shiftKey } = event
58
+ const isModifierPressed = ctrlKey || metaKey
59
+
60
+ // Ctrl/Cmd + A: Select all rows
61
+ if (key === 'a' && isModifierPressed && onToggleAll) {
62
+ event.preventDefault()
63
+ onToggleAll()
64
+ return
65
+ }
66
+
67
+ // Delete: Trigger delete action if available
68
+ if (key === 'Delete' && onDelete && selectedIds && selectedIds.size > 0) {
69
+ event.preventDefault()
70
+ onDelete(selectedIds)
71
+ return
72
+ }
73
+
74
+ // Arrow navigation
75
+ if (key === 'ArrowDown') {
76
+ event.preventDefault()
77
+ const nextIndex = Math.min(focusedRowIndexRef.current + 1, data.length - 1)
78
+ setFocusedRowIndex(nextIndex)
79
+
80
+ // Focus the row element
81
+ if (tableRef.current) {
82
+ const rowElement = tableRef.current.querySelector(
83
+ `[data-row-index="${nextIndex}"]`
84
+ ) as HTMLElement
85
+ if (rowElement && typeof rowElement.focus === 'function') {
86
+ rowElement.focus()
87
+ }
88
+ }
89
+ return
90
+ }
91
+
92
+ if (key === 'ArrowUp') {
93
+ event.preventDefault()
94
+ const prevIndex = Math.max(focusedRowIndexRef.current - 1, 0)
95
+ setFocusedRowIndex(prevIndex)
96
+
97
+ // Focus the row element
98
+ if (tableRef.current) {
99
+ const rowElement = tableRef.current.querySelector(
100
+ `[data-row-index="${prevIndex}"]`
101
+ ) as HTMLElement
102
+ if (rowElement) {
103
+ rowElement.focus()
104
+ }
105
+ }
106
+ return
107
+ }
108
+
109
+ // Enter: Trigger row click
110
+ if (key === 'Enter' && focusedRowIndexRef.current >= 0 && onRowClick) {
111
+ event.preventDefault()
112
+ const row = data[focusedRowIndexRef.current]
113
+ if (row) {
114
+ onRowClick(row)
115
+ }
116
+ return
117
+ }
118
+
119
+ // Space: Toggle row selection
120
+ if (key === ' ' && focusedRowIndexRef.current >= 0 && onToggleRow) {
121
+ event.preventDefault()
122
+ const row = data[focusedRowIndexRef.current]
123
+ if (row) {
124
+ const rowId = getRowId(row)
125
+ onToggleRow(rowId)
126
+ }
127
+ return
128
+ }
129
+
130
+ // Home: Focus first row
131
+ if (key === 'Home') {
132
+ event.preventDefault()
133
+ setFocusedRowIndex(0)
134
+
135
+ if (tableRef.current) {
136
+ const rowElement = tableRef.current.querySelector(
137
+ '[data-row-index="0"]'
138
+ ) as HTMLElement
139
+ if (rowElement) {
140
+ rowElement.focus()
141
+ }
142
+ }
143
+ return
144
+ }
145
+
146
+ // End: Focus last row
147
+ if (key === 'End') {
148
+ event.preventDefault()
149
+ const lastIndex = data.length - 1
150
+ setFocusedRowIndex(lastIndex)
151
+
152
+ if (tableRef.current) {
153
+ const rowElement = tableRef.current.querySelector(
154
+ `[data-row-index="${lastIndex}"]`
155
+ ) as HTMLElement
156
+ if (rowElement) {
157
+ rowElement.focus()
158
+ }
159
+ }
160
+ return
161
+ }
162
+ }, [
163
+ enabled,
164
+ data,
165
+ getRowId,
166
+ selectedIds,
167
+ onToggleRow,
168
+ onToggleAll,
169
+ onRowClick,
170
+ onDelete,
171
+ tableRef,
172
+ setFocusedRowIndex,
173
+ ])
174
+
175
+ // Set up keyboard event listener on the table
176
+ useEffect(() => {
177
+ if (!enabled || !tableRef.current) return
178
+
179
+ const handleKeyboardEvent = (event: KeyboardEvent) => {
180
+ // Convert native event to React synthetic event shape
181
+ const syntheticEvent = {
182
+ key: event.key,
183
+ ctrlKey: event.ctrlKey,
184
+ metaKey: event.metaKey,
185
+ shiftKey: event.shiftKey,
186
+ preventDefault: () => event.preventDefault(),
187
+ stopPropagation: () => event.stopPropagation(),
188
+ } as React.KeyboardEvent
189
+
190
+ handleKeyDown(syntheticEvent)
191
+ }
192
+
193
+ const tableElement = tableRef.current
194
+ tableElement.addEventListener('keydown', handleKeyboardEvent as any)
195
+
196
+ return () => {
197
+ tableElement.removeEventListener('keydown', handleKeyboardEvent as any)
198
+ }
199
+ }, [enabled, tableRef, handleKeyDown])
200
+
201
+ return {
202
+ focusedRowIndex,
203
+ setFocusedRowIndex,
204
+ handleKeyDown,
205
+ }
206
+ }