Compare commits

..
Author SHA1 Message Date
Ken 1a504a1f9f perf: Optimize test 2026-10-22 13:40:42 +02:00
Ken 2be908b51e feat: Dynamic axislabel view 2026-10-22 13:25:18 +02:00
150 changed files with 1375 additions and 1606 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"@navikt/ds-react": patch
"@navikt/ds-css": patch
---
Timeline: Adapt axis label density to available width
+3 -4
View File
@@ -22,7 +22,7 @@
## Repo summary
- Nav's design system. Yarn 4 workspaces monorepo: published packages under `@navikt/` + docs site `aksel.nav.no/` + `apps/` + `tooling/`.
- Stack: TypeScript 6, React 19 (dev), Next.js 16, Storybook 10, Vitest 4, ESLint 9, Stylelint 17, Oxlint (JS/TS lint), Prettier 3 (formatting). Node 22+, Yarn 4.18.0.
- Stack: TypeScript 6, React 19 (dev), Next.js 16, Storybook 10, Vitest 4, ESLint 9, Stylelint 17, Biome 2 (lint only), Prettier 3 (formatting). Node 22+, Yarn 4.18.0.
- Published `@navikt/*` packages must stay React 17 compatible (peer `react ^17 || ^18 || ^19`).
## Start and validate
@@ -45,7 +45,7 @@
## Commands
- `corepack yarn eslint --max-warnings=0 --no-warn-ignored <files...>` / `corepack yarn stylelint <files...>` / `corepack yarn oxlint --deny-warnings <files...>`
- `corepack yarn eslint --max-warnings=0 --no-warn-ignored <files...>` / `corepack yarn stylelint <files...>` / `corepack yarn biome lint --no-errors-on-unmatched <files...>`
- `corepack yarn tsc --noEmit --incremental false --project <tsconfig>` - targeted type-check
- `corepack yarn workspace <name> <script>` - e.g. `@navikt/ds-react test` (Vitest + tsc), `website test`
- `corepack yarn lint` / `corepack yarn test` / `corepack yarn boot` - root suites (slow; cross-workspace changes only)
@@ -64,7 +64,7 @@ Path-specific rules load automatically from `.github/instructions/` (see each `a
- `@navikt/aksel/src/` - CLI and codemods
- `aksel.nav.no/website/` - Next.js docs site · `aksel.nav.no/sanity-studio/` - CMS schema
- `apps/playroom/`, `apps/mcp-server/`, `apps/figma-icon-plugin/` - apps · `tooling/` - ESLint plugin, scripts, changelog, analyzer
- `.changeset/` - release notes · Root config: `eslint.config.js`, `stylelint.config.mjs`, `.oxlintrc.json`, `vitest.config.ts`, `.storybook/`
- `.changeset/` - release notes · Root config: `eslint.config.js`, `stylelint.config.mjs`, `biome.json`, `vitest.config.ts`, `.storybook/`
## Skills and agents
@@ -90,4 +90,3 @@ Path-specific rules load automatically from `.github/instructions/` (see each `a
- Preserve JSDoc on public props/components when changing public APIs.
- Reuse helpers: `cl`, `composeEventHandlers`, `useId`, `omit`, `useClientLayoutEffect`, `useEventListener`.
- No tests for trivial logic. Only tests for complex logic that has a higher risk of breaking.
- Suppress Oxlint rules with `oxlint-disable-next-line <rule>` / `oxlint-disable <rule>`, never `eslint-disable` (breaks ESLint).
@@ -1,13 +1,11 @@
---
description: "JS/TS conventions not enforced by ESLint/Oxlint: loops, export style and placement."
description: "JS/TS conventions not enforced by ESLint/Biome: loops, export style and placement."
applyTo: "**/*.ts,**/*.tsx,**/*.js,**/*.mts,**/*.mjs"
---
# JS/TS conventions
Formatting and import order are handled by Prettier; linting by ESLint and Oxlint. Don't hand-tune formatting or import order.
Suppress Oxlint rules with `// oxlint-disable-next-line <rule>` (file-level: `/* oxlint-disable <rule> */`). Never use `eslint-disable` for Oxlint rules; ESLint reports unknown rules and unused directives.
Formatting, import order and `import type` are handled by Prettier and Biome. Don't hand-tune them.
- Use `for...of` instead of `Array.forEach`. It supports `break`/`continue`/`await` and reads top-down.
- Use named exports only. Keep type exports separate from value exports (`export { X }` / `export type { XProps }`).
@@ -59,7 +59,7 @@ Group only matching files:
```sh
corepack yarn eslint --max-warnings=0 --no-warn-ignored <changed .ts/.tsx/.js/.jsx files>
corepack yarn stylelint <changed .css files>
corepack yarn oxlint --deny-warnings <changed .ts/.tsx/.js/.jsx files>
corepack yarn biome lint --no-errors-on-unmatched <changed supported source/config files>
```
Do not pass unsupported file types to make a command appear comprehensive. Documentation-only changes need no code validation unless a documentation check exists.
+1 -1
View File
@@ -26,7 +26,7 @@ argument-hint: "PR number, branch or files to review"
- 🟡 **Suggestion**: should fix. Missing test, docs or changeset, maintainability risk, over-editing.
- 💭 **Nit**: optional. Leave it out when in doubt.
Skip anything ESLint, Stylelint, Oxlint, Prettier or TypeScript already catches. No praise-only or speculative comments. Every finding says **why** it matters and what to do.
Skip anything ESLint, Stylelint, Biome, Prettier or TypeScript already catches. No praise-only or speculative comments. Every finding says **why** it matters and what to do.
## Checklist
-334
View File
@@ -1,334 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"eslint",
"typescript",
"unicorn",
"oxc",
"react",
"jsx-a11y",
"import",
"vitest"
],
"categories": {
"correctness": "error"
},
"ignorePatterns": [
"**/lib",
"**/public",
"**/esm",
"**/cjs",
"**/dist",
"**/.next",
"**/.build",
"**/coverage",
"**/storybook-static",
"**/test-results",
"**/playwright-report",
"**/.sanity/runtime",
"**/next-env.d.ts",
"**/codemod/**/*.js",
"**/transforms/**/*.input.*",
"**/transforms/**/*.output.*",
"@navikt/aksel/**/*.input.*",
"@navikt/aksel/**/*.output.*",
"**/modal/helpers/dialog-polyfill.ts",
"**/query-types.ts",
"**/component-names.generated.ts",
"**/tokens/**/plugin.js",
"@navikt/aksel-icons/src",
"@navikt/aksel/src/version.ts",
"@navikt/core/tailwind/src/tokens.ts",
"@navikt/core/tailwind/tailwind.config.js",
"@navikt/core/tailwind/tailwind.darkside.config.js",
"@navikt/core/tokens/token_docs.js",
"apps/playroom/playroom.config.js",
"apps/playroom/src/auto-generated-imports.ts",
"apps/figma-icon-plugin/src/ui/vite-env.d.ts",
"tooling/analyzer/temp",
".yarn"
],
"rules": {
"jsx-a11y/alt-text": "error",
"jsx-a11y/anchor-ambiguous-text": "error",
"jsx-a11y/anchor-has-content": "error",
"jsx-a11y/anchor-is-valid": "error",
"jsx-a11y/aria-activedescendant-has-tabindex": "error",
"jsx-a11y/aria-props": "error",
"jsx-a11y/aria-proptypes": "error",
"jsx-a11y/aria-role": "error",
"jsx-a11y/aria-unsupported-elements": "error",
"jsx-a11y/autocomplete-valid": "error",
"jsx-a11y/click-events-have-key-events": "error",
"jsx-a11y/heading-has-content": "error",
"jsx-a11y/html-has-lang": "error",
"jsx-a11y/iframe-has-title": "error",
"jsx-a11y/img-redundant-alt": "error",
"jsx-a11y/interactive-supports-focus": "error",
"jsx-a11y/label-has-associated-control": "error",
"jsx-a11y/lang": "error",
"jsx-a11y/media-has-caption": "error",
"jsx-a11y/mouse-events-have-key-events": "error",
"jsx-a11y/no-access-key": "error",
"jsx-a11y/no-aria-hidden-on-focusable": "error",
"jsx-a11y/no-autofocus": "error",
"jsx-a11y/no-distracting-elements": "error",
"jsx-a11y/no-interactive-element-to-noninteractive-role": "error",
"jsx-a11y/no-noninteractive-element-to-interactive-role": "error",
"jsx-a11y/no-noninteractive-tabindex": "error",
"jsx-a11y/no-redundant-roles": "error",
"jsx-a11y/no-static-element-interactions": "error",
"jsx-a11y/prefer-tag-over-role": "off",
"jsx-a11y/role-has-required-aria-props": "error",
"jsx-a11y/role-supports-aria-props": "error",
"jsx-a11y/scope": "error",
"jsx-a11y/tabindex-no-positive": "error",
"react/button-has-type": "error",
"eslint/no-extra-label": "warn",
"eslint/no-lone-blocks": "warn",
"eslint/no-sequences": "warn",
"eslint/no-unneeded-ternary": "warn",
"eslint/no-useless-computed-key": "warn",
"eslint/no-useless-constructor": "warn",
"eslint/no-useless-rename": "warn",
"eslint/prefer-numeric-literals": "warn",
"eslint/prefer-regex-literals": "warn",
"eslint/prefer-rest-params": "warn",
"react/jsx-no-useless-fragment": [
"warn",
{
"allowExpressions": true
}
],
"typescript/no-useless-empty-export": "warn",
"unicorn/no-static-only-class": "warn",
"unicorn/no-useless-switch-case": "warn",
"unicorn/prefer-array-flat": "warn",
"unicorn/prefer-array-flat-map": "warn",
"unicorn/prefer-array-index-of": "warn",
"unicorn/prefer-date-now": "warn",
"eslint/no-constructor-return": "error",
"eslint/no-inner-declarations": [
"error",
"both",
{
"blockScopedFunctions": "allow"
}
],
"eslint/no-use-before-define": [
"error",
{
"functions": false,
"classes": false,
"variables": false,
"enums": false,
"typedefs": false,
"ignoreTypeReferences": true,
"allowNamedExports": true
}
],
"eslint/radix": "warn",
"oxc/bad-min-max-func": "error",
"react/void-dom-elements-no-children": "error",
"import/namespace": "warn",
"oxc/no-accumulating-spread": "warn",
"eslint/no-eval": "error",
"eslint/no-script-url": "error",
"react/jsx-no-script-url": "error",
"react/no-danger": "error",
"eslint/prefer-const": "warn",
"eslint/prefer-exponentiation-operator": "warn",
"eslint/prefer-template": "warn",
"typescript/consistent-type-imports": [
"warn",
{
"disallowTypeAnnotations": false
}
],
"typescript/no-non-null-assertion": "warn",
"typescript/prefer-function-type": "warn",
"typescript/prefer-literal-enum-member": "warn",
"unicorn/prefer-node-protocol": "warn",
"eslint/array-callback-return": [
"error",
{
"checkForEach": true,
"allowVoid": true
}
],
"eslint/default-case-last": "warn",
"eslint/eqeqeq": [
"error",
"always",
{
"null": "ignore"
}
],
"eslint/no-label-var": "error",
"eslint/no-labels": [
"warn",
{
"allowLoop": true
}
],
"eslint/no-proto": "warn",
"eslint/no-self-compare": "error",
"eslint/no-template-curly-in-string": "warn",
"oxc/approx-constant": "warn",
"oxc/misrefactored-assign-op": "error",
"oxc/no-const-enum": "warn",
"react/no-array-index-key": "error",
"typescript/adjacent-overload-signatures": "warn",
"typescript/no-invalid-void-type": "warn",
"unicorn/no-document-cookie": "warn",
"unicorn/no-instanceof-array": "warn",
"unicorn/no-thenable": "error",
"unicorn/prefer-number-properties": "off",
"eslint/no-console": [
"warn",
{
"allow": [
"info",
"warn",
"error",
"group",
"groupEnd",
"table",
"assert",
"countReset",
"count",
"dir",
"time",
"timeEnd",
"timeStamp"
]
}
],
"eslint/no-else-return": [
"error",
{
"allowElseIf": false
}
],
"eslint/no-shadow": [
"error",
{
"ignoreTypeValueShadow": true,
"ignoreFunctionTypeParameterNameValueShadow": true
}
],
"eslint/object-shorthand": ["error", "always"],
"typescript/array-type": "off",
"vitest/require-mock-type-parameters": "off",
"vitest/require-to-throw-message": "off",
"typescript/no-explicit-any": "off",
"unicorn/no-array-for-each": "off",
"import/no-cycle": "off",
"react/refs": "off",
"eslint/constructor-super": "off",
"eslint/for-direction": "off",
"eslint/getter-return": "off",
"eslint/no-async-promise-executor": "off",
"eslint/no-class-assign": "off",
"eslint/no-compare-neg-zero": "off",
"eslint/no-cond-assign": "off",
"eslint/no-const-assign": "off",
"eslint/no-constant-binary-expression": "off",
"eslint/no-constant-condition": "off",
"eslint/no-control-regex": "off",
"eslint/no-debugger": "off",
"eslint/no-delete-var": "off",
"eslint/no-dupe-class-members": "off",
"eslint/no-dupe-else-if": "off",
"eslint/no-dupe-keys": "off",
"eslint/no-duplicate-case": "off",
"eslint/no-empty-character-class": "off",
"eslint/no-empty-pattern": "off",
"eslint/no-empty-static-block": "off",
"eslint/no-ex-assign": "off",
"eslint/no-extra-boolean-cast": "off",
"eslint/no-func-assign": "off",
"eslint/no-global-assign": "off",
"eslint/no-import-assign": "off",
"eslint/no-invalid-regexp": "off",
"eslint/no-irregular-whitespace": "off",
"eslint/no-loss-of-precision": "off",
"eslint/no-misleading-character-class": "off",
"eslint/no-new-native-nonconstructor": "off",
"eslint/no-nonoctal-decimal-escape": "off",
"eslint/no-obj-calls": "off",
"eslint/no-self-assign": "off",
"eslint/no-setter-return": "off",
"eslint/no-shadow-restricted-names": "off",
"eslint/no-sparse-arrays": "off",
"eslint/no-this-before-super": "off",
"eslint/no-unassigned-vars": "off",
"eslint/no-unreachable": "off",
"eslint/no-unsafe-finally": "off",
"eslint/no-unsafe-negation": "off",
"eslint/no-unsafe-optional-chaining": "off",
"eslint/no-unused-expressions": "off",
"eslint/no-unused-labels": "off",
"eslint/no-unused-private-class-members": "off",
"eslint/no-unused-vars": "off",
"eslint/no-useless-backreference": "off",
"eslint/no-useless-catch": "off",
"eslint/no-useless-escape": "off",
"eslint/no-with": "off",
"eslint/require-yield": "off",
"eslint/use-isnan": "off",
"eslint/valid-typeof": "off",
"import/default": "off",
"react/error-boundaries": "off",
"react/exhaustive-deps": "off",
"react/globals": "off",
"react/immutability": "off",
"react/incompatible-library": "off",
"react/jsx-key": "off",
"react/jsx-no-duplicate-props": "off",
"react/jsx-no-undef": "off",
"react/no-children-prop": "off",
"react/no-danger-with-children": "off",
"react/no-direct-mutation-state": "off",
"react/no-find-dom-node": "off",
"react/no-is-mounted": "off",
"react/no-render-return-value": "off",
"react/no-string-refs": "off",
"react/preserve-manual-memoization": "off",
"react/purity": "off",
"react/set-state-in-effect": "off",
"react/set-state-in-render": "off",
"react/static-components": "off",
"react/use-memo": "off",
"react/void-use-memo": "off",
"typescript/no-duplicate-enum-values": "off",
"typescript/no-extra-non-null-assertion": "off",
"typescript/no-misused-new": "off",
"typescript/no-non-null-asserted-optional-chain": "off",
"typescript/no-this-alias": "off",
"typescript/no-unsafe-declaration-merging": "off",
"typescript/no-wrapper-object-types": "off",
"typescript/prefer-as-const": "off",
"typescript/prefer-namespace-keyword": "off",
"typescript/triple-slash-reference": "off",
"vitest/no-duplicate-hooks": "error",
"vitest/expect-expect": "off",
"vitest/no-conditional-expect": "off",
"vitest/no-disabled-tests": "off",
"vitest/no-focused-tests": "off",
"vitest/no-standalone-expect": "off",
"vitest/require-local-test-context-for-concurrent-snapshots": "off",
"vitest/valid-describe-callback": "off",
"vitest/valid-expect": "off",
"vitest/valid-expect-in-promise": "off",
"vitest/valid-title": "off"
},
"overrides": [
{
"files": ["**/*.stories.ts", "**/*.stories.tsx", "scripts/*.ts"],
"rules": {
"eslint/no-console": "off"
}
}
]
}
+1 -1
View File
@@ -2,7 +2,7 @@
"recommendations": [
"dbaeumer.vscode-eslint",
"stylelint.vscode-stylelint",
"oxc.oxc-vscode",
"biomejs.biome",
"esbenp.prettier-vscode",
"denoland.vscode-deno",
"github.vscode-github-actions",
-2
View File
@@ -1,7 +1,5 @@
# @navikt/aksel-icons
## 8.20.1
## 8.20.0
## 8.19.2
+2 -1
View File
@@ -13,7 +13,8 @@ files.forEach((file) => {
data = data.map((x) => {
return x.includes("React.ForwardRefExoticComponent")
? `${x.split(":")[0]}: React.ForwardRefExoticComponent<React.SVGProps<SVGSVGElement> & SVGRProps & React.RefAttributes<SVGSVGElement>>;`
? x.split(":")[0] +
`: React.ForwardRefExoticComponent<React.SVGProps<SVGSVGElement> & SVGRProps & React.RefAttributes<SVGSVGElement>>;`
: x;
});
+1
View File
@@ -15,6 +15,7 @@ ymlList.forEach((file) => {
const ymlData = load(fs.readFileSync(`${basePath}/${file}`), {
schema: JSON_SCHEMA,
});
// biome-ignore lint/complexity/useOptionalChain: optional chain not supported in enviroment. Consider revriting to Typescript
if (ymlData.keywords && ymlData.keywords.includes("[ignore-docs]")) {
return;
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-icons",
"version": "8.20.1",
"version": "8.20.0",
"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.1",
"react": "19.3.0",
"rehype-parse": "9.0.1",
"tsc-alias": "1.9.7",
"tsx": "^4.23.15",
+1
View File
@@ -1,3 +1,4 @@
/* biome-ignore-all lint/correctness/useHookAtTopLevel: `useId` is invariant at runtime. */
import React, { useEffect, useState } from "react";
// https://github.com/mui/material-ui/blob/master/packages/mui-utils/src/useId.ts
-2
View File
@@ -1,7 +1,5 @@
# @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.1",
"version": "8.20.0",
"type": "module",
"engines": {
"node": ">=18.12.0"
@@ -43,8 +43,8 @@
"dev": "yarn watch:lint"
},
"devDependencies": {
"@navikt/ds-css": "^8.20.1",
"@navikt/ds-tokens": "^8.20.1",
"@navikt/ds-css": "^8.20.0",
"@navikt/ds-tokens": "^8.20.0",
"concurrently": "10.0.6",
"postcss-selector-parser": "^7.1.7",
"postcss-value-parser": "^4.2.0",
-2
View File
@@ -1,7 +1,5 @@
# @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.1",
"version": "8.20.0",
"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": "6.0.1",
"chalk": "5.6.2",
"cli-progress": "3.12.0",
"clipboardy": "5.3.2",
"commander": "15.0.0",
@@ -37,7 +37,7 @@ describe("getJSXLiteralValue", () => {
});
test("returns null for TemplateLiteral with expressions", () => {
// oxlint-disable-next-line eslint/no-template-curly-in-string -- Testing only
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing only
const node = getAttrValue("<Component prop={`hello ${name}`} />");
expect(getJSXLiteralValue(node)).toBe(null);
});
@@ -209,7 +209,7 @@ describe("getTokenRegex JS", () => {
expect("ATextRed").toMatch(regex);
expect(" ATextRed ").toMatch(regex);
// oxlint-disable-next-line eslint/no-template-curly-in-string -- Testing token match inside template string
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing token match inside template string
expect(" background: `${ATextRed}` ").toMatch(regex);
});
@@ -26,7 +26,7 @@ describe("v8-tokens-js keeps boundaries intact", () => {
["const a = ASurfaceDefault;", "const a = BgDefault;"],
["const a = [ASurfaceDefault];", "const a = [BgDefault];"],
["const a = {...ASurfaceDefault};", "const a = {...BgDefault};"],
// oxlint-disable-next-line eslint/no-template-curly-in-string -- Testing token inside template literal
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing token inside template literal
["const a = `${ASurfaceDefault}`;", "const a = `${BgDefault}`;"],
])("%s", (input, output) => {
expect(runJs(`${importLine}\n${input}`)).toBe(
-6
View File
@@ -1,11 +1,5 @@
# @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
+4 -4
View File
@@ -139,14 +139,14 @@ async function bundle() {
/* ------------------------------ /global build ----------------------------- */
for (const style of BASELINE_CSS_LIST) {
const parser = (input: string) => {
function parser(input: string) {
const parsed = input
.split("\n")
.filter((line) => line.startsWith("@import"))
.filter((line) => line.includes(style))
.join("\n");
return `${layerDefinition}\n${parsed}`;
};
}
await bundleCSS(parser).then((file) => {
writeFile({
@@ -208,13 +208,13 @@ async function bundle() {
}
for (const componentLine of componentFiles()) {
const parser = (input: string) => {
function parser(input: string) {
const parsed = input
.split("\n")
.filter((line) => line === componentLine)
.join("\n");
return `${layerDefinition}\n${parsed}`;
};
}
await bundleCSS(parser).then((file) => {
const componentName = componentLine
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-css",
"version": "8.20.1",
"version": "8.20.0",
"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.1",
"@navikt/ds-tokens": "^8.20.0",
"browserslist": "^4.28.7",
"esbuild": "^0.28.2",
"fast-glob": "3.3.3",
+1 -3
View File
@@ -16,9 +16,7 @@
justify-content: center;
min-width: var(--__axc-badge-size);
height: var(--__axc-badge-size);
/* 1px optical adjustment inline */
padding: var(--ax-space-4) calc(var(--ax-space-4) + var(--ax-space-1));
padding: var(--ax-space-4);
border-radius: var(--ax-radius-16);
background: var(--ax-bg-strong);
color: var(--ax-text-contrast);
+2
View File
@@ -2,6 +2,7 @@
display: flex;
flex-direction: column;
min-height: 100vh;
/* biome-ignore lint/suspicious/noDuplicateProperties: vh is fallback for browsers that do not support lvh */
min-height: 100lvh;
}
@@ -19,6 +20,7 @@
.aksel-page__content--fullheight {
min-height: 100vh;
/* biome-ignore lint/suspicious/noDuplicateProperties: vh is fallback for browsers that do not support lvh */
min-height: 100lvh;
}
+27
View File
@@ -13,6 +13,7 @@
box-sizing: content-box;
grid-column: 2;
margin-bottom: var(--ax-space-4);
overflow-x: clip;
}
.aksel-timeline__row-label {
@@ -30,13 +31,39 @@
align-self: flex-end;
}
.aksel-timeline__axislabels-tier {
position: absolute;
top: 0;
container-name: aksel-timeline-axis-labels;
container-type: inline-size;
}
.aksel-timeline__axislabels-label {
position: absolute;
width: 100%;
color: var(--ax-text-neutral-subtle);
white-space: nowrap;
overflow-x: clip;
}
.aksel-timeline__axislabels-tier > .aksel-timeline__axislabels-tier {
display: none;
}
/*
* 4.5rem leaves room for the longest default abbreviated date label.
* Below that, hide this tier and reveal the next coarser one in the same query.
*/
@container aksel-timeline-axis-labels (inline-size < 4.5rem) {
.aksel-timeline__axislabels-tier:not([data-axis-labels-coarsest]) > .aksel-timeline__axislabels-label {
display: none;
}
.aksel-timeline__axislabels-tier > .aksel-timeline__axislabels-tier {
display: block;
}
}
.aksel-timeline__row {
display: flex;
background: var(--ax-bg-neutral-softA);
-8
View File
@@ -1,13 +1,5 @@
# @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
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-react",
"version": "8.20.1",
"version": "8.20.0",
"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.1",
"@navikt/ds-tokens": "^8.20.1",
"@navikt/aksel-icons": "^8.20.0",
"@navikt/ds-tokens": "^8.20.0",
"date-fns": "^4.0.0",
"react-day-picker": "10.0.1"
},
"devDependencies": {
"@testing-library/dom": "10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.8",
"@testing-library/user-event": "^14.6.7",
"@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.1",
"react-dom": "19.3.1",
"react": "19.3.0",
"react-dom": "19.3.0",
"rimraf": "6.1.3",
"swr": "^2.4.2",
"tsc-alias": "1.9.7",
@@ -16,8 +16,10 @@ const ActionMenuShortcut = ({ children }: ShortcutProps) => {
<ActionMenuMarker placement="right">
{parsed.map((char, index) => (
<span
// oxlint-disable-next-line react/no-array-index-key -- This is a static list of characters, so using the index as a key is acceptable in this case
key={`${children}-${index}-${char}`}
key={`${children}-${
// biome-ignore lint/suspicious/noArrayIndexKey: This is a static list of characters, so using the index as a key is acceptable in this case
index
}-${char}`}
className="aksel-action-menu__shortcut"
>
{char}
@@ -43,7 +43,6 @@ const ChipsRemovable = forwardRef<HTMLButtonElement, ChipsRemovableProps>(
data-color={color ?? variantToColor(variant)}
{...rest}
ref={ref}
// oxlint-disable-next-line react/button-has-type -- React's button type union is validated by TypeScript and defaults to "button".
type={type}
className={cl(
"aksel-chips__chip aksel-chips__removable aksel-chips--icon-right",
@@ -149,7 +149,7 @@ export namespace DataGrid {
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Table {
// oxlint-disable-next-line eslint/no-shadow -- intentional namespace re-export
// biome-ignore lint/suspicious/noShadow: intentional namespace re-export
export type Props<T = unknown> = DataGridTable.Props<T>;
export type Sorting = DataGridTable.Sorting;
export type SortEntry = DataGridTable.SortEntry;
@@ -161,7 +161,7 @@ export namespace DataGrid {
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Preferences {
// oxlint-disable-next-line eslint/no-shadow -- intentional namespace re-export
// biome-ignore lint/suspicious/noShadow: intentional namespace re-export
export type Props = DataGridPreferences.Props;
}
}
@@ -1,3 +1,4 @@
/** biome-ignore-all lint/correctness/useHookAtTopLevel: False positive because of the way forwardRef() is added */
import React, {
forwardRef,
useCallback,
@@ -139,6 +139,7 @@ function useTableColumnResize({
[resizeMin, resizeMax, setWidth],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: We only want to run this on mount and when autoResizeOnce changes
useEffect(
function autoResizeColumn() {
if (!autoResizeOnce) {
@@ -220,6 +220,7 @@ export const TokenFilter = forwardRef<HTMLDivElement, TokenFilterProps>(
);
return (
// biome-ignore lint/a11y/useSemanticElements: search-tag is too new (baseline 2023)
<div
ref={ref}
className={cl("aksel-property-filter", className)}
@@ -61,7 +61,7 @@ function createGroups<T>(
}
/* Custom groups first, then default group if it has items */
const groups: OptionGroup<T>[] = Object.values(customGroups);
const groups: OptionGroup<T>[] = [...Object.values(customGroups)];
if (defaultGroup.options.length > 0) {
groups.push(defaultGroup);
@@ -49,15 +49,16 @@ export const Default: StoryObj<DefaultStoryProps> = {
toDate: new Date("Aug 23 2029"),
});
const newProps =
!props.inputfield || props.mode === "multiple"
const newProps = {
...(!props.inputfield || props.mode === "multiple"
? {
open,
onClose: () => setOpen(false),
fromDate: new Date("Aug 23 2020"),
toDate: new Date("Aug 23 2029"),
}
: {};
: {}),
};
const Comp = !props.standalone ? DatePicker : DatePicker.Standalone;
@@ -28,7 +28,6 @@ const DatePickerDayButton = ({
return (
<button
type="button"
{...rest}
ref={ref}
aria-hidden={day.outside}
@@ -104,7 +104,6 @@ const ReactDayPicker = ({
selected={selected}
classNames={LegacyClassNames}
components={{
// oxlint-disable-next-line react/jsx-no-useless-fragment -- react-day-picker requires an Element return type, so null is not allowed.
MonthCaption: () => <></>,
DayButton: useCallback(
(props) => <DatePickerDayButton {...props} locale={locale} />,
@@ -23,7 +23,7 @@ const DatePickerWeekRow = ({
return (
<Show below="sm" asChild>
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role -- role="grid" improves a11y reading */}
{/** biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: role="grid" improves a11y reading */}
<table className="rdp-table" role="grid">
<tbody className="rdp-tbody">
<tr className="rdp-row aksel-date__week-row">
@@ -26,7 +26,8 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
props;
return (
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- clickable div for checkbox card
// biome-ignore lint/a11y/noStaticElementInteractions: clickable div for checkbox card
// biome-ignore lint/a11y/useKeyWithClickEvents: clickable div for checkbox card
<div
className={cl(className, "aksel-checkbox", `aksel-checkbox--${size}`, {
"aksel-checkbox--error": hasError,
@@ -19,7 +19,7 @@ export interface CheckboxProps
*/
children: React.ReactNode;
/**
* Hides the label and description visually while keeping them available to screen readers.
* Visually hides label and description, and makes it viewable for screen-readers only.
*/
hideLabel?: boolean;
/**
@@ -42,6 +42,7 @@ const ComboboxWrapper = ({
}
return (
// biome-ignore lint/a11y/noStaticElementInteractions: Wrapper for combobox input and chips.
<div
ref={wrapperRef}
className={cl(
@@ -52,7 +52,7 @@ const AddNewOption = () => {
activeDescendantId === filteredOptionsUtil.getAddNewOptionId(id),
},
)}
// oxlint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role -- List item acts as an option in the combobox.
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: List item acts as an option in the combobox.
role="option"
aria-selected={false}
>
@@ -113,7 +113,7 @@ const FilteredOptions = () => {
{shouldRenderFilteredOptionsList && (
<ul
ref={setFilteredOptionsRef}
// oxlint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role -- The <ul> element acts as a listbox in the combobox.
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: The <ul> element acts as a listbox in the combobox.
role="listbox"
className="aksel-combobox__list-options"
>
@@ -76,7 +76,7 @@ const FilteredOptionsItem = ({ option }: { option: ComboboxOption }) => {
toggleIsListOpen(false);
}
}}
// oxlint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role -- List item acts as an option in the combobox.
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: List item acts as an option in the combobox.
role="option"
aria-selected={isInList(option.value, selectedOptions)}
aria-disabled={isDisabled(option) || undefined}
@@ -54,7 +54,8 @@ export const InputController = forwardRef<
return (
<Floating.Anchor asChild ref={setAnchorRef}>
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- Acts as wrapper list-toggle and input; key-events are handled by input itself */}
{/** biome-ignore lint/a11y/noStaticElementInteractions: Acts as wrapper list-toggle and input */}
{/** biome-ignore lint/a11y/useKeyWithClickEvents: key-events are handled by input itself */}
<div
className={cl("aksel-combobox__wrapper-inner aksel-text-field__input", {
"aksel-combobox__wrapper-inner--virtually-unfocused":
@@ -112,6 +112,7 @@ const SelectedOptionsProvider = ({
const newHideCaret =
isLimitReached || (!isMultiSelect && selectedOptions.length > 0);
// biome-ignore lint/correctness/useExhaustiveDependencies: We explicitly want to run this effect when selectedOptions changes to match the view with the selected options.
useEffect(() => {
setHideCaret(newHideCaret);
}, [newHideCaret, selectedOptions, setHideCaret]);
@@ -60,7 +60,8 @@ export interface ComboboxProps
*/
filteredOptions?: string[] | ComboboxOption[];
/**
* Hides the label and description visually while keeping them available to screen readers.
* Optionally hide the label visually.
* Not recommended, but can be considered for e.g. search fields in the top menu.
*/
hideLabel?: boolean;
/**
@@ -65,6 +65,7 @@ const highlightSubstring = (text: string, substring: string) => {
const end = text.substring(indexOfHighlightedText + substring.length);
return (
// Aria-label is used to fix testing-library wrongly evaluating the accessible name of the option when highlighting text
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: Doesn't matter if aria-label doesn't work
<span aria-label={text}>
{start}
{highlight && <mark>{highlight}</mark>}
@@ -28,7 +28,6 @@ const ComboboxTrigger = ({
return (
<Floating.Anchor asChild ref={rootContext.triggerRef}>
<Slot
// oxlint-disable-next-line jsx-a11y/role-has-required-aria-props -- aria-controls can be supplied through rest or the slotted trigger.
role="combobox"
aria-expanded={readOnly ? undefined : rootContext.open}
onClick={() => !readOnly && rootContext.setOpen(!rootContext.open)}
@@ -85,6 +85,7 @@ export const ErrorSummary = forwardRef<HTMLDivElement, ErrorSummaryProps>(
const mergedRef = useMergeRefs(ref, wrapperRef);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: Allows focus-calls on div to move focus to heading.
<div
ref={mergedRef}
{...rest}
@@ -90,7 +90,8 @@ const FileUploadDropzone = forwardRef<
{description}
</BodyShort>
)}
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- Assistive technologies and keyboard users access the input/button within. */}
{/** biome-ignore lint/a11y/noStaticElementInteractions: Assistive technologies will use the input within. */}
{/** biome-ignore lint/a11y/useKeyWithClickEvents: Keyboard will access input/button within */}
<div
className="aksel-dropzone__area"
onDragEnter={dropzoneCtx.onDragEnter}
@@ -2,7 +2,7 @@ import { isAcceptedSize } from "./is-accepted-size";
describe("isAcceptedSize", () => {
it("should return true if maxSize is less than or equal to 0", () => {
const file = new File(["a".repeat(4999)], "filename.txt", {
const file = new File([new Array(5000).join("a")], "filename.txt", {
type: "text/plain",
});
expect(isAcceptedSize(file, -1)).toBe(true);
@@ -10,7 +10,7 @@ describe("isAcceptedSize", () => {
});
it("should return true if file size is less than or equal to maxSize", () => {
const file = new File(["a".repeat(4999)], "filename.txt", {
const file = new File([new Array(5000).join("a")], "filename.txt", {
type: "text/plain",
});
expect(isAcceptedSize(file, 5000)).toBe(true);
@@ -18,7 +18,7 @@ describe("isAcceptedSize", () => {
});
it("should return false if file size is greater than maxSize", () => {
const file = new File(["a".repeat(4999)], "filename.txt", {
const file = new File([new Array(5000).join("a")], "filename.txt", {
type: "text/plain",
});
expect(isAcceptedSize(file, 4000)).toBe(false);
+2 -1
View File
@@ -22,7 +22,8 @@ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
const { className, description, children } = props;
return (
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- clickable div for radio card
// biome-ignore lint/a11y/noStaticElementInteractions: clickable div for radio card
// biome-ignore lint/a11y/useKeyWithClickEvents: clickable div for radio card
<div
className={cl(className, "aksel-radio", `aksel-radio--${size}`, {
"aksel-radio--error": hasError,
@@ -39,7 +39,7 @@ export interface SearchProps
*/
label: React.ReactNode;
/**
* Hides the label and description visually while keeping them available to screen readers.
* Shows label and description for screenreaders only.
* @default true
*/
hideLabel?: boolean;
@@ -158,7 +158,7 @@ export const Search = forwardRef<HTMLInputElement, SearchProps>(
clearButton && !inputProps.disabled && (value ?? internalValue);
return (
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- Escape key handler for clearing input
// biome-ignore lint/a11y/noStaticElementInteractions: Escape key handler for clearing input
<div
onKeyDown={(event) => {
if (event.key !== "Escape") {
@@ -23,7 +23,7 @@ export interface SelectProps
*/
label: React.ReactNode;
/**
* Hides the label and description visually while keeping them available to screen readers.
* Shows label and description for screenreaders only.
*/
hideLabel?: boolean;
}
@@ -15,7 +15,7 @@ export interface SwitchProps
*/
children: React.ReactNode;
/**
* Hides the label and description visually while keeping them available to screen readers.
* If enabled shows the label and description for screenreaders only.
*/
hideLabel?: boolean;
/**
@@ -36,7 +36,7 @@ export interface TextareaProps
*/
label: React.ReactNode;
/**
* Hides the label and description visually while keeping them available to screen readers.
* If enabled shows the label and description for screenreaders only.
*/
hideLabel?: boolean;
/**
@@ -20,7 +20,7 @@ export interface TextFieldProps
*/
htmlSize?: number;
/**
* Hides the label and description visually while keeping them available to screen readers
* If enabled shows the label and description for screenreaders only
*/
hideLabel?: boolean;
/**
+1 -1
View File
@@ -80,7 +80,7 @@ export const useFormField = (
const showErrorMsg =
!disabled && !readOnly && !!error && typeof error !== "boolean";
const ariaInvalid = hasError ? { "aria-invalid": true } : {};
const ariaInvalid = { ...(hasError ? { "aria-invalid": true } : {}) };
if ((props as any)?.required) {
consoleWarning(
@@ -52,15 +52,15 @@ export const Variants: StoryFn = () => {
<VStack gap="space-16">
{variants.map((variant, i) => (
<Alert key={variant} variant={variant}>
{"Id elit esse enim reprehenderit enim nisi veniam nostrud. ".repeat(
i + 1,
{new Array(i + 1).fill(
"Id elit esse enim reprehenderit enim nisi veniam nostrud. ",
)}
</Alert>
))}
{variants.map((variant, i) => (
<Alert key={variant} variant={variant} size="small">
{"Id elit esse enim reprehenderit enim nisi veniam nostrud. ".repeat(
i + 1,
{new Array(i + 1).fill(
"Id elit esse enim reprehenderit enim nisi veniam nostrud. ",
)}
</Alert>
))}
@@ -102,6 +102,7 @@ export const Stepper: StepperComponent = forwardRef<
className="aksel-stepper__item"
data-interactive={isInteractive}
data-color={isInteractive ? undefined : "neutral"}
// biome-ignore lint/suspicious/noArrayIndexKey: Unsure how to fix gracefully
key={index + (children?.toString?.() ?? "")}
>
<span className="aksel-stepper__line aksel-stepper__line--1" />
+1
View File
@@ -89,6 +89,7 @@ export const Lookup = forwardRef<HTMLSpanElement, LookupProps>(
return (
<>
{/** biome-ignore lint/a11y/useSemanticElements: We use a span since the built-in button styling does not allow for proper line-break. Lookup-text needs to be able to "flow" across multiple lines. */}
<span
{...rest}
role="button"
+20 -18
View File
@@ -149,24 +149,26 @@ export const EmptyHeader: StoryFn = () => (
);
export const SizeSmall: StoryFn = () => (
<Modal
open
onClose={() => null}
header={{
heading: "Simple header",
size: "small",
}}
size="small"
>
<Modal.Body>
<BodyLong>Lorem ipsum dolor sit amet.</BodyLong>
</Modal.Body>
<Modal.Footer>
<Button size="small" variant="secondary">
Dummy button
</Button>
</Modal.Footer>
</Modal>
<>
<Modal
open
onClose={() => null}
header={{
heading: "Simple header",
size: "small",
}}
size="small"
>
<Modal.Body>
<BodyLong>Lorem ipsum dolor sit amet.</BodyLong>
</Modal.Body>
<Modal.Footer>
<Button size="small" variant="secondary">
Dummy button
</Button>
</Modal.Footer>
</Modal>
</>
);
SizeSmall.storyName = "Size = Small";
@@ -170,7 +170,6 @@ const ModalRoot = forwardRef<HTMLDialogElement, ModalProps>(
: ariaLabelledby;
const component = (
// oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- Native dialog handles backdrop dismissal and bubbling Escape events.
<dialog
{...rest}
ref={mergedRef}
@@ -75,7 +75,6 @@ const MonthPickerButtonInternal = ({
const isThisMonth = dateIsInCurrentMonth(month, year);
return (
// oxlint-disable-next-line jsx-a11y/control-has-associated-label -- The full month name is provided by the nested screen-reader-only span.
<button
ref={ref}
type="button"
@@ -172,7 +172,7 @@ const PaginationRoot = forwardRef<HTMLElement, PaginationProps>(
return Number.isNaN(n) ? (
<li
className="aksel-pagination__ellipsis"
// oxlint-disable-next-line react/no-array-index-key -- Using index as key for ellipsis items
// biome-ignore lint/suspicious/noArrayIndexKey: Using index as key for ellipsis items
key={`${step}-${i}`}
>
<BodyShort
@@ -16,162 +16,176 @@ export default {
export const Default = {
render: () => (
<VStack gap="space-8">
<Box background="meta-purple-moderate" padding="space-20">
<Box background="meta-lime-soft" padding="space-20">
<Bleed marginInline="space-40 space-0">
<Box padding="space-12" background="success-moderate">
<BodyLong>marginInline=&quot;10 0&quot;</BodyLong>
</Box>
</Bleed>
<>
<VStack gap="space-8">
<Box background="meta-purple-moderate" padding="space-20">
<Box background="meta-lime-soft" padding="space-20">
<Bleed marginInline="space-40 space-0">
<Box padding="space-12" background="success-moderate">
<BodyLong>marginInline=&quot;10 0&quot;</BodyLong>
</Box>
</Bleed>
</Box>
</Box>
</Box>
<Box background="meta-purple-moderate" padding="space-20">
<Box background="meta-lime-soft" padding="space-20">
<Bleed marginInline="space-0 space-40">
<Box padding="space-12" background="success-moderate">
<BodyLong>marginInline=&quot;0 10&quot;</BodyLong>
</Box>
</Bleed>
<Box background="meta-purple-moderate" padding="space-20">
<Box background="meta-lime-soft" padding="space-20">
<Bleed marginInline="space-0 space-40">
<Box padding="space-12" background="success-moderate">
<BodyLong>marginInline=&quot;0 10&quot;</BodyLong>
</Box>
</Bleed>
</Box>
</Box>
</Box>
<Box background="meta-purple-moderate" padding="space-20">
<Box background="meta-lime-soft" padding="space-20">
<Bleed marginBlock="space-40 space-0">
<Box padding="space-12" background="success-moderate">
<BodyLong>marginBlock=&quot;10 0&quot;</BodyLong>
</Box>
</Bleed>
<Box background="meta-purple-moderate" padding="space-20">
<Box background="meta-lime-soft" padding="space-20">
<Bleed marginBlock="space-40 space-0">
<Box padding="space-12" background="success-moderate">
<BodyLong>marginBlock=&quot;10 0&quot;</BodyLong>
</Box>
</Bleed>
</Box>
</Box>
</Box>
<Box background="meta-purple-moderate" padding="space-20">
<Box background="meta-lime-soft" padding="space-20">
<Bleed marginBlock="space-0 space-40">
<Box padding="space-12" background="success-moderate">
<BodyLong>marginBlock=&quot;0 10&quot;</BodyLong>
</Box>
</Bleed>
<Box background="meta-purple-moderate" padding="space-20">
<Box background="meta-lime-soft" padding="space-20">
<Bleed marginBlock="space-0 space-40">
<Box padding="space-12" background="success-moderate">
<BodyLong>marginBlock=&quot;0 10&quot;</BodyLong>
</Box>
</Bleed>
</Box>
</Box>
</Box>
</VStack>
</VStack>
</>
),
};
export const AsChild = {
render: () => (
<VStack gap="space-8">
<Box background="meta-purple-moderate" padding="space-20">
<Box background="meta-lime-soft" padding="space-20">
<Bleed marginInline="space-40 space-0" asChild>
<Box padding="space-12" background="success-moderate">
<BodyLong>marginInline=&quot;10 0&quot;</BodyLong>
</Box>
</Bleed>
<>
<VStack gap="space-8">
<Box background="meta-purple-moderate" padding="space-20">
<Box background="meta-lime-soft" padding="space-20">
<Bleed marginInline="space-40 space-0" asChild>
<Box padding="space-12" background="success-moderate">
<BodyLong>marginInline=&quot;10 0&quot;</BodyLong>
</Box>
</Bleed>
</Box>
</Box>
</Box>
<Box background="meta-purple-moderate" padding="space-20">
<Box background="meta-lime-soft" padding="space-20">
<Bleed marginInline="space-0 space-40" asChild>
<Box padding="space-12" background="success-moderate">
<BodyLong>marginInline=&quot;0 10&quot;</BodyLong>
</Box>
</Bleed>
<Box background="meta-purple-moderate" padding="space-20">
<Box background="meta-lime-soft" padding="space-20">
<Bleed marginInline="space-0 space-40" asChild>
<Box padding="space-12" background="success-moderate">
<BodyLong>marginInline=&quot;0 10&quot;</BodyLong>
</Box>
</Bleed>
</Box>
</Box>
</Box>
<Box background="meta-purple-moderate" padding="space-20">
<Box background="meta-lime-soft" padding="space-20">
<Bleed marginBlock="space-40 space-0" asChild>
<Box padding="space-12" background="success-moderate">
<BodyLong>marginBlock=&quot;10 0&quot;</BodyLong>
</Box>
</Bleed>
<Box background="meta-purple-moderate" padding="space-20">
<Box background="meta-lime-soft" padding="space-20">
<Bleed marginBlock="space-40 space-0" asChild>
<Box padding="space-12" background="success-moderate">
<BodyLong>marginBlock=&quot;10 0&quot;</BodyLong>
</Box>
</Bleed>
</Box>
</Box>
</Box>
<Box background="meta-purple-moderate" padding="space-20">
<Box background="meta-lime-soft" padding="space-20">
<Bleed marginBlock="space-0 space-40" asChild>
<Box padding="space-12" background="success-moderate">
<BodyLong>marginBlock=&quot;0 10&quot;</BodyLong>
</Box>
</Bleed>
<Box background="meta-purple-moderate" padding="space-20">
<Box background="meta-lime-soft" padding="space-20">
<Bleed marginBlock="space-0 space-40" asChild>
<Box padding="space-12" background="success-moderate">
<BodyLong>marginBlock=&quot;0 10&quot;</BodyLong>
</Box>
</Bleed>
</Box>
</Box>
</Box>
</VStack>
</VStack>
</>
),
};
export const Breakpoints = {
render: () => (
<VStack gap="space-8">
<Box
background="meta-purple-moderate"
padding={{ xs: "space-20", md: "space-40" }}
>
<>
<VStack gap="space-8">
<Box
background="meta-lime-soft"
background="meta-purple-moderate"
padding={{ xs: "space-20", md: "space-40" }}
>
<Bleed
marginInline={{ xs: "space-40 space-0", md: "space-80 space-0" }}
<Box
background="meta-lime-soft"
padding={{ xs: "space-20", md: "space-40" }}
>
<Box padding="space-12" background="success-moderate">
<BodyLong>{'marginInline={{ xs: "10 0", md: "20 0" }}'}</BodyLong>
</Box>
</Bleed>
<Bleed
marginInline={{ xs: "space-40 space-0", md: "space-80 space-0" }}
>
<Box padding="space-12" background="success-moderate">
<BodyLong>
{'marginInline={{ xs: "10 0", md: "20 0" }}'}
</BodyLong>
</Box>
</Bleed>
</Box>
</Box>
</Box>
<Box
background="meta-purple-moderate"
padding={{ xs: "space-20", md: "space-40" }}
>
<Box
background="meta-lime-soft"
background="meta-purple-moderate"
padding={{ xs: "space-20", md: "space-40" }}
>
<Bleed
marginInline={{ xs: "space-0 space-40", md: "space-0 space-80" }}
<Box
background="meta-lime-soft"
padding={{ xs: "space-20", md: "space-40" }}
>
<Box padding="space-12" background="success-moderate">
<BodyLong>{'marginInline={{ xs: "0 10", md: "0 20" }}'}</BodyLong>
</Box>
</Bleed>
<Bleed
marginInline={{ xs: "space-0 space-40", md: "space-0 space-80" }}
>
<Box padding="space-12" background="success-moderate">
<BodyLong>
{'marginInline={{ xs: "0 10", md: "0 20" }}'}
</BodyLong>
</Box>
</Bleed>
</Box>
</Box>
</Box>
<Box
background="meta-purple-moderate"
padding={{ xs: "space-20", md: "space-40" }}
>
<Box
background="meta-lime-soft"
background="meta-purple-moderate"
padding={{ xs: "space-20", md: "space-40" }}
>
<Bleed
marginBlock={{ xs: "space-40 space-0", md: "space-80 space-0" }}
<Box
background="meta-lime-soft"
padding={{ xs: "space-20", md: "space-40" }}
>
<Box padding="space-12" background="success-moderate">
<BodyLong>{'marginBlock={{ xs: "10 0", md: "20 0" }}'}</BodyLong>
</Box>
</Bleed>
<Bleed
marginBlock={{ xs: "space-40 space-0", md: "space-80 space-0" }}
>
<Box padding="space-12" background="success-moderate">
<BodyLong>
{'marginBlock={{ xs: "10 0", md: "20 0" }}'}
</BodyLong>
</Box>
</Bleed>
</Box>
</Box>
</Box>
<Box
background="meta-purple-moderate"
padding={{ xs: "space-20", md: "space-40" }}
>
<Box
background="meta-lime-soft"
background="meta-purple-moderate"
padding={{ xs: "space-20", md: "space-40" }}
>
<Bleed
marginBlock={{ xs: "space-0 space-40", md: "space-0 space-80" }}
<Box
background="meta-lime-soft"
padding={{ xs: "space-20", md: "space-40" }}
>
<Box padding="space-12" background="success-moderate">
<BodyLong>{'marginBlock={{ xs: "0 10", md: "0 20" }}'}</BodyLong>
</Box>
</Bleed>
<Bleed
marginBlock={{ xs: "space-0 space-40", md: "space-0 space-80" }}
>
<Box padding="space-12" background="success-moderate">
<BodyLong>
{'marginBlock={{ xs: "0 10", md: "0 20" }}'}
</BodyLong>
</Box>
</Bleed>
</Box>
</Box>
</Box>
</VStack>
</VStack>
</>
),
};
@@ -155,30 +155,32 @@ const ChatBubble = () => (
</Box>
);
const PricePill = () => (
<HStack>
<Box
background="success-soft"
padding="space-16"
borderRadius="full 0 0 full"
>
<VStack align="center">
<Detail>Episk ny pris</Detail>
<Heading size="medium">889.99 kr</Heading>
</VStack>
</Box>
<Box
background="danger-soft"
padding="space-16"
borderRadius="0 full full 0"
>
<VStack align="center">
<Detail>Førpris</Detail>
<Heading size="medium" style={{ textDecoration: "line-through" }}>
399.99 kr
</Heading>
</VStack>
</Box>
</HStack>
<>
<HStack>
<Box
background="success-soft"
padding="space-16"
borderRadius="full 0 0 full"
>
<VStack align="center">
<Detail>Episk ny pris</Detail>
<Heading size="medium">889.99 kr</Heading>
</VStack>
</Box>
<Box
background="danger-soft"
padding="space-16"
borderRadius="0 full full 0"
>
<VStack align="center">
<Detail>Førpris</Detail>
<Heading size="medium" style={{ textDecoration: "line-through" }}>
399.99 kr
</Heading>
</VStack>
</Box>
</HStack>
</>
);
export const PaddingBreakpoints = {
+2 -1
View File
@@ -149,7 +149,7 @@ export const Process: ProcessComponent = forwardRef<
<ol
ref={mergedRef}
data-color="info"
// oxlint-disable-next-line jsx-a11y/no-redundant-roles -- See comment above
// biome-ignore lint/a11y/noRedundantRoles: See comment above
role="list"
{...restProps}
className={cl("aksel-process", className)}
@@ -218,6 +218,7 @@ export const ProcessEvent = forwardRef<HTMLLIElement, ProcessEventProps>(
const { syncAriaControls, hideStatusText, rootId } = useProcessContext();
// syncAriaControls is already memoized with useCallback
// biome-ignore lint/correctness/useExhaustiveDependencies: We want to run this only when status changes
useEffect(syncAriaControls, [status]); // eslint-disable-line react-hooks/exhaustive-deps
const isActive = status === "active";
@@ -98,6 +98,7 @@ const TableAnimateHeightInternal: React.FC<AnimateHeightProps> = ({
const [useTransitions, setUseTransitions] = useState<boolean>(false);
const didHideOnMount = useRef(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run this on mount
useEffect(() => {
/**
* We check for multiple runs caused by React.StrictMode.
@@ -117,6 +118,7 @@ const TableAnimateHeightInternal: React.FC<AnimateHeightProps> = ({
}, []);
// ------------------ Height update
// biome-ignore lint/correctness/useExhaustiveDependencies: This should be explicitly run only on height change
useEffect(() => {
if (height !== prevHeight.current && contentElement.current) {
showContent(contentElement.current, prevHeight.current);
@@ -30,7 +30,6 @@ const TabsList = forwardRef<HTMLDivElement, TabsListProps>(
onClick={scrollCtx.scrollLeft}
/>
)}
{/* oxlint-disable-next-line jsx-a11y/interactive-supports-focus -- Focus roves between child tabs; the tablist must not be an extra tab stop. */}
<div
ref={mergedRef}
{...rest}
@@ -35,6 +35,7 @@ const TabsPanel = forwardRef<HTMLDivElement, TabsPanelProps>(
{...rest}
className={cl("aksel-tabs__tabpanel", className)}
role="tabpanel"
// biome-ignore lint/a11y/noNoninteractiveTabindex: TabPanel is a non-interactive element that requires a tabindex for keyboard navigation.
tabIndex={0}
aria-labelledby={rest["aria-labelledby"] ?? ctx.labelledbyId}
id={id ?? ctx.id}
@@ -599,7 +599,15 @@ export const CustomLabel: StoryFn = () => {
export const OverflowingAxisLabels: StoryFn = () => {
return (
<div style={{ width: "800px" }}>
<div
style={{
width: "800px",
maxWidth: "100%",
minWidth: "200px",
resize: "horizontal",
overflow: "auto",
}}
>
<Timeline
startDate={new Date("2026-05-04")}
endDate={new Date("2026-05-31")}
@@ -97,6 +97,40 @@ function TwoRowTimelineWithPins() {
);
}
function ResponsiveAxisTimeline() {
return (
<>
{(["left", "right"] as const).flatMap((direction) =>
[
["2026-05-04", "2026-05-31"],
["2024-02-15", "2025-03-05"],
["2022-06-15", "2047-07-05"],
].map(([start, end]) => (
<div
key={`${direction}-${start}`}
data-axis-size
style={{ width: "320px" }}
>
<Timeline
direction={direction}
startDate={new Date(start)}
endDate={new Date(end)}
>
<Timeline.Row label="Row">
<Timeline.Period
start={new Date(start)}
end={new Date(end)}
status="success"
/>
</Timeline.Row>
</Timeline>
</div>
)),
)}
</>
);
}
export const HorizontalPeriodNavigation: Story = {
render: () => <TwoRowTimeline />,
play: async ({ canvasElement }) => {
@@ -179,3 +213,86 @@ export const PinNavigation: Story = {
expect(pins[0]).toHaveFocus();
},
};
export const AxisLabelsAdaptToWidth: Story = {
render: () => <ResponsiveAxisTimeline />,
play: async ({ canvasElement }) => {
const containers =
canvasElement.querySelectorAll<HTMLElement>("[data-axis-size]");
expect(containers).toHaveLength(6);
for (const container of containers) {
const axis = container.querySelector<HTMLElement>(
".aksel-timeline__axislabels",
);
const finestTier = axis?.querySelector<HTMLElement>(
".aksel-timeline__axislabels-tier",
);
if (!axis || !finestTier) {
throw new Error("Timeline axis or label tier not found");
}
const markup = axis.innerHTML;
const height = axis.getBoundingClientRect().height;
const selectedDensities = new Set<string | null>();
const labelColumnWidth =
container.getBoundingClientRect().width -
axis.getBoundingClientRect().width;
const minSlotWidth =
4.5 *
Number.parseFloat(
window.getComputedStyle(document.documentElement).fontSize,
);
const breakpoint =
(minSlotWidth * 100) / Number.parseFloat(finestTier.style.width) +
labelColumnWidth;
for (const width of [200, breakpoint, 2400]) {
container.style.width = `${width}px`;
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
const visibleLabels = [
...axis.querySelectorAll<HTMLElement>(
".aksel-timeline__axislabels-label",
),
].filter((label) => label.getClientRects().length > 0);
const densities = new Set(
visibleLabels.map((label) =>
label.getAttribute("data-axis-labels-density"),
),
);
expect(densities.size, `Axis density at container width ${width}`).toBe(
1,
);
for (const density of densities) {
selectedDensities.add(density);
}
const axisBounds = axis.getBoundingClientRect();
const textBounds = visibleLabels
.map((label) => {
const range = document.createRange();
range.selectNodeContents(label);
return range.getBoundingClientRect();
})
.sort((a, b) => a.left - b.left);
const labelsFit = textBounds.every(
(bounds, index) =>
bounds.left >= axisBounds.left - 1 &&
bounds.right <= axisBounds.right + 1 &&
(index === 0 || bounds.left >= textBounds[index - 1].right),
);
expect(
labelsFit,
`Labels stay within the axis without overlap at container width ${width}`,
).toBe(true);
expect(axisBounds.height).toBe(height);
expect(axis.innerHTML).toBe(markup);
}
expect(selectedDensities.size).toBeGreaterThan(1);
}
},
};
@@ -4,6 +4,7 @@ import {
addMonths,
addYears,
differenceInDays,
differenceInMilliseconds,
differenceInMonths,
differenceInYears,
endOfMonth,
@@ -14,31 +15,45 @@ import {
startOfYear,
subDays,
} from "date-fns";
import React, { useCallback, useMemo } from "react";
import React, { useMemo } from "react";
import { Detail } from "../../typography/Detail";
import { useDateLocale, useI18n } from "../../utils/i18n/i18n.hooks";
import type { TFunction } from "../../utils/i18n/i18n.types";
import { useTimelineContext } from "../root/TimelineRoot.context";
import { isVisible } from "../utils";
import { horizontalPositionAndWidth } from "../utils/calc";
import type { AxisLabel, AxisLabelTemplates } from "../utils/types.external";
// Use 23 hours as a conservative day length so DST-shortened days still qualify.
const MIN_SAFE_DAY_IN_MILLISECONDS = 23 * 60 * 60 * 1000;
const MAX_LABEL_COUNT = 100;
interface AxisLabelSet {
id: string;
labels: AxisLabel[];
slotWidth: number;
}
const dayLabels = (
start: Date,
end: Date,
totalDays: number,
intervalDays: number,
direction: "left" | "right",
template: string,
locale: Locale,
): AxisLabel[] => {
const increment = Math.ceil(totalDays / 10);
const totalDays = differenceInDays(end, start);
const count = Math.ceil(totalDays / intervalDays) + 1;
if (count > MAX_LABEL_COUNT) {
return [];
}
const lastDay = startOfDay(end);
const count = Math.ceil(totalDays / increment);
return Array.from({ length: count }, (_, i) => {
const day: Date = subDays(lastDay, i * increment);
const day: Date = subDays(lastDay, i * intervalDays);
const { horizontalPosition, width } = horizontalPositionAndWidth(
day,
addDays(day, 1),
addDays(day, intervalDays),
start,
end,
);
@@ -55,18 +70,25 @@ const dayLabels = (
const monthLabels = (
start: Date,
end: Date,
intervalMonths: number,
direction: "left" | "right",
template: string,
locale: Locale,
): AxisLabel[] => {
const startMonth = startOfMonth(start);
const endMonth = endOfMonth(end);
const numberOfMonths = differenceInMonths(endMonth, startMonth) + 1;
// Count calendar boundaries at this interval, not elapsed 30-day periods.
const numberOfMonths =
Math.floor(differenceInMonths(endMonth, startMonth) / intervalMonths) + 1;
if (numberOfMonths > MAX_LABEL_COUNT) {
return [];
}
return Array.from({ length: numberOfMonths }, (_, i) => {
const month: Date = addMonths(startMonth, i);
const month: Date = addMonths(startMonth, i * intervalMonths);
const { horizontalPosition, width } = horizontalPositionAndWidth(
month,
addMonths(month, 1),
addMonths(month, intervalMonths),
start,
end,
);
@@ -83,18 +105,25 @@ const monthLabels = (
const yearLabels = (
start: Date,
end: Date,
intervalYears: number,
direction: "left" | "right",
template: string,
locale: Locale,
): AxisLabel[] => {
const firstYear = startOfYear(start);
const lastYear = endOfYear(end);
const yearCount = differenceInYears(lastYear, start) + 1;
const yearCount =
Math.floor(differenceInYears(lastYear, start) / intervalYears) + 1;
if (yearCount > MAX_LABEL_COUNT) {
return [];
}
return Array.from({ length: yearCount }, (_, i) => {
const year: Date = addYears(firstYear, i);
const year: Date = addYears(firstYear, i * intervalYears);
const { horizontalPosition, width } = horizontalPositionAndWidth(
year,
addYears(year, 1),
addYears(year, intervalYears),
start,
end,
);
@@ -108,32 +137,99 @@ const yearLabels = (
});
};
const getLabels = (
const getLabelSets = (
start: Date,
end: Date,
direction: "left" | "right",
locale: Locale,
translate: TFunction<"Timeline">,
): AxisLabel[] => {
const totalDays = differenceInDays(end, start);
/* Zero or negative total days can break further calculations */
if (totalDays <= 0) {
): AxisLabelSet[] => {
const duration = differenceInMilliseconds(end, start);
if (!Number.isFinite(duration) || duration <= 0) {
return [];
}
if (totalDays < 40) {
const dayTemplate = translate("dayFormat");
return dayLabels(start, end, totalDays, direction, dayTemplate, locale);
}
if (totalDays < 370) {
const monthTemplate = translate("monthFormat");
return monthLabels(start, end, direction, monthTemplate, locale);
}
const dayTemplate = translate("dayFormat");
const monthTemplate = translate("monthFormat");
const yearTemplate = translate("yearFormat");
return yearLabels(start, end, direction, yearTemplate, locale);
const intervals: {
id: string;
template: string;
getLabels: () => AxisLabel[];
}[] = [];
/* CSS progressively reveals nested tiers */
for (const intervalDays of [1, 2, 7, 8, 14]) {
if (intervalDays * MIN_SAFE_DAY_IN_MILLISECONDS > duration) {
continue;
}
intervals.push({
id: `day-${intervalDays}`,
template: dayTemplate,
getLabels: () =>
dayLabels(start, end, intervalDays, direction, dayTemplate, locale),
});
}
for (const intervalMonths of [1, 2, 3, 6]) {
const intervalDays = intervalMonths * 28;
if (intervalDays * MIN_SAFE_DAY_IN_MILLISECONDS > duration) {
continue;
}
intervals.push({
id: `month-${intervalMonths}`,
template: monthTemplate,
getLabels: () =>
monthLabels(
start,
end,
intervalMonths,
direction,
monthTemplate,
locale,
),
});
}
for (let intervalYears = 1; ; intervalYears *= 2) {
const intervalDays = intervalYears * 365;
if (intervalDays * MIN_SAFE_DAY_IN_MILLISECONDS > duration) {
break;
}
intervals.push({
id: `year-${intervalYears}`,
template: yearTemplate,
getLabels: () =>
yearLabels(start, end, intervalYears, direction, yearTemplate, locale),
});
}
return intervals
.map(({ id, template, getLabels }) => {
const labels = getLabels();
/* Use the narrowest calendar slot so this tier fits even in shorter periods */
const slotWidth = Math.min(100, ...labels.map(({ width }) => width));
const visibleLabels = labels
.filter(
({ horizontalPosition }) =>
horizontalPosition >= 0 && horizontalPosition + slotWidth <= 100,
)
.sort((a, b) => a.horizontalPosition - b.horizontalPosition);
/* Coarse interval may have no calendar boundary inside the range */
if (labels.length > 0 && visibleLabels.length === 0) {
visibleLabels.push({
...labels[0],
date: start,
horizontalPosition: 0,
label: format(start, template, { locale }),
});
}
return { id, slotWidth, labels: visibleLabels };
})
.filter(({ labels }) => labels.length > 0);
};
const TimelineAxisLabelsInternal = ({
@@ -150,41 +246,51 @@ const TimelineAxisLabelsInternal = ({
});
const locale = useDateLocale();
const labels = useMemo(
() =>
getLabels(startDate, endDate, direction, locale, translate)
.filter(isVisible)
.sort((a, b) => a.horizontalPosition - b.horizontalPosition),
const labelSets = useMemo(
() => getLabelSets(startDate, endDate, direction, locale, translate),
[startDate, endDate, direction, locale, translate],
);
const getLabelWidth = useCallback(
(index: number): string => {
const next = labels[index + 1];
const bound = next ? next.horizontalPosition : 100;
return `${(bound - labels[index].horizontalPosition).toFixed(3)}%`;
},
[labels],
);
const justifyContent = direction === "left" ? "flex-start" : "flex-end";
return (
<div className="aksel-timeline__axislabels" aria-hidden="true">
{labels.map((etikett, index) => (
<Detail
className="aksel-timeline__axislabels-label"
as="div"
key={etikett.label}
style={{
justifyContent,
[direction]: `${etikett.horizontalPosition.toFixed(3)}%`,
width: getLabelWidth(index),
}}
>
{etikett.label}
</Detail>
))}
{labelSets.reduceRight<React.ReactNode>(
(coarserTier, labelSet, index) => (
<div
className="aksel-timeline__axislabels-tier"
key={labelSet.id}
data-axis-labels-coarsest={
index === labelSets.length - 1 || undefined
}
style={{
[direction]: 0,
width: `${
(labelSet.slotWidth /
(labelSets[index - 1]?.slotWidth ?? 100)) *
100
}%`,
}}
>
{labelSet.labels.map((label) => (
<Detail
className="aksel-timeline__axislabels-label"
as="div"
key={label.date.getTime()}
data-axis-labels-density={labelSet.id}
style={{
textAlign: direction,
[direction]: `${
(label.horizontalPosition / labelSet.slotWidth) * 100
}%`,
}}
>
{label.label}
</Detail>
))}
{coarserTier}
</div>
),
null,
)}
</div>
);
};
@@ -75,7 +75,7 @@ const TimelineRowBase = forwardRef<HTMLOListElement, TimelineRowProps>(
<div className="aksel-timeline__row-label">{label}</div>
))}
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- onKeyDown just captures events on child-elements here. Regular interaction patterns still works as expected. */}
{/** biome-ignore lint/a11y/noStaticElementInteractions: onKeyDown just captures events on child-elements here. Regular interaction patterns still works as expected. */}
<div
className={cl("aksel-timeline__row", {
"aksel-timeline__row--active": activeRow === elementRefState,
@@ -86,6 +86,18 @@ const TimelineRowBase = forwardRef<HTMLOListElement, TimelineRowProps>(
onKeyDown={handleRowKeyDown}
data-timeline-row
>
<span
className="aksel-timeline__row-line"
style={{ left: "12.75%" }}
/>
<span
className="aksel-timeline__row-line"
style={{ left: "24.28%" }}
/>
<span
className="aksel-timeline__row-line"
style={{ left: "49.37%" }}
/>
<ol
{...rest}
ref={ref}
+1
View File
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-empty-object-type */
import type { AkselColorRole } from "@navikt/ds-tokens/types";
// biome-ignore lint/suspicious/noEmptyInterface: Users can/will augment this interface
export interface CustomAkselColor {}
export type AkselColor = AkselColorRole | keyof CustomAkselColor;
@@ -7,9 +7,11 @@ import React, { useEffect, useState } from "react";
let globalId = 0;
function useGlobalId(idOverride?: string): string | undefined {
// biome-ignore lint/correctness/useHookAtTopLevel: At runtime, `useGlobalId` is invariant.
const [defaultId, setDefaultId] = useState(idOverride);
const id = idOverride || defaultId;
// biome-ignore lint/correctness/useHookAtTopLevel: At runtime, `useGlobalId` is invariant.
useEffect(() => {
if (defaultId == null) {
// Fallback to this default id when possible.
@@ -37,6 +39,7 @@ function useId(idOverride?: string): string {
const reactId = maybeReactUseId();
return idOverride ?? reactId.replace(/(:)/g, "");
}
// biome-ignore lint/correctness/useHookAtTopLevel: `useId` is invariant at runtime.
return useGlobalId(idOverride) ?? "";
}
@@ -22,6 +22,7 @@ const HighlightText = ({ text, children }: HighlightTextProps) => {
const end = children.substring(textIndex + text.length);
return (
// aria-label is used to fix testing-library wrongly evaluating the accessible name of the option when highlighting text
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: Doesn't matter if it doesn't work in the browser
<span aria-label={children}>
{start}
{match && <mark>{match}</mark>}
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React, { useState } from "react";
import { expect, fireEvent, fn, within } from "storybook/test";
import { expect, fireEvent, fn } from "storybook/test";
import type { ListboxOptionProps } from "./option/ListboxOption";
import { Listbox } from "./root/ListboxRoot";
@@ -212,14 +212,10 @@ export const EnterKey: StoryObj<{
},
};
const ListboxStoryWithFilterInput = () => {
const [virtuallyFocusedOptionId, setVirtuallyFocusedOptionId] =
useState("second-item");
const ListboxStoryWithSelectedOption = () => {
const [virtuallyFocusedOptionId, setVirtuallyFocusedOptionId] = useState("");
return (
<Listbox setVirtuallyFocusedOptionId={setVirtuallyFocusedOptionId}>
<Listbox.InputSlot virtuallyFocusedOptionId={virtuallyFocusedOptionId}>
<input type="text" aria-label="Filter" />
</Listbox.InputSlot>
<Listbox.Options>
<Listbox.Option
id="first-item"
@@ -230,12 +226,12 @@ const ListboxStoryWithFilterInput = () => {
First item
</Listbox.Option>
<Listbox.Option
id="second-item"
id="selected-item"
onClick={() => {}}
hasVirtualFocus={virtuallyFocusedOptionId === "second-item"}
aria-selected={false}
hasVirtualFocus={virtuallyFocusedOptionId === "selected-item"}
aria-selected
>
Second item
Selected item
</Listbox.Option>
<Listbox.Option
id="last-item"
@@ -250,30 +246,19 @@ const ListboxStoryWithFilterInput = () => {
);
};
// 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,
// 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,
play: async ({ canvasElement }) => {
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();
pressKey(canvasElement, "ArrowDown");
expect(getVirtuallyFocusedValue(canvasElement)).toBe("selected-item");
},
};
export const ArrowDownOnLastOptionFocusInput: StoryObj = {
render: ListboxStoryWithFilterInput,
export const ArrowUpFocusesSelectedOption: StoryObj = {
render: ListboxStoryWithSelectedOption,
play: async ({ canvasElement }) => {
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();
pressKey(canvasElement, "ArrowUp");
expect(getVirtuallyFocusedValue(canvasElement)).toBe("selected-item");
},
};
@@ -11,6 +11,7 @@ function ListboxGroup({ label, children }: ListboxGroupProps) {
const labelId = useId();
return (
// biome-ignore lint/a11y/useSemanticElements: Fieldset is not generic enough
<div
role="group"
className="aksel-listbox__group"
@@ -23,9 +23,7 @@ const ListboxInputSlot = ({
}
// @ts-expect-error - You are meant to use an <input>, but Slot doesn't know that.
autoComplete="off"
// oxlint-disable-next-line jsx-a11y/role-has-required-aria-props -- Required ARIA attributes are supplied by the slotted input.
role="combobox"
data-aksel-listbox-input
>
{children}
</Slot>
@@ -1,3 +1,4 @@
/** biome-ignore-all lint/a11y/useKeyWithMouseEvents: We know what we are doing */
import React from "react";
import { cl } from "../../../helpers";
import { useMedia } from "../../../hooks";
@@ -20,7 +21,6 @@ function ListboxOptions({ children, ...rest }: ListboxOptionsProps) {
className={cl(rest.className, "aksel-listbox__options")}
role="listbox"
tabIndex={-1}
// oxlint-disable-next-line jsx-a11y/mouse-events-have-key-events -- Mouse hover updates virtual focus; the listbox wrapper handles keyboard navigation.
onMouseOver={(event) => {
if (cannotHover) {
// onMouseOver can be triggered by touch, but we don't want virtual focus in that case.
@@ -1,3 +1,4 @@
/** biome-ignore-all lint/a11y/noStaticElementInteractions: We know what we are doing */
import React, { forwardRef } from "react";
import { useId } from "../../../../utils-external";
import { cl } from "../../../helpers";
@@ -41,7 +42,6 @@ const ListboxComponent = forwardRef<HTMLDivElement, ListboxProps>(
};
return (
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- Wrapper handles keyboard navigation for the listbox and input within.
<div
ref={ref}
className={cl("aksel-listbox", className)}
@@ -72,34 +72,30 @@ const ListboxComponent = forwardRef<HTMLDivElement, ListboxProps>(
listbox.focus();
}
const handleArrowUpOrDown = (
getNextOption: (currentOption: HTMLElement) => HTMLElement | null,
const virtuallyFocusWithFallback = (
getNextElement: (currentOption: HTMLElement) => HTMLElement | null,
getFallback: () => HTMLElement | null,
) => {
event.preventDefault();
if (!focusedOptionElm) {
virtuallyFocusOption(getFallback());
const fallback =
listbox.querySelector<HTMLElement>('[aria-selected="true"]') ||
getFallback();
virtuallyFocusOption(fallback);
return;
}
const nextOption = getNextOption(focusedOptionElm);
const nextOption = getNextElement(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":
handleArrowUpOrDown(findPrevOption, getLastOption);
virtuallyFocusWithFallback(findPrevOption, getLastOption);
break;
case "ArrowDown":
handleArrowUpOrDown(findNextOption, getFirstOption);
virtuallyFocusWithFallback(findNextOption, getFirstOption);
break;
case "PageUp":
event.preventDefault();
@@ -49,7 +49,7 @@ export interface DateInputProps
*/
label: React.ReactNode;
/**
* Hides the label and description visually while keeping them available to screen readers
* Shows label and description for screen readers only
* @default false
*/
hideLabel?: boolean;
@@ -135,7 +135,7 @@ export const InteractOutside: Story = {
<div
data-testid="outside-focusable"
style={{ padding: "1rem" }}
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- testing only
// biome-ignore lint/a11y/noNoninteractiveTabindex: testing only
tabIndex={0}
/>
</div>
@@ -182,7 +182,7 @@ export const DisableOutsidePointerEvents: Story = {
<div
data-testid="outside-focusable"
style={{ padding: "1rem" }}
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- testing only
// biome-ignore lint/a11y/noNoninteractiveTabindex: testing only
tabIndex={0}
/>
</div>
@@ -212,7 +212,7 @@ export const SafeZone: Story = {
return (
<div>
<div
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- testing only
// biome-ignore lint/a11y/noNoninteractiveTabindex: testing only
tabIndex={0}
ref={setSafeAnchor}
data-testid="safe-anchor"
@@ -568,7 +568,7 @@ export const PreventEvents: Story = {
<div
data-testid="outside-focusable"
style={{ padding: "1rem" }}
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- testing only
// biome-ignore lint/a11y/noNoninteractiveTabindex: testing only
tabIndex={0}
/>
</div>
@@ -365,6 +365,7 @@ const DismissableLayer = forwardRef<HTMLDivElement, DismissableLayerProps>(
*
* We depend on `enabled` to clean up when the layer is disabled.
*/
// biome-ignore lint/correctness/useExhaustiveDependencies: We need to clean up after enabled changes.
useEffect(() => {
return () => {
if (!node) {
@@ -36,7 +36,7 @@ function FocusGuards({
<React.Fragment>
<span
ref={startRefCombined}
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- All focus guards are non-interactive and should be tabbable
// biome-ignore lint/a11y/noNoninteractiveTabindex: All focus guards are non-interactive and should be tabbable
tabIndex={0}
style={visuallyHidden}
data-aksel-focus-guard=""
@@ -44,7 +44,7 @@ function FocusGuards({
{children}
<span
ref={endRefCombined}
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- All focus guards are non-interactive and should be tabbable
// biome-ignore lint/a11y/noNoninteractiveTabindex: All focus guards are non-interactive and should be tabbable
tabIndex={0}
style={visuallyHidden}
data-aksel-focus-guard=""
@@ -223,6 +223,7 @@ const TextareaAutosize = forwardRef<HTMLTextAreaElement, TextareaAutosizeProps>(
syncHeight();
});
// biome-ignore lint/correctness/useExhaustiveDependencies: Since value is an external prop, we want to reset the renders on every time it changes, even when it is undefined or empty.
useEffect(() => {
renders.current = 0;
}, [value]);
@@ -45,7 +45,9 @@ export const parseDate = (
}
}
for (const format of ALLOWED_FORMATS.map((x) => x.replace("yyyy", "yy"))) {
for (const format of [
...ALLOWED_FORMATS.map((x) => x.replace("yyyy", "yy")),
]) {
parsed = parse(date, format, today, { locale });
if (
isValidDate(parsed) &&
@@ -1,3 +1,4 @@
/** biome-ignore-all lint/suspicious/noAssignInExpressions: Because why not? */
type ClassValue =
| ClassArray
| ClassDictionary
@@ -65,6 +65,7 @@ function createStrictContext<T>(options: {
Context.displayName = name;
function Provider({ children, ...context }: ProviderProps<T>) {
// biome-ignore lint/correctness/useExhaustiveDependencies: Object.values(context) includes all dependencies.
const value = React.useMemo(() => context, Object.values(context)) as T; // eslint-disable-line react-hooks/exhaustive-deps
return <Context.Provider value={value}>{children}</Context.Provider>;
@@ -27,7 +27,8 @@ export function useControllableState<StateT, ChangeT extends StateT = StateT>({
const setValue = useEventCallback(
(next: ChangeT | ((prevState: StateT) => ChangeT)) => {
const nextValue = isFunction(next) ? next(value) : next;
const setter = next as (prevState?: StateT) => ChangeT;
const nextValue = typeof next === "function" ? setter(value) : next;
if (!controlled) {
setUncontrolledState(nextValue);
@@ -39,5 +40,3 @@ export function useControllableState<StateT, ChangeT extends StateT = StateT>({
return [value, setValue] as const;
}
const isFunction = (value: unknown) => typeof value === "function";
@@ -1,7 +1,9 @@
/* eslint-disable react-hooks/set-state-in-effect */
/* eslint-disable react-hooks/exhaustive-deps */
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: Explained bellow */
/* eslint-disable react-hooks/immutability */
/* eslint-disable react-hooks/globals */
/** biome-ignore-all lint/complexity/useLiteralKeys: Explained bellow */
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
-2
View File
@@ -1,7 +1,5 @@
# @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.1",
"version": "8.20.0",
"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.1",
"@navikt/ds-tokens": "^8.20.0",
"@types/node": "^24.10.9",
"tailwindcss": "^3.3.3",
"tsx": "^4.23.15",
-2
View File
@@ -1,7 +1,5 @@
# @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.1",
"version": "8.20.0",
"description": "Design-tokens for Nav designsystem",
"author": "Aksel | Nav designsystem team",
"keywords": [
+2 -2
View File
@@ -285,7 +285,7 @@ async function buildOtherTokenFormats() {
},
});
await SDDictionaryNonCSSFormats.hasInitialized;
await Promise.all([SDDictionaryNonCSSFormats.hasInitialized]);
SDDictionaryNonCSSFormats.registerTransform(transformCSS);
@@ -372,7 +372,7 @@ async function buildCSSBundleForTokens({
},
});
await SDictionary.hasInitialized;
await Promise.all([SDictionary.hasInitialized]);
SDictionary.registerTransform(transformCSS);
await SDictionary.buildAllPlatforms();
-6
View File
@@ -1,11 +1,5 @@
# 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
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more