Skip to content

Column Menu & Sort Features #147

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
0b4a40c
Format ColumnHeader
rembrandtreyes Apr 23, 2025
3704a3b
Add ColumnMenu to ColumnHeader
rembrandtreyes Apr 23, 2025
e8b4457
Format TableHeader
rembrandtreyes Apr 23, 2025
8b3aab6
Add ColumnMenuButton functionality
rembrandtreyes Apr 23, 2025
34f6fcc
Scaffold hidecolumn functionality
rembrandtreyes Apr 23, 2025
03fd7ba
Implement hiding/showing functionality
rembrandtreyes Apr 23, 2025
9db06ec
Skip row error check when columns are hidden
rembrandtreyes Apr 23, 2025
5e67cb9
Fix column header styles when column menu button is shown/hidden
rembrandtreyes Apr 23, 2025
19baffc
Fixup styles and colors to match current theme
rembrandtreyes Apr 23, 2025
a414121
Add tests for new components
rembrandtreyes Apr 24, 2025
ecc6f82
Add sort functionality to the menu
rembrandtreyes May 4, 2025
9ee1ce6
Add menu to columnheader test
rembrandtreyes May 4, 2025
b11d4ca
Remove sort -> Clear sort
rembrandtreyes May 4, 2025
2c1e4c8
Prettier
rembrandtreyes May 4, 2025
447cf3f
fix type error and lint error
rembrandtreyes May 4, 2025
018db93
Style changes
rembrandtreyes May 4, 2025
f26ee02
newlines
rembrandtreyes May 4, 2025
a8ad0a0
fix rebase issue
rembrandtreyes May 4, 2025
48282d5
Update src/components/ColumnMenu/ColumnMenu.tsx
rembrandtreyes May 6, 2025
1803795
Update src/components/ColumnMenuButton/ColumnMenuButton.tsx
rembrandtreyes May 6, 2025
b0e0f42
PR comments
rembrandtreyes May 6, 2025
271c8c3
npm run lint:fix
rembrandtreyes May 6, 2025
42a6b15
Merge branch 'master' into rembrandtreyes/ColumnMenu
rembrandtreyes May 6, 2025
02be8f7
Remove classnames and make CSS generic
rembrandtreyes May 8, 2025
69cc9ca
fix: improve column hiding logic in HighTable
rembrandtreyes May 8, 2025
1272bac
Fix tests
rembrandtreyes May 8, 2025
fdf146c
Fix lint
rembrandtreyes May 8, 2025
b7678b4
Fix typecheck
rembrandtreyes May 8, 2025
274ac7b
Handle last column hiding
rembrandtreyes May 8, 2025
7913561
Remove comment
rembrandtreyes May 8, 2025
64e6588
Update ColumnMenu.tsx
rembrandtreyes May 8, 2025
c9d8c68
Update ColumnHeader.tsx
rembrandtreyes May 8, 2025
5468e25
Update ColumnHeader and ColumnMenu components to improve column handl…
rembrandtreyes May 9, 2025
768965c
Update src/components/HighTable/HighTable.module.css
rembrandtreyes May 9, 2025
fe13254
Update src/components/HighTable/HighTable.tsx
rembrandtreyes May 9, 2025
21f17a7
Update src/components/HighTable/HighTable.module.css
rembrandtreyes May 9, 2025
93e09f3
Refactor ColumnHeader and TableHeader components to correctly manage …
rembrandtreyes May 9, 2025
9a12696
Refactor column index handling in HighTable, ColumnHeader, and TableH…
rembrandtreyes May 11, 2025
860c6e6
Refactor ColumnHeader, HighTable, and TableHeader components to deriv…
rembrandtreyes May 11, 2025
c31f92f
Update rowError function in HighTable.helpers.ts to accept headerLeng…
rembrandtreyes May 11, 2025
7ba75e3
Refactor ColumnHeader and ColumnMenu components to unify sorting logi…
rembrandtreyes May 12, 2025
e2edc76
Enhance ColumnMenu and ColumnHeader components by introducing isHideD…
rembrandtreyes May 15, 2025
c2f785c
Refactor ColumnMenu test to correctly utilize onHideColumn prop and u…
rembrandtreyes May 15, 2025
3942dd1
Refactor ColumnHeader, ColumnMenu, and TableHeader components to unif…
rembrandtreyes May 15, 2025
8ab6289
Update ColumnMenu tests to utilize unified onSort prop for sorting ac…
rembrandtreyes May 15, 2025
000b7ff
Merge remote-tracking branch 'origin/master' into rembrandtreyes/Colu…
rembrandtreyes May 16, 2025
c376432
Update HighTable styles and enhance component functionality by adjust…
rembrandtreyes May 16, 2025
04f39a8
Refactor ColumnHeader and ColumnMenuButton components to adjust the t…
rembrandtreyes May 16, 2025
60fef22
Refactor TableHeader and ColumnHeader components to improve code clar…
rembrandtreyes May 16, 2025
48c8284
Remove fixed positioning and zIndex from ColumnMenu styles for improv…
rembrandtreyes May 16, 2025
cb463e6
Remove relative style from column header
rembrandtreyes May 16, 2025
de177f3
Remove duplicate props and revise column naming in column menu
rembrandtreyes May 16, 2025
3378fff
Fix test
rembrandtreyes May 16, 2025
4656f03
Optimize ColumnHeader component by wrapping it with memo to prevent u…
rembrandtreyes May 16, 2025
2da7876
Add missing types
rembrandtreyes May 16, 2025
43f2fef
Refactor event listener management in ColumnHeader to improve clarity…
rembrandtreyes May 16, 2025
0a38e2f
Enhance prop comparison in ColumnHeader memoization by including chil…
rembrandtreyes May 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion src/components/ColumnHeader/ColumnHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('ColumnHeader', () => {
const content = 'test'
const { getByRole } = render(<table><thead><tr><ColumnHeader columnName="test" {...defaultProps}>{content}</ColumnHeader></tr></thead></table>)
const element = getByRole('columnheader')
expect(element.textContent).toEqual(content)
expect(element.textContent).toEqual(content + '⋮')
expect(measureWidth).not.toHaveBeenCalled()
})

