Compare commits

..
154 changed files with 1671 additions and 3073 deletions

No files matched your search

-6
View File
@@ -1,6 +0,0 @@
---
"@navikt/ds-react": minor
"@navikt/ds-css": minor
---
Checkbox: Add `CheckboxCard` and `CheckboxCardGroup` components
-6
View File
@@ -1,6 +0,0 @@
---
"@navikt/ds-react": patch
"@navikt/ds-css": patch
---
ToggleGroup: Add xsmall as size
-5
View File
@@ -1,5 +0,0 @@
---
"@navikt/ds-css": patch
---
RadioCard, CheckboxCard: Updated border-color in unchecked error-state, input-color in checked disabled state
-5
View File
@@ -1,5 +0,0 @@
---
"@navikt/ds-react": patch
---
Build: `next build --webpack` now works when package is part of `optimizePackageImports`
+1 -2
View File
@@ -49,8 +49,7 @@
- `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; 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.
- `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)
## 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 (Chromium under Copilot CLI, see `vitest.config.ts`). Yarn has `enableScripts: false`, so if the browser is missing, report it instead of installing globally.
- Browser tests need Playwright Firefox. Yarn has `enableScripts: false`, so if the browser is missing, report it instead of installing globally.
@@ -95,7 +95,7 @@ Move stories to component root dir. If stories import from internal paths, updat
### 5. Update `index.ts`
All exports are **named** — no `default` exports anywhere. For compound components, the root file is the single export source: it assembles the compound component, imports every publicly exported sub-component and its prop types, and exports all public values and types together. The component `index.ts` only re-exports these names from `./root/<Component>Root` — never directly from sub-component files or shared type files.
All exports are **named** — no `default` exports anywhere. The component `index.ts` re-exports the compound component, its sub-components, and their types from the root file:
```ts
"use client";
@@ -111,18 +111,13 @@ export type {
} from "./root/<Component>Root";
```
Group values first, then types. If types live in `<Component>.types.ts`, import them into the root file and include them in its type export block. The `index.ts` still re-exports them from the root file.
Group values first, then types. If types live in `<Component>.types.ts`, re-export them from there.
#### Compound root file export pattern
In `<Component>Root.tsx`, assemble the compound component with `Object.assign` — **do not** use an `as <Component>Component` cast or a dedicated `<Component>Component` interface:
```tsx
import {
<Component>Trigger,
type <Component>TriggerProps,
} from "../trigger/<Component>Trigger";
const <Component>Root = forwardRef<HTMLDivElement, <Component>Props>((props, ref) => {
// ...
});
@@ -143,8 +138,6 @@ export { <Component>, <Component>Trigger };
export type { <Component>Props, <Component>TriggerProps };
```
Follow `accordion/root/AccordionRoot.tsx`: export the imported sub-component values and prop types alongside the compound component at the bottom of the root file. Do not add internal-only sub-components to the public export chain. Sub-components must not import from the root file or component `index.ts`; use shared type/context/helper files to avoid circular dependencies.
Why `Object.assign` instead of `as`: the cast types the const as an interface that has no runtime `valueDeclaration`, so `react-docgen-typescript` (used by `yarn docgen:meta`) fails to extract props and can't document the component when it is exported via a grouped `export { }` statement. `Object.assign` lets TypeScript infer the intersection type from the value, so metadata generation works with named, bottom-of-file exports.
Sub-component files follow the same rule — named value export plus named type export at the bottom, no inline `export interface` and no default:
@@ -180,8 +173,6 @@ Concretely: grep for `export` lines in both versions and diff them. All componen
**Package root.** Verify `@navikt/core/react/src/index.ts` still re-exports the component. No changes needed there unless the component's `index.ts` path changed (it shouldn't).
**Root-only re-exports.** For compound components, verify every value and type export in the component `index.ts` comes from `./root/<Component>Root`, and every forwarded name is exported by that root file.
**No case-duplicate tracked paths.** This is the most common restructure bug. On case-insensitive filesystems (macOS, Windows) git can end up tracking **two** entries that differ only in casing (e.g. `accordion.stories.tsx` **and** `Accordion.stories.tsx`) while the working tree shows only one file. The stale entry then breaks tests and checkout on other machines. `ls` / file search will NOT reveal it — you must ask git. Run:
```sh
@@ -290,7 +281,6 @@ dialog/
- **No breaking changes.** Public export names and prop shapes must be identical after restructuring.
- **Named exports only.** No `default` exports; group named exports at the bottom of each file (values first, then types).
- **Compound exports flow through the root.** Component `index.ts` re-exports public values and types only from `root/<Component>Root.tsx`, not directly from sub-component or shared type files.
- **Compound roots use `Object.assign`.** No `as <Component>Component` cast or `<Component>Component` interface — it breaks `yarn docgen:meta` prop extraction.
- Component rules (React 17 compatibility, `"use client"`, `forwardRef`/`className`/`...rest`/`as`, tokens) live in `.github/instructions/ds-react-component.instructions.md`. They still apply.
- Keep JSDoc on public props and components. The component-level JSDoc block sits directly above the `Object.assign` call; sub-component `@see` tags go on the keys inside the `Object.assign` object literal.
@@ -299,6 +289,6 @@ dialog/
- Canonical example: `@navikt/core/react/src/dialog/`
- Package public API: `@navikt/core/react/src/index.ts`
- Component export pattern: `@navikt/core/react/src/accordion/root/AccordionRoot.tsx` and `@navikt/core/react/src/accordion/index.ts`
- Component `index.ts` pattern: `@navikt/core/react/src/dialog/index.ts`
- Context pattern: `@navikt/core/react/src/dialog/root/DialogRoot.context.ts`
- Meta: `@navikt/core/react/src/accordion/Accordion.meta.ts`
+3 -10
View File
@@ -1,13 +1,6 @@
CHANGED=`git diff $1 $2 --stat -- yarn.lock | wc -l`
if [ $CHANGED -gt 0 ];
then
printf '\n\033[1;33m'
printf ' ╔══════════════════════════════════════════╗\n'
printf ' ║ ║\n'
printf ' ║ !! yarn.lock has changed !! ║\n'
printf ' ║ ║\n'
printf ' ║ Run: yarn install ║\n'
printf ' ║ ║\n'
printf ' ╚══════════════════════════════════════════╝\n'
printf '\033[0m\n'
fi
echo
echo "*** 🟡 yarn.lock has changed 🟡 ***"
fi
+3 -10
View File
@@ -1,13 +1,6 @@
CHANGED=`git diff HEAD@{1} --stat -- yarn.lock | wc -l`
if [ $CHANGED -gt 0 ];
then
printf '\n\033[1;33m'
printf ' ╔══════════════════════════════════════════╗\n'
printf ' ║ ║\n'
printf ' ║ !! yarn.lock has changed !! ║\n'
printf ' ║ ║\n'
printf ' ║ Run: yarn install ║\n'
printf ' ║ ║\n'
printf ' ╚══════════════════════════════════════════╝\n'
printf '\033[0m\n'
fi
echo
echo "*** 🟡 yarn.lock has changed 🟡 ***"
fi
+1 -2
View File
@@ -1,2 +1 @@
# Runs the local lint-staged checks before committing
./node_modules/.bin/lint-staged
yarn 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.5.0",
"js-yaml": "^5.4.3",
"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.7",
"postcss-selector-parser": "^7.1.6",
"postcss-value-parser": "^4.2.0",
"stylelint": "^17.15.0",
"stylelint-test-rule-node": "^1.2.0",
+23 -35
View File
@@ -19,8 +19,7 @@
}
}
/* Checkbox and Radio card */
.aksel-checkbox-card,
/* Radio card */
.aksel-radio-card {
width: 100%;
align-content: flex-start;
@@ -31,30 +30,26 @@
border-radius: var(--ax-radius-8);
&:focus-within::after,
&:is(.aksel-checkbox--small, .aksel-radio--small):focus-within::after {
&.aksel-radio--small:focus-within::after {
inset: 0;
border-radius: calc(var(--ax-radius-8) - 1px);
outline-offset: 4px;
}
}
/* 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) {
/* Radio card group */
.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-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) {
&.aksel-radio-group--small > .aksel-radio-buttons > .aksel-hgrid:not(.aksel-hgrid-gap) {
gap: var(--ax-space-8);
}
}
@@ -70,11 +65,14 @@
}
}
.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 {
@@ -248,14 +246,8 @@
--__axc-radio-checkbox-marker-border: 7px;
}
/* Card border in hover and checked states */
.aksel-checkbox-card:is(
.aksel-checkbox--error:not(:where(.aksel-checkbox--disabled, .aksel-checkbox--readonly)),
: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))
),
/* Radio card border in hover and checked states */
.aksel-radio-card:is(
.aksel-radio--error:not(:where(.aksel-radio--disabled, .aksel-radio--readonly)),
: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))
) {
@@ -263,10 +255,7 @@
box-shadow: 0 0 0 1px var(--ax-border-default);
}
/* Card background in checked state */
.aksel-checkbox-card:has(.aksel-checkbox__input:checked):not(
:where(.aksel-checkbox--disabled, .aksel-checkbox--readonly, .aksel-checkbox--error)
),
/* Radio card background in checked state */
.aksel-radio-card:has(> .aksel-radio__input:checked):not(
:where(.aksel-radio--disabled, .aksel-radio--readonly, .aksel-radio--error)
) {
@@ -276,8 +265,6 @@
/* 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);
@@ -296,16 +283,18 @@
opacity: var(--ax-opacity-disabled);
}
/* Card background in readonly state */
.aksel-checkbox-card:where(.aksel-checkbox--readonly),
/* Radio card background in disabled and readonly states */
.aksel-radio-card:where(.aksel-radio--readonly) {
background-color: var(--ax-bg-neutral-moderate);
}
.aksel-radio-card.aksel-radio--disabled > .aksel-radio__input:checked {
border-color: var(--ax-border-neutral-strong);
}
/* 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;
}
@@ -313,7 +302,6 @@
/* 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;
}
-12
View File
@@ -87,18 +87,6 @@
}
}
/* Modifier xsmall size */
.aksel-toggle-group--xsmall {
& .aksel-toggle-group__button {
padding: var(--ax-space-1) var(--ax-space-8);
min-height: 1.5rem;
}
& .aksel-toggle-group__button-inner > svg {
font-size: var(--ax-font-size-large);
}
}
/* High contrast */
@media (forced-colors: active) {
.aksel-toggle-group {
-10
View File
@@ -96,16 +96,6 @@
"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"
},
@@ -1,5 +1,5 @@
import React, { useRef } from "react";
import { useModalContext } from "../../modal/root/ModalRoot.context";
import { useModalContext } from "../../modal/Modal.context";
import { useId } from "../../utils-external";
import {
Menu,
@@ -1,8 +1,8 @@
import React, { type HTMLAttributes, forwardRef } from "react";
import { Detail, Heading, type HeadingProps } from "../../typography";
import { cl } from "../../utils/helpers";
import { Detail, Heading, type HeadingProps } from "../typography";
import { cl } from "../utils/helpers";
interface ChatBubbleProps extends HTMLAttributes<HTMLDivElement> {
export interface ChatBubbleProps extends HTMLAttributes<HTMLDivElement> {
/**
* Bubble text.
*/
@@ -26,7 +26,7 @@ interface ChatBubbleProps extends HTMLAttributes<HTMLDivElement> {
toptextHeadingLevel?: Exclude<HeadingProps["level"], "1">;
}
const ChatBubble = forwardRef<HTMLDivElement, ChatBubbleProps>(
const Bubble = forwardRef<HTMLDivElement, ChatBubbleProps>(
(
{
children,
@@ -65,5 +65,4 @@ const ChatBubble = forwardRef<HTMLDivElement, ChatBubbleProps>(
},
);
export { ChatBubble };
export type { ChatBubbleProps };
export default Bubble;
+1 -1
View File
@@ -3,7 +3,7 @@ import React from "react";
import { PersonIcon } from "@navikt/aksel-icons";
import { VStack } from "../primitives/stack";
import { renderStoriesForChromatic } from "../utils/renderStoriesForChromatic";
import { Chat, POSITIONS, SIZES, VARIANTS } from "./root/ChatRoot";
import Chat, { POSITIONS, SIZES, VARIANTS } from "./Chat";
export default {
title: "ds-react/Chat",
@@ -1,13 +1,13 @@
import React, { type HTMLAttributes, forwardRef } from "react";
import type { AkselStatusColorRole } from "@navikt/ds-tokens/types";
import type { AkselColor } from "../../types";
import { BodyLong, type HeadingProps } from "../../typography";
import { cl } from "../../utils/helpers";
import { ChatBubble, type ChatBubbleProps } from "../bubble/ChatBubble";
import type { AkselColor } from "../types";
import { BodyLong, type HeadingProps } from "../typography";
import { cl } from "../utils/helpers";
import Bubble, { type ChatBubbleProps } from "./Bubble";
const VARIANTS = ["subtle", "info", "neutral"] as const;
const POSITIONS = ["left", "right"] as const;
const SIZES = ["medium", "small"] as const;
export const VARIANTS = ["subtle", "info", "neutral"] as const;
export const POSITIONS = ["left", "right"] as const;
export const SIZES = ["medium", "small"] as const;
interface ChatProps extends HTMLAttributes<HTMLDivElement> {
/**
@@ -62,7 +62,33 @@ interface ChatProps extends HTMLAttributes<HTMLDivElement> {
"data-color"?: Exclude<AkselColor, AkselStatusColorRole>;
}
const ChatRoot = forwardRef<HTMLDivElement, ChatProps>(
interface ChatComponent extends React.ForwardRefExoticComponent<
ChatProps & React.RefAttributes<HTMLDivElement>
> {
/**
* @see 🏷️ {@link ChatBubbleProps}
*/
Bubble: typeof Bubble;
}
/**
* A component for communicating a dialog between two or more parties.
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/chat)
* @see 🏷️ {@link ChatProps}
*
* @example
* ```jsx
* <Chat avatar="A" name="Alice" timestamp="01.01.21 14:00">
* <Chat.Bubble>Hello!</Chat.Bubble>
* <Chat.Bubble>How can I help you?</Chat.Bubble>
* </Chat>
* <Chat avatar="B" name="Bob" timestamp="01.01.21 14:01" position="right">
* <Chat.Bubble>Hi there!</Chat.Bubble>
* </Chat>
* ```
*/
export const Chat = forwardRef<HTMLDivElement, ChatProps>(
(
{
children,
@@ -117,7 +143,7 @@ const ChatRoot = forwardRef<HTMLDivElement, ChatProps>(
</div>
);
},
);
) as ChatComponent;
function variantToColor(variant: ChatProps["variant"]): AkselColor {
switch (variant) {
@@ -132,29 +158,8 @@ function variantToColor(variant: ChatProps["variant"]): AkselColor {
}
}
/**
* A component for communicating a dialog between two or more parties.
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/chat)
* @see 🏷️ {@link ChatProps}
*
* @example
* ```jsx
* <Chat avatar="A" name="Alice" timestamp="01.01.21 14:00">
* <Chat.Bubble>Hello!</Chat.Bubble>
* <Chat.Bubble>How can I help you?</Chat.Bubble>
* </Chat>
* <Chat avatar="B" name="Bob" timestamp="01.01.21 14:01" position="right">
* <Chat.Bubble>Hi there!</Chat.Bubble>
* </Chat>
* ```
*/
const Chat = Object.assign(ChatRoot, {
/**
* @see 🏷️ {@link ChatBubbleProps}
*/
Bubble: ChatBubble,
});
Chat.Bubble = Bubble;
export { Chat, ChatBubble, VARIANTS, POSITIONS, SIZES };
export default Chat;
export { Bubble as ChatBubble };
export type { ChatProps, ChatBubbleProps };
+2 -2
View File
@@ -1,3 +1,3 @@
"use client";
export { Chat, ChatBubble } from "./root/ChatRoot";
export type { ChatProps, ChatBubbleProps } from "./root/ChatRoot";
export { default as Chat, ChatBubble } from "./Chat";
export type { ChatProps, ChatBubbleProps } from "./Chat";
+6 -6
View File
@@ -1,7 +1,7 @@
"use client";
export { Chips, ChipsRemovable, ChipsToggle } from "./root/ChipsRoot";
export type {
ChipsProps,
ChipsRemovableProps,
ChipsToggleProps,
} from "./root/ChipsRoot";
export { default as Chips, type ChipsProps } from "./root/ChipsRoot";
export {
ChipsRemovable,
type ChipsRemovableProps,
} from "./removable/ChipsRemovable";
export { ChipsToggle, type ChipsToggleProps } from "./toggle/ChipsToggle";
@@ -4,7 +4,7 @@ import type { AkselColor } from "../../types";
import { cl, composeEventHandlers } from "../../utils/helpers";
import { useI18n } from "../../utils/i18n/i18n.hooks";
interface ChipsRemovableProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
export interface ChipsRemovableProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: string;
/**
* @deprecated Use `data-color` prop instead.
@@ -22,7 +22,10 @@ interface ChipsRemovableProps extends React.ButtonHTMLAttributes<HTMLButtonEleme
"data-color"?: AkselColor;
}
const ChipsRemovable = forwardRef<HTMLButtonElement, ChipsRemovableProps>(
export const ChipsRemovable = forwardRef<
HTMLButtonElement,
ChipsRemovableProps
>(
(
{
children,
@@ -73,5 +76,4 @@ function variantToColor(
}
}
export { ChipsRemovable };
export type { ChipsRemovableProps };
export default ChipsRemovable;
+41 -39
View File
@@ -1,10 +1,7 @@
import React, { type HTMLAttributes, forwardRef } from "react";
import { cl } from "../../utils/helpers";
import {
ChipsRemovable,
type ChipsRemovableProps,
} from "../removable/ChipsRemovable";
import { ChipsToggle, type ChipsToggleProps } from "../toggle/ChipsToggle";
import { ChipsRemovable } from "../removable/ChipsRemovable";
import { ChipsToggle } from "../toggle/ChipsToggle";
interface ChipsProps extends HTMLAttributes<HTMLUListElement> {
children: React.ReactNode;
@@ -15,24 +12,22 @@ interface ChipsProps extends HTMLAttributes<HTMLUListElement> {
size?: "medium" | "small";
}
const ChipsRoot = forwardRef<HTMLUListElement, ChipsProps>(
({ className, size = "medium", children, ...rest }, ref) => {
return (
<ul
{...rest}
ref={ref}
className={cl("aksel-chips", className, `aksel-chips--${size}`, {
"aksel-body-short aksel-body-short--small": size === "medium",
"aksel-detail aksel-detail--small": size === "small",
})}
>
{React.Children.map(children, (chip, index) => {
return <li key={chip?.toString() || index}>{chip}</li>;
})}
</ul>
);
},
);
interface ChipsComponent extends React.ForwardRefExoticComponent<
ChipsProps & React.RefAttributes<HTMLUListElement>
> {
/**
* Toggle between selected-states.
* @see 🏷️ {@link ToggleChipsProps}
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
*/
Toggle: typeof ChipsToggle;
/**
* Remove filter or the likes on click.
* @see 🏷️ {@link RemovableChipsProps}
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
*/
Removable: typeof ChipsRemovable;
}
/**
* A component that displays a list of items as chips.
@@ -61,20 +56,27 @@ const ChipsRoot = forwardRef<HTMLUListElement, ChipsProps>(
* </Chips>
* ```
*/
const Chips = Object.assign(ChipsRoot, {
/**
* Toggle between selected-states.
* @see 🏷️ {@link ChipsToggleProps}
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
*/
Toggle: ChipsToggle,
/**
* Remove filter or the likes on click.
* @see 🏷️ {@link ChipsRemovableProps}
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
*/
Removable: ChipsRemovable,
});
export const Chips = forwardRef<HTMLUListElement, ChipsProps>(
({ className, size = "medium", children, ...rest }, ref) => {
return (
<ul
{...rest}
ref={ref}
className={cl("aksel-chips", className, `aksel-chips--${size}`, {
"aksel-body-short aksel-body-short--small": size === "medium",
"aksel-detail aksel-detail--small": size === "small",
})}
>
{React.Children.map(children, (chip, index) => {
return <li key={chip?.toString() || index}>{chip}</li>;
})}
</ul>
);
},
) as ChipsComponent;
export { Chips, ChipsRemovable, ChipsToggle };
export type { ChipsProps, ChipsRemovableProps, ChipsToggleProps };
Chips.Toggle = ChipsToggle;
Chips.Removable = ChipsRemovable;
export default Chips;
export type { ChipsProps };
@@ -3,7 +3,7 @@ import type { AkselColor } from "../../types";
import type { OverridableComponent } from "../../utils-external";
import { cl } from "../../utils/helpers";
interface ChipsToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
export interface ChipsToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: string;
/**
* Toggles aria-pressed and visual changes
@@ -26,66 +26,68 @@ interface ChipsToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement>
checkmark?: boolean;
}
const ChipsToggle: OverridableComponent<ChipsToggleProps, HTMLButtonElement> =
forwardRef(
(
{
className,
children,
selected,
variant,
checkmark = true,
as: Component = "button",
"data-color": color,
...rest
},
ref,
) => {
return (
<Component
data-color={color ?? variantToColor(variant)}
{...rest}
ref={ref}
className={cl("aksel-chips__chip aksel-chips__toggle", className, {
"aksel-chips__toggle--with-checkmark": checkmark,
})}
aria-pressed={selected}
data-pressed={selected}
>
{checkmark && (
<svg
aria-hidden
className="aksel-chips__toggle-icon"
width="1.25em"
height="1.25em"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
focusable={false}
>
{selected ? (
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10 18.125C14.4873 18.125 18.125 14.4873 18.125 10C18.125 5.51269 14.4873 1.875 10 1.875C5.51269 1.875 1.875 5.51269 1.875 10C1.875 14.4873 5.51269 18.125 10 18.125ZM14.128 7.72904C14.3695 7.44357 14.3339 7.01635 14.0485 6.7748C13.763 6.53326 13.3358 6.56886 13.0942 6.85432L8.60428 12.1606L6.41627 9.97263C6.15185 9.70822 5.72315 9.70822 5.45873 9.97263C5.19431 10.2371 5.19431 10.6658 5.45873 10.9302L8.16706 13.6385C8.30095 13.7724 8.48479 13.8441 8.67397 13.8362C8.86316 13.8284 9.0404 13.7416 9.16271 13.5971L14.128 7.72904Z"
fill="currentColor"
/>
) : (
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10 3.125C6.20304 3.125 3.125 6.20304 3.125 10C3.125 13.797 6.20304 16.875 10 16.875C13.797 16.875 16.875 13.797 16.875 10C16.875 6.20304 13.797 3.125 10 3.125ZM1.875 10C1.875 5.51269 5.51269 1.875 10 1.875C14.4873 1.875 18.125 5.51269 18.125 10C18.125 14.4873 14.4873 18.125 10 18.125C5.51269 18.125 1.875 14.4873 1.875 10Z"
/* After removing old fallbacks, change to currentColor */
fill="var(--ax-text-default)"
/>
)}
</svg>
)}
<span className="aksel-chips__chip-text">{children}</span>
</Component>
);
export const ChipsToggle: OverridableComponent<
ChipsToggleProps,
HTMLButtonElement
> = forwardRef(
(
{
className,
children,
selected,
variant,
checkmark = true,
as: Component = "button",
"data-color": color,
...rest
},
);
ref,
) => {
return (
<Component
data-color={color ?? variantToColor(variant)}
{...rest}
ref={ref}
className={cl("aksel-chips__chip aksel-chips__toggle", className, {
"aksel-chips__toggle--with-checkmark": checkmark,
})}
aria-pressed={selected}
data-pressed={selected}
>
{checkmark && (
<svg
aria-hidden
className="aksel-chips__toggle-icon"
width="1.25em"
height="1.25em"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
focusable={false}
>
{selected ? (
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10 18.125C14.4873 18.125 18.125 14.4873 18.125 10C18.125 5.51269 14.4873 1.875 10 1.875C5.51269 1.875 1.875 5.51269 1.875 10C1.875 14.4873 5.51269 18.125 10 18.125ZM14.128 7.72904C14.3695 7.44357 14.3339 7.01635 14.0485 6.7748C13.763 6.53326 13.3358 6.56886 13.0942 6.85432L8.60428 12.1606L6.41627 9.97263C6.15185 9.70822 5.72315 9.70822 5.45873 9.97263C5.19431 10.2371 5.19431 10.6658 5.45873 10.9302L8.16706 13.6385C8.30095 13.7724 8.48479 13.8441 8.67397 13.8362C8.86316 13.8284 9.0404 13.7416 9.16271 13.5971L14.128 7.72904Z"
fill="currentColor"
/>
) : (
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10 3.125C6.20304 3.125 3.125 6.20304 3.125 10C3.125 13.797 6.20304 16.875 10 16.875C13.797 16.875 16.875 13.797 16.875 10C16.875 6.20304 13.797 3.125 10 3.125ZM1.875 10C1.875 5.51269 5.51269 1.875 10 1.875C14.4873 1.875 18.125 5.51269 18.125 10C18.125 14.4873 14.4873 18.125 10 18.125C5.51269 18.125 1.875 14.4873 1.875 10Z"
/* After removing old fallbacks, change to currentColor */
fill="var(--ax-text-default)"
/>
)}
</svg>
)}
<span className="aksel-chips__chip-text">{children}</span>
</Component>
);
},
);
function variantToColor(
variant?: ChipsToggleProps["variant"],
@@ -100,5 +102,4 @@ function variantToColor(
}
}
export { ChipsToggle };
export type { ChipsToggleProps };
export default ChipsToggle;
@@ -3,7 +3,7 @@ import { isSameDay } from "date-fns";
import React, { useState } from "react";
import { Button } from "../button";
import { Dialog } from "../dialog";
import { Modal } from "../modal";
import Modal from "../modal/Modal";
import { Box } from "../primitives/box";
import { HGrid } from "../primitives/grid";
import { HStack, Spacer, VStack } from "../primitives/stack";
@@ -1,5 +1,5 @@
import React, { forwardRef } from "react";
import { useModalContext } from "../../modal/root/ModalRoot.context";
import { useModalContext } from "../../modal/Modal.context";
import { Portal, type PortalProps } from "../../portal";
import { DialogBackdropInternal } from "../backdrop/DialogBackdropInternal";
import { useDialogContext } from "../root/DialogRoot.context";
@@ -1,24 +1,51 @@
import React, { forwardRef } from "react";
import type { AkselColor } from "../../types";
import { cl } from "../../utils/helpers";
import { useControllableState } from "../../utils/hooks";
import {
ExpansionCardContent,
import type { AkselColor } from "../types";
import type { OverridableComponent } from "../utils-external";
import { cl } from "../utils/helpers";
import { useControllableState } from "../utils/hooks";
import ExpansionCardContent, {
type ExpansionCardContentProps,
} from "../content/ExpansionCardContent";
} from "./ExpansionCardContent";
import {
ExpansionCardDescription,
type ExpansionCardDescriptionProps,
} from "../description/ExpansionCardDescription";
import {
ExpansionCardHeader,
} from "./ExpansionCardDescription";
import ExpansionCardHeader, {
type ExpansionCardHeaderProps,
} from "../header/ExpansionCardHeader";
} from "./ExpansionCardHeader";
import {
ExpansionCardTitle,
type ExpansionCardTitleProps,
} from "../title/ExpansionCardTitle";
import { ExpansionCardContext } from "./ExpansionCardRoot.context";
} from "./ExpansionCardTitle";
import { ExpansionCardContext } from "./context";
interface ExpansionCardComponent extends React.ForwardRefExoticComponent<
ExpansionCardProps & React.RefAttributes<HTMLDivElement>
> {
/**
* @see 🏷️ {@link ExpansionCardHeaderProps}
*/
Header: React.ForwardRefExoticComponent<
ExpansionCardHeaderProps & React.RefAttributes<HTMLDivElement>
>;
/**
* @see 🏷️ {@link ExpansionCardTitleProps}
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
*/
Title: OverridableComponent<ExpansionCardTitleProps, HTMLHeadingElement>;
/**
* @see 🏷️ {@link ExpansionCardDescriptionProps}
*/
Description: React.ForwardRefExoticComponent<
ExpansionCardDescriptionProps & React.RefAttributes<HTMLParagraphElement>
>;
/**
* @see 🏷️ {@link ExpansionCardContentProps}
*/
Content: React.ForwardRefExoticComponent<
ExpansionCardContentProps & React.RefAttributes<HTMLDivElement>
>;
}
interface ExpansionCardCommonProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
@@ -66,10 +93,28 @@ type ExpansionCardConditionalProps =
"aria-labelledby": string;
};
type ExpansionCardProps = ExpansionCardCommonProps &
export type ExpansionCardProps = ExpansionCardCommonProps &
ExpansionCardConditionalProps;
const ExpansionCardRoot = forwardRef<HTMLDivElement, ExpansionCardProps>(
/**
* A component that displays an expandable card.
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/expansioncard)
* @see 🏷️ {@link ExpansionCardProps}
*
* @example
* ```jsx
* <ExpansionCard aria-label="Utbetaling av sykepenger">
* <ExpansionCard.Header>
* <ExpansionCard.Title>Utbetaling av sykepenger</ExpansionCard.Title>
* </ExpansionCard.Header>
* <ExpansionCard.Content>
* <Innhold />
* </ExpansionCard.Content>
* </ExpansionCard>
* ```
*/
export const ExpansionCard = forwardRef<HTMLDivElement, ExpansionCardProps>(
(
{
className,
@@ -109,57 +154,11 @@ const ExpansionCardRoot = forwardRef<HTMLDivElement, ExpansionCardProps>(
</ExpansionCardContext.Provider>
);
},
);
) as ExpansionCardComponent;
/**
* A component that displays an expandable card.
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/expansioncard)
* @see 🏷️ {@link ExpansionCardProps}
*
* @example
* ```jsx
* <ExpansionCard aria-label="Utbetaling av sykepenger">
* <ExpansionCard.Header>
* <ExpansionCard.Title>Utbetaling av sykepenger</ExpansionCard.Title>
* </ExpansionCard.Header>
* <ExpansionCard.Content>
* <Innhold />
* </ExpansionCard.Content>
* </ExpansionCard>
* ```
*/
const ExpansionCard = Object.assign(ExpansionCardRoot, {
/**
* @see 🏷️ {@link ExpansionCardHeaderProps}
*/
Header: ExpansionCardHeader,
/**
* @see 🏷️ {@link ExpansionCardTitleProps}
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
*/
Title: ExpansionCardTitle,
/**
* @see 🏷️ {@link ExpansionCardDescriptionProps}
*/
Description: ExpansionCardDescription,
/**
* @see 🏷️ {@link ExpansionCardContentProps}
*/
Content: ExpansionCardContent,
});
ExpansionCard.Header = ExpansionCardHeader;
ExpansionCard.Content = ExpansionCardContent;
ExpansionCard.Title = ExpansionCardTitle;
ExpansionCard.Description = ExpansionCardDescription;
export {
ExpansionCard,
ExpansionCardHeader,
ExpansionCardTitle,
ExpansionCardDescription,
ExpansionCardContent,
};
export type {
ExpansionCardProps,
ExpansionCardHeaderProps,
ExpansionCardTitleProps,
ExpansionCardDescriptionProps,
ExpansionCardContentProps,
};
export default ExpansionCard;
@@ -1,10 +1,10 @@
import React, { forwardRef, useContext } from "react";
import { useThemeInternal } from "../../theme/Theme";
import { BodyLong } from "../../typography";
import { cl } from "../../utils/helpers";
import { ExpansionCardContext } from "../root/ExpansionCardRoot.context";
import { useThemeInternal } from "../theme/Theme";
import { BodyLong } from "../typography";
import { cl } from "../utils/helpers";
import { ExpansionCardContext } from "./context";
interface ExpansionCardContentProps extends React.HTMLAttributes<HTMLDivElement> {
export interface ExpansionCardContentProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
@@ -44,5 +44,4 @@ const ExpansionCardContent = forwardRef<
);
});
export { ExpansionCardContent };
export type { ExpansionCardContentProps };
export default ExpansionCardContent;
@@ -1,13 +1,13 @@
import React, { forwardRef, useContext } from "react";
import { BodyLong } from "../../typography";
import { cl } from "../../utils/helpers";
import { ExpansionCardContext } from "../root/ExpansionCardRoot.context";
import { BodyLong } from "../typography";
import { cl } from "../utils/helpers";
import { ExpansionCardContext } from "./context";
interface ExpansionCardDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {
export interface ExpansionCardDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {
children: React.ReactNode;
}
const ExpansionCardDescription = forwardRef<
export const ExpansionCardDescription = forwardRef<
HTMLParagraphElement,
ExpansionCardDescriptionProps
>(({ className, ...rest }, ref) => {
@@ -31,5 +31,4 @@ const ExpansionCardDescription = forwardRef<
);
});
export { ExpansionCardDescription };
export type { ExpansionCardDescriptionProps };
export default ExpansionCardDescription;
@@ -1,10 +1,10 @@
import React, { forwardRef, useContext } from "react";
import { ChevronDownIcon } from "@navikt/aksel-icons";
import { cl } from "../../utils/helpers";
import { useI18n } from "../../utils/i18n/i18n.hooks";
import { ExpansionCardContext } from "../root/ExpansionCardRoot.context";
import { cl } from "../utils/helpers";
import { useI18n } from "../utils/i18n/i18n.hooks";
import { ExpansionCardContext } from "./context";
interface ExpansionCardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
export interface ExpansionCardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
@@ -46,5 +46,4 @@ const ExpansionCardHeader = forwardRef<
);
});
export { ExpansionCardHeader };
export type { ExpansionCardHeaderProps };
export default ExpansionCardHeader;
@@ -1,8 +1,8 @@
import React, { forwardRef } from "react";
import type { OverridableComponent } from "../../utils-external";
import { cl } from "../../utils/helpers";
import type { OverridableComponent } from "../utils-external";
import { cl } from "../utils/helpers";
interface ExpansionCardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
export interface ExpansionCardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
children: React.ReactNode;
/**
* Changes text-sizing
@@ -11,7 +11,7 @@ interface ExpansionCardTitleProps extends React.HTMLAttributes<HTMLHeadingElemen
size?: "large" | "medium" | "small";
}
const ExpansionCardTitle: OverridableComponent<
export const ExpansionCardTitle: OverridableComponent<
ExpansionCardTitleProps,
HTMLHeadingElement
> = forwardRef(
@@ -32,5 +32,4 @@ const ExpansionCardTitle: OverridableComponent<
},
);
export { ExpansionCardTitle };
export type { ExpansionCardTitleProps };
export default ExpansionCardTitle;
@@ -0,0 +1,13 @@
import { createContext } from "react";
export type ExpansionCardContextProps = {
open: boolean;
toggleOpen: () => void;
size: "medium" | "small";
};
export const ExpansionCardContext = createContext<ExpansionCardContextProps>({
open: false,
toggleOpen: () => {},
size: "medium",
});
@@ -1,9 +1,9 @@
"use client";
export {
ExpansionCard,
ExpansionCardContent,
ExpansionCardHeader,
ExpansionCardTitle,
ExpansionCardDescription,
} from "./root/ExpansionCardRoot";
export type { ExpansionCardProps } from "./root/ExpansionCardRoot";
type ExpansionCardProps,
default as ExpansionCard,
} from "./ExpansionCard";
export { default as ExpansionCardContent } from "./ExpansionCardContent";
export { default as ExpansionCardHeader } from "./ExpansionCardHeader";
export { default as ExpansionCardTitle } from "./ExpansionCardTitle";
export { default as ExpansionCardDescription } from "./ExpansionCardDescription";
@@ -1,16 +0,0 @@
import { createContext } from "react";
type ExpansionCardContextProps = {
open: boolean;
toggleOpen: () => void;
size: "medium" | "small";
};
const ExpansionCardContext = createContext<ExpansionCardContextProps>({
open: false,
toggleOpen: () => {},
size: "medium",
});
export { ExpansionCardContext };
export type { ExpansionCardContextProps };
@@ -1,14 +0,0 @@
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 };
@@ -1,129 +0,0 @@
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" };
@@ -1,41 +0,0 @@
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 };
@@ -1,37 +0,0 @@
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 };
@@ -1,3 +0,0 @@
"use client";
export { CheckboxCard } from "./CheckboxCard";
export { CheckboxCardGroup } from "./CheckboxCardGroup";
@@ -88,22 +88,3 @@ 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,33 +1,21 @@
import React, { forwardRef, useRef } from "react";
import React, { forwardRef } from "react";
import { BodyShort } from "../../typography";
import { omit, useId } from "../../utils-external";
import { cl, createStrictContext } from "../../utils/helpers";
import { useMergeRefs } from "../../utils/hooks";
import { cl } from "../../utils/helpers";
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,
@@ -35,26 +23,9 @@ 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={mergedRefs}
ref={forwardedRef}
{...omit(props, [
"children",
"size",
@@ -101,4 +72,3 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
);
export default Checkbox;
export { CheckboxCardContextProvider };
@@ -19,7 +19,7 @@ export interface CheckboxProps
*/
children: React.ReactNode;
/**
* Visually hides label and description, and makes it viewable for screen-readers only.
* Hides the label and description visually while keeping them available to screen readers.
*/
hideLabel?: boolean;
/**
@@ -60,8 +60,7 @@ export interface ComboboxProps
*/
filteredOptions?: string[] | ComboboxOption[];
/**
* Optionally hide the label visually.
* Not recommended, but can be considered for e.g. search fields in the top menu.
* Hides the label and description visually while keeping them available to screen readers.
*/
hideLabel?: boolean;
/**
@@ -21,19 +21,20 @@ import { ComboboxTrigger } from "./trigger/ComboboxTrigger";
interface ComboboxProps<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>
extends Omit<ComboboxRootProps<T>, "children">, Omit<FormFieldProps, "size"> {
/**
* Label
*/
extends
Omit<
ComboboxRootProps<T>,
"children" | "selectedOptions" | "onToggleOption"
>,
Partial<Pick<ComboboxRootProps<T>, "selectedOptions" | "onToggleOption">>,
Omit<FormFieldProps, "size"> {
label: string;
/**
* Hides the label and description visually while keeping them available to screen readers.
*/
hideLabel?: boolean;
/**
* Field name. When provided, the field value will be included in form submissions.
*/
name?: string;
defaultSelectedOptions?: ComboboxRootProps<T>["selectedOptions"];
onSelectedOptionsChange?: (
newSelectedOptions: ComboboxRootProps<T>["selectedOptions"],
) => void;
}
function Combobox<
@@ -42,6 +43,8 @@ function Combobox<
label,
hideLabel,
name,
defaultSelectedOptions,
onSelectedOptionsChange,
size: sizeProp,
error,
errorId: errorIdProp,
@@ -49,7 +52,9 @@ function Combobox<
description,
id,
readOnly: readOnlyProp,
multiple,
multiselect = false, // Default value should be synced with ComboboxRoot
selectedOptions: selectedOptionsProp,
onToggleOption: onToggleOptionProp,
...rest
}: ComboboxProps<T>) {
const {
@@ -73,11 +78,35 @@ function Combobox<
"combobox",
);
const [selectedOptions, setSelectedOptions] = useControllableState({
defaultValue: defaultSelectedOptions || [],
value: selectedOptionsProp,
onChange: onSelectedOptionsChange,
});
const onToggleOption: ComboboxRootProps<T>["onToggleOption"] = (
option,
isSelected,
) => {
onToggleOptionProp?.(option, isSelected);
setSelectedOptions((prevSelectedOptions) => {
if (!multiselect) {
return [option.value];
}
if (isSelected) {
return [...prevSelectedOptions, option.value];
}
return prevSelectedOptions.filter((value) => value !== option.value);
});
};
return (
<ComboboxRoot
size={size}
disabled={inputProps.disabled}
multiple={multiple}
multiselect={multiselect}
selectedOptions={selectedOptions}
onToggleOption={onToggleOption}
{...rest}
>
<ComboboxLabel
@@ -117,8 +146,8 @@ function Combobox<
</div>
{name &&
rest.value.map((val) => (
<input key={val} type="hidden" name={name} value={val} />
selectedOptions.map((value) => (
<input key={value} type="hidden" name={name} value={value} />
))}
<ComboboxOverlay
@@ -140,126 +169,5 @@ function Combobox<
);
}
// ----------- ComboboxSingle ----------- //
interface ComboboxSingleProps<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
> extends Omit<ComboboxProps<T>, "multiple" | "value" | "onToggleOption"> {
/**
* Called when selected option changes.
*/
onChange?: (
newValue: NonNullable<ComboboxProps<T>["value"]>[number] | undefined,
) => void;
/**
* Value of the selected option.
*/
value?: NonNullable<ComboboxProps<T>["value"]>[number];
/**
* Initial value when uncontrolled.
*/
defaultValue?: NonNullable<ComboboxProps<T>["value"]>[number];
}
function ComboboxSingle<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>({ onChange, value, defaultValue, ...rest }: ComboboxSingleProps<T>) {
const [localValue, setLocalValue] = useControllableState({
defaultValue,
value,
onChange,
});
const onToggleOption: ComboboxProps<T>["onToggleOption"] = (
option,
isSelected,
) => {
setLocalValue(isSelected ? option.value : undefined);
}; // TODO: Dumt at ikke får hele option-objektet... Legge til i onChange? Hvis vi tilbyr onToggle så blir det så likt onChange...
return (
<Combobox
{...rest}
value={localValue ? [localValue] : []}
defaultValue={defaultValue ? [defaultValue] : []}
multiple={false}
onToggleOption={onToggleOption}
/>
);
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace ComboboxSingle {
export type Props<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
> = ComboboxSingleProps<T>;
}
// ----------- ComboboxMulti ----------- //
interface ComboboxMultiProps<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>
extends
Omit<ComboboxProps<T>, "multiple" | "value" | "onToggleOption">,
Partial<Pick<ComboboxProps<T>, "onToggleOption">> {
/**
* Called when selected options change.
*/
onChange?: (newValue: ComboboxProps<T>["value"]) => void;
/**
* Value of the selected options.
*/
value?: ComboboxProps<T>["value"];
/**
* Initial value when uncontrolled.
*/
defaultValue?: ComboboxProps<T>["value"];
}
function ComboboxMulti<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>({
onChange,
value,
defaultValue,
onToggleOption,
...rest
}: ComboboxMultiProps<T>) {
const [localValue, setLocalValue] = useControllableState({
defaultValue: defaultValue || [],
value,
onChange,
});
const localOnToggleOption: ComboboxProps<T>["onToggleOption"] = (
option,
isSelected,
) => {
onToggleOption?.(option, isSelected);
setLocalValue((prevSelectedOptions) =>
isSelected
? [...prevSelectedOptions, option.value]
: prevSelectedOptions.filter((optVal) => optVal !== option.value),
);
};
return (
<Combobox
{...rest}
value={localValue}
defaultValue={defaultValue}
multiple
onToggleOption={localOnToggleOption}
/>
);
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace ComboboxMulti {
export type Props<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
> = ComboboxMultiProps<T>;
}
export { ComboboxSingle, ComboboxMulti };
export { Combobox };
export type { ComboboxProps };
@@ -3,7 +3,8 @@ import React, { useMemo, useState } from "react";
import { Button } from "../../button";
import { Dialog } from "../../dialog";
import { HStack, VStack } from "../../primitives/stack";
import { ComboboxMulti, ComboboxSingle } from "./Combobox";
import { Select } from "../select";
import { Combobox, type ComboboxProps } from "./Combobox";
import { ComboboxField } from "./field/ComboboxField";
import { ComboboxFilter } from "./filter/ComboboxFilter";
import { ComboboxLabel } from "./label/ComboboxLabel";
@@ -17,9 +18,9 @@ import {
} from "./root/ComboboxRoot";
import { ComboboxTrigger } from "./trigger/ComboboxTrigger";
const meta: Meta<typeof ComboboxSingle> = {
const meta: Meta<typeof Combobox> = {
title: "ds-react/Combobox2",
component: ComboboxSingle,
component: Combobox,
parameters: {
chromatic: { disable: true },
},
@@ -76,12 +77,13 @@ const groupedCountries: (MyGroup | MyOption)[] = [
];
type DefaultProps = Pick<
ComboboxSingle.Props<MyOption>,
ComboboxProps<MyOption>,
| "label"
| "description"
| "hideLabel"
| "size"
| "defaultOpen"
| "multiselect"
| "id"
| "readOnly"
| "disabled"
@@ -89,13 +91,14 @@ type DefaultProps = Pick<
>;
export const Default: StoryFn<DefaultProps> = (props) => {
return <ComboboxSingle options={countries} {...props} />;
return <Combobox defaultOpen options={countries} {...props} />;
};
Default.args = {
label: "Velg land",
description: "Landet hvor du er født.",
hideLabel: false,
defaultOpen: false,
multiselect: false,
readOnly: false,
disabled: false,
error: "",
@@ -110,21 +113,17 @@ Default.argTypes = {
},
};
export const Multiselect = () => {
return <ComboboxMulti label="Velg land" options={countries} />;
};
Multiselect.storyName = "Multiselect (WIP)";
function BasicCombobox<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData> =
MyOption,
>(props: Partial<ComboboxSingle.Props<T>>) {
>(props: Partial<ComboboxProps<T>>) {
return (
<ComboboxSingle
<Combobox
options={countries as T[]}
defaultValue="opt-1"
defaultSelectedOptions={["opt-1"]}
label="Velg land"
description="Landet hvor du er født."
multiselect={false}
{...props}
/>
);
@@ -134,13 +133,13 @@ export const Small = () => <BasicCombobox size="small" error="Error" />;
export const Hidelabel = () => <BasicCombobox hideLabel />;
export const Readonly = () => <BasicCombobox readOnly error="Test" />;
export const Readonly = () => <BasicCombobox readOnly />;
export const Disabled = () => <BasicCombobox disabled error="Test" />;
export const Disabled = () => <BasicCombobox disabled />;
export const ErrorStory = () => (
<VStack gap="space-16">
<BasicCombobox error="Du må velge et land." defaultValue={undefined} />
<BasicCombobox error="Du må velge et land." defaultSelectedOptions={[]} />
<BasicCombobox error="Ugyldig land." />
</VStack>
);
@@ -149,8 +148,10 @@ ErrorStory.storyName = "Error";
export const DefaultOpen = () => <BasicCombobox defaultOpen />;
DefaultOpen.storyName = "DefaultOpen";
export const DefaultValue = () => <BasicCombobox defaultValue="opt-1" />;
DefaultValue.storyName = "DefaultValue";
export const DefaultSelectedOptions = () => (
<BasicCombobox defaultSelectedOptions={["opt-1"]} />
);
DefaultSelectedOptions.storyName = "DefaultSelectedOptions";
export const Name = () => (
<form
@@ -168,19 +169,50 @@ export const Name = () => (
);
export const Controlled = () => {
const [value, setValue] = useState<MyOption["value"] | undefined>("opt-1");
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
"opt-1",
]);
return (
<VStack gap="space-8" minWidth="250px">
<ComboboxSingle
<Combobox
options={countries}
value={value}
onChange={setValue}
selectedOptions={selectedOptions}
onToggleOption={(option, newSelected) => {
setSelectedOptions((prev) =>
newSelected
? [...prev, option.value]
: prev.filter((v) => v !== option.value),
);
}}
label="Velg land"
/>
Selected: {value}
Selected: {selectedOptions.join(", ")}
<div>
<button type="button" onClick={() => setValue(undefined)}>
<button type="button" onClick={() => setSelectedOptions([])}>
Clear
</button>
</div>
</VStack>
);
};
export const Controlled2 = () => {
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
"opt-1",
]);
return (
<VStack gap="space-8" minWidth="250px">
<Combobox
options={countries}
selectedOptions={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
label="Velg land"
/>
Selected: {selectedOptions.join(", ")}
<div>
<button type="button" onClick={() => setSelectedOptions([])}>
Clear
</button>
</div>
@@ -211,7 +243,7 @@ export const ManyOptions: StoryFn<ManyOptionsProps> = ({ count }) => {
return (
<div style={{ minHeight: "400px", width: "250px" }}>
<ComboboxSingle options={manyOptions} label="Test" />
<Combobox options={manyOptions} label="Test" />
</div>
);
};
@@ -246,7 +278,7 @@ export const LongLabels = () => (
],
},
]}
value={["opt-1"]}
selectedOptions={["opt-1"]}
onToggleOption={() => {}}
>
<ComboboxField />
@@ -258,13 +290,15 @@ export const LongLabels = () => (
);
export const Composition = () => {
const [value, setSelectedOptions] = useState<MyOption["value"][]>(["opt-1"]);
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
"opt-1",
]);
const [selectedOption, setSelectedOption] = useState<MyOption>(countries[0]);
const rootProps = {
options: countries,
value,
selectedOptions,
onToggleOption: (option) => {
setSelectedOptions((prev) =>
prev.includes(option.value)
@@ -301,9 +335,9 @@ export const Composition = () => {
<ComboboxRoot
options={countries}
value={[selectedOption.value]}
selectedOptions={[selectedOption.value]}
onToggleOption={setSelectedOption}
multiple={false}
multiselect={false}
>
<ComboboxTrigger>
<Button>Single select</Button>
@@ -332,9 +366,9 @@ export const Composition = () => {
<ComboboxRoot
options={countries}
value={[selectedOption.value]}
selectedOptions={[selectedOption.value]}
onToggleOption={setSelectedOption}
multiple={false}
multiselect={false}
>
<ComboboxLabel htmlFor="demo2">Velg land (single select)</ComboboxLabel>
<ComboboxTrigger id="demo2">
@@ -349,7 +383,7 @@ export const Composition = () => {
</ComboboxRoot>
<ComboboxRoot {...rootProps}>
<ComboboxLabel htmlFor="demo3">Velg land (multiple)</ComboboxLabel>
<ComboboxLabel htmlFor="demo3">Velg land (multiselect)</ComboboxLabel>
<ComboboxTrigger id="demo3">
<ComboboxField />
</ComboboxTrigger>
@@ -389,20 +423,60 @@ export const InDialog = () => {
);
};
export const Testing = () => {
return (
<VStack gap="space-32" width="300px">
<button type="button" onClick={() => console.log("Knapp før")}>
Knapp før
</button>
<Combobox
options={countries}
multiselect={false}
label="Velg land (Combobox)"
description="Beskrivelse"
/>
<Select
label="Velg land (vanlig select)"
description="Dette er en vanlig select."
//error="Test"
>
<option value=""></option>
<option value="norway">Norway</option>
<option value="finland">Finland</option>
<option value="sweden">Sweden</option>
<option value="denmark">Denmark</option>
<option value="iceland">Iceland</option>
<option value="faroe-islands">Faroe Islands</option>
<option value="aland-islands">Åland Islands</option>
<option value="estonia">Estonia</option>
<option value="latvia">Latvia</option>
<option value="lithuania">Lithuania</option>
</Select>
<Select label="Mange alternativer (vanlig select)">
{Array.from({ length: 100 }, (_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: Don't care
<option key={i} value={`opt-${i + 1}`}>
{`Option ${String(i + 1).padStart(4, "0")}`}
</option>
))}
</Select>
</VStack>
);
};
function WithValidation() {
const [error, setError] = useState("Du må velge et land");
return (
<BasicCombobox
options={nordicCountries}
defaultValue={undefined}
defaultSelectedOptions={[]}
error={error}
onChange={(newVal) => {
if (!newVal) {
setError("Du må velge et land");
return;
}
setError(newVal === "opt-5" ? "" : "Du må velge Island");
}}
onToggleOption={(option) =>
setError(option.label === "Island" ? "" : "Du må velge Island")
}
/>
);
}
@@ -410,10 +484,10 @@ function WithValidation() {
export const ScreenreaderTesting = () => (
<div>
<h2>Med grupper</h2>
<BasicCombobox options={groupedCountries} defaultValue={undefined} />
<BasicCombobox options={groupedCountries} defaultSelectedOptions={[]} />
<h2>Uten grupper</h2>
<BasicCombobox options={nordicCountries} defaultValue={undefined} />
<BasicCombobox options={nordicCountries} defaultSelectedOptions={[]} />
<h2>Label skjult visuelt</h2>
<BasicCombobox options={nordicCountries} hideLabel />
@@ -425,9 +499,18 @@ export const ScreenreaderTesting = () => (
<BasicCombobox readOnly />
<h2>Readonly uten verdi</h2>
<BasicCombobox readOnly defaultValue={undefined} />
<BasicCombobox readOnly defaultSelectedOptions={[]} />
<h2>Disabled</h2>
<BasicCombobox disabled />
</div>
);
/* TODO:
- Navngivning: Vurder å dele opp i separate komponenter for single- og multiselect.
- Hvordan unngå CSS-konflikt med dagens CB?
- Vurder om Label og Description (og error?) skal være sub-komponenter eller ikke.
- Test med skjermleser
*/
@@ -1,7 +1,7 @@
import type { Meta } from "@storybook/react-vite";
import React, { useState } from "react";
import { expect, userEvent, within } from "storybook/test";
import { ComboboxSingle } from "./Combobox";
import { Combobox, type ComboboxProps } from "./Combobox";
import { ComboboxFilter } from "./filter/ComboboxFilter";
import { ComboboxList } from "./list/ComboboxList";
import { ComboboxPopup } from "./popup/ComboboxPopup";
@@ -59,14 +59,20 @@ const groupedOptions = [
function BaseCombobox<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>(props: Partial<ComboboxSingle.Props<T>>) {
const [value, setValue] = useState<string | undefined>("option-1");
>(props: Partial<ComboboxProps<T>>) {
const [selectedOptions, setSelectedOptions] = useState(["option-1"]);
return (
<ComboboxSingle
<Combobox
options={countries as T[]}
value={value}
onChange={setValue}
selectedOptions={selectedOptions}
onToggleOption={(option, newSelected) => {
setSelectedOptions((prev) =>
newSelected
? [...prev, option.value]
: prev.filter((v) => v !== option.value),
);
}}
label="Velg land"
description="Landet hvor du er født."
{...props}
@@ -93,7 +99,7 @@ const optionMemoTestRenderFn = (
return optOrGroup.label;
};
export const OptionMemoization = () => {
const [value, setValue] = useState(["option-1"]);
const [selectedOptions, setSelectedOptions] = useState(["option-1"]);
return (
<div style={{ minHeight: "450px" }}>
@@ -106,9 +112,9 @@ export const OptionMemoization = () => {
<ComboboxRoot
defaultOpen
options={groupedOptions}
value={value}
selectedOptions={selectedOptions}
onToggleOption={(option) => {
setValue((prev) =>
setSelectedOptions((prev) =>
prev.includes(option.value)
? prev.filter((v) => v !== option.value)
: [...prev, option.value],
@@ -13,9 +13,9 @@ interface ComboboxFieldProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
const ComboboxField = forwardRef<HTMLButtonElement, ComboboxFieldProps>(
({ children, hasError, ...rest }, ref) => {
const { options, value, size } = useComboboxRootContext();
const { options, selectedOptions, size } = useComboboxRootContext();
const labels = getLabels(value, options);
const labels = getLabels(selectedOptions, options);
const label =
children ?? (labels.length > 1 ? `${labels.length} valgt` : labels[0]);
@@ -30,8 +30,7 @@ const ComboboxField = forwardRef<HTMLButtonElement, ComboboxFieldProps>(
"aksel-body-short",
`aksel-body-short--${size ?? "medium"}`,
)}
// TODO: This results in other problems. Find another fix!
//aria-label={label} // Without this, VO on Safari will announce the old label when focusing this button right after selecting a new option
aria-label={label} // Without this, VO on Safari will announce the old label when focusing this button right after selecting a new option
{...rest}
>
{label}
@@ -46,7 +45,7 @@ const ComboboxField = forwardRef<HTMLButtonElement, ComboboxFieldProps>(
);
const getLabels = (
value: ComboboxRootProps["value"],
selectedOptions: ComboboxRootProps["selectedOptions"],
options: ComboboxRootProps["options"],
) => {
const labels: string[] = [];
@@ -55,13 +54,13 @@ const getLabels = (
if ("options" in optOrGroup) {
// It's a group
for (const option of optOrGroup.options) {
if (value.includes(option.value)) {
if (selectedOptions.includes(option.value)) {
labels.push(option.label);
}
}
} else {
// It's a single option
if (value.includes(optOrGroup.value)) {
if (selectedOptions.includes(optOrGroup.value)) {
labels.push(optOrGroup.label);
}
}
@@ -11,12 +11,8 @@ type ComboboxFilterProps = Pick<
export const ComboboxFilter = (props: ComboboxFilterProps) => {
const { size } = useComboboxRootContext();
const {
filterString,
setFilterString,
virtuallyFocusedOptionValue,
setVirtuallyFocusedOptionValue,
} = useComboboxPopupContext();
const { filterString, setFilterString, virtuallyFocusedOptionValue } =
useComboboxPopupContext();
return (
<div className="aksel-combobox2__filter">
@@ -34,11 +30,6 @@ 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"
/>
@@ -47,22 +47,28 @@ function ComboboxList<
function ComboboxListContent<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>({ children }: ComboboxListProps<T>) {
const { virtuallyFocusedOptionValue, filterString, setAnnounceText } =
const { virtuallyFocusedOptionValue, filterString } =
useComboboxPopupContext();
const { setOpen, options, value, onToggleOption, multiple, triggerRef } =
useComboboxRootContext();
const {
setOpen,
options,
selectedOptions,
onToggleOption,
multiselect,
triggerRef,
} = useComboboxRootContext();
const memoizedOnToggleOption = useEventCallback(onToggleOption);
const deferredFilterString = useDeferredValue(filterString);
const localOnToggleOption = useCallback(
(option: ComboboxOptionData, isSelected: boolean) => {
memoizedOnToggleOption(option, isSelected);
if (!multiple) {
if (!multiselect) {
setOpen(false);
triggerRef.current?.focus();
}
},
[memoizedOnToggleOption, multiple, triggerRef, setOpen],
[memoizedOnToggleOption, multiselect, triggerRef, setOpen],
);
if (children && typeof children !== "function") {
@@ -74,7 +80,6 @@ function ComboboxListContent<
: options;
if (filteredOptions.length === 0) {
setAnnounceText("Ingen treff");
return (
<div className="aksel-combobox2__no-results" role="status">
Ingen treff
@@ -82,8 +87,6 @@ function ComboboxListContent<
);
}
setAnnounceText("");
return filteredOptions.map((optOrGroup) =>
"options" in optOrGroup ? (
<ComboboxGroup
@@ -96,7 +99,7 @@ function ComboboxListContent<
key={option.value}
option={option}
onToggleOption={localOnToggleOption}
isSelected={value.includes(option.value)}
isSelected={selectedOptions.includes(option.value)}
hasVirtualFocus={virtuallyFocusedOptionValue === option.value}
highlightText={deferredFilterString}
>
@@ -109,7 +112,7 @@ function ComboboxListContent<
key={optOrGroup.value}
option={optOrGroup}
onToggleOption={localOnToggleOption}
isSelected={value.includes(optOrGroup.value)}
isSelected={selectedOptions.includes(optOrGroup.value)}
hasVirtualFocus={virtuallyFocusedOptionValue === optOrGroup.value}
highlightText={deferredFilterString} // TODO: Vurder å kunne slå av dette for bedre ytelse
>
@@ -103,7 +103,6 @@ function ComboboxModal({
useEffect(() => {
// See "Overlay" section in CSS file for context. This handles the case when
// the list doesn't overflow and overscroll-behavior doesn't work on iOS.
// Works best on iOS 27. On iOS 26 you can scroll a little before it triggers.
if (open) {
const scrollToTop = () => (document.documentElement.scrollTop = 0);
visualViewport?.addEventListener("scroll", scrollToTop);
@@ -57,15 +57,17 @@ const groupedOptions: (MyGroup | MyOption)[] = [
];
export const Default = () => {
const [value, setValue] = useState<MyOption["value"][]>(["opt-3"]);
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
"opt-3",
]);
return (
<ComboboxRoot
defaultOpen
options={options}
value={value}
selectedOptions={selectedOptions}
onToggleOption={(option, newSelected) => {
setValue((prev) =>
setSelectedOptions((prev) =>
newSelected
? [...prev, option.value]
: prev.filter((v) => v !== option.value),
@@ -87,7 +89,7 @@ export const SingleSelect = () => {
<ComboboxRoot
defaultOpen
options={options}
value={[selectedOption.value]}
selectedOptions={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -106,7 +108,7 @@ export const Groups = () => {
<ComboboxRoot
defaultOpen
options={groupedOptions}
value={[selectedOption.value]}
selectedOptions={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -119,7 +121,7 @@ export const Groups = () => {
size="small"
defaultOpen
options={groupedOptions}
value={[selectedOption.value]}
selectedOptions={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -145,7 +147,7 @@ export const WithoutFilter = () => {
options: options.slice(0, 3),
},
]}
value={[selectedOption.value]}
selectedOptions={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -156,7 +158,7 @@ export const WithoutFilter = () => {
<ComboboxRoot
defaultOpen
options={options.slice(0, 3)}
value={[selectedOption.value]}
selectedOptions={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -168,18 +170,20 @@ export const WithoutFilter = () => {
};
export const CustomOptionRendering = () => {
const [value, setValue] = useState<MyOption["value"][]>(["opt-1"]);
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
"opt-1",
]);
return (
<ComboboxRoot
defaultOpen
options={options}
value={value}
selectedOptions={selectedOptions}
onToggleOption={(option, isSelected) => {
setValue(
setSelectedOptions(
isSelected
? [...value, option.value]
: value.filter((v) => v !== option.value),
? [...selectedOptions, option.value]
: selectedOptions.filter((v) => v !== option.value),
);
}}
>
@@ -208,7 +212,7 @@ export const CustomOptionRenderingGroups = () => {
<ComboboxRoot
defaultOpen
options={groupedOptions}
value={[selectedOption.value]}
selectedOptions={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -235,7 +239,9 @@ export const CustomOptionRenderingGroups = () => {
// Alternativer: Kun støtte option, måtte iterere selv, renderOption/group-props på root, flytte props ned (hvordan få tak i valgte i Input?) 🤔
export const ControlledInput = () => {
const [value, setValue] = useState<MyOption["value"][]>(["opt-1"]);
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
"opt-1",
]);
const [filterString, setFilterString] = useState("");
const filterStringLowerCase = filterString.toLocaleLowerCase();
@@ -260,9 +266,9 @@ export const ControlledInput = () => {
<ComboboxRoot
defaultOpen
options={filteredOptions}
value={value}
selectedOptions={selectedOptions}
onToggleOption={(option) => {
setValue((prev) =>
setSelectedOptions((prev) =>
prev.includes(option.value)
? prev.filter((v) => v !== option.value)
: [...prev, option.value],
@@ -284,7 +290,7 @@ export const States: StoryFn = () => {
<ComboboxRoot
defaultOpen
options={options.slice(0, 3)}
value={[selectedOption.value]}
selectedOptions={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -5,10 +5,8 @@ import { useComboboxRootContext } from "../root/ComboboxRoot";
interface ComboboxPopupContextProps {
virtuallyFocusedOptionValue: string;
setVirtuallyFocusedOptionValue: (value: string) => void;
filterString: string;
setFilterString: (value: string) => void;
setAnnounceText: (text: string) => void;
}
export const {
@@ -29,7 +27,6 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
const [filterString, setFilterString] = useState("");
const rootContext = useComboboxRootContext();
const ref = useRef<HTMLDivElement | null>(null);
const [announceText, setAnnounceText] = useState("");
useEffect(
function onOpen() {
@@ -83,18 +80,10 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
>
<ComboboxPopupContextProvider
virtuallyFocusedOptionValue={virtuallyFocusedOptionValue}
setVirtuallyFocusedOptionValue={setVirtuallyFocusedOptionValue}
filterString={filterString}
setFilterString={(newString) => {
setFilterString(newString);
setVirtuallyFocusedOptionValue("");
}}
setAnnounceText={setAnnounceText}
setFilterString={setFilterString}
>
{children}
<div className="aksel-sr-only" role="status">
{announceText}
</div>
</ComboboxPopupContextProvider>
</Listbox>
);
@@ -4,26 +4,6 @@ import { cl, createStrictContext } from "../../../utils/helpers";
import type { FormFieldProps } from "../../useFormField";
import type { ComboboxOptionProps } from "../option/ComboboxOption";
type HtmlAttributes = Omit<
React.HTMLAttributes<HTMLDivElement>,
| "onChange"
| "defaultChecked"
| "autoCapitalize"
| "contentEditable"
| "enterKeyHint"
| "spellCheck"
| "inputMode"
| "autoCorrect"
| "autoSave"
| "color"
| "results"
| "security"
| "unselectable"
| "popover"
| "popoverTargetAction"
| "popoverTarget"
>;
/** Resolves the option type based on whether the parameter is a group or a single option */
export type ResolveOption<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
@@ -48,7 +28,9 @@ export interface ComboboxRootProps<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData> =
ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>
extends Pick<FormFieldProps, "disabled" | "size">, HtmlAttributes {
extends
Pick<FormFieldProps, "disabled">,
React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
/**
* Can be either an array of options ({@link ComboboxOptionData})
@@ -58,25 +40,14 @@ export interface ComboboxRootProps<
* put the single options first.
*/
options: T[];
/**
* Value of the selected options.
*/
value: ResolveOption<T>["value"][];
/**
* Called when an option is toggled.
* @param option The option that was toggled.
* @param isSelected The new selected state of the option.
*/
selectedOptions: ResolveOption<T>["value"][]; // TODO: Vurder navn: value(s)
onToggleOption: ComboboxOptionProps<ResolveOption<T>>["onToggleOption"];
/**
* Whether the combobox is open by default.
*/
defaultOpen?: boolean;
multiselect?: boolean; // TODO: Hva skal være default? Vurder navn
/**
* Whether multiple options can be selected.
* @default true
* @default "medium"
*/
multiple?: boolean; // TODO: Hva skal være default?
size?: "small" | "medium";
}
export interface ComboboxRootContextProps {
@@ -84,9 +55,9 @@ export interface ComboboxRootContextProps {
setOpen: (newOpen: boolean) => void;
triggerRef: React.RefObject<HTMLDivElement | null>;
options: ComboboxRootProps["options"];
value: ComboboxRootProps["value"];
selectedOptions: ComboboxRootProps["selectedOptions"];
onToggleOption: ComboboxRootProps["onToggleOption"];
multiple: boolean;
multiselect: boolean;
size: Exclude<ComboboxRootProps["size"], undefined>;
disabled: ComboboxRootProps["disabled"];
}
@@ -104,10 +75,10 @@ export function ComboboxRoot<
>({
children,
options,
value,
selectedOptions,
onToggleOption,
defaultOpen = false,
multiple = true,
multiselect = true,
size = "medium",
disabled,
className,
@@ -128,11 +99,11 @@ export function ComboboxRoot<
setOpen={setOpen}
triggerRef={triggerRef}
options={options}
value={value}
selectedOptions={selectedOptions}
onToggleOption={
onToggleOption as ComboboxRootContextProps["onToggleOption"]
}
multiple={multiple}
multiselect={multiselect}
size={size}
disabled={disabled}
>
@@ -29,16 +29,14 @@ const ComboboxTrigger = ({
if (!readOnly && !rootContext.open) event.preventDefault();
}}
onKeyDown={(event) => {
if (
!readOnly &&
(event.key === "ArrowDown" || event.key === "ArrowUp")
) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
rootContext.setOpen(true);
// Stop keydown from scrolling window
event.preventDefault();
}
}}
data-readonly={readOnly}
aria-readonly={readOnly} // TODO: Vurder
disabled={rootContext.disabled}
{...rest}
>
@@ -39,7 +39,7 @@ export interface SearchProps
*/
label: React.ReactNode;
/**
* Shows label and description for screenreaders only.
* Hides the label and description visually while keeping them available to screen readers.
* @default true
*/
hideLabel?: boolean;
@@ -23,7 +23,7 @@ export interface SelectProps
*/
label: React.ReactNode;
/**
* Shows label and description for screenreaders only.
* Hides the label and description visually while keeping them available to screen readers.
*/
hideLabel?: boolean;
}
@@ -15,7 +15,7 @@ export interface SwitchProps
*/
children: React.ReactNode;
/**
* If enabled shows the label and description for screenreaders only.
* Hides the label and description visually while keeping them available to screen readers.
*/
hideLabel?: boolean;
/**
@@ -36,7 +36,7 @@ export interface TextareaProps
*/
label: React.ReactNode;
/**
* If enabled shows the label and description for screenreaders only.
* Hides the label and description visually while keeping them available to screen readers.
*/
hideLabel?: boolean;
/**
@@ -20,7 +20,7 @@ export interface TextFieldProps
*/
htmlSize?: number;
/**
* If enabled shows the label and description for screenreaders only
* Hides the label and description visually while keeping them available to screen readers
*/
hideLabel?: boolean;
/**
@@ -16,7 +16,6 @@ export interface FormFieldProps {
errorId?: string;
/**
* Changes font-size, padding and gaps.
* @default "medium"
*/
size?: "medium" | "small";
/**
+1
View File
@@ -1,3 +1,4 @@
"use client";
export { Accordion, type AccordionProps } from "./accordion";
export { ActionMenu, type ActionMenuProps } from "./action-menu";
export { Alert, type AlertProps } from "./legacy/alert";
@@ -1,3 +1,2 @@
"use client";
export { default as Panel } from "./Panel";
export type { PanelProps } from "./Panel";
@@ -13,7 +13,7 @@ import {
LinkCardIcon,
LinkCardImage,
LinkCardTitle,
} from "./index";
} from "./LinkCard";
type Story = StoryObj<typeof LinkCard>;
@@ -0,0 +1,334 @@
import React, { type HTMLAttributes, forwardRef } from "react";
import type { AkselColor } from "../types";
import { BodyLong, Heading } from "../typography";
import {
LinkAnchor,
LinkAnchorArrow,
LinkAnchorOverlay,
type LinkAnchorProps,
} from "../utils/components/link-anchor";
import { cl, createStrictContext } from "../utils/helpers";
/* ------------------------------ LinkCard Root ----------------------------- */
interface LinkCardProps extends HTMLAttributes<HTMLDivElement> {
/**
* @default true
*/
arrow?: boolean;
/**
* Adjusts arrow position.
* @default "baseline"
*/
arrowPosition?: "baseline" | "center";
/**
* Changes padding and typo sizes.
* @default "medium"
*/
size?: "small" | "medium";
/**
* Overrides color.
*
* We recommend avoiding status-colors (`info`, `success`, `warning`, `danger`) in LinkCards.
* @default "neutral"
* @see 🏷️ {@link AkselColor}
* @see [📝 Documentation](https://aksel.nav.no/grunnleggende/styling/farger-tokens)
*/
"data-color"?: AkselColor;
/**
* Changes the HTML element used for the root element.
*
* **When using `section`, provide either `aria-label` or `aria-labelledby` for better accessibility.**
* `axe-core` might warn about unique landmarks if you have multiple Accordions on page with the same label.
* In those cases consider updating to unique `aria-label` or `aria-labelledby` props.
* @see [📝 Landmarks unique](https://dequeuniversity.com/rules/axe/4.6/landmark-unique)
*
*
* **When using `article`, make sure `<LinkCard.Title />` is a heading and not a `span`.**
* @default "div"
*/
as?: "div" | "section" | "article";
}
type LinkCardContextProps = {
size: LinkCardProps["size"];
};
const { Provider: LinkCardContextProvider, useContext: useLinkCardContext } =
createStrictContext<LinkCardContextProps>({
name: "LinkCardContextProvider",
});
interface LinkCardComponent extends React.ForwardRefExoticComponent<
LinkCardProps & React.RefAttributes<HTMLDivElement>
> {
/**
* @see 🏷️ {@link LinkCardTitleProps}
*/
Title: typeof LinkCardTitle;
/**
* @see 🏷️ {@link LinkCardAnchorProps}
*/
Anchor: typeof LinkCardAnchor;
/**
* @see 🏷️ {@link LinkCardDescriptionProps}
*/
Description: typeof LinkCardDescription;
/**
* @see 🏷️ {@link LinkCardFooterProps}
*/
Footer: typeof LinkCardFooter;
/**
* @see 🏷️ {@link LinkCardIconProps}
*/
Icon: typeof LinkCardIcon;
/**
* @see 🏷️ {@link LinkCardImageProps}
*/
Image: typeof LinkCardImage;
}
/**
* Accessible clickable card as a link.
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/linkcard)
* @see 🏷️ {@link LinkCardProps}
*
*
* @example
* ```tsx
* <LinkCard>
* <LinkCard.Icon>
* <IconOrPictogram />
* </LinkCard.Icon>
* <LinkCard.Title>
* <LinkCard.Anchor href="/href">
* LinkCard title
* </LinkCard.Anchor>
* </LinkCard.Title>
* <LinkCard.Description>
* This is a description of the link card.
* </LinkCard.Description>
* <LinkCard.Footer>Footer content</LinkCard.Footer>
* </LinkCard>
* ```
*/
export const LinkCard = forwardRef<HTMLDivElement, LinkCardProps>(
(
{
children,
className,
arrow = true,
arrowPosition = "baseline",
size = "medium",
as: Component = "div",
...restProps
}: LinkCardProps,
forwardedRef,
) => {
return (
<LinkCardContextProvider size={size}>
<LinkAnchorOverlay asChild>
<BodyLong
as={Component}
size={size}
ref={forwardedRef}
data-color="neutral"
className={cl(
"aksel-link-card",
className,
`aksel-link-card--${size}`,
)}
data-align-arrow={arrowPosition}
{...restProps}
>
{children}
{arrow && (
<LinkAnchorArrow
fontSize={size === "medium" ? "1.75rem" : "1.5rem"}
className="aksel-link-card__arrow"
/>
)}
</BodyLong>
</LinkAnchorOverlay>
</LinkCardContextProvider>
);
},
) as LinkCardComponent;
/* ---------------------------- LinkCard Title ---------------------------- */
type LinkCardTitleProps = HTMLAttributes<HTMLHeadingElement> & {
children: React.ReactNode;
/**
* Heading tag. Use "span" if you want a non header defining card
* (eg. you have a lot of them all at once, such as in a grid)
* @default "span"
*/
as?: "span" | "h2" | "h3" | "h4" | "h5" | "h6";
};
/**
* @see 🏷️ {@link LinkCardTitleProps}
*/
export const LinkCardTitle = forwardRef<HTMLHeadingElement, LinkCardTitleProps>(
(
{ children, as = "span", className, ...restProps }: LinkCardTitleProps,
forwardedRef,
) => {
const context = useLinkCardContext();
return (
<Heading
ref={forwardedRef}
as={as}
size={context.size === "medium" ? "small" : "xsmall"}
className={cl("aksel-link-card__title", className)}
{...restProps}
>
{children}
</Heading>
);
},
);
/* ---------------------------- LinkCard Anchor ---------------------------- */
type LinkCardAnchorProps = LinkAnchorProps;
/**
* @see 🏷️ {@link LinkCardAnchorProps}
*/
export const LinkCardAnchor = LinkAnchor;
/* ---------------------------- LinkCard Description ---------------------------- */
interface LinkCardDescriptionProps extends HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
/**
* @see 🏷️ {@link LinkCardDescriptionProps}
*/
export const LinkCardDescription = forwardRef<
HTMLDivElement,
LinkCardDescriptionProps
>(
(
{ children, className, ...restProps }: LinkCardDescriptionProps,
forwardedRef,
) => {
return (
<div
ref={forwardedRef}
className={cl("aksel-link-card__description", className)}
{...restProps}
>
{children}
</div>
);
},
);
/* ---------------------------- LinkCard Footer ---------------------------- */
interface LinkCardFooterProps extends HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
/**
* @see 🏷️ {@link LinkCardFooterProps}
*/
export const LinkCardFooter = forwardRef<HTMLDivElement, LinkCardFooterProps>(
(
{ children, className, ...restProps }: LinkCardFooterProps,
forwardedRef,
) => {
return (
<div
ref={forwardedRef}
className={cl("aksel-link-card__footer", className)}
{...restProps}
>
{children}
</div>
);
},
);
/* ---------------------------- LinkCard Icon ---------------------------- */
interface LinkCardIconProps extends HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
/**
* @see 🏷️ {@link LinkCardIconProps}
*/
export const LinkCardIcon = forwardRef<HTMLDivElement, LinkCardIconProps>(
({ children, className, ...restProps }: LinkCardIconProps, forwardedRef) => {
return (
<div
ref={forwardedRef}
aria-hidden
className={cl("aksel-link-card__icon", className)}
{...restProps}
>
{children}
</div>
);
},
);
/* ---------------------------- LinkCard Image ---------------------------- */
type ImageAspectRatio = "1/1" | "16/9" | "16/10" | "4/3" | (string & {});
interface LinkCardImageProps extends HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
/**
* The aspect-ratio CSS property allows you to define the desired width-to-height ratio of an element's box.
* This means that even if the parent container or viewport size changes, the browser will adjust the element's dimensions to maintain the specified width-to-height ratio.
*/
aspectRatio?: ImageAspectRatio;
}
/**
* @see 🏷️ {@link LinkCardImageProps}
*/
export const LinkCardImage = forwardRef<HTMLDivElement, LinkCardImageProps>(
(
{
children,
className,
aspectRatio,
style,
...restProps
}: LinkCardImageProps,
forwardedRef,
) => {
return (
<div
ref={forwardedRef}
className={cl("aksel-link-card__image-container", className)}
style={{
...style,
aspectRatio,
}}
{...restProps}
>
{children}
</div>
);
},
);
LinkCard.Title = LinkCardTitle;
LinkCard.Anchor = LinkCardAnchor;
LinkCard.Description = LinkCardDescription;
LinkCard.Footer = LinkCardFooter;
LinkCard.Icon = LinkCardIcon;
LinkCard.Image = LinkCardImage;
export type {
LinkCardAnchorProps,
LinkCardDescriptionProps,
LinkCardFooterProps,
LinkCardIconProps,
LinkCardImageProps,
LinkCardProps,
LinkCardTitleProps,
};
@@ -1,14 +0,0 @@
import {
LinkAnchor,
type LinkAnchorProps,
} from "../../utils/components/link-anchor";
type LinkCardAnchorProps = LinkAnchorProps;
/**
* @see 🏷️ {@link LinkCardAnchorProps}
*/
const LinkCardAnchor = LinkAnchor;
export { LinkCardAnchor };
export type { LinkCardAnchorProps };
@@ -1,32 +0,0 @@
import React, { type HTMLAttributes, forwardRef } from "react";
import { cl } from "../../utils/helpers";
interface LinkCardDescriptionProps extends HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
/**
* @see 🏷️ {@link LinkCardDescriptionProps}
*/
const LinkCardDescription = forwardRef<
HTMLDivElement,
LinkCardDescriptionProps
>(
(
{ children, className, ...restProps }: LinkCardDescriptionProps,
forwardedRef,
) => {
return (
<div
ref={forwardedRef}
className={cl("aksel-link-card__description", className)}
{...restProps}
>
{children}
</div>
);
},
);
export { LinkCardDescription };
export type { LinkCardDescriptionProps };
@@ -1,29 +0,0 @@
import React, { type HTMLAttributes, forwardRef } from "react";
import { cl } from "../../utils/helpers";
interface LinkCardFooterProps extends HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
/**
* @see 🏷️ {@link LinkCardFooterProps}
*/
const LinkCardFooter = forwardRef<HTMLDivElement, LinkCardFooterProps>(
(
{ children, className, ...restProps }: LinkCardFooterProps,
forwardedRef,
) => {
return (
<div
ref={forwardedRef}
className={cl("aksel-link-card__footer", className)}
{...restProps}
>
{children}
</div>
);
},
);
export { LinkCardFooter };
export type { LinkCardFooterProps };
@@ -1,27 +0,0 @@
import React, { type HTMLAttributes, forwardRef } from "react";
import { cl } from "../../utils/helpers";
interface LinkCardIconProps extends HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
/**
* @see 🏷️ {@link LinkCardIconProps}
*/
const LinkCardIcon = forwardRef<HTMLDivElement, LinkCardIconProps>(
({ children, className, ...restProps }: LinkCardIconProps, forwardedRef) => {
return (
<div
ref={forwardedRef}
aria-hidden
className={cl("aksel-link-card__icon", className)}
{...restProps}
>
{children}
</div>
);
},
);
export { LinkCardIcon };
export type { LinkCardIconProps };
@@ -1,46 +0,0 @@
import React, { type HTMLAttributes, forwardRef } from "react";
import { cl } from "../../utils/helpers";
type ImageAspectRatio = "1/1" | "16/9" | "16/10" | "4/3" | (string & {});
interface LinkCardImageProps extends HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
/**
* The aspect-ratio CSS property allows you to define the desired width-to-height ratio of an element's box.
* This means that even if the parent container or viewport size changes, the browser will adjust the element's dimensions to maintain the specified width-to-height ratio.
*/
aspectRatio?: ImageAspectRatio;
}
/**
* @see 🏷️ {@link LinkCardImageProps}
*/
const LinkCardImage = forwardRef<HTMLDivElement, LinkCardImageProps>(
(
{
children,
className,
aspectRatio,
style,
...restProps
}: LinkCardImageProps,
forwardedRef,
) => {
return (
<div
ref={forwardedRef}
className={cl("aksel-link-card__image-container", className)}
style={{
...style,
aspectRatio,
}}
{...restProps}
>
{children}
</div>
);
},
);
export { LinkCardImage };
export type { LinkCardImageProps };
+2 -2
View File
@@ -7,7 +7,7 @@ export {
LinkCardAnchor,
LinkCardIcon,
LinkCardImage,
} from "./root/LinkCardRoot";
} from "./LinkCard";
export type {
LinkCardProps,
@@ -17,4 +17,4 @@ export type {
LinkCardAnchorProps,
LinkCardIconProps,
LinkCardImageProps,
} from "./root/LinkCardRoot";
} from "./LinkCard";
@@ -1,14 +0,0 @@
import { createStrictContext } from "../../utils/helpers";
import type { LinkCardProps } from "./LinkCardRoot";
type LinkCardContextProps = {
size: LinkCardProps["size"];
};
const { Provider: LinkCardContextProvider, useContext: useLinkCardContext } =
createStrictContext<LinkCardContextProps>({
name: "LinkCardContextProvider",
});
export { LinkCardContextProvider, useLinkCardContext };
export type { LinkCardContextProps };
@@ -1,177 +0,0 @@
import React, { type HTMLAttributes, forwardRef } from "react";
import type { AkselColor } from "../../types";
import { BodyLong } from "../../typography";
import {
LinkAnchorArrow,
LinkAnchorOverlay,
} from "../../utils/components/link-anchor";
import { cl } from "../../utils/helpers";
import {
LinkCardAnchor,
type LinkCardAnchorProps,
} from "../anchor/LinkCardAnchor";
import {
LinkCardDescription,
type LinkCardDescriptionProps,
} from "../description/LinkCardDescription";
import {
LinkCardFooter,
type LinkCardFooterProps,
} from "../footer/LinkCardFooter";
import { LinkCardIcon, type LinkCardIconProps } from "../icon/LinkCardIcon";
import { LinkCardImage, type LinkCardImageProps } from "../image/LinkCardImage";
import { LinkCardTitle, type LinkCardTitleProps } from "../title/LinkCardTitle";
import { LinkCardContextProvider } from "./LinkCardRoot.context";
interface LinkCardProps extends HTMLAttributes<HTMLDivElement> {
/**
* @default true
*/
arrow?: boolean;
/**
* Adjusts arrow position.
* @default "baseline"
*/
arrowPosition?: "baseline" | "center";
/**
* Changes padding and typo sizes.
* @default "medium"
*/
size?: "small" | "medium";
/**
* Overrides color.
*
* We recommend avoiding status-colors (`info`, `success`, `warning`, `danger`) in LinkCards.
* @default "neutral"
* @see 🏷️ {@link AkselColor}
* @see [📝 Documentation](https://aksel.nav.no/grunnleggende/styling/farger-tokens)
*/
"data-color"?: AkselColor;
/**
* Changes the HTML element used for the root element.
*
* **When using `section`, provide either `aria-label` or `aria-labelledby` for better accessibility.**
* `axe-core` might warn about unique landmarks if you have multiple Accordions on page with the same label.
* In those cases consider updating to unique `aria-label` or `aria-labelledby` props.
* @see [📝 Landmarks unique](https://dequeuniversity.com/rules/axe/4.6/landmark-unique)
*
*
* **When using `article`, make sure `<LinkCard.Title />` is a heading and not a `span`.**
* @default "div"
*/
as?: "div" | "section" | "article";
}
const LinkCardRoot = forwardRef<HTMLDivElement, LinkCardProps>(
(
{
children,
className,
arrow = true,
arrowPosition = "baseline",
size = "medium",
as: Component = "div",
...restProps
}: LinkCardProps,
forwardedRef,
) => {
return (
<LinkCardContextProvider size={size}>
<LinkAnchorOverlay asChild>
<BodyLong
as={Component}
size={size}
ref={forwardedRef}
data-color="neutral"
className={cl(
"aksel-link-card",
className,
`aksel-link-card--${size}`,
)}
data-align-arrow={arrowPosition}
{...restProps}
>
{children}
{arrow && (
<LinkAnchorArrow
fontSize={size === "medium" ? "1.75rem" : "1.5rem"}
className="aksel-link-card__arrow"
/>
)}
</BodyLong>
</LinkAnchorOverlay>
</LinkCardContextProvider>
);
},
);
/**
* Accessible clickable card as a link.
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/linkcard)
* @see 🏷️ {@link LinkCardProps}
*
*
* @example
* ```tsx
* <LinkCard>
* <LinkCard.Icon>
* <IconOrPictogram />
* </LinkCard.Icon>
* <LinkCard.Title>
* <LinkCard.Anchor href="/href">
* LinkCard title
* </LinkCard.Anchor>
* </LinkCard.Title>
* <LinkCard.Description>
* This is a description of the link card.
* </LinkCard.Description>
* <LinkCard.Footer>Footer content</LinkCard.Footer>
* </LinkCard>
* ```
*/
const LinkCard = Object.assign(LinkCardRoot, {
/**
* @see 🏷️ {@link LinkCardTitleProps}
*/
Title: LinkCardTitle,
/**
* @see 🏷️ {@link LinkCardAnchorProps}
*/
Anchor: LinkCardAnchor,
/**
* @see 🏷️ {@link LinkCardDescriptionProps}
*/
Description: LinkCardDescription,
/**
* @see 🏷️ {@link LinkCardFooterProps}
*/
Footer: LinkCardFooter,
/**
* @see 🏷️ {@link LinkCardIconProps}
*/
Icon: LinkCardIcon,
/**
* @see 🏷️ {@link LinkCardImageProps}
*/
Image: LinkCardImage,
});
export {
LinkCard,
LinkCardTitle,
LinkCardDescription,
LinkCardFooter,
LinkCardAnchor,
LinkCardIcon,
LinkCardImage,
};
export type {
LinkCardProps,
LinkCardTitleProps,
LinkCardDescriptionProps,
LinkCardFooterProps,
LinkCardAnchorProps,
LinkCardIconProps,
LinkCardImageProps,
};
@@ -1,41 +0,0 @@
import React, { type HTMLAttributes, forwardRef } from "react";
import { Heading } from "../../typography";
import { cl } from "../../utils/helpers";
import { useLinkCardContext } from "../root/LinkCardRoot.context";
type LinkCardTitleProps = HTMLAttributes<HTMLHeadingElement> & {
children: React.ReactNode;
/**
* Heading tag. Use "span" if you want a non header defining card
* (eg. you have a lot of them all at once, such as in a grid)
* @default "span"
*/
as?: "span" | "h2" | "h3" | "h4" | "h5" | "h6";
};
/**
* @see 🏷️ {@link LinkCardTitleProps}
*/
const LinkCardTitle = forwardRef<HTMLHeadingElement, LinkCardTitleProps>(
(
{ children, as = "span", className, ...restProps }: LinkCardTitleProps,
forwardedRef,
) => {
const context = useLinkCardContext();
return (
<Heading
ref={forwardedRef}
as={as}
size={context.size === "medium" ? "small" : "xsmall"}
className={cl("aksel-link-card__title", className)}
{...restProps}
>
{children}
</Heading>
);
},
);
export { LinkCardTitle };
export type { LinkCardTitleProps };
@@ -1,14 +1,14 @@
import React, { forwardRef, useContext } from "react";
import { BodyLong } from "../../typography";
import { cl } from "../../utils/helpers";
import { consoleWarning } from "../../utils/helpers/consoleWarning";
import type { ListItemProps } from "../List.types";
import { ListContext } from "../root/ListRoot.context";
import { BodyLong } from "../typography";
import { cl } from "../utils/helpers";
import { consoleWarning } from "../utils/helpers/consoleWarning";
import { ListContext } from "./List.context";
import type { ListItemProps } from "./List.types";
/**
* @see 🏷️ {@link ListItemProps}
*/
const ListItem = forwardRef<HTMLLIElement, ListItemProps>(
export const ListItem = forwardRef<HTMLLIElement, ListItemProps>(
({ className, children, title, icon, ...rest }, ref) => {
const { listType, size } = useContext(ListContext);
@@ -57,6 +57,4 @@ const ListItem = forwardRef<HTMLLIElement, ListItemProps>(
);
ListItem.displayName = "List.Item";
export { ListItem };
export type { ListItemProps };
export default ListItem;
@@ -1,14 +1,12 @@
import { createContext } from "react";
import type { ListProps } from "../List.types";
import type { ListProps } from "./List.types";
interface ListContextProps {
listType: Exclude<ListProps["as"], undefined>;
size: Exclude<ListProps["size"], undefined>;
}
const ListContext = createContext<ListContextProps>({
export const ListContext = createContext<ListContextProps>({
listType: "ul",
size: "medium",
});
export { ListContext };
+1 -1
View File
@@ -9,7 +9,7 @@ import {
import { Box } from "../primitives/box";
import { VStack } from "../primitives/stack";
import { BodyLong } from "../typography";
import { List } from "./index";
import List from "./List";
export default {
title: "ds-react/List",
@@ -1,11 +1,35 @@
import React, { forwardRef, useContext } from "react";
import { BodyLong } from "../../typography";
import { cl } from "../../utils/helpers";
import type { ListItemProps, ListProps } from "../List.types";
import { ListItem } from "../item/ListItem";
import { ListContext } from "./ListRoot.context";
import { BodyLong } from "../typography";
import { cl } from "../utils/helpers";
import { ListItem } from "./List.Item";
import { ListContext } from "./List.context";
import type { ListItemProps, ListProps } from "./List.types";
const ListRoot = forwardRef<HTMLDivElement, ListProps>(
interface ListComponent extends React.ForwardRefExoticComponent<
ListProps & React.RefAttributes<HTMLDivElement>
> {
/**
* @see 🏷️ {@link ListItemProps}
*/
Item: typeof ListItem;
}
/**
* A list component
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/list)
* @see 🏷️ {@link ListProps | Props}
*
* @example
* ```jsx
* <List>
* <List.Item>Coffee</List.Item>
* <List.Item>Tea</List.Item>
* <List.Item>Milk</List.Item>
* </List>
* ```
*/
export const List = forwardRef<HTMLDivElement, ListProps>(
(
{
children,
@@ -47,29 +71,10 @@ const ListRoot = forwardRef<HTMLDivElement, ListProps>(
</ListContext.Provider>
);
},
);
) as ListComponent;
/**
* A list component
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/list)
* @see 🏷️ {@link ListProps | Props}
*
* @example
* ```jsx
* <List>
* <List.Item>Coffee</List.Item>
* <List.Item>Tea</List.Item>
* <List.Item>Milk</List.Item>
* </List>
* ```
*/
const List = Object.assign(ListRoot, {
/**
* @see 🏷️ {@link ListItemProps}
*/
Item: ListItem,
});
List.Item = ListItem;
export { List, ListItem };
export default List;
export { ListItem };
export type { ListProps, ListItemProps };
+2 -4
View File
@@ -1,4 +1,4 @@
interface ListProps extends React.HTMLAttributes<HTMLDivElement> {
export interface ListProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
/**
* HTML list element to render.
@@ -16,7 +16,7 @@ interface ListProps extends React.HTMLAttributes<HTMLDivElement> {
size?: "small" | "medium" | "large";
}
interface ListItemProps extends React.HTMLAttributes<HTMLLIElement> {
export interface ListItemProps extends React.HTMLAttributes<HTMLLIElement> {
/**
* List item content.
*/
@@ -30,5 +30,3 @@ interface ListItemProps extends React.HTMLAttributes<HTMLLIElement> {
*/
icon?: React.ReactNode;
}
export type { ListProps, ListItemProps };
+2 -2
View File
@@ -1,3 +1,3 @@
"use client";
export { List, ListItem } from "./root/ListRoot";
export type { ListProps, ListItemProps } from "./root/ListRoot";
export { default as List, ListItem } from "./List";
export type { ListProps, ListItemProps } from "./List";
@@ -1,15 +1,13 @@
import React from "react";
import { createStrictContext } from "../../utils/helpers";
import { createStrictContext } from "../utils/helpers";
interface ModalContextProps {
closeHandler?: React.MouseEventHandler<HTMLButtonElement>;
modalRef: React.RefObject<HTMLDialogElement | null>;
}
const { Provider: ModalContextProvider, useContext: useModalContext } =
export const { Provider: ModalContextProvider, useContext: useModalContext } =
createStrictContext<ModalContextProps>({
name: "ModalContext",
errorMessage: "<Modal.Header> must be used within a <Modal>",
});
export { ModalContextProvider, useModalContext };
@@ -6,7 +6,7 @@ import { Checkbox, CheckboxGroup } from "../form/checkbox";
import { VStack } from "../primitives/stack";
import { Tooltip } from "../tooltip";
import { BodyLong, Heading } from "../typography";
import { Modal } from "./index";
import Modal from "./Modal";
const meta: Meta<typeof Modal> = {
title: "ds-react/Modal",
@@ -1,8 +1,7 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import React, { useState } from "react";
import { describe, expect, test } from "vitest";
import { Button } from "../../button";
import { Modal } from "../index";
import { Button, Modal } from "..";
const Test = () => {
const [open, setOpen] = useState(true);
@@ -1,28 +1,82 @@
import { useFloatingPortalNode } from "@floating-ui/react";
import React, { forwardRef, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { useProvider } from "../../provider/Provider";
import { Detail, Heading } from "../../typography";
import { useId } from "../../utils-external";
import { useDateInputContext } from "../../utils/components/date/input/DateInput";
import { cl, composeEventHandlers } from "../../utils/helpers";
import { useMergeRefs, useScrollLock } from "../../utils/hooks";
import type { ModalProps } from "../Modal.types";
import { ModalBody, type ModalBodyProps } from "../body/ModalBody";
import { ModalFooter, type ModalFooterProps } from "../footer/ModalFooter";
import { ModalHeader, type ModalHeaderProps } from "../header/ModalHeader";
import { dialogPolyfill, needPolyfill } from "../helpers/dialog-polyfill";
import { useProvider } from "../provider/Provider";
import { Detail, Heading } from "../typography";
import { useId } from "../utils-external";
import { useDateInputContext } from "../utils/components/date/input/DateInput";
import { cl, composeEventHandlers } from "../utils/helpers";
import { useMergeRefs, useScrollLock } from "../utils/hooks";
import { ModalContextProvider, useModalContext } from "./Modal.context";
import ModalBody from "./ModalBody";
import ModalFooter from "./ModalFooter";
import ModalHeader from "./ModalHeader";
import {
type MouseCoordinates,
coordsAreInside,
getCloseHandler,
} from "../helpers/modal-utils";
import { ModalContextProvider, useModalContext } from "./ModalRoot.context";
import { useIsModalOpen } from "./useIsModalOpen";
useIsModalOpen,
} from "./ModalUtils";
import dialogPolyfill, { needPolyfill } from "./dialog-polyfill";
import type { ModalProps } from "./types";
const polyfillClassName = "aksel-modal--polyfilled";
const ModalRoot = forwardRef<HTMLDialogElement, ModalProps>(
interface ModalComponent extends React.ForwardRefExoticComponent<
ModalProps & React.RefAttributes<HTMLDialogElement>
> {
Header: typeof ModalHeader;
Body: typeof ModalBody;
Footer: typeof ModalFooter;
}
/**
* A component that displays a modal dialog.
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/modal)
* @see 🏷️ {@link ModalProps}
*
* @example
* State change with `useRef`
* ```jsx
* const ref = useRef<HTMLDialogElement>(null);
* <Button onClick={() => ref.current?.showModal()}>Open modal</Button>
* <Modal
* ref={ref}
* header={{
* label: "Optional label",
* icon: <FileIcon aria-hidden />,
* heading: "My heading",
* }}
* >
* <Modal.Body>
* <BodyLong>Hello world</BodyLong>
* </Modal.Body>
* <Modal.Footer>
* <Button>Save</Button>
* <Button type="button" variant="tertiary" onClick={() => ref.current?.close()}>Close</Button>
* </Modal.Footer>
* </Modal>
* ```
* @example
* State change with `useState`
* ```jsx
* const [open, setOpen] = useState(false);
* <Modal
* open={open}
* onClose={() => setOpen(false)}
* aria-labelledby="modal-heading"
* >
* <Modal.Header>
* <Heading level="1" size="large" id="modal-heading">My heading</Heading>
* </Modal.Header>
* <Modal.Body>
* <BodyLong>Hello world</BodyLong>
* </Modal.Body>
* </Modal>
* ```
*/
export const Modal = forwardRef<HTMLDialogElement, ModalProps>(
(
{
header,
@@ -231,68 +285,10 @@ const ModalRoot = forwardRef<HTMLDialogElement, ModalProps>(
}
return component;
},
);
) as ModalComponent;
/**
* A component that displays a modal dialog.
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/modal)
* @see 🏷️ {@link ModalProps}
*
* @example
* State change with `useRef`
* ```jsx
* const ref = useRef<HTMLDialogElement>(null);
* <Button onClick={() => ref.current?.showModal()}>Open modal</Button>
* <Modal
* ref={ref}
* header={{
* label: "Optional label",
* icon: <FileIcon aria-hidden />,
* heading: "My heading",
* }}
* >
* <Modal.Body>
* <BodyLong>Hello world</BodyLong>
* </Modal.Body>
* <Modal.Footer>
* <Button>Save</Button>
* <Button type="button" variant="tertiary" onClick={() => ref.current?.close()}>Close</Button>
* </Modal.Footer>
* </Modal>
* ```
* @example
* State change with `useState`
* ```jsx
* const [open, setOpen] = useState(false);
* <Modal
* open={open}
* onClose={() => setOpen(false)}
* aria-labelledby="modal-heading"
* >
* <Modal.Header>
* <Heading level="1" size="large" id="modal-heading">My heading</Heading>
* </Modal.Header>
* <Modal.Body>
* <BodyLong>Hello world</BodyLong>
* </Modal.Body>
* </Modal>
* ```
*/
const Modal = Object.assign(ModalRoot, {
/**
* @see 🏷️ {@link ModalHeaderProps}
*/
Header: ModalHeader,
/**
* @see 🏷️ {@link ModalBodyProps}
*/
Body: ModalBody,
/**
* @see 🏷️ {@link ModalFooterProps}
*/
Footer: ModalFooter,
});
Modal.Header = ModalHeader;
Modal.Body = ModalBody;
Modal.Footer = ModalFooter;
export { Modal, ModalBody, ModalFooter, ModalHeader };
export type { ModalProps, ModalBodyProps, ModalFooterProps, ModalHeaderProps };
export default Modal;
@@ -1,7 +1,7 @@
import React, { forwardRef } from "react";
import { cl } from "../../utils/helpers";
import { cl } from "../utils/helpers";
interface ModalBodyProps extends React.HTMLAttributes<HTMLDivElement> {
export interface ModalBodyProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
@@ -13,5 +13,4 @@ const ModalBody = forwardRef<HTMLDivElement, ModalBodyProps>(
},
);
export { ModalBody };
export type { ModalBodyProps };
export default ModalBody;
@@ -1,7 +1,7 @@
import React, { forwardRef } from "react";
import { cl } from "../../utils/helpers";
import { cl } from "../utils/helpers";
interface ModalFooterProps extends React.HTMLAttributes<HTMLDivElement> {
export interface ModalFooterProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
@@ -17,5 +17,4 @@ const ModalFooter = forwardRef<HTMLDivElement, ModalFooterProps>(
},
);
export { ModalFooter };
export type { ModalFooterProps };
export default ModalFooter;
@@ -1,11 +1,11 @@
import React, { forwardRef } from "react";
import { XMarkIcon } from "@navikt/aksel-icons";
import { Button } from "../../button";
import { cl } from "../../utils/helpers";
import { useI18n } from "../../utils/i18n/i18n.hooks";
import { useModalContext } from "../root/ModalRoot.context";
import { Button } from "../button";
import { cl } from "../utils/helpers";
import { useI18n } from "../utils/i18n/i18n.hooks";
import { useModalContext } from "./Modal.context";
interface ModalHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
export interface ModalHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
/**
* Removes close-button (X) when false
@@ -43,5 +43,4 @@ const ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(
},
);
export { ModalHeader };
export type { ModalHeaderProps };
export default ModalHeader;
@@ -0,0 +1,57 @@
import React, { useEffect } from "react";
import type { ModalProps } from "./types";
export interface MouseCoordinates {
clientX: number;
clientY: number;
}
export const coordsAreInside = (
{ clientX, clientY }: MouseCoordinates,
{ left, top, right, bottom }: DOMRect,
) => {
if (clientX < left || clientY < top) return false;
if (clientX > right || clientY > bottom) return false;
return true;
};
export function getCloseHandler(
modalRef: React.RefObject<HTMLDialogElement | null>,
header: ModalProps["header"],
onBeforeClose: ModalProps["onBeforeClose"],
) {
if (header && header.closeButton === false) return undefined;
if (onBeforeClose) {
return () => onBeforeClose() !== false && modalRef.current?.close();
}
return () => modalRef.current?.close();
}
function useIsModalOpen(modalRef: HTMLDialogElement | null) {
const [isOpen, setIsOpen] = React.useState<boolean>(false);
useEffect(() => {
if (!modalRef) {
return;
}
setIsOpen(modalRef.open); // eslint-disable-line react-hooks/set-state-in-effect
const observer = new MutationObserver(() => {
setIsOpen(modalRef.open);
});
observer.observe(modalRef, {
attributes: true,
attributeFilter: ["open"],
});
return () => {
observer.disconnect();
};
}, [modalRef]);
return isOpen;
}
export { useIsModalOpen };
@@ -2,7 +2,7 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
const needPolyfill =
export const needPolyfill =
typeof window !== "undefined" &&
(window.HTMLDialogElement === undefined ||
navigator.userAgent.includes("jsdom"));
@@ -503,10 +503,7 @@ dialogPolyfillInfo.prototype = /** @type {HTMLDialogElement.prototype} */ {
},
};
var dialogPolyfill = {} as {
registerDialog(dialog: HTMLDialogElement): void;
forceRegisterDialog(dialog: HTMLDialogElement): void;
};
var dialogPolyfill = {};
dialogPolyfill.reposition = function (element) {
var scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
@@ -950,4 +947,7 @@ if (needPolyfill) {
HTMLFormElement.prototype.submit = replacementFormSubmit;
}
export { dialogPolyfill, needPolyfill };
export default dialogPolyfill as {
registerDialog(dialog: HTMLDialogElement): void;
forceRegisterDialog(dialog: HTMLDialogElement): void;
};
@@ -1,31 +0,0 @@
import React from "react";
import type { ModalProps } from "../Modal.types";
interface MouseCoordinates {
clientX: number;
clientY: number;
}
const coordsAreInside = (
{ clientX, clientY }: MouseCoordinates,
{ left, top, right, bottom }: DOMRect,
) => {
if (clientX < left || clientY < top) return false;
if (clientX > right || clientY > bottom) return false;
return true;
};
function getCloseHandler(
modalRef: React.RefObject<HTMLDialogElement | null>,
header: ModalProps["header"],
onBeforeClose: ModalProps["onBeforeClose"],
) {
if (header && header.closeButton === false) return undefined;
if (onBeforeClose) {
return () => onBeforeClose() !== false && modalRef.current?.close();
}
return () => modalRef.current?.close();
}
export { coordsAreInside, getCloseHandler };
export type { MouseCoordinates };
+5 -7
View File
@@ -1,8 +1,6 @@
"use client";
export { Modal, ModalBody, ModalFooter, ModalHeader } from "./root/ModalRoot";
export type {
ModalProps,
ModalBodyProps,
ModalFooterProps,
ModalHeaderProps,
} from "./root/ModalRoot";
export { default as Modal } from "./Modal";
export { default as ModalBody, type ModalBodyProps } from "./ModalBody";
export { default as ModalFooter, type ModalFooterProps } from "./ModalFooter";
export { default as ModalHeader, type ModalHeaderProps } from "./ModalHeader";
export type { ModalProps } from "./types";
@@ -1,30 +0,0 @@
import React, { useEffect } from "react";
function useIsModalOpen(modalRef: HTMLDialogElement | null) {
const [isOpen, setIsOpen] = React.useState<boolean>(false);
useEffect(() => {
if (!modalRef) {
return;
}
setIsOpen(modalRef.open); // eslint-disable-line react-hooks/set-state-in-effect
const observer = new MutationObserver(() => {
setIsOpen(modalRef.open);
});
observer.observe(modalRef, {
attributes: true,
attributeFilter: ["open"],
});
return () => {
observer.disconnect();
};
}, [modalRef]);
return isOpen;
}
export { useIsModalOpen };
@@ -1,5 +1,5 @@
import { expectTypeOf } from "vitest";
import type { ModalProps } from "./Modal.types";
import type { ModalProps } from "./types";
test("ModalProps works as intended", () => {
expectTypeOf({
@@ -93,7 +93,7 @@ interface ModalPropsBase extends React.DialogHTMLAttributes<HTMLDialogElement> {
}
// Require onClose if you use open & Require either header, aria-labelledby or aria-label
type ModalProps = ModalPropsBase &
export type ModalProps = ModalPropsBase &
(
| { onClose: ModalPropsBase["onClose"]; open: boolean | undefined }
| { onClose?: ModalPropsBase["onClose"]; open?: never }
@@ -117,5 +117,3 @@ type ModalProps = ModalPropsBase &
| { "aria-labelledby": string; "aria-label"?: never }
| { "aria-labelledby"?: never; "aria-label": string }
);
export type { ModalProps };
@@ -1,6 +1,6 @@
import React, { useState } from "react";
import { Button } from "../button";
import { Popover } from "./root/PopoverRoot";
import Popover from "./Popover";
const placements = [
"top",
@@ -1,7 +1,7 @@
import type { StoryObj } from "@storybook/react-vite";
import React, { useState } from "react";
import { expect, userEvent, waitFor, within } from "storybook/test";
import { Popover } from "./root/PopoverRoot";
import Popover from "./Popover";
export default {
title: "ds-react/Popover/tests",
@@ -9,17 +9,14 @@ import {
useFloating,
} from "@floating-ui/react";
import React, { type HTMLAttributes, forwardRef } from "react";
import { useModalContext } from "../../modal/root/ModalRoot.context";
import { omit, useClientLayoutEffect } from "../../utils-external";
import { DismissableLayer } from "../../utils/components/dismissablelayer/DismissableLayer";
import { cl } from "../../utils/helpers";
import { useMergeRefs } from "../../utils/hooks";
import {
PopoverContent,
type PopoverContentProps,
} from "../content/PopoverContent";
import { useModalContext } from "../modal/Modal.context";
import { omit, useClientLayoutEffect } from "../utils-external";
import { DismissableLayer } from "../utils/components/dismissablelayer/DismissableLayer";
import { cl } from "../utils/helpers";
import { useMergeRefs } from "../utils/hooks";
import PopoverContent, { type PopoverContentType } from "./PopoverContent";
interface PopoverProps extends HTMLAttributes<HTMLDivElement> {
export interface PopoverProps extends HTMLAttributes<HTMLDivElement> {
/**
* Popover content
*/
@@ -77,7 +74,33 @@ interface PopoverProps extends HTMLAttributes<HTMLDivElement> {
flip?: boolean;
}
const PopoverRoot = forwardRef<HTMLDivElement, PopoverProps>(
interface PopoverComponent extends React.ForwardRefExoticComponent<
PopoverProps & React.RefAttributes<HTMLDivElement>
> {
Content: PopoverContentType;
}
/**
* A component that displays a popover.
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/popover)
* @see 🏷️ {@link PopoverProps}
*
* @example
* ```jsx
* <Button ref={buttonRef} onClick={() => setOpenState(true)}>
* Åpne popover
* </Button>
* <Popover
* open={openState}
* onClose={() => setOpenState(false)}
* anchorEl={buttonRef.current}
* >
* <Popover.Content>Innhold her!</Popover.Content>
* </Popover>
* ```
*/
export const Popover = forwardRef<HTMLDivElement, PopoverProps>(
(
{
className,
@@ -163,7 +186,7 @@ const PopoverRoot = forwardRef<HTMLDivElement, PopoverProps>(
</DismissableLayer>
);
},
);
) as PopoverComponent;
const oppositeSideMap: Record<Side, Side> = {
top: "bottom",
@@ -191,32 +214,6 @@ function getOppositePlacement(placement: Placement): Placement[] {
return [alignment ? `${oppositeSide}-${alignment}` : oppositeSide];
}
/**
* A component that displays a popover.
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/popover)
* @see 🏷️ {@link PopoverProps}
*
* @example
* ```jsx
* <Button ref={buttonRef} onClick={() => setOpenState(true)}>
* Åpne popover
* </Button>
* <Popover
* open={openState}
* onClose={() => setOpenState(false)}
* anchorEl={buttonRef.current}
* >
* <Popover.Content>Innhold her!</Popover.Content>
* </Popover>
* ```
*/
const Popover = Object.assign(PopoverRoot, {
/**
* @see 🏷️ {@link PopoverContentProps}
*/
Content: PopoverContent,
});
Popover.Content = PopoverContent;
export { Popover, PopoverContent };
export type { PopoverProps, PopoverContentProps };
export default Popover;
@@ -1,11 +1,11 @@
import React, { forwardRef } from "react";
import { cl } from "../../utils/helpers";
import { cl } from "../utils/helpers";
interface PopoverContentProps extends React.HTMLAttributes<HTMLDivElement> {
export interface PopoverContentProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
type PopoverContentType = React.ForwardRefExoticComponent<
export type PopoverContentType = React.ForwardRefExoticComponent<
PopoverContentProps & React.RefAttributes<HTMLDivElement>
>;
@@ -21,5 +21,4 @@ const PopoverContent: PopoverContentType = forwardRef(
},
);
export { PopoverContent };
export type { PopoverContentProps, PopoverContentType };
export default PopoverContent;
+5 -2
View File
@@ -1,3 +1,6 @@
"use client";
export { Popover, PopoverContent } from "./root/PopoverRoot";
export type { PopoverProps, PopoverContentProps } from "./root/PopoverRoot";
export { default as Popover, type PopoverProps } from "./Popover";
export {
default as PopoverContent,
type PopoverContentProps,
} from "./PopoverContent";
+3 -3
View File
@@ -1,5 +1,5 @@
"use client";
export { DataGrid } from "./data-grid";
export { Lookup } from "./lookup/index-deprecated";
export { Badge } from "./badge/index-deprecated";
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";
@@ -1,5 +1,5 @@
import { createStrictContext } from "../../utils/helpers";
import type { TabsProps } from "../Tabs.types";
import { createStrictContext } from "../utils/helpers";
import type { TabsProps } from "./Tabs.types";
import type { useTabs } from "./useTabs";
type TabsProviderProps = ReturnType<typeof useTabs> &
@@ -9,11 +9,9 @@ type TabsProviderProps = ReturnType<typeof useTabs> &
>;
/* State context */
const { Provider: TabsProvider, useContext: useTabsContext } =
export const { Provider: TabsProvider, useContext: useTabsContext } =
createStrictContext<TabsProviderProps>({
name: "TabsContext",
errorMessage:
"Tabs.List, Tabs.Tag and Tabs.Panel needs to be wrapped within <Tabs>",
});
export { TabsProvider, useTabsContext };
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff. Show more