Compare commits

..
Author SHA1 Message Date
Ken 51c4742a3f chore: Removed next dependabot group, removed extra text-case 2026-11-06 15:04:37 +01:00
Halvor Haugan 33973c810f misc fixes 2026-11-06 15:04:37 +01:00
Ken a7ca549fc2 fix(website): Close mobile-dialog on navigation 2026-11-06 15:04:37 +01:00
Ken ee4f6f5b15 feat(lint): Replace Biome linting with Oxlint
Move the remaining lint rules and suppressions to Oxlint, remove Biome configuration and dependency, and update lint scripts and repository guidance.
2026-11-06 15:04:37 +01:00
Ken 992fd79388 refactor(lint): Remove obsolete JSX lint suppressions
Remove unused `react/jsx-no-undef` disable comments from codemod test fixtures.
2026-11-06 15:04:37 +01:00
Ken a26d51a1c7 refactor(oxlint): Error on unused disable rules 2026-11-06 15:04:37 +01:00
Ken 13de73da0b feat(lint): Remove ESLint linting step
Use Oxlint exclusively for JS/TS linting. Remove the ESLint config and unused dependencies, and update lint scripts, editor settings, CI labels, and documentation.
2026-11-06 15:04:37 +01:00
Ken 50670edc5f refactor(eslint): Move React Compiler lint rules to Oxlint
Use native Oxlint rules where available and the React Hooks plugin for remaining checks. Update suppression comments while preserving intentional exceptions.
2026-11-06 15:04:37 +01:00
Ken d63ac10b6f refactor(eslint): Move import rules from ESLint to Oxlint
Remove eslint-plugin-import and its dependency tree, and enable import/export checking through Oxlint instead.
2026-11-06 15:04:37 +01:00
Ken 306c1e0656 refactor(eslint): Move remaining Vitest linting to Oxlint
Enable no-conditional-expect in Oxlint and remove the Vitest ESLint plugin. Refactor locale assertions and update date-test suppressions for Oxlint compatibility.
2026-11-06 15:04:37 +01:00
Ken 10aee814ee refactor(eslint): Move remaining Next.js lint rules to Oxlint
Remove the Next.js ESLint plugin and update image-rule suppressions. Enforce internal links through Oxlint and use NextLink without prefetching in mobile navigation.
2026-11-06 15:04:37 +01:00
Ken 6a662509ef refactor(eslint): Remove redundant ESLint rule overrides
Drop explicit disables for rules handled by oxlint and update the comment about rules that remain ESLint-only.
2026-11-06 15:04:37 +01:00
Ken 7a0d6c703a refactor(oxlint): Removed unused eslint-plugin-react 2026-11-06 15:04:37 +01:00
Ken c5b5957af5 refactor(oxlint): Removed unused eslint/js plugin 2026-11-06 15:04:37 +01:00
Ken 9d524e2532 refactor(oxlint): Clean up unused eslint rules 2026-11-06 15:04:37 +01:00
Ken 5474f07f99 refactor(oxlint): Convert config to .mts 2026-11-06 15:04:37 +01:00
136 changed files with 862 additions and 2557 deletions

No files matched your search