Expand Down Expand Up @@ -116,6 +116,32 @@ describe('ColumnHeader', () => {
expect(header.style.maxWidth).toEqual(`${savedWidth + delta}px`)
})

it('stops event propagation when menu button is clicked', async () => {
const onClick = vi.fn()
const { user, getByRole } = render(
<ColumnWidthProvider localStorageKey={cacheKey}>
<table>
<thead>
<tr>
<ColumnHeader
columnName="test"
columnIndex={0}
onClick={onClick}
ariaColIndex={1}
ariaRowIndex={1}
/>
</tr>
</thead>
</table>
</ColumnWidthProvider>
)

const button = getByRole('button')
await user.click(button)

expect(onClick).not.toHaveBeenCalled()
})

it('reloads column width when localStorageKey changes', () => {
const cacheKey2 = 'key-2'
const width1 = 150
Expand Down
223 changes: 181 additions & 42 deletions src/components/ColumnHeader/ColumnHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import { KeyboardEvent, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'
import {
KeyboardEvent,
MouseEvent,
ReactNode,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { flushSync } from 'react-dom'
import { Direction } from '../../helpers/sort.js'
import { measureWidth } from '../../helpers/width.js'
import { useCellNavigation } from '../../hooks/useCellsNavigation.js'
import useColumnWidth from '../../hooks/useColumnWidth.js'
import ColumnResizer from '../ColumnResizer/ColumnResizer.js'
import ColumnMenu from '../ColumnMenu/ColumnMenu.js'
import ColumnMenuButton from '../ColumnMenuButton/ColumnMenuButton.js'

interface Props {
columnIndex: number // index of the column in the dataframe (0-based)
Expand All @@ -13,6 +25,9 @@ interface Props {
dataReady?: boolean
direction?: Direction
onClick?: () => void
onHideColumn?: () => void
isHideDisabled?: boolean
onShowAllColumns?: () => void
sortable?: boolean
orderByIndex?: number // index of the column in the orderBy array (0-based)
orderBySize?: number // size of the orderBy array
Expand All @@ -21,9 +36,34 @@ interface Props {
className?: string // optional class name
}

export default function ColumnHeader({ columnIndex, columnName, dataReady, direction, onClick, sortable, orderByIndex, orderBySize, ariaColIndex, ariaRowIndex, className, children }: Props) {
function ColumnHeader({
orderByIndex,
orderBySize,
columnIndex,
columnName,
dataReady,
direction,
onClick,
onHideColumn,
isHideDisabled,
onShowAllColumns,
sortable,
className,
children,
ariaColIndex,
ariaRowIndex,
}: Props) {
const ref = useRef<HTMLTableCellElement>(null)
const { tabIndex, navigateToCell } = useCellNavigation({ ref, ariaColIndex, ariaRowIndex })
const [showMenu, setShowMenu] = useState(false)
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 })

// Derive sortable from onClick
const isSortable = sortable !== false && (sortable ?? onClick !== undefined)
const { tabIndex, navigateToCell } = useCellNavigation({
ref,
ariaColIndex,
ariaRowIndex,
})
const handleClick = useCallback(() => {
navigateToCell()
onClick?.()
Expand All @@ -33,9 +73,12 @@ export default function ColumnHeader({ columnIndex, columnName, dataReady, direc
const { getColumnStyle, setColumnWidth, getColumnWidth } = useColumnWidth()
const columnStyle = getColumnStyle?.(columnIndex)
const width = getColumnWidth?.(columnIndex)
const setWidth = useCallback((nextWidth: number | undefined) => {
setColumnWidth?.({ columnIndex, width: nextWidth })
}, [setColumnWidth, columnIndex])
const setWidth = useCallback(
(nextWidth: number | undefined) => {
setColumnWidth?.({ columnIndex, width: nextWidth })
},
[setColumnWidth, columnIndex]
)

// Measure default column width when data is ready, if no width is set
useEffect(() => {
Expand Down Expand Up @@ -65,7 +108,7 @@ export default function ColumnHeader({ columnIndex, columnName, dataReady, direc
}, [setWidth])

const description = useMemo(() => {
if (!sortable) {
if (!isSortable) {
return `The column ${columnName} cannot be sorted`
} else if (orderByIndex !== undefined && orderByIndex > 0) {
return `Press to sort by ${columnName} in ascending order`
Expand All @@ -76,46 +119,142 @@ export default function ColumnHeader({ columnIndex, columnName, dataReady, direc
} else {
return `Press to sort by ${columnName} in ascending order`
}
}, [sortable, columnName, direction, orderByIndex])
}, [isSortable, columnName, direction, orderByIndex])

const handleContextMenu = useCallback((e: MouseEvent) => {
e.preventDefault()
setMenuPosition({
x: e.clientX,
y: e.clientY,
})
setShowMenu(true)
}, [])

const onKeyDown = useCallback((e: KeyboardEvent) => {
if (e.target !== ref.current) {
// only handle keyboard events when the header is focused
return
const closeMenu = useCallback(() => {
setShowMenu(false)
}, [])

// Close menu when clicking outside
useEffect(() => {
function handleClickOutside() {
setShowMenu(false)
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
onClick?.()

if (showMenu) {
document.addEventListener('click', handleClickOutside)
}

return () => {
document.removeEventListener('click', handleClickOutside)
}
}, [showMenu])

// Handle menu button click
const handleMenuButtonClick = useCallback((e: MouseEvent) => {
e.stopPropagation()
const rect = ref.current?.getBoundingClientRect()
if (rect) {
setMenuPosition({
x: e.clientX,
y: rect.bottom,
})
setShowMenu(true)
}
}, [onClick])
}, [])

const handleHideThisColumn = useCallback(() => {
onHideColumn?.()
}, [onHideColumn])

function renderColumnMenu() {
return (
<ColumnMenu
columnName={columnName}
onHideColumn={handleHideThisColumn}
isHideDisabled={isHideDisabled}
onShowAllColumns={onShowAllColumns}
sortable={isSortable}
direction={direction}
onSort={onClick}
isVisible={showMenu}
position={menuPosition}
onClose={closeMenu}
/>
)
}

const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.target !== ref.current) {
// only handle keyboard events when the header is focused
return
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
onClick?.()
}
},
[onClick]
)

return (
<th
ref={ref}
scope="col"
role="columnheader"
aria-sort={direction ?? (sortable ? 'none' : undefined)}
data-order-by-index={orderBySize !== undefined ? orderByIndex : undefined}
data-order-by-size={orderBySize}
aria-label={columnName}
aria-description={description}
aria-colindex={ariaColIndex}
tabIndex={tabIndex}
title={description}
onClick={handleClick}
onKeyDown={onKeyDown}
style={columnStyle}
className={className}
>
{children}
<ColumnResizer
setWidth={setWidth}
onDoubleClick={autoResize}
width={width}
<>
<th
ref={ref}
scope='col'
role='columnheader'
aria-sort={direction ?? (isSortable ? 'none' : undefined)}
data-order-by-index={
orderBySize !== undefined ? orderByIndex : undefined
}
data-order-by-size={orderBySize}
aria-description={description}
aria-colindex={ariaColIndex}
tabIndex={tabIndex}
navigateToCell={navigateToCell}
/>
</th>
title={description}
onClick={handleClick}
onContextMenu={handleContextMenu}
style={columnStyle}
className={className}
aria-label={columnName}
onKeyDown={onKeyDown}
>
<span>{children}</span>
<ColumnResizer
setWidth={setWidth}
onDoubleClick={autoResize}
width={width}
tabIndex={tabIndex}
navigateToCell={navigateToCell}
/>
<ColumnMenuButton onClick={handleMenuButtonClick} />
</th>
{/* ColumnMenu is rendered via portal to document.body */}
{renderColumnMenu()}
</>
)
}

// Export with memo to prevent unnecessary re-renders
export default memo(ColumnHeader, (prevProps, nextProps) => {
// Return true if the component should NOT re-render (props are equal)
return (
prevProps.columnIndex === nextProps.columnIndex &&
prevProps.columnName === nextProps.columnName &&
prevProps.dataReady === nextProps.dataReady &&
prevProps.direction === nextProps.direction &&
prevProps.orderByIndex === nextProps.orderByIndex &&
prevProps.orderBySize === nextProps.orderBySize &&
prevProps.sortable === nextProps.sortable &&
prevProps.isHideDisabled === nextProps.isHideDisabled &&
prevProps.className === nextProps.className &&
prevProps.ariaColIndex === nextProps.ariaColIndex &&
prevProps.ariaRowIndex === nextProps.ariaRowIndex &&
prevProps.children === nextProps.children &&
// Compare function references - if they're the same instance we don't need to re-render
prevProps.onClick === nextProps.onClick &&
prevProps.onHideColumn === nextProps.onHideColumn &&
prevProps.onShowAllColumns === nextProps.onShowAllColumns
)
})
Loading