Compare commits

...
Author SHA1 Message Date
Ken 3d566298e4 📝 Colspan update 2026-03-16 09:49:14 +01:00
Ken b5dc86785d 🎉 Fix keyboard-nav 2026-03-13 22:08:29 +01:00
Ken 58e564fed0 Merge branch 'main' into auto-size-table 2026-03-13 21:51:28 +01:00
Ken c4c51dcd37 ⚡ Optimize merge refs 2026-03-13 21:46:45 +01:00
Ken 5c913ae3d6 WIP: Merged auto-generated code 2026-03-13 15:36:12 +01:00
9 changed files with 246 additions and 28 deletions

No files matched your search

+1
View File
@@ -330,6 +330,7 @@
.aksel-data-table__filler-cell {
width: auto;
display: table-cell;
padding: 0;
}
.aksel-data-table__drag-and-drop-item {
@@ -15,6 +15,13 @@ export default meta;
type Story = StoryObj<typeof DataTable>;
const DATA = [
{ name: "Arne", age: 42, city: "Oslo" },
{ name: "Bjørg", age: 31, city: "Bergen" },
{ name: "Carl", age: 55, city: "Trondheim" },
{ name: "Diana", age: 28, city: "Stavanger" },
];
export const Resize: Story = {
render: () => (
<DataTable withKeyboardNav>
@@ -66,3 +73,72 @@ export const Resize: Story = {
</DataTable>
),
};
export const FrAutoSizing: Story = {
render: () => (
<DataTable style={{ width: "800px" }}>
<DataTable.Thead>
<DataTable.Tr>
<DataTable.Th>Auto 1fr</DataTable.Th>
<DataTable.Th>Auto 1fr</DataTable.Th>
<DataTable.Th>Auto 1fr</DataTable.Th>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
{DATA.map((row) => (
<DataTable.Tr key={row.name}>
<DataTable.Td>{row.name}</DataTable.Td>
<DataTable.Td>{row.age}</DataTable.Td>
<DataTable.Td>{row.city}</DataTable.Td>
</DataTable.Tr>
))}
</DataTable.Tbody>
</DataTable>
),
};
export const MixedFrAndFixed: Story = {
render: () => (
<DataTable style={{ width: "800px" }}>
<DataTable.Thead>
<DataTable.Tr>
<DataTable.Th defaultWidth={100}>Fixed 100px</DataTable.Th>
<DataTable.Th defaultWidth="2fr">2fr</DataTable.Th>
<DataTable.Th defaultWidth="1fr">1fr</DataTable.Th>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
{DATA.map((row) => (
<DataTable.Tr key={row.name}>
<DataTable.Td>{row.name}</DataTable.Td>
<DataTable.Td>{row.age}</DataTable.Td>
<DataTable.Td>{row.city}</DataTable.Td>
</DataTable.Tr>
))}
</DataTable.Tbody>
</DataTable>
),
};
export const UnequalFr: Story = {
render: () => (
<DataTable style={{ width: "800px" }}>
<DataTable.Thead>
<DataTable.Tr>
<DataTable.Th defaultWidth="1fr">1fr</DataTable.Th>
<DataTable.Th defaultWidth="2fr">2fr</DataTable.Th>
<DataTable.Th defaultWidth="3fr">3fr</DataTable.Th>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
{DATA.map((row) => (
<DataTable.Tr key={row.name}>
<DataTable.Td>{row.name}</DataTable.Td>
<DataTable.Td>{row.age}</DataTable.Td>
<DataTable.Td>{row.city}</DataTable.Td>
</DataTable.Tr>
))}
</DataTable.Tbody>
</DataTable>
),
};
@@ -34,7 +34,11 @@ function restoreTabIndex(cell: Element): void {
*/
function findFocusableElementInCell(cell: Element): HTMLElement | null {
const el = cell as HTMLElement | null;
if (!el || isHiddenElement(el)) {
if (
!el ||
isHiddenElement(el) ||
el.hasAttribute("data-block-keyboard-nav")
) {
return null;
}
@@ -48,6 +52,11 @@ function findFocusableElementInCell(cell: Element): HTMLElement | null {
for (let i = 0; i < focusables.length; i += 1) {
const focusable = focusables[i] as HTMLElement;
/* Ignore element if it has [data-block-keyboard-nav="true"] */
if (focusable.hasAttribute("data-block-keyboard-nav")) {
continue;
}
if (!isHiddenElement(focusable) && !isDisabledElement(focusable)) {
return focusable;
}
@@ -52,7 +52,6 @@ function getNavigationAction(event: KeyboardEvent): NavigationAction | null {
* - Select arrow down/up for opening popup
* - User is navigating inside multiline textarea
* - contenteditable attrb is in use
* - element has attribute data-block-keyboard-nav="true"
*/
function shouldBlockNavigation(event: KeyboardEvent): boolean {
const key = event.key;
@@ -71,7 +70,7 @@ function shouldBlockNavigation(event: KeyboardEvent): boolean {
/* If not any of these elements, assume "safe" to navigate */
const editable = el.closest(
'input, textarea, select, [contenteditable="true"], [data-block-keyboard-nav="true"]',
'input, textarea, select, [contenteditable="true"]',
);
if (!editable) {
@@ -93,10 +92,7 @@ function shouldBlockNavigation(event: KeyboardEvent): boolean {
return false;
}
return (
editable.hasAttribute("contenteditable") ||
editable.hasAttribute("data-block-keyboard-nav")
);
return editable.hasAttribute("contenteditable");
}
function shouldBlockInputArrow(input: HTMLInputElement, key: string): boolean {
@@ -1,7 +1,19 @@
import { createStrictContext } from "../../../utils/helpers";
interface ColumnRegistration {
type: "fr" | "fixed";
frValue: number;
fixedWidth: number;
minWidth: number;
maxWidth: number;
setResolvedWidth: (width: number) => void;
}
interface DataTableContextProps {
layout: "fixed" | "auto";
registerColumn: (id: string, config: ColumnRegistration) => void;
unregisterColumn: (id: string) => void;
notifyResize: (id: string, newWidth: number) => void;
}
const { Provider: DataTableContextProvider, useContext: useDataTableContext } =
@@ -11,3 +23,4 @@ const { Provider: DataTableContextProvider, useContext: useDataTableContext } =
});
export { DataTableContextProvider, useDataTableContext };
export type { ColumnRegistration };
@@ -1,4 +1,5 @@
import React, { forwardRef, useState } from "react";
import React, { forwardRef, useCallback, useRef, useState } from "react";
import { useClientLayoutEffect } from "../../../utils-external/hooks/useClientLayoutEffect";
import { cl } from "../../../utils/helpers";
import { useMergeRefs } from "../../../utils/hooks";
import {
@@ -20,7 +21,10 @@ import {
type DataTableTheadProps,
} from "../thead/DataTableThead";
import { DataTableTr, type DataTableTrProps } from "../tr/DataTableTr";
import { DataTableContextProvider } from "./DataTableRoot.context";
import {
type ColumnRegistration,
DataTableContextProvider,
} from "./DataTableRoot.context";
import { useTableKeyboardNav } from "./useTableKeyboardNav";
interface DataTableProps extends React.HTMLAttributes<HTMLTableElement> {
@@ -186,15 +190,91 @@ const DataTable = forwardRef<HTMLTableElement, DataTableProps>(
forwardedRef,
) => {
const [tableRef, setTableRef] = useState<HTMLTableElement | null>(null);
const mergedRef = useMergeRefs(forwardedRef, setTableRef);
const tableElRef = useRef<HTMLTableElement | null>(null);
const mergedRef = useMergeRefs(forwardedRef, setTableRef, tableElRef);
const { tabIndex } = useTableKeyboardNav(tableRef, {
enabled: withKeyboardNav,
shouldBlockNavigation,
});
const columnsRef = useRef(new Map<string, ColumnRegistration>());
const resolveColumns = useCallback(() => {
const tableEl = tableElRef.current;
if (!tableEl || layout !== "fixed") return;
const columns = columnsRef.current;
if (columns.size === 0) return;
const tableWidth = tableEl.offsetWidth;
let fixedSum = 0;
let totalFr = 0;
for (const col of columns.values()) {
if (col.type === "fixed") {
fixedSum += col.fixedWidth;
} else {
totalFr += col.frValue;
}
}
if (totalFr === 0) return;
const remaining = Math.max(0, tableWidth - fixedSum);
for (const col of columns.values()) {
if (col.type === "fr") {
const resolvedWidth = (col.frValue / totalFr) * remaining;
const clamped = Math.min(
Math.max(resolvedWidth, col.minWidth),
col.maxWidth,
);
col.setResolvedWidth(clamped);
}
}
}, [layout]);
const registerColumn = useCallback(
(id: string, config: ColumnRegistration) => {
columnsRef.current.set(id, config);
resolveColumns();
},
[resolveColumns],
);
const unregisterColumn = useCallback(
(id: string) => {
columnsRef.current.delete(id);
resolveColumns();
},
[resolveColumns],
);
const notifyResize = useCallback(
(id: string, newWidth: number) => {
const col = columnsRef.current.get(id);
if (col) {
col.type = "fixed";
col.fixedWidth = newWidth;
}
resolveColumns();
},
[resolveColumns],
);
useClientLayoutEffect(() => {
resolveColumns();
});
return (
<DataTableContextProvider layout={layout}>
<DataTableContextProvider
layout={layout}
registerColumn={registerColumn}
unregisterColumn={unregisterColumn}
notifyResize={notifyResize}
>
<div className="aksel-data-table__border-wrapper">
<div className="aksel-data-table__scroll-wrapper">
<table
@@ -79,6 +79,7 @@ const DataTableTh = forwardRef<HTMLTableCellElement, DataTableThProps>(
maxWidth,
onWidthChange,
defaultWidth,
colSpan,
...rest
},
forwardedRef,
@@ -93,6 +94,7 @@ const DataTableTh = forwardRef<HTMLTableCellElement, DataTableThProps>(
maxWidth,
onWidthChange,
style,
colSpan,
});
const SortIcon = sortable ? SORT_ICON[sortDirection] : null;
@@ -113,6 +115,7 @@ const DataTableTh = forwardRef<HTMLTableCellElement, DataTableThProps>(
onPointerLeave={() => setIsOverflowing(false)}
tabIndex={-1}
data-align={textAlign}
colSpan={colSpan}
>
{sortable ? (
<button
@@ -1,4 +1,6 @@
import { type DOMAttributes, useCallback, useState } from "react";
import { useClientLayoutEffect } from "../../../utils-external/hooks/useClientLayoutEffect";
import { useId } from "../../../utils-external/hooks/useId";
import { useControllableState } from "../../../utils/hooks";
import { useDataTableContext } from "../root/DataTableRoot.context";
@@ -13,6 +15,10 @@ type ResizeProps = {
width?: ColumnWidth;
/**
* Initial width of the column. Only used when `width` is not set.
*
* Supports fr units (e.g. `"1fr"`, `"2fr"`) for proportional distribution
* of remaining space. Defaults to `"1fr"` when neither `width` nor
* `defaultWidth` is provided.
*/
defaultWidth?: ColumnWidth;
/**
@@ -35,6 +41,10 @@ type ResizeProps = {
* Forwarded styles
*/
style?: React.CSSProperties;
/**
* Forwarded colSpan
*/
colSpan?: number;
};
type TableColumnResizeArgs = ResizeProps & {};
@@ -57,35 +67,33 @@ type TableColumnResizeResult =
enabled: false;
};
/**
* TODO:
* - Do we allow % widths?
* - Auto-width mode is hard now since that might cause layout-shifts on mount. But would be preferable to
* be able to set "1fr" or similar and have it fill remaining space.
*/
function useTableColumnResize(
args: TableColumnResizeArgs,
): TableColumnResizeResult {
const {
width: userWidth,
defaultWidth = 140,
defaultWidth,
onWidthChange,
maxWidth = Infinity,
minWidth = 40,
style,
colSpan,
} = args;
const tableContext = useDataTableContext();
const columnId = useId();
const [width, _setWidth] = useControllableState({
const frConfig = parseFrValue(defaultWidth);
const isFr =
userWidth === undefined &&
(frConfig !== undefined || defaultWidth === undefined);
const [width, _setWidth] = useControllableState<ColumnWidth | undefined>({
value: userWidth,
defaultValue: defaultWidth,
/**
* TODO:
* - Potential optimization: Only call when width as "stopped" changing, e.g. on mouse up or after a debounce when resizing with keyboard.
* Otherwise, this could cause excessive calls when resizing quickly.
*/
onChange: onWidthChange,
defaultValue: isFr ? undefined : (parseWidth(defaultWidth) ?? 140),
onChange: onWidthChange as
| ((value: ColumnWidth | undefined) => void)
| undefined,
});
const [isResizingWithKeyboard, setIsResizingWithKeyboard] = useState(false);
@@ -95,9 +103,13 @@ function useTableColumnResize(
(newWidth: number) => {
const min = parseWidth(minWidth) ?? 0;
const max = parseWidth(maxWidth) ?? Infinity;
_setWidth(Math.min(Math.max(newWidth, min), max));
const clamped = Math.min(Math.max(newWidth, min), max);
_setWidth(clamped);
if (tableContext.layout === "fixed") {
tableContext.notifyResize(columnId, clamped);
}
},
[maxWidth, minWidth, _setWidth],
[maxWidth, minWidth, _setWidth, tableContext, columnId],
);
const handleKeyDown: DOMAttributes<HTMLButtonElement>["onKeyDown"] =
@@ -114,6 +126,7 @@ function useTableColumnResize(
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
event.stopPropagation();
const th = (event.target as HTMLElement).closest(
"th",
@@ -179,6 +192,24 @@ function useTableColumnResize(
[setWidth],
);
useClientLayoutEffect(() => {
if (tableContext.layout !== "fixed") {
return;
}
tableContext.registerColumn(columnId, {
type: isFr ? "fr" : "fixed",
frValue: frConfig ?? colSpan ?? 1,
fixedWidth: isFr ? 0 : (parseWidth(userWidth ?? defaultWidth) ?? 140),
minWidth: parseWidth(minWidth) ?? 0,
maxWidth: parseWidth(maxWidth) ?? Infinity,
setResolvedWidth: (w) => _setWidth(w),
});
return () => tableContext.unregisterColumn(columnId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columnId, tableContext.layout]);
if (tableContext.layout !== "fixed") {
return {
width: style?.width,
@@ -214,5 +245,13 @@ function parseWidth(width: ColumnWidth | undefined): number | undefined {
return undefined;
}
function parseFrValue(width: ColumnWidth | undefined): number | undefined {
if (typeof width !== "string") return undefined;
const match = width.match(/^(\d+(?:\.\d+)?)fr$/);
if (!match) return undefined;
const value = Number(match[1]);
return value > 0 ? value : undefined;
}
export { useTableColumnResize };
export type { ResizeProps };
@@ -27,6 +27,7 @@ const DataTableTr = forwardRef<HTMLTableRowElement, DataTableTrProps>(
<td
aria-hidden
className="aksel-data-table__th aksel-data-table__filler-cell"
data-block-keyboard-nav
/>
)}
</tr>