Skip to content

Create column menu #174

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

Open
wants to merge 44 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
be48281
Add column menu functionality to table headers
rembrandtreyes May 18, 2025
cfe0102
Enhance ColumnMenu with sorting functionality and refactor rendering …
rembrandtreyes May 18, 2025
51c5a41
Refactor ColumnMenu and integrate PortalContainer for improved rendering
rembrandtreyes May 18, 2025
2e1dcd7
Enhance HighTable CSS for improved sorting visuals
rembrandtreyes May 18, 2025
ecfb269
Refactor ColumnHeader and HighTable styles for improved usability
rembrandtreyes May 18, 2025
b17d387
Enhance ColumnMenu and ColumnMenuButton for improved accessibility an…
rembrandtreyes May 18, 2025
2231d6c
Improve ColumnMenuButton interaction and positioning
rembrandtreyes May 18, 2025
a907f22
Enhance ColumnHeader and ColumnMenu integration for improved function…
rembrandtreyes May 18, 2025
cfcdbef
Enhance ColumnMenu and ColumnMenuButton for improved focus management
rembrandtreyes May 18, 2025
d73acee
Refactor ColumnHeader and ColumnMenu for improved event handling and …
rembrandtreyes May 18, 2025
9059a97
Add tests for ColumnMenu and ColumnMenuButton components
rembrandtreyes May 18, 2025
8da548a
Update sortable prop handling in ColumnHeader for consistency
rembrandtreyes May 18, 2025
f397c61
Merge remote-tracking branch 'upstream/master' into rembrandtreyes/Co…
rembrandtreyes May 18, 2025
45d40fd
cleanup styles
rembrandtreyes May 18, 2025
9f77d7b
Add default props for ColumnHeader tests
rembrandtreyes May 19, 2025
56f76ac
Apply suggestions from code review
rembrandtreyes May 21, 2025
7c3c9be
Refactor HighTable to utilize usePortalContainer hook
rembrandtreyes May 21, 2025
6054ef0
Refactor ColumnHeader and ColumnMenu for improved state management an…
rembrandtreyes May 29, 2025
66da8cd
Enhance ColumnMenu functionality and styles
rembrandtreyes May 29, 2025
f27d220
Implement MenuItem and Overlay components in ColumnMenu
rembrandtreyes May 29, 2025
ff7f80b
Enhance ColumnMenu focus management and keyboard navigation
rembrandtreyes May 29, 2025
43b7d39
Enhance ColumnHeader toggle functionality
rembrandtreyes May 29, 2025
8906fd0
Enhance ColumnMenuButton accessibility and ColumnHeader functionality
rembrandtreyes May 29, 2025
eb3332b
Merge remote-tracking branch 'upstream/master' into rembrandtreyes/Co…
rembrandtreyes May 29, 2025
c11e9cc
Remove unused `buttonRef` prop from ColumnHeader and ColumnMenu compo…
rembrandtreyes May 29, 2025
b1c9dd9
Refactor ColumnHeader and ColumnMenu for improved functionality and a…
rembrandtreyes May 30, 2025
82498bd
Update HighTable styles for improved focus visibility
rembrandtreyes May 30, 2025
e01a906
Enhance accessibility and styles in ColumnHeader and HighTable
rembrandtreyes May 30, 2025
cec8566
Refactor ColumnMenu and TableHeader tests for improved clarity and fu…
rembrandtreyes May 30, 2025
3d5dc25
Update HighTable styles for sort arrow positioning
rembrandtreyes May 30, 2025
795da76
Refactor imports in HighTable and TableHeader components for cleaner …
rembrandtreyes May 30, 2025
b11258c
Refactor ColumnMenu to utilize useScrollLock hook for scroll management
rembrandtreyes May 30, 2025
62887fc
Implement focus management in ColumnMenu using useFocusManagement hook
rembrandtreyes May 30, 2025
3191a6e
Enhance ColumnHeader and ColumnMenuButton for improved accessibility …
rembrandtreyes May 30, 2025
5ec8d2f
Refactor ColumnHeader to utilize useColumnMenu hook for improved func…
rembrandtreyes May 30, 2025
1a65e7b
Update tests for ColumnHeader, ColumnMenuButton, and TableHeader for …
rembrandtreyes May 30, 2025
e2ad3ab
Refactor TableHeader tests to enhance column menu integration and acc…
rembrandtreyes May 30, 2025
701dc1f
Add onEscape handler to ColumnMenuButton for improved keyboard naviga…
rembrandtreyes May 30, 2025
fffbd0e
Enhance ColumnMenu tests for improved coverage and functionality
rembrandtreyes May 30, 2025
e6269a8
Refactor ColumnMenu tests for consistency and readability
rembrandtreyes May 30, 2025
26711c8
Refactor ColumnMenuButton to use button element for improved accessib…
rembrandtreyes Jun 12, 2025
57d6bc8
Merge remote-tracking branch 'upstream/master' into rembrandtreyes/Co…
rembrandtreyes Jun 12, 2025
59c1ee8
Update ColumnHeader and ColumnMenuButton to use button elements for i…
rembrandtreyes Jun 13, 2025
ea8981a
Update ColumnHeader and ColumnMenuButton tests for improved accuracy
rembrandtreyes Jun 13, 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
32 changes: 30 additions & 2 deletions src/components/ColumnHeader/ColumnHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { KeyboardEvent, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'
import { KeyboardEvent, MouseEvent, ReactNode, 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 ColumnMenu from '../ColumnMenu/ColumnMenu.js'
import ColumnMenuButton from '../ColumnMenuButton/ColumnMenuButton.js'
import ColumnResizer from '../ColumnResizer/ColumnResizer.js'

interface Props {
Expand All @@ -19,9 +21,12 @@ interface Props {
ariaColIndex: number // aria col index for the header
ariaRowIndex: number // aria row index for the header
className?: string // optional class name
isColumnMenuOpen: boolean
onToggleColumnMenu: (columnIndex: number) => void
}

export default function ColumnHeader({ columnIndex, columnName, dataReady, direction, onClick, sortable, orderByIndex, orderBySize, ariaColIndex, ariaRowIndex, className, children }: Props) {
export default function ColumnHeader({ columnIndex, columnName, dataReady, direction, onClick, sortable, orderByIndex, orderBySize, ariaColIndex, ariaRowIndex, className, children, isColumnMenuOpen, onToggleColumnMenu }: Props) {
const [position, setPosition] = useState({ left: 0, top: 0 })
const ref = useRef<HTMLTableCellElement>(null)
const { tabIndex, navigateToCell } = useCellNavigation({ ref, ariaColIndex, ariaRowIndex })
const handleClick = useCallback(() => {
Expand Down Expand Up @@ -90,6 +95,20 @@ export default function ColumnHeader({ columnIndex, columnName, dataReady, direc
}
}, [onClick])

const handleColumnMenuClick = useCallback((e: MouseEvent) => {
e.stopPropagation()
const rect = ref.current?.getBoundingClientRect()
const buttonRect = (e.currentTarget as HTMLElement).getBoundingClientRect()

if (rect) {
setPosition({
left: buttonRect.left,
top: rect.bottom,
})
}
onToggleColumnMenu(columnIndex)
}, [columnIndex, onToggleColumnMenu])

return (
<th
ref={ref}
Expand All @@ -109,13 +128,22 @@ export default function ColumnHeader({ columnIndex, columnName, dataReady, direc
className={className}
>
{children}
{sortable && <ColumnMenuButton onClick={handleColumnMenuClick} />}
<ColumnResizer
setWidth={setWidth}
onDoubleClick={autoResize}
width={width}
tabIndex={tabIndex}
navigateToCell={navigateToCell}
/>
<ColumnMenu
columnName={columnName}
isVisible={isColumnMenuOpen}
position={position}
direction={direction}
sortable={sortable ?? false}
onClick={onClick}
/>
</th>
)
}
101 changes: 101 additions & 0 deletions src/components/ColumnMenu/ColumnMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { createPortal } from 'react-dom'
import { Direction } from '../../helpers/sort'
import { usePortalContainer } from '../../hooks/usePortalContainer'
import { KeyboardEvent, useCallback, useEffect, useRef } from 'react'

interface ColumnMenuProps {
columnName: string
isVisible: boolean
position: {
left: number
top: number
}
direction?: Direction
sortable?: boolean
onClick?: () => void
}

export default function ColumnMenu({
columnName,
isVisible,
position,
direction,
sortable,
onClick,
}: ColumnMenuProps) {
const { containerRef } = usePortalContainer()
const { top, left } = position
const menuRef = useRef<HTMLDivElement>(null)

const getSortDirection = useCallback(() => {
if (!sortable) return null

switch (direction) {
case 'ascending':
return 'Ascending'
case 'descending':
return 'Descending'
default:
return 'Sort'
}
}, [direction, sortable])

// Focus the menu when it becomes visible
useEffect(() => {
if (isVisible && menuRef.current) {
menuRef.current.focus()
}
}, [isVisible])

const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
const columnButton =
document.activeElement?.parentElement?.querySelector(
'div[role="button"]'
)
if (columnButton instanceof HTMLElement) {
columnButton.focus()
}
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
onClick?.()
}
},
[onClick]
)

if (!isVisible) {
return null
}

return createPortal(
<div
role='menu'
style={{ top, left }}
ref={menuRef}
tabIndex={-1}
aria-label={`${columnName} column menu`}
onKeyDown={handleKeyDown}
>
<div role='presentation'>{columnName}</div>
<hr role='separator' />
{sortable &&
<>
<button
role='menuitem'
onClick={onClick}
tabIndex={0}
aria-label={`${getSortDirection()} ${columnName}`}
>
{getSortDirection()}
</button>
</>
}
</div>,
containerRef.current ?? document.body
)
}
46 changes: 46 additions & 0 deletions src/components/ColumnMenuButton/ColumnMenuButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { KeyboardEvent, MouseEvent, useCallback, useRef } from 'react'

interface ColumnMenuButtonProps {
onClick?: (e: MouseEvent) => void
}

export default function ColumnMenuButton({ onClick }: ColumnMenuButtonProps) {
const buttonRef = useRef<HTMLDivElement>(null)

const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()

// Create a synthetic mouse event with position information from the button
if (buttonRef.current && onClick) {
const rect = buttonRef.current.getBoundingClientRect()
const syntheticEvent = {
...e,
clientX: rect.left + rect.width / 2,
stopPropagation: () => {
e.stopPropagation()
},
} as unknown as MouseEvent

onClick(syntheticEvent)
}
}
},
[onClick]
)

return (
<div
ref={buttonRef}
onClick={onClick}
onKeyDown={handleKeyDown}
aria-label='Column Menu Button'
role='button'
tabIndex={0}
>
<span>⋮</span>
</div>
)
}
77 changes: 73 additions & 4 deletions src/components/HighTable/HighTable.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,24 @@
user-select: none;
top: 0;
z-index: var(--header-z-index, auto);
overflow: auto;
}
}

