mirror of
https://github.com/navikt/aksel.git
synced 2026-11-08 22:04:06 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51c4742a3f | ||
|
|
33973c810f | ||
|
|
a7ca549fc2 | ||
|
|
ee4f6f5b15 | ||
|
|
992fd79388 | ||
|
|
a26d51a1c7 | ||
|
|
13de73da0b | ||
|
|
50670edc5f | ||
|
|
d63ac10b6f | ||
|
|
306c1e0656 | ||
|
|
10aee814ee | ||
|
|
6a662509ef | ||
|
|
7a0d6c703a | ||
|
|
c5b5957af5 | ||
|
|
9d524e2532 | ||
|
|
5474f07f99 |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 }`).
|
||||
|
||||
@@ -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"`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
-2
@@ -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",
|
||||
|
||||
Vendored
-1
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
+4
-4
@@ -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(),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user