@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.
- package/README.md +537 -0
- package/package.json +80 -0
- package/src/components/index.ts +50 -0
- package/src/components/navigation/sidebar.tsx +178 -0
- package/src/components/ui/accordion.tsx +58 -0
- package/src/components/ui/alert.tsx +59 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/button.tsx +57 -0
- package/src/components/ui/calendar.tsx +70 -0
- package/src/components/ui/card.tsx +68 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/collapsible.tsx +12 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/dropdown-menu.tsx +200 -0
- package/src/components/ui/index.ts +24 -0
- package/src/components/ui/input.tsx +25 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/popover.tsx +31 -0
- package/src/components/ui/progress.tsx +28 -0
- package/src/components/ui/scroll-area.tsx +48 -0
- package/src/components/ui/select.tsx +160 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/table.tsx +117 -0
- package/src/components/ui/tabs.tsx +55 -0
- package/src/components/ui/textarea.tsx +24 -0
- package/src/components/ui/tooltip.tsx +30 -0
- package/src/components/unified-table/UnifiedTable.tsx +553 -0
- package/src/components/unified-table/__tests__/components/BulkActionBar.test.tsx +477 -0
- package/src/components/unified-table/__tests__/components/ExportButton.test.tsx +467 -0
- package/src/components/unified-table/__tests__/components/InlineEditCell.test.tsx +159 -0
- package/src/components/unified-table/__tests__/components/SavedViewsDropdown.test.tsx +128 -0
- package/src/components/unified-table/__tests__/components/TablePagination.test.tsx +374 -0
- package/src/components/unified-table/__tests__/hooks/useColumnReorder.test.ts +191 -0
- package/src/components/unified-table/__tests__/hooks/useColumnResize.test.ts +122 -0
- package/src/components/unified-table/__tests__/hooks/useColumnVisibility.test.ts +594 -0
- package/src/components/unified-table/__tests__/hooks/useFilters.test.ts +460 -0
- package/src/components/unified-table/__tests__/hooks/usePagination.test.ts +439 -0
- package/src/components/unified-table/__tests__/hooks/useResponsive.test.ts +421 -0
- package/src/components/unified-table/__tests__/hooks/useSelection.test.ts +367 -0
- package/src/components/unified-table/__tests__/hooks/useTableKeyboard.test.ts +803 -0
- package/src/components/unified-table/__tests__/hooks/useTableState.test.ts +210 -0
- package/src/components/unified-table/__tests__/integration/table-with-selection.test.tsx +624 -0
- package/src/components/unified-table/__tests__/utils/export.test.ts +427 -0
- package/src/components/unified-table/components/BulkActionBar/index.tsx +119 -0
- package/src/components/unified-table/components/DataTableCore/index.tsx +473 -0
- package/src/components/unified-table/components/InlineEditCell/index.tsx +159 -0
- package/src/components/unified-table/components/MobileView/Card.tsx +218 -0
- package/src/components/unified-table/components/MobileView/CardActions.tsx +126 -0
- package/src/components/unified-table/components/MobileView/README.md +411 -0
- package/src/components/unified-table/components/MobileView/index.tsx +77 -0
- package/src/components/unified-table/components/MobileView/types.ts +77 -0
- package/src/components/unified-table/components/TableFilters/index.tsx +298 -0
- package/src/components/unified-table/components/TablePagination/index.tsx +157 -0
- package/src/components/unified-table/components/Toolbar/ExportButton.tsx +229 -0
- package/src/components/unified-table/components/Toolbar/SavedViewsDropdown.tsx +251 -0
- package/src/components/unified-table/components/Toolbar/StandardTableToolbar.tsx +146 -0
- package/src/components/unified-table/components/Toolbar/index.tsx +3 -0
- package/src/components/unified-table/hooks/index.ts +21 -0
- package/src/components/unified-table/hooks/useColumnReorder.ts +90 -0
- package/src/components/unified-table/hooks/useColumnResize.ts +123 -0
- package/src/components/unified-table/hooks/useColumnVisibility.ts +92 -0
- package/src/components/unified-table/hooks/useFilters.ts +53 -0
- package/src/components/unified-table/hooks/usePagination.ts +120 -0
- package/src/components/unified-table/hooks/useResponsive.ts +50 -0
- package/src/components/unified-table/hooks/useSelection.ts +152 -0
- package/src/components/unified-table/hooks/useTableKeyboard.ts +206 -0
- package/src/components/unified-table/hooks/useTablePreferences.ts +198 -0
- package/src/components/unified-table/hooks/useTableState.ts +103 -0
- package/src/components/unified-table/hooks/useTableURL.test.tsx +921 -0
- package/src/components/unified-table/hooks/useTableURL.ts +301 -0
- package/src/components/unified-table/index.ts +16 -0
- package/src/components/unified-table/types.ts +393 -0
- package/src/components/unified-table/utils/export.ts +236 -0
- package/src/components/unified-table/utils/index.ts +4 -0
- package/src/components/unified-table/utils/renderers.ts +105 -0
- package/src/components/unified-table/utils/themes.ts +87 -0
- package/src/components/unified-table/utils/validation.ts +122 -0
- package/src/index.ts +6 -0
- package/src/lib/utils.ts +1 -0
- package/src/theme/contract.ts +46 -0
- package/src/theme/index.ts +9 -0
- package/src/theme/tailwind.config.js +70 -0
- package/src/theme/tailwind.preset.ts +93 -0
- package/src/utils/cn.ts +6 -0
- 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
|
+
}
|