Compare commits

...
6 Commits
Author SHA1 Message Date
Halvor Haugan 3553bbaf6d feat(Combobox2): internal i18n 2026-10-28 16:51:25 +01:00
Halvor Haugan 9fc71b6e8a fix(FocusBoundary): avoid setting aria-hidden on elements with role=alert/status (#5466) 2026-10-28 16:21:19 +01:00
Ken 4ef7c2d85e feat: Constrain the detail-panel width to table-width (#5433) 2026-10-28 14:01:09 +01:00
dependabot[bot] fcdc1044ae ⬆️ Bump fast-uri from 3.1.8 to 3.1.9 (#5463)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-27 09:35:01 +01:00
1873e0f0a4 ⬆️ Bump react-day-picker from 10.0.1 to 10.0.2 (#5408)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: KenAJoh <26967723+KenAJoh@users.noreply.github.com>
Co-authored-by: Ken <ken.aleksander@gmail.com>
2026-10-27 09:04:21 +01:00
Halvor Haugan 071ce07d4a feat(Combobox2): announce number of hits (#5445) 2026-10-27 09:00:44 +01:00
15 changed files with 299 additions and 26 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@navikt/ds-react": patch
---
DatePicker: Preserve keyboard focus when controlled month changes replace the focused day.
+5
View File
@@ -0,0 +1,5 @@
---
"@navikt/ds-react": patch
---
Dialog, Lookup: Avoid setting aria-hidden on elements with role=alert/status when open
+5
View File
@@ -0,0 +1,5 @@
---
"@navikt/ds-css": patch
---
DataTable: Constrain details panel width to the visible table area.
@@ -19,6 +19,7 @@
overflow: auto;
width: 100%;
position: relative;
container-type: inline-size;
scroll-timeline: --horizontal-table-scroll inline;
}
@@ -683,6 +684,17 @@ html[data-dragging-cursor="true"] *::after {
}
.aksel-data-table__details-panel-row-cell {
> div {
position: sticky;
/* TODO: Sync 1px with `variants` that might not have outer border */
inset-inline-start: 1px;
/* 3px magic number: Avoids content from overflowing when scrolling all the way to the right */
max-inline-size: calc(100cqi - 3px);
box-sizing: border-box;
}
&:focus-visible {
outline: 2px solid var(--ax-border-focus);
outline-offset: -4px;
+1 -1
View File
@@ -791,7 +791,7 @@
"@navikt/aksel-icons": "^8.20.1",
"@navikt/ds-tokens": "^8.20.1",
"date-fns": "^4.0.0",
"react-day-picker": "10.0.1"
"react-day-picker": "10.0.2"
},
"devDependencies": {
"@testing-library/dom": "10.4.2",
@@ -873,9 +873,11 @@ export const RowExpansionAll: Story = {
selection={{
mode: args.selection?.mode ?? "none",
}}
settings={{ stickyColumns: { start: 1 } }}
>
<DataGrid.Table<UserDataTest>
onRowAction={() => console.info("Row clicked!")}
detailsPanel={{
getContent: (rowData) => {
return (
@@ -103,6 +103,66 @@ export const DropdownCaptionNavigation: Story = {
},
};
export const ControlledMonthPreservesFocus: Story = {
tags: ["play-fn", "!skip-test"],
render: () => {
const [month, setMonth] = React.useState(new Date(2026, 2, 15));
const [selected, setSelected] = React.useState<Date | undefined>(
new Date(2026, 2, 15),
);
return (
<fieldset
onKeyDown={(event) => {
if (event.key.toLowerCase() === "j") {
const nextDate = new Date(2026, 10, 15);
setMonth(nextDate);
setSelected(nextDate);
}
}}
>
<legend>Bytt måned med J</legend>
<DatePicker.Standalone
locale="nb"
month={month}
onMonthChange={setMonth}
selected={selected}
onSelect={setSelected}
/>
</fieldset>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const selectedDay = canvas.getByRole("button", {
name: "søndag 15",
pressed: true,
});
await userEvent.click(selectedDay);
await expect(selectedDay).toHaveFocus();
await userEvent.keyboard("j");
await expect(canvas.getByText("november 2026")).toBeInTheDocument();
await expect(
canvas.getByRole("button", { name: "søndag 15", pressed: true }),
).toHaveFocus();
await userEvent.keyboard("{ArrowRight}");
await expect(
canvas.getByRole("button", { name: "mandag 16" }),
).toHaveFocus();
const nextMonthButton = canvas.getByRole("button", {
name: "Gå til neste måned",
});
await userEvent.click(nextMonthButton);
await expect(canvas.getByText("desember 2026")).toBeInTheDocument();
await expect(nextMonthButton).toHaveFocus();
},
};
export const Required: Story = {
render: () => {
const { datepickerProps } = useDatepicker({
@@ -1,5 +1,6 @@
import React from "react";
import { Listbox } from "../../../utils/components/Listbox/root/ListboxRoot";
import { useI18n } from "../../../utils/i18n/i18n.hooks";
import { Search } from "../../search";
import { useComboboxPopupContext } from "../popup/ComboboxPopup";
import { useComboboxRootContext } from "../root/ComboboxRoot";
@@ -10,6 +11,7 @@ type ComboboxFilterProps = Pick<
>;
export const ComboboxFilter = (props: ComboboxFilterProps) => {
const translate = useI18n("Combobox2");
const { size } = useComboboxRootContext();
const {
filterString,
@@ -26,7 +28,7 @@ export const ComboboxFilter = (props: ComboboxFilterProps) => {
onChange={setFilterString}
size={size}
{...props}
label="Filtrer"
label={translate("filter")}
hideLabel
variant="simple"
onKeyUp={(event) => {
@@ -1,6 +1,7 @@
import React, { useCallback } from "react";
import { Listbox } from "../../../utils/components/Listbox/root/ListboxRoot";
import { useDeferredValue, useEventCallback } from "../../../utils/hooks";
import { useI18n } from "../../../utils/i18n/i18n.hooks";
import { ComboboxGroup } from "../group/ComboboxGroup";
import { ComboboxOption } from "../option/ComboboxOption";
import { useComboboxPopupContext } from "../popup/ComboboxPopup";
@@ -10,6 +11,7 @@ import {
type ResolveOption,
useComboboxRootContext,
} from "../root/ComboboxRoot";
import { useHitsAnnouncer } from "./useHitsAnnouncer";
interface ComboboxListProps<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
@@ -47,12 +49,14 @@ function ComboboxList<
function ComboboxListContent<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>({ children }: ComboboxListProps<T>) {
const { virtuallyFocusedOptionValue, filterString, setAnnounceText } =
const { virtuallyFocusedOptionValue, filterString } =
useComboboxPopupContext();
const { setOpen, options, value, onToggleOption, multiple, triggerRef } =
useComboboxRootContext();
const memoizedOnToggleOption = useEventCallback(onToggleOption);
const deferredFilterString = useDeferredValue(filterString);
const maybeAnnouceHits = useHitsAnnouncer(deferredFilterString, filterString);
const translate = useI18n("Combobox2");
const localOnToggleOption = useCallback(
(option: ComboboxOptionData, isSelected: boolean) => {
@@ -73,17 +77,14 @@ function ComboboxListContent<
? filterOptions(options, deferredFilterString)
: options;
maybeAnnouceHits(filteredOptions);
if (filteredOptions.length === 0) {
setAnnounceText("Ingen treff");
return (
<div className="aksel-combobox2__no-results" role="status">
Ingen treff
</div>
<div className="aksel-combobox2__no-results">{translate("noHits")}</div>
);
}
setAnnounceText("");
return filteredOptions.map((optOrGroup) =>
"options" in optOrGroup ? (
<ComboboxGroup
@@ -0,0 +1,108 @@
// Based on https://github.com/facebook/astryx/blob/main/packages/core/src/hooks/useAnnounce.ts
// Copyright (c) Meta Platforms, Inc. and affiliates.
/**
* Provides imperative screen-reader announcements via persistently-mounted
* polite/assertive live regions. Regions are auto-cleared shortly after
* announcing so stale status text does not linger in the accessibility tree.
*/
/**
* Announcement urgency:
* - `'polite'` (default): announced when the screen reader is idle. Use for
* status updates, result counts, "no results", non-urgent confirmations.
* - `'assertive'`: interrupts the current announcement. Reserve for errors and
* time-sensitive alerts.
*/
type Politeness = "polite" | "assertive";
const clearTimers: {
[K in Politeness]: ReturnType<typeof setTimeout> | null;
} = {
polite: null,
assertive: null,
};
function cancelScheduledClear(politeness: Politeness) {
const timer = clearTimers[politeness];
if (timer != null) {
clearTimeout(timer);
clearTimers[politeness] = null;
}
}
function scheduleClear(politeness: Politeness, target: HTMLElement) {
cancelScheduledClear(politeness);
clearTimers[politeness] = setTimeout(() => {
clearTimers[politeness] = null;
target.textContent = "";
}, 2000);
}
let regions: {
polite: HTMLElement;
assertive: HTMLElement;
} | null = null;
function createRegion(politeness: Politeness) {
const el = document.createElement("div");
el.dataset.akselAnnouncer = "";
el.className = "aksel-sr-only";
el.role = politeness === "assertive" ? "alert" : "status";
document.body.appendChild(el);
return el;
}
function getRegions() {
if (typeof document === "undefined") {
return null;
}
if (regions) {
// Re-attach if a region was removed from the DOM (e.g. by a test cleanup).
if (!regions.polite.isConnected) {
document.body.appendChild(regions.polite);
}
if (!regions.assertive.isConnected) {
document.body.appendChild(regions.assertive);
}
return regions;
}
regions = {
polite: createRegion("polite"),
assertive: createRegion("assertive"),
};
return regions;
}
function announce(message: string, politeness: Politeness = "polite") {
const r = getRegions();
if (!r) {
return;
}
const target = r[politeness];
if (!message) {
cancelScheduledClear(politeness);
target.textContent = "";
return;
}
// If the message is the same as the current text,
// append a non-breaking space to force a re-announcement.
target.textContent = message === target.textContent ? `${message} ` : message; // eslint-disable-line no-irregular-whitespace
scheduleClear(politeness, target);
}
/**
* Speaks a message through a persistently-mounted, visually-hidden live region.
*
* The polite and assertive regions are created once and kept mounted.
*
* Each message is automatically cleared a couple of seconds after being
* announced, so users browsing the DOM later do not encounter stale status
* text. Announcing again before the clear simply resets the countdown.
*/
function useAnnounce() {
getRegions(); // Ensure regions are created on beforehand
// NOTE: Before using this hook several places, we should give each instance their own live regions to avoid conflicts.
return announce;
}
export { useAnnounce };
@@ -0,0 +1,70 @@
import { useEffect, useMemo, useRef } from "react";
import { debounce } from "../../../utils-external";
import { useI18n } from "../../../utils/i18n/i18n.hooks";
import type {
ComboboxGroupData,
ComboboxOptionData,
} from "../root/ComboboxRoot";
import { useAnnounce } from "./useAnnounce";
function getNumberOfOptions<T extends ComboboxOptionData>(
options: (T | ComboboxGroupData<T>)[],
) {
return options.reduce((count, optOrGroup) => {
if ("options" in optOrGroup) {
return count + optOrGroup.options.length;
}
return count + 1;
}, 0);
}
function useHitsAnnouncer(deferredFilterString: string, filterString: string) {
const translate = useI18n("Combobox2");
const shouldAnnounceRef = useRef(false);
const announce = useAnnounce();
const announceHits = useMemo(() => {
return debounce((count: number) => {
return announce(
count === 1 ? translate("oneHit") : translate("hitsCount", { count }),
);
}, 2000);
}, [announce, translate]);
const announceNoHits = useMemo(
() => debounce(() => announce(translate("noHits")), 500),
[announce, translate],
);
useEffect(
() =>
function cleanUp() {
announceHits.clear();
announceNoHits.clear();
},
[announceHits, announceNoHits],
);
return (
filteredOptions: (
ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>
)[],
) => {
// The options are filtered based on deferredFilterString, so we need to wait for it to settle.
if (deferredFilterString !== filterString) {
shouldAnnounceRef.current = true;
} else if (shouldAnnounceRef.current) {
if (filteredOptions.length === 0) {
announceHits.clear();
announceNoHits();
} else {
const hits = getNumberOfOptions(filteredOptions);
announceNoHits.clear();
announceHits(hits);
}
shouldAnnounceRef.current = false;
}
};
}
export { useHitsAnnouncer };
@@ -8,7 +8,6 @@ interface ComboboxPopupContextProps {
setVirtuallyFocusedOptionValue: (value: string) => void;
filterString: string;
setFilterString: (value: string) => void;
setAnnounceText: (text: string) => void;
}
export const {
@@ -29,7 +28,6 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
const [filterString, setFilterString] = useState("");
const rootContext = useComboboxRootContext();
const ref = useRef<HTMLDivElement | null>(null);
const [announceText, setAnnounceText] = useState("");
useEffect(
function onOpen() {
@@ -89,12 +87,8 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
setFilterString(newString);
setVirtuallyFocusedOptionValue("");
}}
setAnnounceText={setAnnounceText}
>
{children}
<div className="aksel-sr-only" role="status">
{announceText}
</div>
</ComboboxPopupContextProvider>
</Listbox>
);
@@ -166,11 +166,11 @@ function hideNonTargetElements(avoidElements: Element[]): UndoFn {
const body = ownerDocument(avoidElements[0]).body;
/**
* Assume that elements with `aria-live` or `script` tags should not be hidden.
* This ensures that live regions and scripts continue to function properly.
* Assume that live regions and `script` tags should not be hidden.
* This ensures that they continue to function properly.
*/
const ignoredElements = Array.from(
body.querySelectorAll("[aria-live], script"),
body.querySelectorAll("[aria-live], [role=alert], [role=status], script"),
);
return applyAttributeToOthers(avoidElements.concat(ignoredElements), body);
@@ -4,6 +4,15 @@ const internalTranslations = {
label: "Internal grid",
selected: "{count} rows selected",
},
Combobox2: {
/** Label for filter input. Only visible to screen readers. */
filter: "Filtrer",
noHits: "Ingen treff",
/** Only announced to screen readers. */
oneHit: "Ett treff",
/** Only announced to screen readers. */
hitsCount: "{count} treff",
},
} satisfies Record<string, Record<string, string | Record<string, string>>>;
export { internalTranslations };
+8 -8
View File
@@ -4992,7 +4992,7 @@ __metadata:
jscodeshift: "npm:17.4.0"
jsdom: "npm:29.1.1"
react: "npm:19.3.1"
react-day-picker: "npm:10.0.1"
react-day-picker: "npm:10.0.2"
react-dom: "npm:19.3.1"
rimraf: "npm:6.1.3"
swr: "npm:^2.4.2"
@@ -13865,9 +13865,9 @@ __metadata:
linkType: hard
"fast-uri@npm:^3.0.1":
version: 3.1.8
resolution: "fast-uri@npm:3.1.8"
checksum: 10/d3dfa29fd2e5fa2371833699f3f78c975b79f65e52e0ce37604db10dffb85b5b448b5075e93453272cb95fb1c6a759495164e60649ffd69e59ec43f20d44f791
version: 3.1.9
resolution: "fast-uri@npm:3.1.9"
checksum: 10/4276cf58661e68b4fefcbc3308a2c8429e5664d2a77b393a79c1cdbe9e5ba80617d27c7ce6685194ae40e4816df73dbd72a0324e5012d94a7e9cf85ee711f658
languageName: node
linkType: hard
@@ -20555,9 +20555,9 @@ __metadata:
languageName: node
linkType: hard
"react-day-picker@npm:10.0.1":
version: 10.0.1
resolution: "react-day-picker@npm:10.0.1"
"react-day-picker@npm:10.0.2":
version: 10.0.2
resolution: "react-day-picker@npm:10.0.2"
dependencies:
"@date-fns/tz": "npm:^1.4.1"
date-fns: "npm:^4.1.0"
@@ -20567,7 +20567,7 @@ __metadata:
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10/fdc1c30c5520b455832c624fd44214b94ec7f6b75d87616746c7c172b338032686b2b1496bccc44cc48383a1d18622295c63d58f092dca6bfd3d6214a0de0859
checksum: 10/85b5570e577e3ee84ad1c484cdcc0a8d2003346168afbc46d3889784d50290ee1db1f3356523634b08dd2f7f3b2898d4c710fc895008c8d6b91a83d74656ec24
languageName: node
linkType: hard