Compare commits

...
Author SHA1 Message Date
Halvor Haugan 3fe9563078 feat(Combobox2): remove virtual focus when editing filter input 2026-10-20 21:55:45 +02:00
Halvor HauganandCopilot Autofix powered by AI 62d2f5af01 fix(website): avoid sandbox error on DataGrid examples (#5421)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-10-20 17:04:57 +02:00
dependabot[bot] 53780d0c67 ⬆️ Bump js-yaml from 5.4.3 to 5.5.0 (#5419)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 15:38:18 +02:00
dependabot[bot] d476be6604 ⬆️ Bump postcss-selector-parser from 7.1.6 to 7.1.7 (#5420)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 15:38:06 +02:00
KenandHalvor Haugan a0c9494b07 feat: Added support for internal translations (#5413)
Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
2026-10-20 15:19:15 +02:00
Ken bf857007cd fix(KI): Update vitest config and husky to support proper testing and linting while using cplt 2026-10-20 14:14:49 +02:00
Ken e6db21586e fix(sanity): Code-example block in sanity now defaults compact-view to false 2026-10-20 13:47:43 +02:00
Ken 619ebd5088 feat: Added egnet/uegnet til fields to markdown-version of component docs 2026-10-20 13:46:22 +02:00
Ken 1b539a9b13 chore: Added more website examples for CheckboxCard 2026-10-20 13:35:08 +02:00
Ken 8859bbb7ea feat(core): CheckboxCard component (#5364) 2026-10-20 13:26:36 +02:00
38 changed files with 861 additions and 78 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"@navikt/ds-react": minor
"@navikt/ds-css": minor
---
Checkbox: Add `CheckboxCard` and `CheckboxCardGroup` components
+2 -1
View File
@@ -49,7 +49,8 @@
- `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)
- `corepack yarn storybook` (:6006) / `corepack yarn storybook:aksel` (website examples, :6007, no secrets) / `corepack yarn storybook:test` (play tests, Playwright Firefox) / `corepack yarn dev` (website, :3000, needs Sanity token)
- `corepack yarn storybook` (:6006) / `corepack yarn storybook:aksel` (website examples, :6007, no secrets) / `corepack yarn storybook:test` (play tests, Playwright Firefox; Chromium when `COPILOT_CLI` is set) / `corepack yarn dev` (website, :3000, needs Sanity token)
- Targeted play tests: `corepack yarn vitest run --config ./vitest.config.ts --project=storybook <path/to/X.tests.stories.tsx>` (repo root). Run after changing play tests. Under Copilot CLI this runs Chromium; if the browser can't launch, report tests as not run with the exact command.
## Where to edit
+1 -1
View File
@@ -12,4 +12,4 @@ applyTo: "**/*.test.ts,**/*.test.tsx,**/*.stories.tsx"
- Query by role and accessible name (`getByRole("button", { name })`). Avoid snapshots unless nearby tests already use them.
- Cover the main path plus 1-2 meaningful edge cases. A behavior change updates the nearest story and test in the same change.
- ds-react unit tests run with `TZ=UTC`. Date logic must not depend on the local timezone.
- Browser tests need Playwright Firefox. Yarn has `enableScripts: false`, so if the browser is missing, report it instead of installing globally.
- Browser tests need Playwright Firefox (Chromium under Copilot CLI, see `vitest.config.ts`). Yarn has `enableScripts: false`, so if the browser is missing, report it instead of installing globally.
+2 -1
View File
@@ -1 +1,2 @@
yarn lint-staged
# Runs the local lint-staged checks before committing
./node_modules/.bin/lint-staged
+1 -1
View File
@@ -82,7 +82,7 @@
"copyfiles": "^2.4.1",
"fast-glob": "3.3.3",
"hast-util-select": "6.0.4",
"js-yaml": "^5.4.3",
"js-yaml": "^5.5.0",
"lodash": "4.18.1",
"prettier": "3.9.6",
"react": "19.3.0",
+1 -1
View File
@@ -46,7 +46,7 @@
"@navikt/ds-css": "^8.19.2",
"@navikt/ds-tokens": "^8.19.2",
"concurrently": "10.0.4",
"postcss-selector-parser": "^7.1.6",
"postcss-selector-parser": "^7.1.7",
"postcss-value-parser": "^4.2.0",
"stylelint": "^17.15.0",
"stylelint-test-rule-node": "^1.2.0",
+39 -19
View File
@@ -19,7 +19,8 @@
}
}
/* Radio card */
/* Checkbox and Radio card */
.aksel-checkbox-card,
.aksel-radio-card {
width: 100%;
align-content: flex-start;
@@ -30,26 +31,30 @@
border-radius: var(--ax-radius-8);
&:focus-within::after,
&.aksel-radio--small:focus-within::after {
&:is(.aksel-checkbox--small, .aksel-radio--small):focus-within::after {
inset: 0;
border-radius: calc(var(--ax-radius-8) - 1px);
outline-offset: 4px;
}
}
/* Radio card group */
.aksel-radio-card-group {
& > .aksel-radio-buttons {
display: flex;
flex-direction: column;
/* Checkbox and Radio card group */
.aksel-checkbox-card-group > .aksel-checkboxes,
.aksel-radio-card-group > .aksel-radio-buttons {
display: flex;
flex-direction: column;
gap: var(--ax-space-12);
> .aksel-hgrid:not(.aksel-hgrid-gap) {
gap: var(--ax-space-12);
> .aksel-hgrid:not(.aksel-hgrid-gap) {
gap: var(--ax-space-12);
}
}
}
&.aksel-radio-group--small > .aksel-radio-buttons > .aksel-hgrid:not(.aksel-hgrid-gap) {
.aksel-checkbox-card-group.aksel-checkbox-group--small > .aksel-checkboxes,
.aksel-radio-card-group.aksel-radio-group--small > .aksel-radio-buttons {
gap: var(--ax-space-8);
> .aksel-hgrid:not(.aksel-hgrid-gap) {
gap: var(--ax-space-8);
}
}
@@ -65,14 +70,11 @@
}
}
.aksel-checkbox-card.aksel-checkbox--small,
.aksel-radio-card.aksel-radio--small {
padding: var(--ax-space-12) var(--ax-space-16);
}
.aksel-radio-card-group.aksel-radio-group--small > .aksel-radio-buttons {
gap: var(--ax-space-8);
}
/* Label */
.aksel-checkbox__label,
.aksel-radio__label {
@@ -246,7 +248,11 @@
--__axc-radio-checkbox-marker-border: 7px;
}
/* Radio card border in hover and checked states */
/* Card border in hover and checked states */
.aksel-checkbox-card:is(
:hover:not(:where(.aksel-checkbox--disabled, .aksel-checkbox--readonly, .aksel-checkbox--error)),
:has(.aksel-checkbox__input:checked):not(:where(.aksel-checkbox--disabled, .aksel-checkbox--readonly))
),
.aksel-radio-card:is(
:hover:not(:where(.aksel-radio--disabled, .aksel-radio--readonly, .aksel-radio--error)),
:has(> .aksel-radio__input:checked):not(:where(.aksel-radio--disabled, .aksel-radio--readonly))
@@ -255,7 +261,10 @@
box-shadow: 0 0 0 1px var(--ax-border-default);
}
/* Radio card background in checked state */
/* Card background in checked state */
.aksel-checkbox-card:has(.aksel-checkbox__input:checked):not(
:where(.aksel-checkbox--disabled, .aksel-checkbox--readonly, .aksel-checkbox--error)
),
.aksel-radio-card:has(> .aksel-radio__input:checked):not(
:where(.aksel-radio--disabled, .aksel-radio--readonly, .aksel-radio--error)
) {
@@ -265,6 +274,8 @@
/* Marker colors in unchecked hover state (unless disabled) */
.aksel-checkbox__input:hover:not(:disabled, :checked, :indeterminate),
.aksel-radio__input:hover:not(:disabled, :checked),
.aksel-checkbox-card:hover:not(:where(.aksel-checkbox--disabled, .aksel-checkbox--readonly))
.aksel-checkbox__input:not(:disabled, :checked, :indeterminate),
.aksel-radio-card:hover:not(:where(.aksel-radio--disabled, .aksel-radio--readonly))
> .aksel-radio__input:not(:disabled, :checked) {
border-color: var(--ax-border-strong);
@@ -283,11 +294,18 @@
opacity: var(--ax-opacity-disabled);
}
/* Radio card background in disabled and readonly states */
/* Card background in readonly state */
.aksel-checkbox-card:where(.aksel-checkbox--readonly),
.aksel-radio-card:where(.aksel-radio--readonly) {
background-color: var(--ax-bg-neutral-moderate);
}
/* Card marker colors in disabled + checked state */
.aksel-checkbox-card.aksel-checkbox--disabled .aksel-checkbox__input:where(:checked, :indeterminate) {
background-color: var(--ax-bg-neutral-strong);
border-color: var(--ax-border-neutral-strong);
}
.aksel-radio-card.aksel-radio--disabled > .aksel-radio__input:checked {
border-color: var(--ax-border-neutral-strong);
}
@@ -295,6 +313,7 @@
/* Cursor in disabled state on otherwise clickable elements */
.aksel-checkbox--disabled :where(.aksel-checkbox__input, .aksel-checkbox__label),
.aksel-radio--disabled > :where(.aksel-radio__input, .aksel-radio__label),
.aksel-checkbox-card.aksel-checkbox--disabled,
.aksel-radio-card.aksel-radio--disabled {
cursor: not-allowed;
}
@@ -302,6 +321,7 @@
/* Cursor in readonly state on otherwise clickable elements */
.aksel-checkbox--readonly :where(.aksel-checkbox__input, .aksel-checkbox__label),
.aksel-radio--readonly > :where(.aksel-radio__input, .aksel-radio__label),
.aksel-checkbox-card.aksel-checkbox--readonly,
.aksel-radio-card.aksel-radio--readonly {
cursor: default;
}
+10
View File
@@ -96,6 +96,16 @@
"default": "./cjs/form/radio-card/index.js"
}
},
"./PREVIEW/CheckboxCard": {
"import": {
"types": "./esm/form/checkbox-card/index.d.ts",
"default": "./esm/form/checkbox-card/index.js"
},
"require": {
"types": "./cjs/form/checkbox-card/index.d.ts",
"default": "./cjs/form/checkbox-card/index.js"
}
},
"./types/theme": {
"types": "./esm/types/theme.d.ts"
},
@@ -0,0 +1,14 @@
import type { ComponentMetadata } from "../../utils/types/metadata";
import { CheckboxCard, CheckboxCardGroup } from "./index";
const metadata: ComponentMetadata = {
name: "CheckboxCard",
components: {
CheckboxCard,
CheckboxCardGroup,
},
keywords: ["checkbox", "avkrysningsboks", "form", "input", "choice", "card"],
related: ["Checkbox"],
};
export { metadata };
@@ -0,0 +1,129 @@
import type { Meta } from "@storybook/react-vite";
import React from "react";
import { HGrid } from "../../primitives/grid";
import { renderStoriesForChromatic } from "../../utils/renderStoriesForChromatic";
import { CheckboxCard } from "./CheckboxCard";
import { CheckboxCardGroup } from "./CheckboxCardGroup";
const meta: Meta<typeof CheckboxCardGroup> = {
title: "ds-react/CheckboxCard",
component: CheckboxCardGroup,
subcomponents: { CheckboxCard },
parameters: {
chromatic: { disable: true },
},
};
export default meta;
export const Default = () => (
<CheckboxCardGroup
legend="Velg varslingskanaler"
description="Du kan velge flere alternativer."
>
<CheckboxCard value="sms" description="Sendes til mobilnummeret ditt.">
SMS
</CheckboxCard>
<CheckboxCard value="epost" description="Sendes til e-postadressen din.">
E-post
</CheckboxCard>
</CheckboxCardGroup>
);
export const HorizontalLayout = () => (
<CheckboxCardGroup
legend="Velg varslingskanaler"
description="Du kan velge flere alternativer."
defaultValue={["sms"]}
size="small"
>
<HGrid columns={{ xs: 1, md: 3 }}>
<CheckboxCard value="sms" description="Sendes til mobilnummeret ditt.">
SMS
</CheckboxCard>
<CheckboxCard value="epost" description="Sendes til e-postadressen din.">
E-post
</CheckboxCard>
<CheckboxCard
value="brev"
description="Sendes til folkeregistrert adresse."
>
Brev
</CheckboxCard>
</HGrid>
</CheckboxCardGroup>
);
export const Small = () => (
<CheckboxCardGroup legend="Velg varslingskanaler" size="small">
<CheckboxCard value="sms" description="Sendes til mobilnummeret ditt.">
SMS
</CheckboxCard>
<CheckboxCard value="epost" description="Sendes til e-postadressen din.">
E-post
</CheckboxCard>
</CheckboxCardGroup>
);
export const ErrorProp = () => (
<CheckboxCardGroup
legend="Velg varslingskanaler"
error="Du må velge minst én varslingskanal."
defaultValue={["sms"]}
>
<CheckboxCard value="sms" description="Sendes til mobilnummeret ditt.">
SMS
</CheckboxCard>
<CheckboxCard value="epost" description="Sendes til e-postadressen din.">
E-post
</CheckboxCard>
</CheckboxCardGroup>
);
export const Readonly = () => (
<CheckboxCardGroup
legend="Valgte varslingskanaler"
defaultValue={["sms"]}
readOnly
>
<CheckboxCard value="sms" description="Sendes til mobilnummeret ditt.">
SMS
</CheckboxCard>
<CheckboxCard value="epost" description="Sendes til e-postadressen din.">
E-post
</CheckboxCard>
</CheckboxCardGroup>
);
export const Disabled = () => (
<CheckboxCardGroup
legend="Velg varslingskanaler"
defaultValue={["sms"]}
disabled
>
<CheckboxCard value="sms" description="Sendes til mobilnummeret ditt.">
SMS
</CheckboxCard>
<CheckboxCard value="epost" description="Sendes til e-postadressen din.">
E-post
</CheckboxCard>
</CheckboxCardGroup>
);
export const Chromatic = renderStoriesForChromatic({
Default,
HorizontalLayout,
Small,
ErrorProp,
Readonly,
Disabled,
});
export const ChromaticDark = renderStoriesForChromatic({
Default,
HorizontalLayout,
Small,
ErrorProp,
Readonly,
Disabled,
});
ChromaticDark.globals = { theme: "dark" };
@@ -0,0 +1,41 @@
import React, { forwardRef } from "react";
import { cl } from "../../utils/helpers";
import { Checkbox, CheckboxCardContextProvider } from "../checkbox/Checkbox";
import type { CheckboxProps } from "../checkbox/types";
type CheckboxCardProps = CheckboxProps;
/**
* Checkbox option styled as a card.
* @see 🏷️ {@link CheckboxCardProps}
* @example
* ```tsx
* <CheckboxCardGroup legend="Velg varslingskanaler">
* <CheckboxCard value="sms" description="Sendes til mobilnummeret ditt.">
* SMS
* </CheckboxCard>
* <CheckboxCard value="epost">E-post</CheckboxCard>
* </CheckboxCardGroup>
* ```
*/
const CheckboxCard = forwardRef<HTMLInputElement, CheckboxCardProps>(
({ className, ...rest }, ref) => {
return (
<CheckboxCardContextProvider>
<Checkbox
{...rest}
ref={ref}
className={cl("aksel-checkbox-card", className)}
/>
</CheckboxCardContextProvider>
);
},
);
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace CheckboxCard {
export type Props = CheckboxCardProps;
}
export { CheckboxCard };
export type { CheckboxCardProps };
@@ -0,0 +1,37 @@
import React, { forwardRef } from "react";
import { cl } from "../../utils/helpers";
import CheckboxGroup, {
type CheckboxGroupProps,
} from "../checkbox/CheckboxGroup";
type CheckboxCardGroupProps = CheckboxGroupProps;
/**
* CheckboxGroup for `<CheckboxCard />` elements. Horizontal layout is best suited for a small number of checkbox cards.
* @see 🏷️ {@link CheckboxCardGroupProps}
* @example
* ```tsx
* <CheckboxCardGroup legend="Velg varslingskanaler">
* <CheckboxCard value="sms">SMS</CheckboxCard>
* <CheckboxCard value="epost">E-post</CheckboxCard>
* </CheckboxCardGroup>
* ```
*/
const CheckboxCardGroup = forwardRef<
HTMLFieldSetElement,
CheckboxCardGroupProps
>(({ className, ...rest }, ref) => (
<CheckboxGroup
{...rest}
ref={ref}
className={cl("aksel-checkbox-card-group", className)}
/>
));
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace CheckboxCardGroup {
export type Props = CheckboxCardGroupProps;
}
export { CheckboxCardGroup };
export type { CheckboxCardGroupProps };
@@ -0,0 +1,3 @@
"use client";
export { CheckboxCard } from "./CheckboxCard";
export { CheckboxCardGroup } from "./CheckboxCardGroup";
@@ -88,3 +88,22 @@ describe("Checkbox handles controlled-state correctly", () => {
);
});
});
describe("Checkbox outside CheckboxCard", () => {
test("does not toggle when clicking description", () => {
render(
<CheckboxGroup legend="legend">
<Checkbox value="value1" description="Checkbox description">
label1
</Checkbox>
</CheckboxGroup>,
);
fireEvent.click(screen.getByText("Checkbox description"));
expect(
screen.getByRole<HTMLInputElement>("checkbox", { name: "label1" })
.checked,
).toBe(false);
});
});
@@ -1,21 +1,33 @@
import React, { forwardRef } from "react";
import React, { forwardRef, useRef } from "react";
import { BodyShort } from "../../typography";
import { omit, useId } from "../../utils-external";
import { cl } from "../../utils/helpers";
import { cl, createStrictContext } from "../../utils/helpers";
import { useMergeRefs } from "../../utils/hooks";
import { ReadOnlyIconWithTitle } from "../ReadOnlyIcon";
import { CheckboxInput } from "./checkbox-input/CheckboxInput";
import type { CheckboxProps } from "./types";
import useCheckbox from "./useCheckbox";
const {
Provider: CheckboxCardContextProvider,
useContext: useCheckboxCardContext,
} = createStrictContext({ name: "checkbox-card" });
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
(props: CheckboxProps, forwardedRef) => {
const { inputProps, hasError, size, readOnly, nested } = useCheckbox(props);
const descriptionId = useId();
const cardContext = useCheckboxCardContext(false);
const checkboxRef = useRef<HTMLInputElement>(null);
const mergedRefs = useMergeRefs(forwardedRef, checkboxRef);
const { className, description, children, indeterminate, hideLabel } =
props;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: clickable div for checkbox card
// biome-ignore lint/a11y/useKeyWithClickEvents: clickable div for checkbox card
<div
className={cl(className, "aksel-checkbox", `aksel-checkbox--${size}`, {
"aksel-checkbox--error": hasError,
@@ -23,9 +35,26 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
"aksel-checkbox--readonly": readOnly,
})}
data-color={hasError ? "danger" : props["data-color"]}
onClick={(event) => {
if (inputProps.disabled || readOnly || !cardContext) {
return;
}
/* Let input and label handle their own click events */
const target = event.target;
const nativeHandler =
target instanceof Element ? target.closest("label, input") : null;
if (nativeHandler && event.currentTarget.contains(nativeHandler)) {
return;
}
checkboxRef.current?.click();
checkboxRef.current?.focus({ preventScroll: true });
}}
>
<CheckboxInput
ref={forwardedRef}
ref={mergedRefs}
{...omit(props, [
"children",
"size",
@@ -72,3 +101,4 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
);
export default Checkbox;
export { CheckboxCardContextProvider };
@@ -11,8 +11,12 @@ type ComboboxFilterProps = Pick<
export const ComboboxFilter = (props: ComboboxFilterProps) => {
const { size } = useComboboxRootContext();
const { filterString, setFilterString, virtuallyFocusedOptionValue } =
useComboboxPopupContext();
const {
filterString,
setFilterString,
virtuallyFocusedOptionValue,
setVirtuallyFocusedOptionValue,
} = useComboboxPopupContext();
return (
<div className="aksel-combobox2__filter">
@@ -30,6 +34,11 @@ export const ComboboxFilter = (props: ComboboxFilterProps) => {
event.preventDefault(); // Prevents navigation with screen reader
}
}}
onKeyDown={(event) => {
if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
setVirtuallyFocusedOptionValue("");
}
}}
aria-autocomplete="list"
inputMode="text"
/>
@@ -5,6 +5,7 @@ import { useComboboxRootContext } from "../root/ComboboxRoot";
interface ComboboxPopupContextProps {
virtuallyFocusedOptionValue: string;
setVirtuallyFocusedOptionValue: (value: string) => void;
filterString: string;
setFilterString: (value: string) => void;
setAnnounceText: (text: string) => void;
@@ -82,8 +83,12 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
>
<ComboboxPopupContextProvider
virtuallyFocusedOptionValue={virtuallyFocusedOptionValue}
setVirtuallyFocusedOptionValue={setVirtuallyFocusedOptionValue}
filterString={filterString}
setFilterString={setFilterString}
setFilterString={(newString) => {
setFilterString(newString);
setVirtuallyFocusedOptionValue("");
}}
setAnnounceText={setAnnounceText}
>
{children}
+1
View File
@@ -3,3 +3,4 @@ export { DataGrid } from "./data-grid";
export { LookupDeprecated as Lookup } from "./lookup/Lookup";
export { BadgeDeprecated as Badge } from "./badge/Badge";
export { RadioCard, RadioCardGroup } from "./form/radio-card";
export { CheckboxCard, CheckboxCardGroup } from "./form/checkbox-card";
+4 -4
View File
@@ -1,10 +1,10 @@
import type { PartialTranslations } from "./i18n.types";
import type { Component, ComponentTranslation } from "./i18n.types";
// Based on https://github.com/Shopify/polaris/blob/main/polaris-react/src/utilities/get.ts
export function get(
export function get<T extends Component>(
keypath: string,
objs: (PartialTranslations | undefined)[],
objs: (ComponentTranslation<T> | undefined)[],
) {
const keys = keypath.split(".");
@@ -13,7 +13,7 @@ export function get(
continue;
}
let acc: string | PartialTranslations = obj;
let acc: string | ComponentTranslation<T> = obj;
for (let i = 0; i < keys.length; i++) {
const val = acc[keys[i]];
@@ -4,7 +4,7 @@ import { bench, describe } from "vitest";
import { Provider } from "../../provider";
import { get } from "./get";
import { useI18n } from "./i18n.hooks";
import type { PartialTranslations } from "./i18n.types";
import type { ComponentTranslation } from "./i18n.types";
import { nb } from "./locales";
describe("translate function throughput", () => {
@@ -54,11 +54,11 @@ describe("translate with multiple context objects", () => {
});
describe("get() function throughput", () => {
const objects: (PartialTranslations | undefined)[] = [
const objects: (ComponentTranslation<"FileUpload"> | undefined)[] = [
undefined,
{ FileUpload: { item: { uploading: "First" } } },
{ FileUpload: { item: { downloading: "Second" } } },
{ FileUpload: nb.FileUpload },
{ item: { uploading: "First" } },
{ item: { downloading: "Second" } },
nb.FileUpload,
];
bench("shallow key hit in first object", () => {
@@ -74,6 +74,6 @@ describe("get() function throughput", () => {
});
bench("long key dot notation parsing", () => {
get("dropzone.dragAndDropMultiple", [{ FileUpload: nb.FileUpload }]);
get("dropzone.dragAndDropMultiple", [nb.FileUpload]);
});
});
@@ -110,6 +110,44 @@ describe("useI18n", () => {
const translate = result.current;
expect(() => translate("item.uploading", { other: "John" })).toThrowError();
});
test("should use internal defaults without entries in the public locales", () => {
const { result } = renderHook(() => useI18n("TEST_DATA"), {
wrapper: ({ children }) => <Provider locale={en}>{children}</Provider>,
});
expect(result.current("label")).toBe("Internal grid");
expect(result.current("selected", { count: 3 })).toBe("3 rows selected");
/* @ts-expect-error Testing nonexistent key */
expect(() => result.current("nonexistentKey")).toThrowError();
});
test("should prioritize local internal translations and fall back to internal defaults", () => {
const { result } = renderHook(() =>
useI18n(
"TEST_DATA",
undefined,
{ label: "First override" },
{ label: "Second override" },
),
);
expect(result.current("label")).toBe("First override");
expect(result.current("selected", { count: 2 })).toBe("2 rows selected");
});
test("should update internal overrides without changing the translate function", () => {
const { result, rerender } = renderHook(
({ label }) => useI18n("TEST_DATA", { label }),
{ initialProps: { label: "Initial label" } },
);
const translate = result.current;
rerender({ label: "Updated label" });
expect(result.current).toBe(translate);
expect(translate("label")).toBe("Updated label");
});
});
describe("useDateLocale", () => {
+29 -12
View File
@@ -2,12 +2,8 @@ import type { Locale } from "date-fns";
import { useCallback, useRef } from "react";
import { useProvider } from "../../provider/Provider";
import { get } from "./get";
import type {
Component,
ComponentTranslation,
PartialTranslations,
TFunction,
} from "./i18n.types";
import { internalTranslations } from "./i18n.internal";
import type { Component, ComponentTranslation, TFunction } from "./i18n.types";
/* https://regex101.com/r/LYKWi3/1 */
const REPLACE_REGEX = /{[^}]*}/g;
@@ -17,13 +13,9 @@ function useI18n<T extends Component>(
...localTranslations: (ComponentTranslation<T> | undefined)[]
) {
const context = useProvider();
const contextTranslations = context.translations || [];
const i18nObjects: (PartialTranslations | undefined)[] = [
const i18nObjects: (ComponentTranslation<T> | undefined)[] = [
...localTranslations,
...(Array.isArray(contextTranslations)
? contextTranslations.map((t) => t[componentName])
: [contextTranslations[componentName]]),
context.locale[componentName],
...geti18nObjects(componentName, context),
];
const i18nRef = useRef(i18nObjects);
@@ -57,6 +49,31 @@ function useI18n<T extends Component>(
return translate;
}
function geti18nObjects(
componentName: Component,
context: ReturnType<typeof useProvider>,
) {
if (isInternalComponent(componentName)) {
return [internalTranslations[componentName]];
}
const contextTranslations = context.translations || [];
const i18nObjects: (ComponentTranslation<Component> | undefined)[] = [
...(Array.isArray(contextTranslations)
? contextTranslations.map((t) => t[componentName])
: [contextTranslations[componentName]]),
context.locale[componentName],
];
return i18nObjects;
}
function isInternalComponent(
componentName: Component,
): componentName is keyof typeof internalTranslations {
return componentName in internalTranslations;
}
function useDateLocale() {
const context = useProvider();
const contextTranslations = context.translations || [];
@@ -0,0 +1,9 @@
/** Defaults for internal components, independent of the public locales. Cant be overridden by users. */
const internalTranslations = {
TEST_DATA: {
label: "Internal grid",
selected: "{count} rows selected",
},
} satisfies Record<string, Record<string, string | Record<string, string>>>;
export { internalTranslations };
@@ -1,3 +1,4 @@
import type { internalTranslations } from "./i18n.internal";
import type nb from "./locales/nb";
/* https://dev.to/pffigueiredo/typescript-utility-keyof-nested-object-2pa3 */
@@ -7,8 +8,10 @@ type NestedKeyOf<ObjectType extends object> = {
: `${Key}`;
}[keyof ObjectType & (string | number)];
type InternalTranslations = typeof internalTranslations;
export type TFunction<T extends Component> = (
keypath: NestedKeyOf<Translations[T]>,
keypath: NestedKeyOf<(Translations & InternalTranslations)[T]>,
replacements?: Record<string, string | number>,
) => string;
@@ -21,8 +24,8 @@ export type Translations = typeof nb;
export type PartialTranslations = RecursivePartial<Translations>;
export type Component = keyof Translations;
export type Component = keyof Translations | keyof InternalTranslations;
export type ComponentTranslation<T extends Component> = RecursivePartial<
Translations[T]
(Translations & InternalTranslations)[T]
>;
@@ -27,6 +27,7 @@ export const KodeEksempler = defineType({
"Mindre padding i forhåndsvisningen, og koden er skjult som standard.",
name: "compact",
type: "boolean",
initialValue: false,
}),
],
preview: {
@@ -20,7 +20,7 @@ type RouteConfig<T extends RouteItemBase> = {
heading: string;
description: string;
buildAttributes: (item: T) => Record<string, string>;
getIntro?: (item: T) => any[] | undefined;
getIntro?: (item: T) => string;
};
function createRoute<T extends RouteItemBase>(config: RouteConfig<T>) {
@@ -96,7 +96,7 @@ function buildItemMarkdown<T extends RouteItemBase>(
return buildMarkdown(
open,
{ heading: item.heading ?? "" },
portableMarkdown(config.getIntro?.(item)),
config.getIntro?.(item) ?? null,
portableMarkdown(item.content ?? undefined),
MetadataSeksjonMarkdown(item.component_metadata),
close,
@@ -4,7 +4,9 @@ import {
KOMPONENT_BY_SLUG_MARKDOWN_QUERY,
} from "@/app/_sanity/queries";
import type { ALL_KOMPONENTS_MARKDOWN_QUERY_RESULT } from "@/app/_sanity/query-types";
import { buildMarkdown } from "../helpers/build-markdown";
import { createRoute } from "../helpers/create-route";
import { portableMarkdown } from "../helpers/portable-markdown";
type KomponentItem = ALL_KOMPONENTS_MARKDOWN_QUERY_RESULT[number];
@@ -44,5 +46,27 @@ export default createRoute<KomponentItem>({
packages: (item.kodepakker ?? []).join(", ") || "Ukjent",
version: PackageJson?.version,
}),
getIntro: (item) => item.intro?.body,
getIntro: (item) => {
const useFor = [
...(item.status?.internal ? ["Bruk på interne flater"] : []),
...(item.intro?.brukes_til ?? []),
];
const avoidUseFor = item.intro?.brukes_ikke_til ?? [];
return buildMarkdown(
portableMarkdown(item.intro?.body),
useFor.length > 0
? buildMarkdown(
{ heading: "Egnet til:", level: 2 },
useFor.map((text) => `- ${text}`).join("\n"),
)
: null,
avoidUseFor.length > 0
? buildMarkdown(
{ heading: "Uegnet til:", level: 2 },
avoidUseFor.map((text) => `- ${text}`).join("\n"),
)
: null,
);
},
});
@@ -19,7 +19,7 @@ type CaseT = {
keywords: string[];
priority: 0 | 1 | 2 | 3;
sender: string;
age: Date;
created: Date;
status:
| "Mottatt"
| "Under behandling"
@@ -29,9 +29,8 @@ type CaseT = {
deadline: Date;
};
const now = Date.now();
function createDemoRows(count: number): CaseT[] {
const now = Date.now();
/* prettier-ignore */
const names = ["Emma","Olivia","Nora","Sofie","Leah","Ella","Frida","Sofia","Ellinor","Astrid","Noah","Jakob","Lucas","Emil","Oskar","William","Elias","Isak","Oliver","Ludvig"];
/* prettier-ignore */
@@ -59,15 +58,16 @@ function createDemoRows(count: number): CaseT[] {
keywords: Array.from(keywords),
priority: ([0, 1, 2, 3] as const)[rng(4)],
sender: names[rng(names.length)],
age: new Date(now - index * daysInMs(1)),
created: new Date(now - index * daysInMs(1)),
status: statuses[rng(statuses.length)],
deadline: new Date(now - daysInMs(8) + (index % 50) * daysInMs(1)),
};
});
}
/* prettier-ignore */
function createDemoColumns({ withSorting = false }: { withSorting?: boolean; } = {}): DataGrid.Columns<CaseT> {
function createDemoColumns({
withSorting = false,
}: { withSorting?: boolean } = {}): DataGrid.Columns<CaseT> {
return [
{
id: "caseId",
@@ -116,10 +116,10 @@ function createDemoColumns({ withSorting = false }: { withSorting?: boolean; } =
isSortable: withSorting,
},
{
id: "age",
id: "created",
header: "Saksalder",
bodyCell: ({ age }) =>
`${Math.floor((Date.now() - age.getTime()) / daysInMs(1))}d`,
bodyCell: ({ created }) =>
`${Math.floor((Date.now() - created.getTime()) / daysInMs(1))}d`,
align: "right",
width: { defaultValue: 110 },
isSortable: withSorting,
@@ -161,7 +161,8 @@ function createDemoColumns({ withSorting = false }: { withSorting?: boolean; } =
align: "center",
width: { autoResizeOnce: true },
},
];
] satisfies (DataGrid.Columns<CaseT>[number] &
({ isSortable?: never } | { isSortable: boolean; id: keyof CaseT }))[];
}
const PriorityNames = {
@@ -188,7 +189,7 @@ function PriorityTag({ priority }: { priority: CaseT["priority"] }) {
}
function DeadlineCell({ deadline }: { deadline: CaseT["deadline"] }) {
const isOverdue = deadline.getTime() < now;
const isOverdue = deadline.getTime() < Date.now(); // eslint-disable-line react-hooks/purity
return (
<HGrid
@@ -0,0 +1,47 @@
import { useState } from "react";
import { CheckboxCard, CheckboxCardGroup } from "@navikt/ds-react/PREVIEW";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
const [value, setValue] = useState(["hyre"]);
return (
<CheckboxCardGroup
legend="Hvilke inntekter har du fra fisket?"
description="Velg alle som gjelder deg."
value={value}
onChange={setValue}
>
<CheckboxCard
value="lott"
description="Du får betalt ut fra hva fartøyet fanger - ikke fast lønn"
>
Lott - andel av fangsten
</CheckboxCard>
<CheckboxCard
value="hyre"
description="Du får fast lønn fra arbeidsgiveren din, som en vanlig ansatt"
>
Hyre - fast lønn
</CheckboxCard>
<CheckboxCard
value="provisjon"
description="Du får tillegg basert på salg eller fangstmengde"
>
Provisjon
</CheckboxCard>
</CheckboxCardGroup>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 5,
};
@@ -0,0 +1,45 @@
import { CheckboxCard, CheckboxCardGroup } from "@navikt/ds-react/PREVIEW";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
const handleChange = (val: string[]) => console.info(val);
return (
<CheckboxCardGroup
legend="Hvilke inntekter har du fra fisket?"
description="Velg alle som gjelder deg."
onChange={handleChange}
>
<CheckboxCard
value="lott"
description="Du får betalt ut fra hva fartøyet fanger - ikke fast lønn"
>
Lott - andel av fangsten
</CheckboxCard>
<CheckboxCard
value="hyre"
description="Du får fast lønn fra arbeidsgiveren din, som en vanlig ansatt"
>
Hyre - fast lønn
</CheckboxCard>
<CheckboxCard
value="provisjon"
description="Du får tillegg basert på salg eller fangstmengde"
>
Provisjon
</CheckboxCard>
</CheckboxCardGroup>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 0,
};
@@ -0,0 +1,45 @@
import { CheckboxCard, CheckboxCardGroup } from "@navikt/ds-react/PREVIEW";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<CheckboxCardGroup
legend="Hvilke inntekter har du fra fisket?"
description="Velg alle som gjelder deg."
defaultValue={["lott", "hyre"]}
disabled
>
<CheckboxCard
value="lott"
description="Du får betalt ut fra hva fartøyet fanger - ikke fast lønn"
>
Lott - andel av fangsten
</CheckboxCard>
<CheckboxCard
value="hyre"
description="Du får fast lønn fra arbeidsgiveren din, som en vanlig ansatt"
>
Hyre - fast lønn
</CheckboxCard>
<CheckboxCard
value="provisjon"
description="Du får tillegg basert på salg eller fangstmengde"
>
Provisjon
</CheckboxCard>
</CheckboxCardGroup>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 99,
desc: "Vi fraråder bruk av disabled state. Vurder om du trenger å vise feltet i det hele tatt, om du heller kan bruke `readOnly`, eller bare kan skrive det ut i ren tekst.",
};
@@ -0,0 +1,52 @@
import { useState } from "react";
import { CheckboxCard, CheckboxCardGroup } from "@navikt/ds-react/PREVIEW";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
const [value, setValue] = useState<string[]>([]);
return (
<CheckboxCardGroup
legend="Hvilke inntekter har du fra fisket?"
description="Velg alle som gjelder deg."
value={value}
onChange={setValue}
error={
value.length === 0
? "Du må velge minst én inntekt før du kan gå videre."
: undefined
}
>
<CheckboxCard
value="lott"
description="Du får betalt ut fra hva fartøyet fanger - ikke fast lønn"
>
Lott - andel av fangsten
</CheckboxCard>
<CheckboxCard
value="hyre"
description="Du får fast lønn fra arbeidsgiveren din, som en vanlig ansatt"
>
Hyre - fast lønn
</CheckboxCard>
<CheckboxCard
value="provisjon"
description="Du får tillegg basert på salg eller fangstmengde"
>
Provisjon
</CheckboxCard>
</CheckboxCardGroup>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 4,
};
@@ -0,0 +1,40 @@
import { CheckboxCard, CheckboxCardGroup } from "@navikt/ds-react/PREVIEW";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<CheckboxCardGroup legend="Hvilke inntekter har du fra fisket?" hideLegend>
<CheckboxCard
value="lott"
description="Du får betalt ut fra hva fartøyet fanger - ikke fast lønn"
>
Lott - andel av fangsten
</CheckboxCard>
<CheckboxCard
value="hyre"
description="Du får fast lønn fra arbeidsgiveren din, som en vanlig ansatt"
>
Hyre - fast lønn
</CheckboxCard>
<CheckboxCard
value="provisjon"
description="Du får tillegg basert på salg eller fangstmengde"
>
Provisjon
</CheckboxCard>
</CheckboxCardGroup>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 3,
desc: "Legend må være meningsfull selv om den skjules, siden den fortsatt leses av skjermlesere.",
};
@@ -0,0 +1,27 @@
import { HGrid } from "@navikt/ds-react";
import { CheckboxCard, CheckboxCardGroup } from "@navikt/ds-react/PREVIEW";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<CheckboxCardGroup legend="Hvordan vil du bli varslet?">
<HGrid columns={{ xs: 1, md: 2 }}>
<CheckboxCard value="sms">SMS</CheckboxCard>
<CheckboxCard value="epost">E-post</CheckboxCard>
</HGrid>
</CheckboxCardGroup>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example, { variant: "full" });
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 1,
desc: "Bruk `HGrid` for å plassere kortene horisontalt. Egner seg best for få alternativer med korte tekster. Du trenger ikke legge til eget `gap`-prop for å få avstand mellom kortene.",
};
@@ -0,0 +1,45 @@
import { CheckboxCard, CheckboxCardGroup } from "@navikt/ds-react/PREVIEW";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<CheckboxCardGroup
legend="Hvilke inntekter har du fra fisket?"
description="Velg alle som gjelder deg."
value={["lott", "hyre"]}
readOnly
>
<CheckboxCard
value="lott"
description="Du får betalt ut fra hva fartøyet fanger - ikke fast lønn"
>
Lott - andel av fangsten
</CheckboxCard>
<CheckboxCard
value="hyre"
description="Du får fast lønn fra arbeidsgiveren din, som en vanlig ansatt"
>
Hyre - fast lønn
</CheckboxCard>
<CheckboxCard
value="provisjon"
description="Du får tillegg basert på salg eller fangstmengde"
>
Provisjon
</CheckboxCard>
</CheckboxCardGroup>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 98,
desc: "Readonly-attributtet gjør at valgene ikke kan endres, men brukere vil fortsatt kunne markere og kopiere teksten. Til forskjell fra disabled vil brukere også kunne tabbe til det, og feltet vil inkluderes når skjemaet sendes inn.",
};
@@ -0,0 +1,43 @@
import { CheckboxCard, CheckboxCardGroup } from "@navikt/ds-react/PREVIEW";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<CheckboxCardGroup
legend="Hvilke inntekter har du fra fisket?"
description="Velg alle som gjelder deg."
size="small"
>
<CheckboxCard
value="lott"
description="Du får betalt ut fra hva fartøyet fanger - ikke fast lønn"
>
Lott - andel av fangsten
</CheckboxCard>
<CheckboxCard
value="hyre"
description="Du får fast lønn fra arbeidsgiveren din, som en vanlig ansatt"
>
Hyre - fast lønn
</CheckboxCard>
<CheckboxCard
value="provisjon"
description="Du får tillegg basert på salg eller fangstmengde"
>
Provisjon
</CheckboxCard>
</CheckboxCardGroup>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 2,
};
+4 -1
View File
@@ -23,7 +23,10 @@ export default defineConfig({
enabled: true,
headless: true,
provider: playwright(),
instances: [{ browser: "firefox" }],
/* Firefox can't start inside the cplt sandbox used by Copilot CLI */
instances: [
{ browser: process.env.COPILOT_CLI ? "chromium" : "firefox" },
],
},
},
},
+25 -8
View File
@@ -5236,7 +5236,7 @@ __metadata:
copyfiles: "npm:^2.4.1"
fast-glob: "npm:3.3.3"
hast-util-select: "npm:6.0.4"
js-yaml: "npm:^5.4.3"
js-yaml: "npm:^5.5.0"
lodash: "npm:4.18.1"
prettier: "npm:3.9.6"
react: "npm:19.3.0"
@@ -5256,7 +5256,7 @@ __metadata:
"@navikt/ds-css": "npm:^8.19.2"
"@navikt/ds-tokens": "npm:^8.19.2"
concurrently: "npm:10.0.4"
postcss-selector-parser: "npm:^7.1.6"
postcss-selector-parser: "npm:^7.1.7"
postcss-value-parser: "npm:^4.2.0"
stylelint: "npm:^17.15.0"
stylelint-test-rule-node: "npm:^1.2.0"
@@ -11291,6 +11291,13 @@ __metadata:
languageName: node
linkType: hard
"argparse@npm:^3.0.3":
version: 3.0.3
resolution: "argparse@npm:3.0.3"
checksum: 10/6834e247fe8a8d21a0061dc2b7134ae79625d9ab795eca043d8ba9434be3bc1e7d049c9a4aa01bd3d6bfc145b29addfb290d17b8b3f7b11710cc52ab13629f31
languageName: node
linkType: hard
"aria-query@npm:5.3.0":
version: 5.3.0
resolution: "aria-query@npm:5.3.0"
@@ -17807,14 +17814,14 @@ __metadata:
languageName: node
linkType: hard
"js-yaml@npm:^5.4.3":
version: 5.4.3
resolution: "js-yaml@npm:5.4.3"
"js-yaml@npm:^5.5.0":
version: 5.5.0
resolution: "js-yaml@npm:5.5.0"
dependencies:
argparse: "npm:^2.0.1"
argparse: "npm:^3.0.3"
bin:
js-yaml: bin/js-yaml.mjs
checksum: 10/2409d1238efddc29ab62ddb26d70bc7f2e8b58b84eb8d41d09d50fab86f8670aa2fec5dc6c99ef71939acab5d7c0b3526f96bdd0055042aa0136cca749b20afc
checksum: 10/c01359a4fe69a1c032e9e57d7387a1cbdfbbd45bc69924885a3c9a9a020e066627dc125f7b46f7b929186b5a65471ae5fa478d3f773e35f7ccdf57d32fcf369f
languageName: node
linkType: hard
@@ -21534,7 +21541,7 @@ __metadata:
languageName: node
linkType: hard
"postcss-selector-parser@npm:^7.0.0, postcss-selector-parser@npm:^7.1.5, postcss-selector-parser@npm:^7.1.6":
"postcss-selector-parser@npm:^7.0.0, postcss-selector-parser@npm:^7.1.5":
version: 7.1.6
resolution: "postcss-selector-parser@npm:7.1.6"
dependencies:
@@ -21544,6 +21551,16 @@ __metadata:
languageName: node
linkType: hard
"postcss-selector-parser@npm:^7.1.7":
version: 7.1.7
resolution: "postcss-selector-parser@npm:7.1.7"
dependencies:
cssesc: "npm:^3.0.0"
util-deprecate: "npm:^1.0.2"
checksum: 10/9413749ad38d8e22e4a5e47287e5501493238ab73eea276381476933332604df49468451e99a3d6614463fd3ee96655a80821a93949cfe425ecd6acf210535b0
languageName: node
linkType: hard
"postcss-value-parser@npm:^4.0.0, postcss-value-parser@npm:^4.0.2, postcss-value-parser@npm:^4.1.0, postcss-value-parser@npm:^4.2.0":
version: 4.2.0
resolution: "postcss-value-parser@npm:4.2.0"