/* sortable */
thead th[aria-sort] {
cursor: pointer;
padding-right: calc(var(--cell-horizontal-padding) + 48px);
}
th[aria-sort="ascending"]::after,
th[aria-sort="descending"]::after {
position: absolute;
right: 0;
top: 0;
position: relative;
display: inline-block;
margin-left: 24px;
font-size: 0.875em;
font-weight: bold;
line-height: 1;
vertical-align: text-top;
}
th[aria-sort="ascending"]::after {
content: "▴";
Expand All @@ -63,6 +69,69 @@
content: "▾";
}

/* column menu */
div[role="menu"] {
background-color: white;
border: 1px solid #c9c9c9;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
min-width: 150px;
font-size: 14px;
color: #444;
position: fixed;
}

div[role="menu"] > [role="presentation"] {
padding: 8px 12px;
font-weight: bold;
color: #444;
background-color: #f1f1f3;
border-top-left-radius: 4px;
}

div[role="menu"] > [role="menuitem"] {
display: block;
width: 100%;
text-align: left;
padding: 8px 12px;
border: none;
background: none;
cursor: pointer;
}

/* column menu button */
th > div[role="button"] {
display: inline-flex;
align-items: center;
justify-content: center;
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease;
border-radius: 3px;
background-color: #f1f1f3;
z-index: 10;
}

