Compare commits

..
18 changed files with 131 additions and 321 deletions

No files matched your search

-6
View File
@@ -1,6 +0,0 @@
---
"@navikt/ds-react": minor
"@navikt/ds-css": patch
---
DataGrid: Add top and bottom content areas outside the table's scrollable viewport.
+5
View File
@@ -0,0 +1,5 @@
---
"@navikt/ds-css": patch
---
Badge: Increase inline padding by 1px for better optical balance
+3 -1
View File
@@ -16,7 +16,9 @@
justify-content: center;
min-width: var(--__axc-badge-size);
height: var(--__axc-badge-size);
padding: var(--ax-space-4);
/* 1px optical adjustment inline */
padding: var(--ax-space-4) calc(var(--ax-space-4) + var(--ax-space-1));
border-radius: var(--ax-radius-16);
background: var(--ax-bg-strong);
color: var(--ax-text-contrast);
+1 -53
View File
@@ -9,27 +9,15 @@
overflow: hidden;
}
.aksel-data-table__frame {
display: flex;
flex: 1 1 auto;
flex-direction: column;
width: 100%;
max-height: 100%;
min-height: 0;
}
.aksel-data-table__border-wrapper {
/* display:flex and max-height:100% is needed for a "no page scroll" layout */
display: flex;
flex: 1 1 auto;
max-height: 100%;
min-height: 0;
}
.aksel-data-table__scroll-wrapper {
overflow: auto;
flex: 1 1 auto;
width: 100%;
min-height: 0;
position: relative;
scroll-timeline: --horizontal-table-scroll inline;
}
@@ -149,26 +137,6 @@
border-radius: var(--ax-radius-12);
z-index: 3;
}
:is([data-attachments="top"], [data-attachments="both"]) & {
border-top-left-radius: 0;
border-top-right-radius: 0;
&::after {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
}
:is([data-attachments="bottom"], [data-attachments="both"]) & {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
&::after {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
}
.aksel-data-table {
@@ -742,23 +710,3 @@ html[data-dragging-cursor="true"] *::after {
}
/* ------------------------ DataGrid.Preferences End ------------------------ */
.aksel-data-table__attachment {
z-index: 10;
flex: 0 0 auto;
padding: var(--ax-space-12) var(--ax-space-16);
border: 1px solid var(--ax-border-neutral-subtle);
background-color: var(--ax-bg-raised);
&[data-attachment="top"] {
border-bottom: none;
border-top-left-radius: var(--ax-radius-12);
border-top-right-radius: var(--ax-radius-12);
}
&[data-attachment="bottom"] {
border-top: none;
border-bottom-left-radius: var(--ax-radius-12);
border-bottom-right-radius: var(--ax-radius-12);
}
}
@@ -1,66 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React, { useCallback } from "react";
import { Button } from "../../button";
import { DataGrid } from "../../data-grid";
import { HStack, VStack } from "../../primitives/stack";
import { TEST_DATA, columnDef_TEST_DATA } from "./Data.test-data";
const meta: Meta<typeof DataGrid> = {
title: "ds-react/Data/Attachments",
component: DataGrid,
parameters: {
chromatic: { disable: true },
layout: "fullscreen",
a11y: { disable: true },
docs: { disable: true },
},
};
export default meta;
const getColDefs = (overflow: string) =>
overflow === "none" || overflow === "y"
? columnDef_TEST_DATA.slice(0, 5)
: columnDef_TEST_DATA;
const getData = (overflow: string) =>
overflow === "none" || overflow === "x" ? TEST_DATA.slice(0, 5) : TEST_DATA;
type Story = StoryObj<{ overflow: "none" | "x" | "y" | "both" }>;
export const Attachments: Story = {
render: ({ overflow }) => {
const Content = useCallback(
() => (
<HStack gap="space-8">
<Button size="xsmall" variant="secondary" data-color="neutral">
Button
</Button>
<Button size="xsmall" variant="secondary" data-color="neutral">
Button
</Button>
</HStack>
),
[],
);
return (
<VStack padding="space-16" gap="space-16" height="100vh">
<div style={{ overflow: "hidden", display: "flex" }}>
<DataGrid columns={getColDefs(overflow)} data={getData(overflow)}>
<DataGrid.Table
topContent={<Content />}
bottomContent={<Content />}
/>
</DataGrid>
</div>
</VStack>
);
},
argTypes: {
overflow: {
control: { type: "radio" },
options: ["none", "x", "y", "both"],
},
},
};
@@ -57,11 +57,6 @@ const fallbackIdData: TestRow[] = [
},
];
const scrollableData: TestRow[] = Array.from({ length: 20 }, (_, index) => ({
id: `row-${index + 1}`,
name: `Row ${index + 1}`,
}));
const columns: ColumnDefinitions<TestRow> = [
{
id: "name",
@@ -233,45 +228,3 @@ export const SelectAllIncludesHiddenDescendantsForCollapsedParents: Story = {
expect(getCheckboxes(canvasElement)[3].checked).toBe(false);
},
};
export const AttachedContentStaysVisibleWhileTableScrolls: Story = {
render: () => (
<div style={{ height: 240, display: "flex" }}>
<DataGrid
columns={columns}
data={scrollableData}
getRowId={(row) => row.id}
>
<DataGrid.Table
topContent={<button type="button">Top action</button>}
bottomContent={<button type="button">Bottom action</button>}
/>
</DataGrid>
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const topAction = canvas.getByRole("button", { name: "Top action" });
const bottomAction = canvas.getByRole("button", { name: "Bottom action" });
const scrollWrapper = canvasElement.querySelector(
".aksel-data-table__scroll-wrapper",
);
if (!(scrollWrapper instanceof HTMLElement)) {
throw new Error("Expected table scroll wrapper");
}
expect(scrollWrapper.scrollHeight).toBeGreaterThan(
scrollWrapper.clientHeight,
);
const topPosition = topAction.getBoundingClientRect().top;
const bottomPosition = bottomAction.getBoundingClientRect().top;
scrollWrapper.scrollTop = scrollWrapper.scrollHeight;
expect(scrollWrapper.scrollTop).toBeGreaterThan(0);
expect(topAction.getBoundingClientRect().top).toBe(topPosition);
expect(bottomAction.getBoundingClientRect().top).toBe(bottomPosition);
},
};
@@ -138,14 +138,6 @@ interface DataGridTableProps<T> extends React.HTMLAttributes<HTMLTableElement> {
* @default "row"
*/
selectionTrigger?: "row" | "control";
/**
* Content to render above the table, outside its scrollable area.
*/
topContent?: React.ReactNode;
/**
* Content to render below the table, outside its scrollable area.
*/
bottomContent?: React.ReactNode;
}
const DataGridTableInternal = forwardRef<
@@ -169,8 +161,6 @@ const DataGridTableInternal = forwardRef<
subRows,
sorting,
selectionTrigger = "row",
topContent,
bottomContent,
...rest
}: DataGridTableProps<unknown>,
forwardedRef,
@@ -213,115 +203,80 @@ const DataGridTableInternal = forwardRef<
/* TODO: Need to figure out how to handle this since settings is provided from root, not table itself */
const truncateContent = tableSettings?.truncateContent ?? layout !== "auto";
const hasTopContent = topContent != null;
const hasBottomContent = bottomContent != null;
let attachmentPosition: "top" | "bottom" | "both" | undefined;
if (hasTopContent && hasBottomContent) {
attachmentPosition = "both";
} else if (hasTopContent) {
attachmentPosition = "top";
} else if (hasBottomContent) {
attachmentPosition = "bottom";
}
return (
<div
className="aksel-data-table__frame"
data-attachments={attachmentPosition}
<DataTableContextProvider
layout={layout}
withKeyboardNav={true}
selectionState={tableSelectionState}
stickyStart={stickyStart}
stickyHeader={stickyHeader}
tableId={tableId}
loading={loadingContent}
onRowAction={onRowAction}
columns={columns}
totalColSpan={totalColSpan}
tableItems={tableItems}
sortingState={sortingState}
>
{hasTopContent && (
<div className="aksel-data-table__attachment" data-attachment="top">
{topContent}
</div>
)}
<DataTableContextProvider
layout={layout}
withKeyboardNav={true}
selectionState={tableSelectionState}
stickyStart={stickyStart}
stickyHeader={stickyHeader}
tableId={tableId}
loading={loadingContent}
onRowAction={onRowAction}
columns={columns}
totalColSpan={totalColSpan}
tableItems={tableItems}
sortingState={sortingState}
<TableElementWrapper
enabled={true}
hasStickyColumns={
!!(
tableSettings?.stickyColumns?.start ||
tableSettings?.stickyColumns?.end
)
}
>
<TableElementWrapper
enabled={true}
hasStickyColumns={
!!(
tableSettings?.stickyColumns?.start ||
tableSettings?.stickyColumns?.end
)
}
<table
{...rest}
ref={forwardedRef}
className={cl("aksel-data-table", className)}
data-zebra-stripes={tableSettings?.zebraStripes}
data-column-dividers={tableSettings?.columnDividers}
data-truncate-content={truncateContent}
data-density={tableSettings?.rowDensity}
data-text-size={tableSettings?.textSize}
data-layout={layout}
data-loading={isLoading || undefined}
aria-busy={isLoading || undefined}
>
<table
{...rest}
ref={forwardedRef}
className={cl("aksel-data-table", className)}
data-zebra-stripes={tableSettings?.zebraStripes}
data-column-dividers={tableSettings?.columnDividers}
data-truncate-content={truncateContent}
data-density={tableSettings?.rowDensity}
data-text-size={tableSettings?.textSize}
data-layout={layout}
data-loading={isLoading || undefined}
aria-busy={isLoading || undefined}
>
<DataTableDetailsPanelProvider detailsPanel={detailsPanel}>
<DataTableThead data-sticky={stickyHeader || undefined}>
<DataTableTr>
{columns.map(
({
isSticky,
isStickyLast,
stickyLeftOffset,
colDef,
}) => {
return (
<DataTableColumnHeader
id={colDef.id}
width={colDef.width}
align={colDef.align ?? "left"}
key={colDef.id}
isSticky={isSticky}
sortable={colDef.isSortable}
label={colDef.header}
style={
stickyLeftOffset
? { left: stickyLeftOffset }
: undefined
}
data-sticky-last={isStickyLast || undefined}
>
{colDef.headerCell ?? colDef.header}
</DataTableColumnHeader>
);
},
)}
</DataTableTr>
</DataTableThead>
<DataTableDetailsPanelProvider detailsPanel={detailsPanel}>
<DataTableThead data-sticky={stickyHeader || undefined}>
<DataTableTr>
{columns.map(
({ isSticky, isStickyLast, stickyLeftOffset, colDef }) => {
return (
<DataTableColumnHeader
id={colDef.id}
width={colDef.width}
align={colDef.align ?? "left"}
key={colDef.id}
isSticky={isSticky}
sortable={colDef.isSortable}
label={colDef.header}
style={
stickyLeftOffset
? { left: stickyLeftOffset }
: undefined
}
data-sticky-last={isStickyLast || undefined}
>
{colDef.headerCell ?? colDef.header}
</DataTableColumnHeader>
);
},
)}
</DataTableTr>
</DataTableThead>
<DataTableTbody>
<DataTableTBodyContent emptyContent={emptyContent} />
</DataTableTbody>
</DataTableDetailsPanelProvider>
</table>
</TableElementWrapper>
</DataTableContextProvider>
{hasBottomContent && (
<div
className="aksel-data-table__attachment"
data-attachment="bottom"
>
{bottomContent}
</div>
)}
</div>
<DataTableTbody>
<DataTableTBodyContent emptyContent={emptyContent} />
</DataTableTbody>
</DataTableDetailsPanelProvider>
</table>
</TableElementWrapper>
</DataTableContextProvider>
);
},
);
@@ -19,7 +19,7 @@ export interface CheckboxProps
*/
children: React.ReactNode;
/**
* Visually hides label and description, and makes it viewable for screen-readers only.
* Hides the label and description visually while keeping them available to screen readers.
*/
hideLabel?: boolean;
/**
@@ -60,8 +60,7 @@ export interface ComboboxProps
*/
filteredOptions?: string[] | ComboboxOption[];
/**
* Optionally hide the label visually.
* Not recommended, but can be considered for e.g. search fields in the top menu.
* Hides the label and description visually while keeping them available to screen readers.
*/
hideLabel?: boolean;
/**
@@ -39,7 +39,7 @@ export interface SearchProps
*/
label: React.ReactNode;
/**
* Shows label and description for screenreaders only.
* Hides the label and description visually while keeping them available to screen readers.
* @default true
*/
hideLabel?: boolean;
@@ -23,7 +23,7 @@ export interface SelectProps
*/
label: React.ReactNode;
/**
* Shows label and description for screenreaders only.
* Hides the label and description visually while keeping them available to screen readers.
*/
hideLabel?: boolean;
}
@@ -15,7 +15,7 @@ export interface SwitchProps
*/
children: React.ReactNode;
/**
* If enabled shows the label and description for screenreaders only.
* Hides the label and description visually while keeping them available to screen readers.
*/
hideLabel?: boolean;
/**
@@ -36,7 +36,7 @@ export interface TextareaProps
*/
label: React.ReactNode;
/**
* If enabled shows the label and description for screenreaders only.
* Hides the label and description visually while keeping them available to screen readers.
*/
hideLabel?: boolean;
/**
@@ -20,7 +20,7 @@ export interface TextFieldProps
*/
htmlSize?: number;
/**
* If enabled shows the label and description for screenreaders only
* Hides the label and description visually while keeping them available to screen readers
*/
hideLabel?: boolean;
/**
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React, { useState } from "react";
import { expect, fireEvent, fn } from "storybook/test";
import { expect, fireEvent, fn, within } from "storybook/test";
import type { ListboxOptionProps } from "./option/ListboxOption";
import { Listbox } from "./root/ListboxRoot";
@@ -212,10 +212,14 @@ export const EnterKey: StoryObj<{
},
};
const ListboxStoryWithSelectedOption = () => {
const [virtuallyFocusedOptionId, setVirtuallyFocusedOptionId] = useState("");
const ListboxStoryWithFilterInput = () => {
const [virtuallyFocusedOptionId, setVirtuallyFocusedOptionId] =
useState("second-item");
return (
<Listbox setVirtuallyFocusedOptionId={setVirtuallyFocusedOptionId}>
<Listbox.InputSlot virtuallyFocusedOptionId={virtuallyFocusedOptionId}>
<input type="text" aria-label="Filter" />
</Listbox.InputSlot>
<Listbox.Options>
<Listbox.Option
id="first-item"
@@ -226,12 +230,12 @@ const ListboxStoryWithSelectedOption = () => {
First item
</Listbox.Option>
<Listbox.Option
id="selected-item"
id="second-item"
onClick={() => {}}
hasVirtualFocus={virtuallyFocusedOptionId === "selected-item"}
aria-selected
hasVirtualFocus={virtuallyFocusedOptionId === "second-item"}
aria-selected={false}
>
Selected item
Second item
</Listbox.Option>
<Listbox.Option
id="last-item"
@@ -246,19 +250,30 @@ const ListboxStoryWithSelectedOption = () => {
);
};
// When there's a selected option, but no option has virtual focus,
// pressing ArrowUp or ArrowDown should focus the selected option.
export const ArrowDownFocusesSelectedOption: StoryObj = {
render: ListboxStoryWithSelectedOption,
// When a filter input has real focus, ArrowUp on first option and
// ArrowDown on last option should remove virtual focus so that
// perceived focus is returned to the input.
export const ArrowUpOnFirstOptionFocusInput: StoryObj = {
render: ListboxStoryWithFilterInput,
play: async ({ canvasElement }) => {
pressKey(canvasElement, "ArrowDown");
expect(getVirtuallyFocusedValue(canvasElement)).toBe("selected-item");
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Filter");
input.focus();
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(getVirtuallyFocusedValue(canvasElement)).toBe("first-item");
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(getVirtuallyFocusedValue(canvasElement)).toBeUndefined();
},
};
export const ArrowUpFocusesSelectedOption: StoryObj = {
render: ListboxStoryWithSelectedOption,
export const ArrowDownOnLastOptionFocusInput: StoryObj = {
render: ListboxStoryWithFilterInput,
play: async ({ canvasElement }) => {
pressKey(canvasElement, "ArrowUp");
expect(getVirtuallyFocusedValue(canvasElement)).toBe("selected-item");
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Filter");
input.focus();
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(getVirtuallyFocusedValue(canvasElement)).toBe("last-item");
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(getVirtuallyFocusedValue(canvasElement)).toBeUndefined();
},
};
@@ -24,6 +24,7 @@ const ListboxInputSlot = ({
// @ts-expect-error - You are meant to use an <input>, but Slot doesn't know that.
autoComplete="off"
role="combobox"
data-aksel-listbox-input
>
{children}
</Slot>
@@ -72,30 +72,34 @@ const ListboxComponent = forwardRef<HTMLDivElement, ListboxProps>(
listbox.focus();
}
const virtuallyFocusWithFallback = (
getNextElement: (currentOption: HTMLElement) => HTMLElement | null,
const handleArrowUpOrDown = (
getNextOption: (currentOption: HTMLElement) => HTMLElement | null,
getFallback: () => HTMLElement | null,
) => {
event.preventDefault();
if (!focusedOptionElm) {
const fallback =
listbox.querySelector<HTMLElement>('[aria-selected="true"]') ||
getFallback();
virtuallyFocusOption(fallback);
virtuallyFocusOption(getFallback());
return;
}
const nextOption = getNextElement(focusedOptionElm);
const nextOption = getNextOption(focusedOptionElm);
if (nextOption) {
virtuallyFocusOption(nextOption);
return;
}
// No nextOption: Assume you pressed up on first option or down on last option
const activeElement = document.activeElement as HTMLElement;
if (activeElement.dataset?.akselListboxInput) {
setVirtuallyFocusedOptionId("");
return;
}
};
switch (event.key) {
case "ArrowUp":
virtuallyFocusWithFallback(findPrevOption, getLastOption);
handleArrowUpOrDown(findPrevOption, getLastOption);
break;
case "ArrowDown":
virtuallyFocusWithFallback(findNextOption, getFirstOption);
handleArrowUpOrDown(findNextOption, getFirstOption);
break;
case "PageUp":
event.preventDefault();
@@ -49,7 +49,7 @@ export interface DateInputProps
*/
label: React.ReactNode;
/**
* Shows label and description for screen readers only
* Hides the label and description visually while keeping them available to screen readers
* @default false
*/
hideLabel?: boolean;