Compare 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
github-actions[bot] d140ee84cc Version Packages (#5444)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-26 11:57:15 +01:00
dependabot[bot]andKen dfd3ddbe51 ⬆️ Bump the sanity group across 1 directory with 6 updates (#5452)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ken <ken.aleksander@gmail.com>
2026-10-26 11:56:43 +01:00
Halvor Haugan 68c66f345a chore(deps): sync versions of @next/env to 16.4.2 2026-10-26 11:39:42 +01:00
dependabot[bot] 64726b8874 ⬆️ Bump body-parser from 1.20.6 to 1.20.8 (#5456)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 09:48:36 +01:00
dependabot[bot] 774e25dd27 ⬆️ Bump the react group with 2 updates (#5455)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 09:48:08 +01:00
Halvor Haugan 1b639f4ede feat(website): add example of small Combobox (#5447) 2026-10-26 09:45:56 +01:00
dependabot[bot] c234d98d7c ⬆️ Bump the next group with 3 updates (#5453)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 09:45:12 +01:00
dependabot[bot] a0c7f1fe54 ⬆️ Bump ip-address from 10.7.2 to 10.7.4 (#5449)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-23 21:22:01 +02:00
Halvor Haugan 13bd3cbfd0 chore(website): bump isomorphic-dompurify to 4.6.0 (#5450) 2026-10-23 21:20:44 +02:00
Halvor Haugan d2325fb33b chore(aksel): bump chalk to 6.0.1 2026-10-23 20:19:40 +02:00
Halvor Haugan d8abc9bfeb chore(react): upgrade testing-library packages (#5446) 2026-10-23 20:10:33 +02:00
Halvor Haugan 769c477392 chore(deps): bump markdown-it to 15.0.3 2026-10-23 18:35:45 +02:00
Ken 6fb7eda476 fix(badge): Increase inline padding by 1px for better optical balance (#5443) 2026-10-23 14:34:27 +02:00
Halvor Haugan a013d5312f docs(core): update hideLabel prop description for consistency across components (#5412) 2026-10-23 10:10:46 +02:00
Halvor Haugan dabfb56def feat(Listbox): remove virtual focus on arrow up on first opt. and arrow up on last opt. if input has real focus (#5440) 2026-10-23 09:18:20 +02:00
Halvor Haugan c571a7d4ba revert(Listbox): don't fall back to selected opt. (reverts #5353) (#5439) 2026-10-23 09:18:20 +02:00
dependabot[bot] 848783bb9f ⬆️ Bump launch-editor from 2.14.1 to 2.14.2 (#5438)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-22 14:42:37 +02:00
57 changed files with 997 additions and 553 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.
+2
View File
@@ -1,5 +1,7 @@
# @navikt/aksel-icons
## 8.20.1
## 8.20.0
## 8.19.2
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-icons",
"version": "8.20.0",
"version": "8.20.1",
"author": "Aksel | Nav",
"homepage": "https://aksel.nav.no/ikoner",
"repository": {
@@ -85,7 +85,7 @@
"js-yaml": "^5.5.0",
"lodash": "4.18.1",
"prettier": "3.9.6",
"react": "19.3.0",
"react": "19.3.1",
"rehype-parse": "9.0.1",
"tsc-alias": "1.9.7",
"tsx": "^4.23.15",
+2
View File
@@ -1,5 +1,7 @@
# @navikt/aksel-stylelint
## 8.20.1
## 8.20.0
## 8.19.2
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-stylelint",
"version": "8.20.0",
"version": "8.20.1",
"type": "module",
"engines": {
"node": ">=18.12.0"
@@ -43,8 +43,8 @@
"dev": "yarn watch:lint"
},
"devDependencies": {
"@navikt/ds-css": "^8.20.0",
"@navikt/ds-tokens": "^8.20.0",
"@navikt/ds-css": "^8.20.1",
"@navikt/ds-tokens": "^8.20.1",
"concurrently": "10.0.6",
"postcss-selector-parser": "^7.1.7",
"postcss-value-parser": "^4.2.0",
+2
View File
@@ -1,5 +1,7 @@
# @navikt/aksel
## 8.20.1
## 8.20.0
## 8.19.2
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel",
"version": "8.20.0",
"version": "8.20.1",
"description": "Aksel command line interface. Codemods and other utilities for Aksel users.",
"author": "Aksel | Nav designsystem team",
"license": "MIT",
@@ -40,7 +40,7 @@
"url": "https://github.com/navikt/aksel/issues"
},
"dependencies": {
"chalk": "5.6.2",
"chalk": "6.0.1",
"cli-progress": "3.12.0",
"clipboardy": "5.3.2",
"commander": "15.0.0",
+6
View File
@@ -1,5 +1,11 @@
# @navikt/ds-css
## 8.20.1
### Patch Changes
- Badge: Increase inline padding by 1px for better optical balance ([#5443](https://github.com/navikt/aksel/pull/5443))
## 8.20.0
### Minor Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-css",
"version": "8.20.0",
"version": "8.20.1",
"description": "CSS for Nav Designsystem",
"author": "Aksel | Nav designsystem team",
"keywords": [
@@ -32,7 +32,7 @@
"css:get-version": "node config/get-version.js"
},
"devDependencies": {
"@navikt/ds-tokens": "^8.20.0",
"@navikt/ds-tokens": "^8.20.1",
"browserslist": "^4.28.7",
"esbuild": "^0.28.2",
"fast-glob": "3.3.3",
+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);
@@ -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;
+8
View File
@@ -1,5 +1,13 @@
# @navikt/ds-react
## 8.20.1
### Patch Changes
- Updated dependencies []:
- @navikt/ds-tokens@8.20.1
- @navikt/aksel-icons@8.20.1
## 8.20.0
### Minor Changes
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-react",
"version": "8.20.0",
"version": "8.20.1",
"description": "React components from the Norwegian Labour and Welfare Administration.",
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
"license": "MIT",
@@ -788,16 +788,16 @@
"dependencies": {
"@floating-ui/react": "0.27.20",
"@floating-ui/react-dom": "^2.1.9",
"@navikt/aksel-icons": "^8.20.0",
"@navikt/ds-tokens": "^8.20.0",
"@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.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/dom": "10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@testing-library/user-event": "^14.6.8",
"@types/jscodeshift": "17.3.0",
"@types/node": "^24.10.9",
"@types/react": "19.2.14",
@@ -807,8 +807,8 @@
"cross-env": "^10.1.0",
"jscodeshift": "17.4.0",
"jsdom": "29.1.1",
"react": "19.3.0",
"react-dom": "19.3.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"rimraf": "6.1.3",
"swr": "^2.4.2",
"tsc-alias": "1.9.7",
@@ -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({
@@ -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;
/**
@@ -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>
);
@@ -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;
@@ -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 };
+2
View File
@@ -1,5 +1,7 @@
# @navikt/ds-tailwind
## 8.20.1
## 8.20.0
## 8.19.2
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tailwind",
"version": "8.20.0",
"version": "8.20.1",
"description": "Tailwind config based on @navikt/ds-tokens",
"author": "Aksel | Nav designsystem team",
"keywords": [
@@ -28,7 +28,7 @@
},
"homepage": "https://aksel.nav.no/grunnleggende/kode/tailwind/",
"devDependencies": {
"@navikt/ds-tokens": "^8.20.0",
"@navikt/ds-tokens": "^8.20.1",
"@types/node": "^24.10.9",
"tailwindcss": "^3.3.3",
"tsx": "^4.23.15",
+2
View File
@@ -1,5 +1,7 @@
# @navikt/ds-tokens
## 8.20.1
## 8.20.0
## 8.19.2
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tokens",
"version": "8.20.0",
"version": "8.20.1",
"description": "Design-tokens for Nav designsystem",
"author": "Aksel | Nav designsystem team",
"keywords": [
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 8.20.1
### @navikt/ds-css
- Badge: Increase inline padding by 1px for better optical balance ([#5443](https://github.com/navikt/aksel/pull/5443))
## 8.20.0
### @navikt/ds-css
+6 -6
View File
@@ -11,11 +11,11 @@
"sanity:update": "yarn workspace website update:sanity"
},
"dependencies": {
"@navikt/aksel": "^8.20.0",
"@navikt/aksel-icons": "^8.20.0",
"@navikt/ds-css": "^8.20.0",
"@navikt/ds-react": "^8.20.0",
"@navikt/ds-tailwind": "^8.20.0",
"@navikt/ds-tokens": "^8.20.0"
"@navikt/aksel": "^8.20.1",
"@navikt/aksel-icons": "^8.20.1",
"@navikt/ds-css": "^8.20.1",
"@navikt/ds-react": "^8.20.1",
"@navikt/ds-tailwind": "^8.20.1",
"@navikt/ds-tokens": "^8.20.1"
}
}
+7 -7
View File
@@ -18,29 +18,29 @@
"@portabletext/react": "^8.0.1",
"@sanity/client": "7.27.0",
"@sanity/code-input": "7.3.8",
"@sanity/color-input": "6.1.4",
"@sanity/color-input": "6.1.8",
"@sanity/image-url": "2.1.1",
"@sanity/locale-nb-no": "1.1.39",
"@sanity/preview-url-secret": "4.1.5",
"@sanity/table": "3.1.16",
"@sanity/table": "3.1.20",
"@sanity/vision": "6.17.0",
"date-fns": "^4.0.0",
"lodash": "4.18.1",
"react": "19.3.0",
"react-dom": "19.3.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"react-rx": "^5.1.1",
"rxjs": "^7.8.2",
"sanity": "6.17.0",
"sanity-plugin-iframe-pane": "5.0.35",
"sanity-plugin-iframe-pane": "5.0.41",
"sanity-plugin-media": "6.1.8",
"sanity-plugin-references": "1.2.0",
"styled-components": "6.4.4",
"swr": "^2.4.2"
},
"devDependencies": {
"@sanity/blueprints": "0.24.0",
"@sanity/blueprints": "0.28.0",
"@sanity/export": "7.0.3",
"@sanity/functions": "1.7.2",
"@sanity/functions": "1.10.1",
"@sanity/schema": "6.17.0",
"@types/lodash": "4.17.25",
"@types/react": "19.2.14",
@@ -1,4 +1,4 @@
import DOMPurify from "isomorphic-dompurify";
import { sanitize } from "isomorphic-dompurify";
import "server-only";
import styles from "./GodPraksisPictogram.module.css";
@@ -26,7 +26,7 @@ async function GodPraksisPictogram({
const formattedSvgString = formatSvgString(svgString, active);
const cleanedSvg = DOMPurify.sanitize(formattedSvgString, {
const cleanedSvg = sanitize(formattedSvgString, {
USE_PROFILES: { svg: true },
});
@@ -1,4 +1,4 @@
import DOMPurify from "isomorphic-dompurify";
import { sanitize } from "isomorphic-dompurify";
import styles from "./ImageAsSvg.module.css";
/**
@@ -27,7 +27,7 @@ async function ImageAsThemedSvg({
const formattedSvgString = formatSvgString(svgString);
const cleanedSvg = DOMPurify.sanitize(formattedSvgString, {
const cleanedSvg = sanitize(formattedSvgString, {
USE_PROFILES: { svg: true },
});
+6 -6
View File
@@ -40,18 +40,18 @@
"codesandbox-import-utils": "^2.2.3",
"date-fns": "^4.0.0",
"fuse.js": "7.4.2",
"isomorphic-dompurify": "2.26.0",
"isomorphic-dompurify": "4.6.0",
"jscodeshift": "17.4.0",
"lodash": "4.18.1",
"lz-string": "^1.5.0",
"next": "16.3.8",
"next": "16.4.2",
"next-logger": "^5.0.2",
"next-sanity": "13.2.3",
"next-themes": "^0.4.6",
"pino": "^10.3.1",
"prism-react-renderer": "^2.0.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"react-hook-form": "^7.90.0",
"react-markdown": "^9.0.1",
"react-responsive-masonry": "2.7.2",
@@ -72,8 +72,8 @@
"@babel/parser": "^7.27.5",
"@babel/traverse": "^7.27.4",
"@google-cloud/bigquery": "^9.0.2",
"@next/bundle-analyzer": "16.3.8",
"@next/eslint-plugin-next": "16.3.8",
"@next/bundle-analyzer": "16.4.2",
"@next/eslint-plugin-next": "16.4.2",
"@playwright/test": "1.61.1",
"@portabletext/block-tools": "^6.0.0",
"@portabletext/markdown": "^2.5.1",
@@ -39,6 +39,6 @@ export const Demo = {
};
export const args: ExampleArgsT = {
index: 6,
index: 7,
desc: "Med `allowNewValues` kan brukeren legge til egne alternativer som ikke finnes i listen.",
};
@@ -55,6 +55,6 @@ export const Demo = {
};
export const args: ExampleArgsT = {
index: 8,
index: 9,
desc: "Ved å sende inn `options` som objekter er det mulig å vise en brukervennlig tekst til brukeren, samtidig som systemet i bakkant kan forholde seg til en ID.",
};
@@ -64,6 +64,6 @@ export const Demo = {
};
export const args: ExampleArgsT = {
index: 9,
index: 10,
desc: "Ved å sette `shouldShowSelectedOptions=false` kan du vise valgte alternativer et annet sted. Dette gjøres ofte i løsninger med flere filtre. Dette er også en mulig løsning når alternativene har mye tekst eller du ønsker å vise ekstra informasjon tilknyttet alternativene.",
};
@@ -50,6 +50,6 @@ export const Demo = {
};
export const args: ExampleArgsT = {
index: 7,
index: 8,
desc: "`maxSelected` lar deg sette en grense for antall alternativer brukeren kan velge.",
};
@@ -79,7 +79,7 @@ export const Demo = {
};
export const args: ExampleArgsT = {
index: 10,
index: 11,
desc: "Eksempel på bruk med react-hook-form.",
sandbox: false,
};
@@ -25,5 +25,5 @@ export const Demo = {
};
export const args: ExampleArgsT = {
index: 11,
index: 12,
};
@@ -0,0 +1,43 @@
import { UNSAFE_Combobox } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<UNSAFE_Combobox
label="Hva er de kuleste transportmidlene?"
options={options}
isMultiSelect
size="small"
/>
);
};
const options = [
"Bil",
"Buss",
"Tog",
"Skateboard",
"Sykkel",
"Motorsykkel",
"Båt",
"Fly",
"Helikopter",
"Lastebil",
"Van",
"Scooter",
];
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example, {
variant: "static",
minHeight: "260px",
});
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 6,
};
+5 -5
View File
@@ -11,12 +11,12 @@
"preview": "vite preview"
},
"dependencies": {
"@navikt/aksel-icons": "^8.20.0",
"@navikt/ds-css": "^8.20.0",
"@navikt/ds-react": "^8.20.0",
"@navikt/aksel-icons": "^8.20.1",
"@navikt/ds-css": "^8.20.1",
"@navikt/ds-react": "^8.20.1",
"fuse.js": "7.4.2",
"react": "19.3.0",
"react-dom": "19.3.0"
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@figma/plugin-typings": "^1.131.0",
+3 -3
View File
@@ -19,9 +19,9 @@
"devDependencies": {
"@modelcontextprotocol/inspector": "^2.11.0",
"@modelcontextprotocol/sdk": "^1.32.0",
"@navikt/aksel": "^8.20.0",
"@navikt/aksel-icons": "^8.20.0",
"@navikt/ds-tokens": "^8.20.0",
"@navikt/aksel": "^8.20.1",
"@navikt/aksel-icons": "^8.20.1",
"@navikt/ds-tokens": "^8.20.1",
"@sanity/client": "7.27.0",
"@types/express": "^5.0.3",
"@types/node": "^24.10.9",
+4 -4
View File
@@ -20,9 +20,9 @@
"typescript": "6.0.3"
},
"dependencies": {
"@navikt/aksel-icons": "^8.20.0",
"@navikt/ds-css": "^8.20.0",
"@navikt/ds-react": "^8.20.0",
"react": "19.3.0"
"@navikt/aksel-icons": "^8.20.1",
"@navikt/ds-css": "^8.20.1",
"@navikt/ds-react": "^8.20.1",
"react": "19.3.1"
}
}
+2 -2
View File
@@ -111,9 +111,9 @@
"lint-staged": "17.6.0",
"playwright": "1.61.1",
"prettier": "3.9.6",
"react": "19.3.0",
"react": "19.3.1",
"react-docgen-typescript": "2.4.0",
"react-dom": "19.3.0",
"react-dom": "19.3.1",
"storybook": "^10.6.3",
"stylelint": "^17.15.0",
"stylelint-config-standard": "^40.0.0",
+513 -436
View File
File diff suppressed because it is too large. Load diff