th:hover > div[role="button"],
th > div[role="button"]:focus,
th > div[role="button"]:focus-visible {
background-color: #dbdbe5;
opacity: 1;
}

th > div[role="button"] > span {
font-size: 1rem;
line-height: 1;
color: #444;
font-weight: bold;
}

/* column resize */
thead [role="separator"] {
position: absolute;
Expand Down Expand Up @@ -216,7 +285,7 @@

/* sorting is enabled - add space for the sort caret */
&[aria-sort] {
padding-right: calc(var(--cell-horizontal-padding) + 12px);
padding-right: calc(var(--cell-horizontal-padding) + 24px);
}
}
}
Expand Down
20 changes: 13 additions & 7 deletions src/components/HighTable/HighTable.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CSSProperties, KeyboardEvent, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { CSSProperties, KeyboardEvent, MouseEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { DataFrame } from '../../helpers/dataframe.js'
import { PartialRow } from '../../helpers/row.js'
import { Selection, areAllSelected, isSelected, toggleAll, toggleIndexInSelection, toggleRangeInSelection, toggleRangeInTable } from '../../helpers/selection.js'
Expand All @@ -16,6 +16,7 @@ import TableCorner from '../TableCorner/TableCorner.js'
import TableHeader from '../TableHeader/TableHeader.js'
import { formatRowNumber, rowError } from './HighTable.helpers.js'
import styles from './HighTable.module.css'
import { PortalContainerProvider } from '../../hooks/usePortalContainer.js'

/**
* A slice of the (optionally sorted) rows to render as HTML.
Expand Down Expand Up @@ -47,6 +48,7 @@ interface Props {
className?: string // additional class names for the component
columnClassNames?: (string | undefined)[] // list of additional class names for the header and cells of each column. The index in this array corresponds to the column index in data.header
styled?: boolean // use styled component? (default true)
containerRef?: RefObject<HTMLDivElement | null>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't want to add this property to Props, because it's used only internally (for HighTableInner, not for HighTable)

So: I think it's better to move

const containerRef = useRef<HTMLDivElement>(null)

to hooks/usePortalContainer.tsx, and use the hook inside HighTableInner to get the ref.

}

const defaultPadding = 20
Expand All @@ -62,15 +64,18 @@ const ariaOffset = 2 // 1-based index, +1 for the header
* onSelectionChange: the callback to call when the selection changes. If undefined, the component selection is read-only if controlled (selection is set), or disabled if not.
*/
export default function HighTable(props: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const { data, cacheKey } = props
const ariaColCount = data.header.length + 1 // don't forget the selection column
const ariaRowCount = data.numRows + 1 // don't forget the header row
return (
<ColumnWidthProvider localStorageKey={cacheKey ? `${cacheKey}:column-widths` : undefined}>
<CellsNavigationProvider colCount={ariaColCount} rowCount={ariaRowCount} rowPadding={props.padding ?? defaultPadding}>
<HighTableInner {...props} />
</CellsNavigationProvider>
</ColumnWidthProvider>
<PortalContainerProvider containerRef={containerRef}>
<ColumnWidthProvider localStorageKey={cacheKey ? `${cacheKey}:column-widths` : undefined}>
<CellsNavigationProvider colCount={ariaColCount} rowCount={ariaRowCount} rowPadding={props.padding ?? defaultPadding}>
<HighTableInner {...props} containerRef={containerRef} />
</CellsNavigationProvider>
</ColumnWidthProvider>
</PortalContainerProvider>
)
}

Expand All @@ -96,6 +101,7 @@ export function HighTableInner({
className = '',
columnClassNames = [],
styled = true,
containerRef,
}: Props) {
/**
* The component relies on the model of a virtual table which rows are ordered and only the
Expand Down Expand Up @@ -470,7 +476,7 @@ export function HighTableInner({
const ariaColCount = data.header.length + 1 // don't forget the selection column
const ariaRowCount = numRows + 1 // don't forget the header row
return (
<div className={`${styles.hightable} ${styled ? styles.styled : ''} ${className}`}>
<div ref={containerRef} className={`${styles.hightable} ${styled ? styles.styled : ''} ${className}`}>
<div className={styles.tableScroll} ref={scrollRef} role="group" aria-labelledby="caption" style={tableScrollStyle} onKeyDown={restrictedOnScrollKeyDown} tabIndex={0}>
<div style={{ height: `${scrollHeight}px` }}>
<table
Expand Down
Loading