+3 -3
View File
@@ -21,7 +21,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, Oxlint 1 + ESLint 9, Stylelint 17, Biome 2 (lint only), Prettier 3 (formatting). Node 22+, Yarn 4.18.0.
- Stack: TypeScript 6, React 19 (dev), Next.js 16, Storybook 10, Vitest 5, Oxlint 1 (with ESLint plugins as JS plugins), Stylelint 17, 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
@@ -44,7 +44,7 @@
## Commands
- `corepack yarn oxlint --deny-warnings <files...>` / `corepack yarn eslint --max-warnings=0 --no-warn-ignored <files...>` (both: oxlint runs most rules, ESLint the rest) / `corepack yarn stylelint <files...>` / `corepack yarn biome lint --no-errors-on-unmatched <files...>`
- `corepack yarn oxlint --deny-warnings <files...>` / `corepack yarn stylelint <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)
@@ -63,7 +63,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: `.oxlintrc.json`, `eslint.config.js`, `stylelint.config.mjs`, `biome.json`, `vitest.config.mts`, `.storybook/`
- `.changeset/` - release notes · Root config: `oxlint.config.mts`, `stylelint.config.mjs`, `vitest.config.mts`, `.storybook/`
## Skills and agents
-12
View File
@@ -15,18 +15,12 @@ updates:
registries:
- npm-github
groups:
next:
applies-to: version-updates
patterns:
- "@next/eslint-plugin-next"
- "next"
sanity:
applies-to: version-updates
patterns:
- "sanity"
- "sanity-*"
- "@sanity/*"
- "next-sanity"
- "@portabletext/*"
eslint:
applies-to: version-updates
@@ -48,18 +42,12 @@ updates:
- "vitest"
- "@vitest/*"
# Security updates ignore version-update groups, so mirror them to keep lockstep packages together.
next-security:
applies-to: security-updates
patterns:
- "@next/eslint-plugin-next"
- "next"
sanity-security:
applies-to: security-updates
patterns:
- "sanity"
- "sanity-*"
- "@sanity/*"
- "next-sanity"
- "@portabletext/*"
eslint-security:
applies-to: security-updates
@@ -1,11 +1,11 @@
---
description: "JS/TS conventions not enforced by ESLint/Biome: loops, export style and placement."
description: "JS/TS conventions not enforced by Oxlint: loops, export style and placement."
applyTo: "**/*.ts,**/*.tsx,**/*.js,**/*.mts,**/*.mjs"
---
# JS/TS conventions
Formatting, import order and `import type` are handled by Prettier and Biome. Don't hand-tune them.
Formatting, import order and `import type` are handled by Prettier. 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 }`).
+1 -1
View File
@@ -9,7 +9,7 @@ applyTo: "@navikt/aksel/**"
- Every transform is registered in `src/migrations-config.ts` (`value`, `path` without extension, `description`, `ignoredExtensions`). `value` must be unique and is the public CLI name (`v8-accordion-variant`).
- Test with fixtures: `tests/<fixture>.input.js` → `tests/<fixture>.output.js`, run via `check(__dirname, { fixture, migration })` from `src/codemod/utils/check.ts`. Output is compared after Prettier, so whitespace doesn't matter.
- Reuse helpers in `src/codemod/utils/` (imports, props, JSX values, token translation) before writing new AST code.
- Fixtures (`*.input.*`, `*.output.*`) are excluded from ESLint and TypeScript. Don't "fix" them to match lint rules.
- Fixtures (`*.input.*`, `*.output.*`) are excluded from Oxlint and TypeScript. Don't "fix" them to match lint rules.
- `src/version.ts` is generated by `build`. Don't edit it.
- `tests/migrations.test.ts` checks `dist/`. Run `corepack yarn workspace @navikt/aksel build` before `test`, or new transforms fail with "transform-path" errors.
- Consumer docs (`README.md`, website) show `npx @navikt/aksel ...` on purpose. Keep them.
@@ -11,7 +11,7 @@ For changes to component state, callbacks, async behavior, composition, semantic
## Compatibility (why: peer range is `react ^17 || ^18 || ^19`)
- No React 18/19-only APIs. ESLint `no-restricted-imports` blocks e.g. `React.useId`. Use the internal `useId`, `useClientLayoutEffect`, etc. from `utils-external/`.
- No React 18/19-only APIs. Oxlint `no-restricted-imports` blocks e.g. `React.useId`. Use the internal `useId`, `useClientLayoutEffect`, etc. from `utils-external/`.
- Import React explicitly in TSX (`import React from "react"`). `tsconfig` uses classic `jsx: "react"`.
- Component `index.ts` starts with `"use client"`.
+1 -1
View File
@@ -8,7 +8,7 @@ applyTo: "aksel.nav.no/website/**"
- Both routers are in use: `app/` (App Router, `cacheComponents: true` in `next.config.ts`) and `pages/` (examples, templates, legacy). Stay in the router already used nearby. Don't move routes between them.
- In `app/`, dynamic data needs Suspense or `"use cache"`, following nearby code. Cache Components errors at build time are real failures.
- For website build, runtime, or performance issues, use the `website-debugging` skill to establish the cause before changing code.
- `pages/eksempler/**` and `pages/templates/**` are public code snippets. Use the `website-example` skill. ESLint rules `aksel-local/{import-check,comment-check,args-check}` enforce their shape.
- `pages/eksempler/**` and `pages/templates/**` are public code snippets. Use the `website-example` skill. Oxlint rules `aksel-local/{import-check,comment-check,args-check}` enforce their shape.
- Reuse `@/` utils and components nearby before adding abstractions.
- Examples/templates: `corepack yarn storybook:aksel` (no secrets). `corepack yarn dev` only for app behavior; it needs `SANITY_READ_NO_DRAFTS` and `NPM_AUTH_TOKEN`.
- Never run `update:*`, `backup` or other Sanity-writing scripts unless asked. They write to the CMS.
@@ -19,7 +19,7 @@ Validate the smallest relevant surface with repository-pinned tools. Expand only
- Do not add a dependency only to run a one-off check. If the task truly needs new tooling, add and lock it explicitly.
- Public consumer docs may intentionally show `npx @navikt/aksel`. Preserve those examples unless the task changes consumer setup.
- If Corepack/Yarn reports a user-level configuration error, stop and report it. Never edit configuration outside the repository.
- Do not claim type safety from ESLint or tests. Run a TypeScript-aware command when TypeScript behavior changed.
- Do not claim type safety from Oxlint or tests. Run a TypeScript-aware command when TypeScript behavior changed.
## 1. Determine affected projects
@@ -58,9 +58,7 @@ Group only matching files:
```sh
corepack yarn oxlint --deny-warnings <changed .ts/.tsx/.js/.jsx files>
corepack yarn eslint --max-warnings=0 --no-warn-ignored <changed .ts/.tsx/.js/.jsx files>
corepack yarn stylelint <changed .css 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
@@ -41,7 +41,7 @@ For changes to component state, callbacks, async behavior, composition, semantic
- 🟡 **Suggestion**: should fix. Missing test, docs or changeset, maintainability risk, over-editing.
- 💭 **Nit**: optional. Leave it out when in doubt.
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.
Skip anything Oxlint, Stylelint, Prettier or TypeScript already catches. No praise-only or speculative comments. Every finding says **why** it matters and what to do.
## Checklist
+2 -3
View File
@@ -4,7 +4,7 @@ description: >
Add or update a code example or template shown on aksel.nav.no
(aksel.nav.no/website/pages/eksempler/** or pages/templates/**). Use when
asked to create a component example, demo, usage snippet or page template
for the documentation site, or when an example fails the aksel-local ESLint
for the documentation site, or when an example fails the aksel-local Oxlint
rules or validate:eksempler.
argument-hint: "Component group and example name, e.g. button/loading"
---
@@ -55,7 +55,6 @@ Hard rules (enforced by `tooling/eslint` rules `aksel-local/*`):
```sh
corepack yarn oxlint --deny-warnings <file>
corepack yarn eslint --max-warnings=0 --no-warn-ignored <file>
corepack yarn workspace website validate:eksempler
corepack yarn storybook:aksel # visual check at :6007, needs no secrets
```
@@ -64,4 +63,4 @@ Never run `update:eksempler`. It writes to the Sanity CMS. Tell the user that a
## Done when
ESLint and `validate:eksempler` pass, and the story renders in `storybook:aksel`.
Oxlint and `validate:eksempler` pass, and the story renders in `storybook:aksel`.
+1 -1
View File
@@ -17,7 +17,7 @@ permissions:
jobs:
lint:
name: Lint (Oxlint, ESLint, Stylelint etc.)
name: Lint (Oxlint, Stylelint etc.)
runs-on: ubuntu-latest
steps:
- name: Checkout code
-467
View File
@@ -1,467 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "import"],
"categories": {
"correctness": "off"
},
"settings": {
"react": {
"version": "19.3.1"
}
},
"env": {
"builtin": true,
"es2026": true,
"browser": true,
"node": true
},
"ignorePatterns": [
"**/public",
"@navikt/aksel/**/*.input.*",
"@navikt/aksel/**/*.output.*",
"**/codemod/**/*.js",
"**/tokens/**/plugin.js",
"**/query-types.ts",
".yarn",
"**/next-env.d.ts",
"@navikt/aksel-icons/src/**",
"@navikt/aksel/src/version.ts",
"@navikt/core/tailwind/{src/tokens.ts,tailwind.config.js,tailwind.darkside.config.js}",
"@navikt/core/tokens/token_docs.js",
"apps/playroom/{playroom.config.js,src/auto-generated-imports.ts}",
"**/.build",
"**/storybook-static",
"**/test-results",
"tooling/analyzer/temp",
"apps/figma-icon-plugin/src/ui/vite-env.d.ts"
],
"rules": {
"constructor-super": "error",
"for-direction": "error",
"getter-return": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unassigned-vars": "error",
"no-unexpected-multiline": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": "error",
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"preserve-caught-error": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error",
"react/jsx-key": "error",
"react/jsx-no-comment-textnodes": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-target-blank": "error",
"react/jsx-no-undef": "error",
"react/no-children-prop": "error",
"react/no-danger-with-children": "error",
"react/no-direct-mutation-state": "error",
"react/no-find-dom-node": "error",
"react/no-is-mounted": "error",
"react/no-render-return-value": "error",
"react/no-string-refs": "error",
"react/no-unescaped-entities": "error",
"react/no-unknown-property": "error",
"react/react-in-jsx-scope": "error",
"import/default": "error",
"import/no-named-as-default-member": "warn",
"import/no-duplicates": "warn",
"react/jsx-curly-brace-presence": [
"error",
{
"propElementValues": "always"
}
],
"react/rules-of-hooks": "error",
"react/exhaustive-deps": [
"warn",
{
"additionalHooks": "(useClientLayoutEffect)"
}
]
},
"overrides": [
{
"files": ["aksel.nav.no/sanity-studio/**"],
"rules": {
"react/react-in-jsx-scope": "off"
}
},
{
"files": [
"**/*.stories.{ts,tsx,js,jsx,mjs,cjs}",
"**/*.story.{ts,tsx,js,jsx,mjs,cjs}"
],
"rules": {
"storybook/await-interactions": "error",
"storybook/context-in-play-function": "error",
"storybook/default-exports": "error",
"storybook/hierarchy-separator": "warn",
"storybook/no-redundant-story-name": "warn",
"storybook/no-renderer-packages": "error",
"storybook/prefer-pascal-case": "warn",
"storybook/story-exports": "error",
"storybook/use-storybook-expect": "error",
"storybook/use-storybook-testing-library": "error",
"import/no-anonymous-default-export": "off",
"react/rules-of-hooks": "off"
},
"jsPlugins": ["eslint-plugin-storybook"]
},
{
"files": ["**/.storybook/main.{js,cjs,mjs,ts}"],
"rules": {
"storybook/no-uninstalled-addons": "error"
},
"jsPlugins": ["eslint-plugin-storybook"]
},
{
"files": ["**/*.{ts,tsx}"],
"rules": {
"constructor-super": "off",
"getter-return": "off",
"no-class-assign": "off",
"no-const-assign": "off",
"no-dupe-class-members": "off",
"no-dupe-keys": "off",
"no-func-assign": "off",
"no-import-assign": "off",
"no-new-native-nonconstructor": "off",
"no-obj-calls": "off",
"no-redeclare": "off",
"no-setter-return": "off",
"no-this-before-super": "off",
"no-unreachable": "off",
"no-unsafe-negation": "off",
"no-with": "off",
"no-var": "error",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"no-array-constructor": "error",
"no-unused-expressions": [
"error",
{
"allowShortCircuit": true,
"allowTernary": true
}
],
"typescript/ban-ts-comment": "error",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "off",
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-misused-new": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-require-imports": "error",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-declaration-merging": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/triple-slash-reference": "error",
"import/default": "off",
"import/no-named-as-default-member": "off"
},
"plugins": ["typescript"]
},
{
"files": ["**/*.test.*", "**/__tests__/*"],
"rules": {
"testing-library/await-async-events": [
"error",
{
"eventModule": "userEvent"
}
],
"testing-library/await-async-queries": "error",
"testing-library/await-async-utils": "error",
"testing-library/no-await-sync-events": [
"error",
{
"eventModules": ["fire-event"]
}
],
"testing-library/no-await-sync-queries": "error",
"testing-library/no-container": "error",
"testing-library/no-debugging-utils": "warn",
"testing-library/no-dom-import": ["error", "react"],
"testing-library/no-global-regexp-flag-in-query": "error",
"testing-library/no-manual-cleanup": "error",
"testing-library/no-node-access": "error",
"testing-library/no-promise-in-fire-event": "error",
"testing-library/no-render-in-lifecycle": "error",
"testing-library/no-unnecessary-act": "error",
"testing-library/no-wait-for-multiple-assertions": "error",
"testing-library/no-wait-for-side-effects": "error",
"testing-library/no-wait-for-snapshot": "error",
"testing-library/prefer-find-by": "error",
"testing-library/prefer-presence-queries": "error",
"testing-library/prefer-query-by-disappearance": "error",
"testing-library/prefer-screen-queries": "error",
"testing-library/render-result-naming-convention": "error",
"vitest/expect-expect": "error",
"vitest/no-commented-out-tests": "error",
"vitest/no-disabled-tests": "warn",
"vitest/no-focused-tests": "error",
"vitest/no-identical-title": "error",
"vitest/no-import-node-test": "error",
"vitest/no-interpolation-in-snapshots": "error",
"vitest/no-mocks-import": "error",
"vitest/no-standalone-expect": "error",
"vitest/no-unneeded-async-expect-function": "error",
"vitest/prefer-called-exactly-once-with": "error",
"vitest/require-local-test-context-for-concurrent-snapshots": "error",
"vitest/valid-describe-callback": "error",
"vitest/valid-expect": "error",
"vitest/valid-expect-in-promise": "error",
"vitest/valid-title": "error"
},
"jsPlugins": ["eslint-plugin-testing-library"],
"plugins": ["vitest"]
},
{
"files": ["aksel.nav.no/website/**"],
"rules": {
"react/no-unknown-property": [
"error",
{
"ignore": ["jsx", "global"]
}
],
"react/react-in-jsx-scope": "off",
"nextjs/google-font-display": "warn",
"nextjs/google-font-preconnect": "warn",
"nextjs/next-script-for-ga": "warn",
"nextjs/no-async-client-component": "warn",
"nextjs/no-before-interactive-script-outside-document": "warn",
"nextjs/no-css-tags": "warn",
"nextjs/no-head-element": "warn",
"nextjs/no-img-element": "warn",
"nextjs/no-page-custom-font": "warn",
"nextjs/no-styled-jsx-in-document": "warn",
"nextjs/no-sync-scripts": "warn",
"nextjs/no-title-in-document-head": "warn",
"nextjs/no-typos": "warn",
"nextjs/no-unwanted-polyfillio": "warn",
"nextjs/inline-script-id": "error",
"nextjs/no-assign-module-variable": "error",
"nextjs/no-document-import-in-page": "error",
"nextjs/no-duplicate-head": "error",
"nextjs/no-head-import-in-document": "error",
"nextjs/no-script-component-in-head": "error"
},
"plugins": ["nextjs"]
},
{
"files": ["aksel.nav.no/website/pages/eksempler/**"],
"rules": {
"nextjs/no-img-element": "off"
},
"plugins": ["nextjs"]
},
{
"files": [
"aksel.nav.no/website/pages/eksempler/**/*.tsx",
"aksel.nav.no/website/pages/templates/**/*.tsx"
],
"rules": {
"aksel-local/args-check": ["error"],
"aksel-local/comment-check": ["error"]
},
"jsPlugins": ["eslint-plugin-aksel-local"]
},
{
"files": [
"**/examples/__parts*/*.tsx",
"**/pages/templates/**/*.tsx",
"**/pages/eksempler/**/*.tsx"
],
"rules": {
"aksel-local/import-check": ["error"]
},
"jsPlugins": ["eslint-plugin-aksel-local"]
},
{
"files": ["**/examples/__parts-inline/*.tsx"],
"rules": {
"arrow-body-style": ["error", "never"],
"func-style": ["error", "expression"],
"import/no-named-export": "error"
}
},
{
"files": ["@navikt/**"],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "react",
"importNames": [
"useId",
"useInsertionEffect",
"useSyncExternalStore",
"useDeferredValue",
"useTransition",
"startTransition"
],
"message": "React 18+ API not allowed. We currently only support React-features accesible in React 17. To add new features, we will need to update peerDependencies (breaking change)."
},
{
"name": "react",
"importNames": [
"useOptimistic",
"useActionState",
"use",
"cache",
"useEffectEvent",
"ViewTransition",
"addTransitionType"
],
"message": "React 19+ API not allowed. We currently only support React-features accesible in React 17. To add new features, we will need to update peerDependencies (breaking change)."
},
{
"name": "react-dom/client",
"importNames": ["createRoot", "hydrateRoot"],
"message": "React 18+ API not allowed (targeting React 17)."
},
{
"name": "react-dom",
"importNames": ["browser"],
"message": "React 19+ API not allowed (targeting React 17)."
}
],
"patterns": [
{
"group": ["fs", "path"],
"message": "Use `node:` prefix when importing native node modules, e.g. `import path from \"node:path\"`."
},
{
"group": ["**/esm/**", "**/cjs/**"],
"message": "Do not import from build output directories. Import from source instead."
}
]
}
],
"no-restricted-properties": [
"error",
{
"object": "React",
"property": "useId",
"message": "React 18+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "useInsertionEffect",
"message": "React 18+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "useSyncExternalStore",
"message": "React 18+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "useDeferredValue",
"message": "React 18+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "useTransition",
"message": "React 18+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "startTransition",
"message": "React 18+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "useOptimistic",
"message": "React 19+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "useActionState",
"message": "React 19+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "use",
"message": "React 19+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "cache",
"message": "React 19+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "useEffectEvent",
"message": "React 19+ API not allowed (targeting React 17)."
}
]
}
},
{
"files": ["@navikt/**/*.stories.tsx"],
"rules": {
"no-restricted-imports": "off",
"no-restricted-properties": "off"
}
}
]
}
-2
View File
@@ -1,9 +1,7 @@
{
"recommendations": [
"oxc.oxc-vscode",
"dbaeumer.vscode-eslint",
"stylelint.vscode-stylelint",
"biomejs.biome",
"esbenp.prettier-vscode",
"denoland.vscode-deno",
"github.vscode-github-actions",
-1
View File
@@ -1,6 +1,5 @@
{
"cssVariables.lookupFiles": ["@navikt/core/tokens/dist/tokens.css"],
"eslint.problems.shortenToSingleLine": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"files.watcherExclude": {
"**/.yarn/**": true,
-1
View File
@@ -15,7 +15,6 @@ 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,4 +1,4 @@
/* biome-ignore-all lint/correctness/useHookAtTopLevel: `useId` is invariant at runtime. */
/* oxlint-disable react/rules-of-hooks -- `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
@@ -14,7 +14,7 @@ function useGlobalId(idOverride?: string): string | undefined {
// We can't use it server-side.
// If you want to use random values please consider the Birthday Problem: https://en.wikipedia.org/wiki/Birthday_problem
globalId += 1;
setDefaultId(`aksel-icon-${globalId}`); // eslint-disable-line react-hooks/set-state-in-effect
setDefaultId(`aksel-icon-${globalId}`);
}
}, [defaultId]);
return id;
@@ -1,4 +1,3 @@
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return (
<SpeechBubble illustration={<Illustration />}>
@@ -1,4 +1,3 @@
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return (
<SpeechBubble avatar={<Illustration />}>
@@ -1,6 +1,5 @@
import { SpeechBubble, Button } from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return (
<SpeechBubble
@@ -1,6 +1,5 @@
import { Chat, Button } from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return (
<Chat
@@ -1,6 +1,5 @@
import { Chat, Button } from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return (
<Chat
@@ -1,6 +1,5 @@
import { Chat, Button } from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return (
<Chat
@@ -1,5 +1,4 @@
import { SpeechBubble, Button } from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return (
<div>
@@ -1,5 +1,4 @@
import { Chat, Button } from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return (
<div>
@@ -1,5 +1,4 @@
import { SpeechBubble as DsBubble, Button } from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return (
<div>
@@ -1,5 +1,4 @@
import { Chat as DsBubble, Button } from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return (
<div>
@@ -1,6 +1,5 @@
import { CopyToClipboard as DsCpyButton } from "@navikt/ds-react-internal";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return <DsCpyButton></DsCpyButton>;
};
@@ -1,6 +1,5 @@
import { CopyButton } from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return <CopyButton />;
};
@@ -1,6 +1,5 @@
import { CopyToClipboard } from "@navikt/ds-react-internal";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return (
<CopyToClipboard
@@ -1,6 +1,5 @@
import { CopyButton } from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return (
<CopyButton copyText="Text to copy" size="medium" text="text">
@@ -1,6 +1,5 @@
import { CopyToClipboard } from "@navikt/ds-react-internal";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return <CopyToClipboard></CopyToClipboard>;
};
@@ -1,6 +1,5 @@
import { CopyButton } from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return <CopyButton />;
};
@@ -1,6 +1,5 @@
import { CopyToClipboard } from "@navikt/ds-react-internal";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return (
<CopyToClipboard
@@ -1,6 +1,5 @@
import { CopyButton } from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return <CopyButton copyText="Text to copy" size="medium" text="text" />;
};
@@ -1,6 +1,5 @@
import { CopyButton } from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return <CopyButton copyText="Text to copy" size="medium" text={props.text} />;
};
@@ -1,6 +1,5 @@
import { CopyButton } from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return <CopyButton copyText="Text to copy" size="medium" text={props.text} />;
};
@@ -1,7 +1,6 @@
import { CopyToClipboard, Dropdown } from "@navikt/ds-react-internal";
import { Button } from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return <CopyToClipboard></CopyToClipboard>;
};
@@ -1,7 +1,6 @@
import { Dropdown } from "@navikt/ds-react-internal";
import { Button, CopyButton } from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const Page = () => {
return <CopyButton />;
};
@@ -6,7 +6,6 @@ import {
UNSAFE_useRangeDatepicker,
} from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const UseDatepicker = () => {
const { datepickerProps, inputProps } = UNSAFE_useDatepicker({
fromDate: new Date("Aug 23 2019"),
@@ -6,7 +6,6 @@ import {
useRangeDatepicker,
} from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const UseDatepicker = () => {
const { datepickerProps, inputProps } = useDatepicker({
fromDate: new Date("Aug 23 2019"),
@@ -6,7 +6,6 @@ import {
useRangeDatepicker,
} from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const UseDatepicker = () => {
const { datepickerProps, inputProps } = useDatepicker({
fromDate: new Date("Aug 23 2019"),
@@ -6,7 +6,6 @@ import {
useRangeDatepicker,
} from "@navikt/ds-react";
/* eslint-disable react/jsx-no-undef */
export const UseDatepicker = () => {
const { datepickerProps, inputProps } = useDatepicker({
fromDate: new Date("Aug 23 2019"),
@@ -37,7 +37,7 @@ describe("getJSXLiteralValue", () => {
});
test("returns null for TemplateLiteral with expressions", () => {
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing only
// oxlint-disable-next-line no-template-curly-in-string -- 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);
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing token match inside template string
// oxlint-disable-next-line no-template-curly-in-string -- 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};"],
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing token inside template literal
// oxlint-disable-next-line no-template-curly-in-string -- Testing token inside template literal
["const a = `${ASurfaceDefault}`;", "const a = `${BgDefault}`;"],
])("%s", (input, output) => {
expect(runJs(`${importLine}\n${input}`)).toBe(
-2
View File
@@ -2,7 +2,6 @@
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;
}
@@ -20,7 +19,6 @@
.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;
}
@@ -16,10 +16,8 @@ const ActionMenuShortcut = ({ children }: ShortcutProps) => {
<ActionMenuMarker placement="right">
{parsed.map((char, index) => (
<span
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}`}
// 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}`}
className="aksel-action-menu__shortcut"
>
{char}
@@ -147,7 +147,7 @@ export namespace DataGrid {
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Table {
// biome-ignore lint/suspicious/noShadow: intentional namespace re-export
// oxlint-disable-next-line no-shadow -- intentional namespace re-export
export type Props<T = unknown> = DataGridTable.Props<T>;
export type Sorting = DataGridTable.Sorting;
export type SortEntry = DataGridTable.SortEntry;
@@ -159,7 +159,7 @@ export namespace DataGrid {
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Preferences {
// biome-ignore lint/suspicious/noShadow: intentional namespace re-export
// oxlint-disable-next-line no-shadow -- intentional namespace re-export
export type Props = DataGridPreferences.Props;
}
}
@@ -1,4 +1,3 @@
/** biome-ignore-all lint/correctness/useHookAtTopLevel: False positive because of the way forwardRef() is added */
import React, {
forwardRef,
useCallback,
@@ -139,7 +139,6 @@ 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,7 +220,6 @@ 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)}
@@ -23,7 +23,7 @@ const DatePickerWeekRow = ({
return (
<Show below="sm" asChild>
{/** biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: role="grid" improves a11y reading */}
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role -- role="grid" improves a11y reading */}
<table className="rdp-table" role="grid">
<tbody className="rdp-tbody">
<tr className="rdp-row aksel-date__week-row">
@@ -26,8 +26,7 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
props;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: clickable div for checkbox card
// biome-ignore lint/a11y/useKeyWithClickEvents: clickable div for checkbox card
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- clickable div for checkbox card
<div
className={cl(className, "aksel-checkbox", `aksel-checkbox--${size}`, {
"aksel-checkbox--error": hasError,
@@ -42,7 +42,6 @@ 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),
},
)}
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: List item acts as an option in the combobox.
// oxlint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role -- List item acts as an option in the combobox
role="option"
aria-selected={false}
>
@@ -113,7 +113,7 @@ const FilteredOptions = () => {
{shouldRenderFilteredOptionsList && (
<ul
ref={setFilteredOptionsRef}
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: The <ul> element acts as a listbox in the combobox.
// oxlint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role -- The <ul> element acts as a listbox in the combobox
role="listbox"
tabIndex={-1}
className="aksel-combobox__list-options"
@@ -76,7 +76,7 @@ const FilteredOptionsItem = ({ option }: { option: ComboboxOption }) => {
toggleIsListOpen(false);
}
}}
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: List item acts as an option in the combobox.
// oxlint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role -- List item acts as an option in the combobox
role="option"
aria-selected={isInList(option.value, selectedOptions)}
aria-disabled={isDisabled(option) || undefined}
@@ -54,8 +54,7 @@ export const InputController = forwardRef<
return (
<Floating.Anchor asChild ref={setAnchorRef}>
{/** biome-ignore lint/a11y/noStaticElementInteractions: Acts as wrapper list-toggle and input */}
{/** biome-ignore lint/a11y/useKeyWithClickEvents: key-events are handled by input itself */}
{/* oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- 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,7 +112,6 @@ 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]);
@@ -272,7 +272,6 @@ export const Composition = () => {
: [...prev, option.value],
);
},
multiple: true,
};
return (
@@ -126,7 +126,6 @@ export const OptionMemoization = () => {
</div>
<ComboboxRoot
defaultOpen
multiple
options={groupedOptions}
value={value}
onToggleOption={(option) => {
@@ -28,6 +28,7 @@ const ComboboxField = forwardRef<HTMLButtonElement, ComboboxFieldProps>(
const [ariaLabel, setAriaLabel] = useState<string>();
useEffect(() => {
if (label) {
// oxlint-disable-next-line react/set-state-in-effect -- Briefly sets and resets the label as part of the VoiceOver workaround.
setAriaLabel(label);
setTimeout(() => setAriaLabel(undefined), 100);
}
@@ -38,7 +38,7 @@ export const ComboboxFilter = (props: ComboboxFilterProps) => {
}}
onKeyDown={(event) => {
if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
setVirtuallyFocusedOptionValue(""); // Assume you want to edit the input, so remove virtual focus
setVirtuallyFocusedOptionValue("");
}
}}
aria-autocomplete="list"
@@ -1,4 +1,4 @@
import React from "react";
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";
@@ -51,16 +51,24 @@ function ComboboxListContent<
>({ children }: ComboboxListProps<T>) {
const { virtuallyFocusedOptionValue, filterString } =
useComboboxPopupContext();
const {
options,
value, // Selected options
onToggleOption: originalOnToggleOption,
} = useComboboxRootContext();
const onToggleOption = useEventCallback(originalOnToggleOption);
const { setOpen, options, value, onToggleOption, multiple, triggerRef } =
useComboboxRootContext();
const memoizedOnToggleOption = useEventCallback(onToggleOption);
const deferredFilterString = useDeferredValue(filterString);
const maybeAnnouceHits = useHitsAnnouncer(deferredFilterString);
const translate = useI18n("Combobox2");
const localOnToggleOption = useCallback(
(option: ComboboxOptionData, isSelected: boolean) => {
memoizedOnToggleOption(option, isSelected);
if (!multiple) {
setOpen(false);
triggerRef.current?.focus();
}
},
[memoizedOnToggleOption, multiple, triggerRef, setOpen],
);
if (children && typeof children !== "function") {
return children;
}
@@ -89,7 +97,7 @@ function ComboboxListContent<
<ComboboxOption
key={option.value}
option={option}
onToggleOption={onToggleOption}
onToggleOption={localOnToggleOption}
isSelected={value.includes(option.value)}
hasVirtualFocus={virtuallyFocusedOptionValue === option.value}
highlightText={deferredFilterString}
@@ -102,7 +110,7 @@ function ComboboxListContent<
<ComboboxOption
key={optOrGroup.value}
option={optOrGroup}
onToggleOption={onToggleOption}
onToggleOption={localOnToggleOption}
isSelected={value.includes(optOrGroup.value)}
hasVirtualFocus={virtuallyFocusedOptionValue === optOrGroup.value}
highlightText={deferredFilterString} // TODO: Vurder å kunne slå av dette for bedre ytelse
@@ -118,27 +126,26 @@ function filterOptions<T extends ComboboxOptionData>(
filterString: string,
): (T | ComboboxGroupData<T>)[] {
const filterStringLowerCase = filterString.toLocaleLowerCase();
const results: (T | ComboboxGroupData<T>)[] = [];
for (const optOrGroup of options) {
if ("options" in optOrGroup) {
const matchingOptions: T[] = [];
for (const option of optOrGroup.options) {
if (option.label.toLocaleLowerCase().includes(filterStringLowerCase)) {
matchingOptions.push(option);
return options
.map((optOrGroup) => {
if ("options" in optOrGroup) {
const matchingOptions = optOrGroup.options.filter((option) =>
option.label.toLocaleLowerCase().includes(filterStringLowerCase),
);
if (matchingOptions.length > 0) {
return { ...optOrGroup, options: matchingOptions };
}
return null;
}
if (matchingOptions.length > 0) {
results.push({ ...optOrGroup, options: matchingOptions });
if (
optOrGroup.label.toLocaleLowerCase().includes(filterStringLowerCase)
) {
return optOrGroup;
}
} else if (
optOrGroup.label.toLocaleLowerCase().includes(filterStringLowerCase)
) {
results.push(optOrGroup);
}
}
return results;
return null;
})
.filter((option): option is T | ComboboxGroupData<T> => option !== null);
}
export type { ComboboxListProps };
@@ -86,12 +86,12 @@ function announce(message: string, politeness: Politeness = "polite") {
}
// 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
target.textContent = message === target.textContent ? `${message} ` : message;
scheduleClear(politeness, target);
}
/**
* Announces a message through a persistently-mounted, visually-hidden live region.
* Speaks a message through a persistently-mounted, visually-hidden live region.
*
* The polite and assertive regions are created once and kept mounted.
*
@@ -9,7 +9,6 @@ function highlightSubstring(text: string, substring: string) {
const [rangeStart, rangeEnd] = range;
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 normal browsers
<span aria-label={text}>
{text.substring(0, rangeStart)}
<mark>{text.substring(rangeStart, rangeEnd)}</mark>
@@ -19,7 +19,16 @@ function ComboboxOverlay({ children, mobileHeader }: ComboboxOverlayProps) {
if (isMobile) {
return (
<ComboboxModal open={rootContext.open} onOpenChange={rootContext.setOpen}>
<ComboboxModal
open={rootContext.open}
onOpenChange={(open) => {
rootContext.setOpen(open);
!open && rootContext.triggerRef.current?.focus(); // This does not work when closing with button
}}
onOpenChangeComplete={(open) =>
!open && rootContext.triggerRef.current?.focus()
}
>
{mobileHeader}
{children}
</ComboboxModal>
@@ -33,6 +42,7 @@ function ComboboxOverlay({ children, mobileHeader }: ComboboxOverlayProps) {
return (
<DismissableLayer
asChild
//safeZone={{ anchor: rootContext.triggerRef.current }}
onDismiss={(event) => {
// We don't have trigger as `safeZone` because we want to close when trigger is focused.
// To avoid immediately re-opening the popup when trigger is clicked (blur closes, click opens),
@@ -45,6 +55,7 @@ function ComboboxOverlay({ children, mobileHeader }: ComboboxOverlayProps) {
return;
}
rootContext.setOpen(false);
rootContext.triggerRef.current?.focus();
}}
enabled={rootContext.open}
>
@@ -66,6 +77,7 @@ type ComboboxModalProps = {
children: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
onOpenChangeComplete: (open: boolean) => void;
};
function subscribeToResize(callback: () => void) {
@@ -77,7 +89,12 @@ function getVisualViewportHeight() {
return visualViewport?.height; // Takes into account virtual keyboard etc.
}
function ComboboxModal({ children, open, onOpenChange }: ComboboxModalProps) {
function ComboboxModal({
children,
open,
onOpenChange,
onOpenChangeComplete,
}: ComboboxModalProps) {
const visualViewportHeight = useSyncExternalStore(
subscribeToResize,
getVisualViewportHeight,
@@ -95,7 +112,12 @@ function ComboboxModal({ children, open, onOpenChange }: ComboboxModalProps) {
}, [open]);
return (
<Dialog size="small" open={open} onOpenChange={onOpenChange}>
<Dialog
size="small"
open={open}
onOpenChange={onOpenChange}
onOpenChangeComplete={onOpenChangeComplete}
>
<Dialog.Popup className="aksel-combobox2__modal" position="fullscreen">
<div
className="aksel-combobox2__modal-inner"
@@ -62,7 +62,6 @@ export const Default = () => {
return (
<ComboboxRoot
defaultOpen
multiple
options={options}
value={value}
onToggleOption={(option, newSelected) => {
@@ -174,7 +173,6 @@ export const CustomOptionRendering = () => {
return (
<ComboboxRoot
defaultOpen
multiple
options={options}
value={value}
onToggleOption={(option, isSelected) => {
@@ -261,7 +259,6 @@ export const ControlledInput = () => {
return (
<ComboboxRoot
defaultOpen
multiple
options={filteredOptions}
value={value}
onToggleOption={(option) => {
@@ -1,5 +1,4 @@
import React, { useRef, useState } from "react";
import { useClientLayoutEffect } from "../../../utils-external";
import React, { useLayoutEffect, useRef, useState } from "react";
import { Listbox } from "../../../utils/components/Listbox/root/ListboxRoot";
import { createStrictContext } from "../../../utils/helpers";
import { useComboboxRootContext } from "../root/ComboboxRoot";
@@ -30,7 +29,7 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
const rootContext = useComboboxRootContext();
const ref = useRef<HTMLDivElement | null>(null);
useClientLayoutEffect(
useLayoutEffect(
function onOpen() {
const canHover = window.matchMedia("(hover: hover)").matches;
@@ -86,7 +85,7 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
filterString={filterString}
setFilterString={(newString) => {
setFilterString(newString);
setVirtuallyFocusedOptionValue(""); // Remove virtual focus when filter string changes
setVirtuallyFocusedOptionValue("");
}}
>
{children}
@@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from "react";
import React, { useRef, useState } from "react";
import { Floating } from "../../../utils/components/floating/Floating";
import { cl, createStrictContext } from "../../../utils/helpers";
import type { FormFieldProps } from "../../useFormField";
@@ -74,8 +74,9 @@ export interface ComboboxRootProps<
defaultOpen?: boolean;
/**
* Whether multiple options can be selected.
* @default true
*/
multiple?: boolean;
multiple?: boolean; // TODO: Hva skal være default?
}
export interface ComboboxRootContextProps {
@@ -83,10 +84,9 @@ export interface ComboboxRootContextProps {
setOpen: (newOpen: boolean) => void;
triggerRef: React.RefObject<HTMLDivElement | null>;
options: ComboboxRootProps["options"];
/** Values of selected options */
value: ComboboxRootProps["value"];
/** Consumer callback */
onToggleOption: ComboboxRootProps["onToggleOption"];
multiple: boolean;
size: Exclude<ComboboxRootProps["size"], undefined>;
disabled: ComboboxRootProps["disabled"];
}
@@ -107,7 +107,7 @@ export function ComboboxRoot<
value,
onToggleOption,
defaultOpen = false,
multiple = false,
multiple = true,
size = "medium",
disabled,
className,
@@ -125,25 +125,14 @@ export function ComboboxRoot<
>
<ComboboxRootContextProvider
open={open}
setOpen={useCallback((newOpen) => {
setOpen(newOpen);
if (!newOpen) {
// queueMicrotask is needed when closing with close button in Dialog
queueMicrotask(() => triggerRef.current?.focus());
}
}, [])}
setOpen={setOpen}
triggerRef={triggerRef}
options={options}
value={value}
onToggleOption={useCallback(
(...args) => {
(onToggleOption as ComboboxRootProps["onToggleOption"])(...args);
if (!multiple) {
setOpen(false);
}
},
[onToggleOption, multiple],
)}
onToggleOption={
onToggleOption as ComboboxRootContextProps["onToggleOption"]
}
multiple={multiple}
size={size}
disabled={disabled}
>
@@ -28,6 +28,7 @@ const ComboboxTrigger = ({
return (
<Floating.Anchor asChild ref={rootContext.triggerRef}>
<Slot
// oxlint-disable-next-line jsx-a11y/role-has-required-aria-props -- aria-controls is not really needed
role="combobox"
aria-expanded={readOnly ? undefined : rootContext.open}
onClick={() => !readOnly && rootContext.setOpen(!rootContext.open)}
@@ -85,7 +85,6 @@ 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,8 +90,7 @@ const FileUploadDropzone = forwardRef<
{description}
</BodyShort>
)}
{/** biome-ignore lint/a11y/noStaticElementInteractions: Assistive technologies will use the input within. */}
{/** biome-ignore lint/a11y/useKeyWithClickEvents: Keyboard will access input/button within */}
{/* oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- Keyboard will access input/button within */}
<div
className="aksel-dropzone__area"
onDragEnter={dropzoneCtx.onDragEnter}
+1 -2
View File
@@ -22,8 +22,7 @@ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
const { className, description, children } = props;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: clickable div for radio card
// biome-ignore lint/a11y/useKeyWithClickEvents: clickable div for radio card
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- clickable div for radio card
<div
className={cl(className, "aksel-radio", `aksel-radio--${size}`, {
"aksel-radio--error": hasError,
@@ -158,7 +158,7 @@ export const Search = forwardRef<HTMLInputElement, SearchProps>(
clearButton && !inputProps.disabled && (value ?? internalValue);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: Escape key handler for clearing input
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- Escape key handler for clearing input
<div
onKeyDown={(event) => {
if (event.key !== "Escape") {
@@ -102,7 +102,6 @@ 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,7 +89,6 @@ 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"
@@ -172,7 +172,7 @@ const PaginationRoot = forwardRef<HTMLElement, PaginationProps>(
return Number.isNaN(n) ? (
<li
className="aksel-pagination__ellipsis"
// biome-ignore lint/suspicious/noArrayIndexKey: Using index as key for ellipsis items
// oxlint-disable-next-line react/no-array-index-key -- Using index as key for ellipsis items
key={`${step}-${i}`}
>
<BodyShort
+2 -2
View File
@@ -149,7 +149,7 @@ export const Process: ProcessComponent = forwardRef<
<ol
ref={mergedRef}
data-color="info"
// biome-ignore lint/a11y/noRedundantRoles: See comment above
// oxlint-disable-next-line jsx-a11y/no-redundant-roles -- See comment above
role="list"
{...restProps}
className={cl("aksel-process", className)}
@@ -218,7 +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
// oxlint-disable-next-line react/exhaustive-deps -- We want to run this only when status changes
useEffect(syncAriaControls, [status]); // eslint-disable-line react-hooks/exhaustive-deps
const isActive = status === "active";
@@ -98,7 +98,6 @@ 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.
@@ -118,7 +117,6 @@ 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);
@@ -35,7 +35,6 @@ 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}
@@ -75,7 +75,7 @@ const TimelineRowBase = forwardRef<HTMLOListElement, TimelineRowProps>(
<div className="aksel-timeline__row-label">{label}</div>
))}
{/** biome-ignore lint/a11y/noStaticElementInteractions: onKeyDown just captures events on child-elements here. Regular interaction patterns still works as expected. */}
{/* 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 */}
<div
className={cl("aksel-timeline__row", {
"aksel-timeline__row--active": activeRow === elementRefState,
+1 -1
View File
@@ -1,7 +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
/* Users can/will augment this interface */
export interface CustomAkselColor {}
export type AkselColor = AkselColorRole | keyof CustomAkselColor;
@@ -7,11 +7,9 @@ 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.
@@ -19,7 +17,7 @@ function useGlobalId(idOverride?: string): string | undefined {
// We can't use it server-side.
// If you want to use random values please consider the Birthday Problem: https://en.wikipedia.org/wiki/Birthday_problem
globalId += 1;
setDefaultId(`aksel-id-${globalId}`); // eslint-disable-line react-hooks/set-state-in-effect
setDefaultId(`aksel-id-${globalId}`);
}
}, [defaultId]);
return id;
@@ -39,7 +37,7 @@ function useId(idOverride?: string): string {
const reactId = maybeReactUseId();
return idOverride ?? reactId.replace(/(:)/g, "");
}
// biome-ignore lint/correctness/useHookAtTopLevel: `useId` is invariant at runtime.
// oxlint-disable-next-line react/rules-of-hooks -- `useId` is invariant at runtime
return useGlobalId(idOverride) ?? "";
}
@@ -22,7 +22,6 @@ 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>}
@@ -240,7 +240,7 @@ const MyMemoizedOption = React.memo(
hasVirtualFocus,
onSelect,
}: MyMemoizedOptionProps) => {
//console.log(Date.now(), "Rendering option", value); // eslint-disable-line react-hooks/purity
//console.log(Date.now(), "Rendering option", value); // oxlint-disable-line react-hooks-js/purity
return (
<Listbox.Option
key={value}
@@ -11,7 +11,6 @@ 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"
@@ -26,6 +26,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 -- Remaining aria props are set on the child element, which Slot merges with these props
role="combobox"
data-aksel-listbox-input
>
@@ -1,4 +1,4 @@
/** biome-ignore-all lint/a11y/useKeyWithMouseEvents: We know what we are doing */
/* oxlint-disable jsx-a11y/mouse-events-have-key-events -- We know what we are doing */
import React from "react";
import { cl } from "../../../helpers";
import { useMedia } from "../../../hooks";
@@ -1,4 +1,4 @@
/** biome-ignore-all lint/a11y/noStaticElementInteractions: We know what we are doing */
/* oxlint-disable jsx-a11y/no-static-element-interactions -- We know what we are doing */
import React, { forwardRef } from "react";
import { useId } from "../../../../utils-external";
import { cl } from "../../../helpers";
@@ -135,7 +135,7 @@ export const InteractOutside: Story = {
<div
data-testid="outside-focusable"
style={{ padding: "1rem" }}
// biome-ignore lint/a11y/noNoninteractiveTabindex: testing only
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- testing only
tabIndex={0}
/>
</div>
@@ -182,7 +182,7 @@ export const DisableOutsidePointerEvents: Story = {
<div
data-testid="outside-focusable"
style={{ padding: "1rem" }}
// biome-ignore lint/a11y/noNoninteractiveTabindex: testing only
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- testing only
tabIndex={0}
/>
</div>
@@ -212,7 +212,7 @@ export const SafeZone: Story = {
return (
<div>
<div
// biome-ignore lint/a11y/noNoninteractiveTabindex: testing only
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 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" }}
// biome-ignore lint/a11y/noNoninteractiveTabindex: testing only
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- testing only
tabIndex={0}
/>
</div>
@@ -340,6 +340,7 @@ const DismissableLayer = forwardRef<HTMLDivElement, DismissableLayerProps>(
if (disableOutsidePointerEvents) {
if (context.layersWithOutsidePointerEventsDisabled.size === 0) {
originalBodyPointerEvents = ownerDoc.body.style.pointerEvents;
// oxlint-disable-next-line react/immutability -- Intentional DOM mutation in an effect, restored on cleanup.
ownerDoc.body.style.pointerEvents = "none";
}
context.layersWithOutsidePointerEventsDisabled.add(node);
@@ -365,7 +366,6 @@ 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}
// biome-ignore lint/a11y/noNoninteractiveTabindex: All focus guards are non-interactive and should be tabbable
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 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}
// biome-ignore lint/a11y/noNoninteractiveTabindex: All focus guards are non-interactive and should be tabbable
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- All focus guards are non-interactive and should be tabbable
tabIndex={0}
style={visuallyHidden}
data-aksel-focus-guard=""
@@ -223,7 +223,6 @@ 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]);
@@ -1,3 +1,4 @@
/* oxlint-disable vitest/expect-expect, vitest/valid-expect -- assertions are made through the `check` helpers */
import { getMonth } from "date-fns/getMonth";
import { nb } from "date-fns/locale/nb";
import { describe, expect, test } from "vitest";
@@ -5,29 +6,24 @@ import { isValidDate } from "./check-dates";
import { parseDate } from "./parse-date";
const check = (inp: string) =>
// eslint-disable-next-line vitest/valid-expect
expect(isValidDate(parseDate(inp, new Date(), nb, "date", false)));
const checkTwoDigit = (inp: string) =>
// eslint-disable-next-line vitest/valid-expect
expect(isValidDate(parseDate(inp, new Date(), nb, "date", true)));
const parse = (inp: string) => parseDate(inp, new Date(), nb, "date", false);
describe("Parse date-inputs with 4-digit years", () => {
// eslint-disable-next-line vitest/expect-expect
test("No spaces", () => {
check("11052022").toBeTruthy();
check("15052022").toBeTruthy();
});
// eslint-disable-next-line vitest/expect-expect
test(". divider", () => {
check("1.5.2022").toBeTruthy();
check("11.05.2022").toBeTruthy();
});
// eslint-disable-next-line vitest/expect-expect
test("/ divider", () => {
check("1/5/2022").toBeTruthy();
check("10/5/2022").toBeTruthy();
@@ -35,7 +31,6 @@ describe("Parse date-inputs with 4-digit years", () => {
check("10/05/2022").toBeTruthy();
});
// eslint-disable-next-line vitest/expect-expect
test("- divider", () => {
check("1-5-2022").toBeTruthy();
check("10-5-2022").toBeTruthy();
@@ -45,19 +40,16 @@ describe("Parse date-inputs with 4-digit years", () => {
});
describe("Parse date-inputs with 2-digit years", () => {
// eslint-disable-next-line vitest/expect-expect
test("No spaces", () => {
checkTwoDigit("110522").toBeTruthy();
checkTwoDigit("150522").toBeTruthy();
});
// eslint-disable-next-line vitest/expect-expect
test(". divider", () => {
checkTwoDigit("1.5.22").toBeTruthy();
checkTwoDigit("11.05.22").toBeTruthy();
});
// eslint-disable-next-line vitest/expect-expect
test("/ divider", () => {
checkTwoDigit("1/5/22").toBeTruthy();
checkTwoDigit("10/5/22").toBeTruthy();
@@ -65,7 +57,6 @@ describe("Parse date-inputs with 2-digit years", () => {
checkTwoDigit("10/05/22").toBeTruthy();
});
// eslint-disable-next-line vitest/expect-expect
test("- divider", () => {
checkTwoDigit("1-5-22").toBeTruthy();
checkTwoDigit("10-5-22").toBeTruthy();
@@ -73,14 +64,12 @@ describe("Parse date-inputs with 2-digit years", () => {
checkTwoDigit("10-05-22").toBeTruthy();
});
// eslint-disable-next-line vitest/expect-expect
test("Dissallow 1 and 3 digit years", () => {
checkTwoDigit("11052").toBeFalsy();
checkTwoDigit("1105222").toBeFalsy();
checkTwoDigit("1105999").toBeFalsy();
});
// eslint-disable-next-line vitest/expect-expect
test("Dissallow date before year 1000", () => {
checkTwoDigit("11050999").toBeFalsy();
checkTwoDigit("11050010").toBeFalsy();
@@ -1,4 +1,3 @@
/** biome-ignore-all lint/suspicious/noAssignInExpressions: Because why not? */
type ClassValue =
| ClassArray
| ClassDictionary
@@ -65,8 +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
const value = React.useMemo(() => context, Object.values(context)) as T; // oxlint-disable-line react/exhaustive-deps, react/use-memo
return <Context.Provider value={value}>{children}</Context.Provider>;
}
@@ -1,9 +1,5 @@
/* 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 */
/* oxlint-disable react/exhaustive-deps -- Explained bellow */
/* oxlint-disable react-hooks-js/globals */
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
@@ -6,7 +6,7 @@ import { useRefWithInit } from "./useRefWithInit";
function useValueAsRef<T>(value: T) {
const latest = useRefWithInit(createLatestRef, value).current;
latest.next = value; // eslint-disable-line react-hooks/immutability
latest.next = value;
// eslint-disable-next-line react-hooks/exhaustive-deps
useClientLayoutEffect(latest.effect);
@@ -1,4 +1,3 @@
/* eslint-disable vitest/expect-expect */
import { describe, expect, test } from "vitest";
import type { Translations } from "./i18n.types";
import en from "./locales/en";
@@ -7,52 +6,62 @@ import nn from "./locales/nn";
type NestedObject = Record<string, string | Record<string, string>>;
function checkValues(obj: Translations | NestedObject) {
Object.entries(obj).forEach(
([key, value]: [string, string | NestedObject]) => {
if (key === "dateLocale") {
return;
}
if (typeof value === "object") {
checkValues(value);
} else {
expect(value).toBeTypeOf("string");
expect(value).not.toBe("");
}
},
);
/** Flattens nested translations to `[path, value]` pairs, skipping `dateLocale`. */
function flattenTranslations(
obj: Translations | NestedObject,
prefix = "",
): [string, unknown][] {
const entries: [string, unknown][] = [];
for (const [key, value] of Object.entries(obj)) {
if (key === "dateLocale") {
continue;
}
const path = prefix ? `${prefix}.${key}` : key;
if (typeof value === "object") {
entries.push(...flattenTranslations(value as NestedObject, path));
} else {
entries.push([path, value]);
}
}
return entries;
}
function checkTranslationPlaceholders(
baseTranslations: Translations | NestedObject, // Translations to check against
translations: Translations | NestedObject, // Translations to check
) {
Object.entries(baseTranslations).forEach(
([key, baseVal]: [string, string | NestedObject]) => {
if (key === "dateLocale") {
return;
}
if (typeof baseVal === "object") {
checkTranslationPlaceholders(baseVal, translations[key]);
} else {
const correctPlaceholders = baseVal.match(/{[^}]*}/g) || [];
const transPlaceholders = translations[key].match(/{[^}]*}/g) || [];
expect(
transPlaceholders.sort(),
`Wrong placeholders in "${translations[key]}" (key=${key})`,
).toEqual(correctPlaceholders.sort());
}
},
);
function getPlaceholders(value: unknown) {
return (typeof value === "string" ? value.match(/{[^}]*}/g) : null) ?? [];
}
describe("Locale", () => {
test("NB should have no empty strings", () => checkValues(nb));
test("NN should have no empty strings", () => checkValues(nn));
test("EN should have no empty strings", () => checkValues(en));
test.each([
["NB", nb],
["NN", nn],
["EN", en],
])("%s should have no empty strings", (_, locale) => {
for (const [path, value] of flattenTranslations(locale)) {
expect({ path, type: typeof value }).toEqual({ path, type: "string" });
expect({ path, empty: value === "" }).toEqual({ path, empty: false });
}
});
test("NN should have the same placeholders as NB", () =>
checkTranslationPlaceholders(nb, nn));
test("EN should have the same placeholders as NB", () =>
checkTranslationPlaceholders(nb, en));
test.each([
["NN", nn],
["EN", en],
])("%s should have the same placeholders as NB", (_, locale) => {
const translations = new Map(flattenTranslations(locale));
for (const [path, baseValue] of flattenTranslations(nb)) {
const value = translations.get(path);
expect({
path,
value,
placeholders: getPlaceholders(value).sort(),
}).toEqual({
path,
value,
placeholders: getPlaceholders(baseValue).sort(),
});
}
});
});
+1 -1
View File
@@ -30,7 +30,7 @@ For å komme i gang, se [Aksel dokumentasjonen](https://aksel.nav.no/grunnleggen
| `yarn dev` | Starter dokumentasjonssiden |
| `yarn storybook` | Starter Storybook |
| `yarn test` | Kjører alle tester |
| `yarn lint` | Kjører ESLint, Stylelint og Biome |
| `yarn lint` | Kjører Oxlint og Stylelint |
| `yarn clean` | Fjerner genererte filer |
| `yarn changeset` | Oppretter en ny versjon. |
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more