mirror of
https://github.com/navikt/aksel.git
synced 2026-10-21 05:02:03 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a713ea2df | ||
|
|
2bcc26643d | ||
|
|
3cd254db9d | ||
|
|
d10be00e84 | ||
|
|
072a2e8aff | ||
|
|
dff2c96b4b | ||
|
|
2177f3ab64 | ||
|
|
51629f9229 |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@navikt/ds-react": patch
|
||||
"@navikt/ds-css": patch
|
||||
---
|
||||
|
||||
ToggleGroup: Add xsmall as size
|
||||
@@ -87,6 +87,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef } from "react";
|
||||
import { useModalContext } from "../../modal/Modal.context";
|
||||
import { useModalContext } from "../../modal/root/ModalRoot.context";
|
||||
import { useId } from "../../utils-external";
|
||||
import {
|
||||
Menu,
|
||||
|
||||
@@ -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 "./Chat";
|
||||
import { Chat, POSITIONS, SIZES, VARIANTS } from "./root/ChatRoot";
|
||||
|
||||
export default {
|
||||
title: "ds-react/Chat",
|
||||
|
||||
+6
-5
@@ -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";
|
||||
|
||||
export interface ChatBubbleProps extends HTMLAttributes<HTMLDivElement> {
|
||||
interface ChatBubbleProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Bubble text.
|
||||
*/
|
||||
@@ -26,7 +26,7 @@ export interface ChatBubbleProps extends HTMLAttributes<HTMLDivElement> {
|
||||
toptextHeadingLevel?: Exclude<HeadingProps["level"], "1">;
|
||||
}
|
||||
|
||||
const Bubble = forwardRef<HTMLDivElement, ChatBubbleProps>(
|
||||
const ChatBubble = forwardRef<HTMLDivElement, ChatBubbleProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
@@ -65,4 +65,5 @@ const Bubble = forwardRef<HTMLDivElement, ChatBubbleProps>(
|
||||
},
|
||||
);
|
||||
|
||||
export default Bubble;
|
||||
export { ChatBubble };
|
||||
export type { ChatBubbleProps };
|
||||
@@ -1,3 +1,3 @@
|
||||
"use client";
|
||||
export { default as Chat, ChatBubble } from "./Chat";
|
||||
export type { ChatProps, ChatBubbleProps } from "./Chat";
|
||||
export { Chat, ChatBubble } from "./root/ChatRoot";
|
||||
export type { ChatProps, ChatBubbleProps } from "./root/ChatRoot";
|
||||
+33
-38
@@ -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 Bubble, { type ChatBubbleProps } from "./Bubble";
|
||||
import type { AkselColor } from "../../types";
|
||||
import { BodyLong, type HeadingProps } from "../../typography";
|
||||
import { cl } from "../../utils/helpers";
|
||||
import { ChatBubble, type ChatBubbleProps } from "../bubble/ChatBubble";
|
||||
|
||||
export const VARIANTS = ["subtle", "info", "neutral"] as const;
|
||||
export const POSITIONS = ["left", "right"] as const;
|
||||
export const SIZES = ["medium", "small"] as const;
|
||||
const VARIANTS = ["subtle", "info", "neutral"] as const;
|
||||
const POSITIONS = ["left", "right"] as const;
|
||||
const SIZES = ["medium", "small"] as const;
|
||||
|
||||
interface ChatProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
@@ -62,33 +62,7 @@ interface ChatProps extends HTMLAttributes<HTMLDivElement> {
|
||||
"data-color"?: Exclude<AkselColor, AkselStatusColorRole>;
|
||||
}
|
||||
|
||||
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>(
|
||||
const ChatRoot = forwardRef<HTMLDivElement, ChatProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
@@ -143,7 +117,7 @@ export const Chat = forwardRef<HTMLDivElement, ChatProps>(
|
||||
</div>
|
||||
);
|
||||
},
|
||||
) as ChatComponent;
|
||||
);
|
||||
|
||||
function variantToColor(variant: ChatProps["variant"]): AkselColor {
|
||||
switch (variant) {
|
||||
@@ -158,8 +132,29 @@ function variantToColor(variant: ChatProps["variant"]): AkselColor {
|
||||
}
|
||||
}
|
||||
|
||||
Chat.Bubble = 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>
|
||||
* ```
|
||||
*/
|
||||
const Chat = Object.assign(ChatRoot, {
|
||||
/**
|
||||
* @see 🏷️ {@link ChatBubbleProps}
|
||||
*/
|
||||
Bubble: ChatBubble,
|
||||
});
|
||||
|
||||
export default Chat;
|
||||
export { Bubble as ChatBubble };
|
||||
export { Chat, ChatBubble, VARIANTS, POSITIONS, SIZES };
|
||||
export type { ChatProps, ChatBubbleProps };
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
export { default as Chips, type ChipsProps } from "./root/ChipsRoot";
|
||||
export {
|
||||
ChipsRemovable,
|
||||
type ChipsRemovableProps,
|
||||
} from "./removable/ChipsRemovable";
|
||||
export { ChipsToggle, type ChipsToggleProps } from "./toggle/ChipsToggle";
|
||||
export { Chips } from "./root/ChipsRoot";
|
||||
export { ChipsRemovable } from "./removable/ChipsRemovable";
|
||||
export { ChipsToggle } from "./toggle/ChipsToggle";
|
||||
export type { ChipsProps } from "./root/ChipsRoot";
|
||||
export type { ChipsRemovableProps } from "./removable/ChipsRemovable";
|
||||
export 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";
|
||||
|
||||
export interface ChipsRemovableProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
interface ChipsRemovableProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: string;
|
||||
/**
|
||||
* @deprecated Use `data-color` prop instead.
|
||||
@@ -22,10 +22,7 @@ export interface ChipsRemovableProps extends React.ButtonHTMLAttributes<HTMLButt
|
||||
"data-color"?: AkselColor;
|
||||
}
|
||||
|
||||
export const ChipsRemovable = forwardRef<
|
||||
HTMLButtonElement,
|
||||
ChipsRemovableProps
|
||||
>(
|
||||
const ChipsRemovable = forwardRef<HTMLButtonElement, ChipsRemovableProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
@@ -76,4 +73,5 @@ function variantToColor(
|
||||
}
|
||||
}
|
||||
|
||||
export default ChipsRemovable;
|
||||
export { ChipsRemovable };
|
||||
export type { ChipsRemovableProps };
|
||||
@@ -12,22 +12,24 @@ interface ChipsProps extends HTMLAttributes<HTMLUListElement> {
|
||||
size?: "medium" | "small";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* A component that displays a list of items as chips.
|
||||
@@ -56,27 +58,20 @@ interface ChipsComponent extends React.ForwardRefExoticComponent<
|
||||
* </Chips>
|
||||
* ```
|
||||
*/
|
||||
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;
|
||||
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,
|
||||
});
|
||||
|
||||
Chips.Toggle = ChipsToggle;
|
||||
Chips.Removable = ChipsRemovable;
|
||||
|
||||
export default Chips;
|
||||
export { Chips };
|
||||
export type { ChipsProps };
|
||||
@@ -3,7 +3,7 @@ import type { AkselColor } from "../../types";
|
||||
import type { OverridableComponent } from "../../utils-external";
|
||||
import { cl } from "../../utils/helpers";
|
||||
|
||||
export interface ChipsToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
interface ChipsToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: string;
|
||||
/**
|
||||
* Toggles aria-pressed and visual changes
|
||||
@@ -26,68 +26,66 @@ export interface ChipsToggleProps extends React.ButtonHTMLAttributes<HTMLButtonE
|
||||
checkmark?: boolean;
|
||||
}
|
||||
|
||||
export const ChipsToggle: OverridableComponent<
|
||||
ChipsToggleProps,
|
||||
HTMLButtonElement
|
||||
> = forwardRef(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
selected,
|
||||
variant,
|
||||
checkmark = true,
|
||||
as: Component = "button",
|
||||
"data-color": color,
|
||||
...rest
|
||||
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>
|
||||
);
|
||||
},
|
||||
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"],
|
||||
@@ -102,4 +100,5 @@ function variantToColor(
|
||||
}
|
||||
}
|
||||
|
||||
export default ChipsToggle;
|
||||
export { ChipsToggle };
|
||||
export type { ChipsToggleProps };
|
||||
@@ -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/Modal";
|
||||
import { Modal } from "../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/Modal.context";
|
||||
import { useModalContext } from "../../modal/root/ModalRoot.context";
|
||||
import { Portal, type PortalProps } from "../../portal";
|
||||
import { DialogBackdropInternal } from "../backdrop/DialogBackdropInternal";
|
||||
import { useDialogContext } from "../root/DialogRoot.context";
|
||||
|
||||
+7
-6
@@ -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 "./context";
|
||||
import { useThemeInternal } from "../../theme/Theme";
|
||||
import { BodyLong } from "../../typography";
|
||||
import { cl } from "../../utils/helpers";
|
||||
import { ExpansionCardContext } from "../root/ExpansionCardRoot.context";
|
||||
|
||||
export interface ExpansionCardContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
interface ExpansionCardContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -44,4 +44,5 @@ const ExpansionCardContent = forwardRef<
|
||||
);
|
||||
});
|
||||
|
||||
export default ExpansionCardContent;
|
||||
export { ExpansionCardContent };
|
||||
export type { ExpansionCardContentProps };
|
||||
@@ -1,13 +0,0 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export type ExpansionCardContextProps = {
|
||||
open: boolean;
|
||||
toggleOpen: () => void;
|
||||
size: "medium" | "small";
|
||||
};
|
||||
|
||||
export const ExpansionCardContext = createContext<ExpansionCardContextProps>({
|
||||
open: false,
|
||||
toggleOpen: () => {},
|
||||
size: "medium",
|
||||
});
|
||||
+7
-6
@@ -1,13 +1,13 @@
|
||||
import React, { forwardRef, useContext } from "react";
|
||||
import { BodyLong } from "../typography";
|
||||
import { cl } from "../utils/helpers";
|
||||
import { ExpansionCardContext } from "./context";
|
||||
import { BodyLong } from "../../typography";
|
||||
import { cl } from "../../utils/helpers";
|
||||
import { ExpansionCardContext } from "../root/ExpansionCardRoot.context";
|
||||
|
||||
export interface ExpansionCardDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
interface ExpansionCardDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ExpansionCardDescription = forwardRef<
|
||||
const ExpansionCardDescription = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
ExpansionCardDescriptionProps
|
||||
>(({ className, ...rest }, ref) => {
|
||||
@@ -31,4 +31,5 @@ export const ExpansionCardDescription = forwardRef<
|
||||
);
|
||||
});
|
||||
|
||||
export default ExpansionCardDescription;
|
||||
export { ExpansionCardDescription };
|
||||
export type { ExpansionCardDescriptionProps };
|
||||
+6
-5
@@ -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 "./context";
|
||||
import { cl } from "../../utils/helpers";
|
||||
import { useI18n } from "../../utils/i18n/i18n.hooks";
|
||||
import { ExpansionCardContext } from "../root/ExpansionCardRoot.context";
|
||||
|
||||
export interface ExpansionCardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
interface ExpansionCardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -46,4 +46,5 @@ const ExpansionCardHeader = forwardRef<
|
||||
);
|
||||
});
|
||||
|
||||
export default ExpansionCardHeader;
|
||||
export { ExpansionCardHeader };
|
||||
export type { ExpansionCardHeaderProps };
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
export {
|
||||
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";
|
||||
ExpansionCard,
|
||||
ExpansionCardContent,
|
||||
ExpansionCardHeader,
|
||||
ExpansionCardTitle,
|
||||
ExpansionCardDescription,
|
||||
} from "./root/ExpansionCardRoot";
|
||||
export type { ExpansionCardProps } from "./root/ExpansionCardRoot";
|
||||
@@ -0,0 +1,16 @@
|
||||
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 };
|
||||
+66
-65
@@ -1,51 +1,24 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import type { AkselColor } from "../types";
|
||||
import type { OverridableComponent } from "../utils-external";
|
||||
import { cl } from "../utils/helpers";
|
||||
import { useControllableState } from "../utils/hooks";
|
||||
import ExpansionCardContent, {
|
||||
import type { AkselColor } from "../../types";
|
||||
import { cl } from "../../utils/helpers";
|
||||
import { useControllableState } from "../../utils/hooks";
|
||||
import {
|
||||
ExpansionCardContent,
|
||||
type ExpansionCardContentProps,
|
||||
} from "./ExpansionCardContent";
|
||||
} from "../content/ExpansionCardContent";
|
||||
import {
|
||||
ExpansionCardDescription,
|
||||
type ExpansionCardDescriptionProps,
|
||||
} from "./ExpansionCardDescription";
|
||||
import ExpansionCardHeader, {
|
||||
} from "../description/ExpansionCardDescription";
|
||||
import {
|
||||
ExpansionCardHeader,
|
||||
type ExpansionCardHeaderProps,
|
||||
} from "./ExpansionCardHeader";
|
||||
} from "../header/ExpansionCardHeader";
|
||||
import {
|
||||
ExpansionCardTitle,
|
||||
type ExpansionCardTitleProps,
|
||||
} 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>
|
||||
>;
|
||||
}
|
||||
} from "../title/ExpansionCardTitle";
|
||||
import { ExpansionCardContext } from "./ExpansionCardRoot.context";
|
||||
|
||||
interface ExpansionCardCommonProps extends Omit<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
@@ -93,28 +66,10 @@ type ExpansionCardConditionalProps =
|
||||
"aria-labelledby": string;
|
||||
};
|
||||
|
||||
export type ExpansionCardProps = ExpansionCardCommonProps &
|
||||
type ExpansionCardProps = ExpansionCardCommonProps &
|
||||
ExpansionCardConditionalProps;
|
||||
|
||||
/**
|
||||
* 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>(
|
||||
const ExpansionCardRoot = forwardRef<HTMLDivElement, ExpansionCardProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
@@ -154,11 +109,57 @@ export const ExpansionCard = forwardRef<HTMLDivElement, ExpansionCardProps>(
|
||||
</ExpansionCardContext.Provider>
|
||||
);
|
||||
},
|
||||
) as ExpansionCardComponent;
|
||||
);
|
||||
|
||||
ExpansionCard.Header = ExpansionCardHeader;
|
||||
ExpansionCard.Content = ExpansionCardContent;
|
||||
ExpansionCard.Title = ExpansionCardTitle;
|
||||
ExpansionCard.Description = ExpansionCardDescription;
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
|
||||
export default ExpansionCard;
|
||||
export {
|
||||
ExpansionCard,
|
||||
ExpansionCardHeader,
|
||||
ExpansionCardTitle,
|
||||
ExpansionCardDescription,
|
||||
ExpansionCardContent,
|
||||
};
|
||||
export type {
|
||||
ExpansionCardProps,
|
||||
ExpansionCardHeaderProps,
|
||||
ExpansionCardTitleProps,
|
||||
ExpansionCardDescriptionProps,
|
||||
ExpansionCardContentProps,
|
||||
};
|
||||
+6
-5
@@ -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";
|
||||
|
||||
export interface ExpansionCardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
|
||||
interface ExpansionCardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Changes text-sizing
|
||||
@@ -11,7 +11,7 @@ export interface ExpansionCardTitleProps extends React.HTMLAttributes<HTMLHeadin
|
||||
size?: "large" | "medium" | "small";
|
||||
}
|
||||
|
||||
export const ExpansionCardTitle: OverridableComponent<
|
||||
const ExpansionCardTitle: OverridableComponent<
|
||||
ExpansionCardTitleProps,
|
||||
HTMLHeadingElement
|
||||
> = forwardRef(
|
||||
@@ -32,4 +32,5 @@ export const ExpansionCardTitle: OverridableComponent<
|
||||
},
|
||||
);
|
||||
|
||||
export default ExpansionCardTitle;
|
||||
export { ExpansionCardTitle };
|
||||
export type { ExpansionCardTitleProps };
|
||||
@@ -21,20 +21,19 @@ import { ComboboxTrigger } from "./trigger/ComboboxTrigger";
|
||||
interface ComboboxProps<
|
||||
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
|
||||
>
|
||||
extends
|
||||
Omit<
|
||||
ComboboxRootProps<T>,
|
||||
"children" | "selectedOptions" | "onToggleOption"
|
||||
>,
|
||||
Partial<Pick<ComboboxRootProps<T>, "selectedOptions" | "onToggleOption">>,
|
||||
Omit<FormFieldProps, "size"> {
|
||||
extends Omit<ComboboxRootProps<T>, "children">, Omit<FormFieldProps, "size"> {
|
||||
/**
|
||||
* Label
|
||||
*/
|
||||
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<
|
||||
@@ -43,8 +42,6 @@ function Combobox<
|
||||
label,
|
||||
hideLabel,
|
||||
name,
|
||||
defaultSelectedOptions,
|
||||
onSelectedOptionsChange,
|
||||
size: sizeProp,
|
||||
error,
|
||||
errorId: errorIdProp,
|
||||
@@ -52,9 +49,7 @@ function Combobox<
|
||||
description,
|
||||
id,
|
||||
readOnly: readOnlyProp,
|
||||
multiselect = false, // Default value should be synced with ComboboxRoot
|
||||
selectedOptions: selectedOptionsProp,
|
||||
onToggleOption: onToggleOptionProp,
|
||||
multiple,
|
||||
...rest
|
||||
}: ComboboxProps<T>) {
|
||||
const {
|
||||
@@ -78,35 +73,11 @@ 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}
|
||||
multiselect={multiselect}
|
||||
selectedOptions={selectedOptions}
|
||||
onToggleOption={onToggleOption}
|
||||
multiple={multiple}
|
||||
{...rest}
|
||||
>
|
||||
<ComboboxLabel
|
||||
@@ -146,8 +117,8 @@ function Combobox<
|
||||
</div>
|
||||
|
||||
{name &&
|
||||
selectedOptions.map((value) => (
|
||||
<input key={value} type="hidden" name={name} value={value} />
|
||||
rest.value.map((val) => (
|
||||
<input key={val} type="hidden" name={name} value={val} />
|
||||
))}
|
||||
|
||||
<ComboboxOverlay
|
||||
@@ -169,5 +140,126 @@ function Combobox<
|
||||
);
|
||||
}
|
||||
|
||||
export { Combobox };
|
||||
export type { ComboboxProps };
|
||||
// ----------- 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 };
|
||||
@@ -3,8 +3,7 @@ import React, { useMemo, useState } from "react";
|
||||
import { Button } from "../../button";
|
||||
import { Dialog } from "../../dialog";
|
||||
import { HStack, VStack } from "../../primitives/stack";
|
||||
import { Select } from "../select";
|
||||
import { Combobox, type ComboboxProps } from "./Combobox";
|
||||
import { ComboboxMulti, ComboboxSingle } from "./Combobox";
|
||||
import { ComboboxField } from "./field/ComboboxField";
|
||||
import { ComboboxFilter } from "./filter/ComboboxFilter";
|
||||
import { ComboboxLabel } from "./label/ComboboxLabel";
|
||||
@@ -18,9 +17,9 @@ import {
|
||||
} from "./root/ComboboxRoot";
|
||||
import { ComboboxTrigger } from "./trigger/ComboboxTrigger";
|
||||
|
||||
const meta: Meta<typeof Combobox> = {
|
||||
const meta: Meta<typeof ComboboxSingle> = {
|
||||
title: "ds-react/Combobox2",
|
||||
component: Combobox,
|
||||
component: ComboboxSingle,
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
@@ -77,13 +76,12 @@ const groupedCountries: (MyGroup | MyOption)[] = [
|
||||
];
|
||||
|
||||
type DefaultProps = Pick<
|
||||
ComboboxProps<MyOption>,
|
||||
ComboboxSingle.Props<MyOption>,
|
||||
| "label"
|
||||
| "description"
|
||||
| "hideLabel"
|
||||
| "size"
|
||||
| "defaultOpen"
|
||||
| "multiselect"
|
||||
| "id"
|
||||
| "readOnly"
|
||||
| "disabled"
|
||||
@@ -91,14 +89,13 @@ type DefaultProps = Pick<
|
||||
>;
|
||||
|
||||
export const Default: StoryFn<DefaultProps> = (props) => {
|
||||
return <Combobox defaultOpen options={countries} {...props} />;
|
||||
return <ComboboxSingle 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: "",
|
||||
@@ -113,17 +110,21 @@ 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<ComboboxProps<T>>) {
|
||||
>(props: Partial<ComboboxSingle.Props<T>>) {
|
||||
return (
|
||||
<Combobox
|
||||
<ComboboxSingle
|
||||
options={countries as T[]}
|
||||
defaultSelectedOptions={["opt-1"]}
|
||||
defaultValue="opt-1"
|
||||
label="Velg land"
|
||||
description="Landet hvor du er født."
|
||||
multiselect={false}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -133,13 +134,13 @@ export const Small = () => <BasicCombobox size="small" error="Error" />;
|
||||
|
||||
export const Hidelabel = () => <BasicCombobox hideLabel />;
|
||||
|
||||
export const Readonly = () => <BasicCombobox readOnly />;
|
||||
export const Readonly = () => <BasicCombobox readOnly error="Test" />;
|
||||
|
||||
export const Disabled = () => <BasicCombobox disabled />;
|
||||
export const Disabled = () => <BasicCombobox disabled error="Test" />;
|
||||
|
||||
export const ErrorStory = () => (
|
||||
<VStack gap="space-16">
|
||||
<BasicCombobox error="Du må velge et land." defaultSelectedOptions={[]} />
|
||||
<BasicCombobox error="Du må velge et land." defaultValue={undefined} />
|
||||
<BasicCombobox error="Ugyldig land." />
|
||||
</VStack>
|
||||
);
|
||||
@@ -148,10 +149,8 @@ ErrorStory.storyName = "Error";
|
||||
export const DefaultOpen = () => <BasicCombobox defaultOpen />;
|
||||
DefaultOpen.storyName = "DefaultOpen";
|
||||
|
||||
export const DefaultSelectedOptions = () => (
|
||||
<BasicCombobox defaultSelectedOptions={["opt-1"]} />
|
||||
);
|
||||
DefaultSelectedOptions.storyName = "DefaultSelectedOptions";
|
||||
export const DefaultValue = () => <BasicCombobox defaultValue="opt-1" />;
|
||||
DefaultValue.storyName = "DefaultValue";
|
||||
|
||||
export const Name = () => (
|
||||
<form
|
||||
@@ -169,50 +168,19 @@ export const Name = () => (
|
||||
);
|
||||
|
||||
export const Controlled = () => {
|
||||
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
|
||||
"opt-1",
|
||||
]);
|
||||
const [value, setValue] = useState<MyOption["value"] | undefined>("opt-1");
|
||||
|
||||
return (
|
||||
<VStack gap="space-8" minWidth="250px">
|
||||
<Combobox
|
||||
<ComboboxSingle
|
||||
options={countries}
|
||||
selectedOptions={selectedOptions}
|
||||
onToggleOption={(option, newSelected) => {
|
||||
setSelectedOptions((prev) =>
|
||||
newSelected
|
||||
? [...prev, option.value]
|
||||
: prev.filter((v) => v !== option.value),
|
||||
);
|
||||
}}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
label="Velg land"
|
||||
/>
|
||||
Selected: {selectedOptions.join(", ")}
|
||||
Selected: {value}
|
||||
<div>
|
||||
<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([])}>
|
||||
<button type="button" onClick={() => setValue(undefined)}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
@@ -243,7 +211,7 @@ export const ManyOptions: StoryFn<ManyOptionsProps> = ({ count }) => {
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: "400px", width: "250px" }}>
|
||||
<Combobox options={manyOptions} label="Test" />
|
||||
<ComboboxSingle options={manyOptions} label="Test" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -278,7 +246,7 @@ export const LongLabels = () => (
|
||||
],
|
||||
},
|
||||
]}
|
||||
selectedOptions={["opt-1"]}
|
||||
value={["opt-1"]}
|
||||
onToggleOption={() => {}}
|
||||
>
|
||||
<ComboboxField />
|
||||
@@ -290,15 +258,13 @@ export const LongLabels = () => (
|
||||
);
|
||||
|
||||
export const Composition = () => {
|
||||
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
|
||||
"opt-1",
|
||||
]);
|
||||
const [value, setSelectedOptions] = useState<MyOption["value"][]>(["opt-1"]);
|
||||
|
||||
const [selectedOption, setSelectedOption] = useState<MyOption>(countries[0]);
|
||||
|
||||
const rootProps = {
|
||||
options: countries,
|
||||
selectedOptions,
|
||||
value,
|
||||
onToggleOption: (option) => {
|
||||
setSelectedOptions((prev) =>
|
||||
prev.includes(option.value)
|
||||
@@ -335,9 +301,9 @@ export const Composition = () => {
|
||||
|
||||
<ComboboxRoot
|
||||
options={countries}
|
||||
selectedOptions={[selectedOption.value]}
|
||||
value={[selectedOption.value]}
|
||||
onToggleOption={setSelectedOption}
|
||||
multiselect={false}
|
||||
multiple={false}
|
||||
>
|
||||
<ComboboxTrigger>
|
||||
<Button>Single select</Button>
|
||||
@@ -366,9 +332,9 @@ export const Composition = () => {
|
||||
|
||||
<ComboboxRoot
|
||||
options={countries}
|
||||
selectedOptions={[selectedOption.value]}
|
||||
value={[selectedOption.value]}
|
||||
onToggleOption={setSelectedOption}
|
||||
multiselect={false}
|
||||
multiple={false}
|
||||
>
|
||||
<ComboboxLabel htmlFor="demo2">Velg land (single select)</ComboboxLabel>
|
||||
<ComboboxTrigger id="demo2">
|
||||
@@ -383,7 +349,7 @@ export const Composition = () => {
|
||||
</ComboboxRoot>
|
||||
|
||||
<ComboboxRoot {...rootProps}>
|
||||
<ComboboxLabel htmlFor="demo3">Velg land (multiselect)</ComboboxLabel>
|
||||
<ComboboxLabel htmlFor="demo3">Velg land (multiple)</ComboboxLabel>
|
||||
<ComboboxTrigger id="demo3">
|
||||
<ComboboxField />
|
||||
</ComboboxTrigger>
|
||||
@@ -423,60 +389,20 @@ 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}
|
||||
defaultSelectedOptions={[]}
|
||||
defaultValue={undefined}
|
||||
error={error}
|
||||
onToggleOption={(option) =>
|
||||
setError(option.label === "Island" ? "" : "Du må velge Island")
|
||||
}
|
||||
onChange={(newVal) => {
|
||||
if (!newVal) {
|
||||
setError("Du må velge et land");
|
||||
return;
|
||||
}
|
||||
setError(newVal === "opt-5" ? "" : "Du må velge Island");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -484,10 +410,10 @@ function WithValidation() {
|
||||
export const ScreenreaderTesting = () => (
|
||||
<div>
|
||||
<h2>Med grupper</h2>
|
||||
<BasicCombobox options={groupedCountries} defaultSelectedOptions={[]} />
|
||||
<BasicCombobox options={groupedCountries} defaultValue={undefined} />
|
||||
|
||||
<h2>Uten grupper</h2>
|
||||
<BasicCombobox options={nordicCountries} defaultSelectedOptions={[]} />
|
||||
<BasicCombobox options={nordicCountries} defaultValue={undefined} />
|
||||
|
||||
<h2>Label skjult visuelt</h2>
|
||||
<BasicCombobox options={nordicCountries} hideLabel />
|
||||
@@ -499,18 +425,9 @@ export const ScreenreaderTesting = () => (
|
||||
<BasicCombobox readOnly />
|
||||
|
||||
<h2>Readonly uten verdi</h2>
|
||||
<BasicCombobox readOnly defaultSelectedOptions={[]} />
|
||||
<BasicCombobox readOnly defaultValue={undefined} />
|
||||
|
||||
<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 { Combobox, type ComboboxProps } from "./Combobox";
|
||||
import { ComboboxSingle } from "./Combobox";
|
||||
import { ComboboxFilter } from "./filter/ComboboxFilter";
|
||||
import { ComboboxList } from "./list/ComboboxList";
|
||||
import { ComboboxPopup } from "./popup/ComboboxPopup";
|
||||
@@ -59,20 +59,14 @@ const groupedOptions = [
|
||||
|
||||
function BaseCombobox<
|
||||
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
|
||||
>(props: Partial<ComboboxProps<T>>) {
|
||||
const [selectedOptions, setSelectedOptions] = useState(["option-1"]);
|
||||
>(props: Partial<ComboboxSingle.Props<T>>) {
|
||||
const [value, setValue] = useState<string | undefined>("option-1");
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
<ComboboxSingle
|
||||
options={countries as T[]}
|
||||
selectedOptions={selectedOptions}
|
||||
onToggleOption={(option, newSelected) => {
|
||||
setSelectedOptions((prev) =>
|
||||
newSelected
|
||||
? [...prev, option.value]
|
||||
: prev.filter((v) => v !== option.value),
|
||||
);
|
||||
}}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
label="Velg land"
|
||||
description="Landet hvor du er født."
|
||||
{...props}
|
||||
@@ -99,7 +93,7 @@ const optionMemoTestRenderFn = (
|
||||
return optOrGroup.label;
|
||||
};
|
||||
export const OptionMemoization = () => {
|
||||
const [selectedOptions, setSelectedOptions] = useState(["option-1"]);
|
||||
const [value, setValue] = useState(["option-1"]);
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: "450px" }}>
|
||||
@@ -112,9 +106,9 @@ export const OptionMemoization = () => {
|
||||
<ComboboxRoot
|
||||
defaultOpen
|
||||
options={groupedOptions}
|
||||
selectedOptions={selectedOptions}
|
||||
value={value}
|
||||
onToggleOption={(option) => {
|
||||
setSelectedOptions((prev) =>
|
||||
setValue((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, selectedOptions, size } = useComboboxRootContext();
|
||||
const { options, value, size } = useComboboxRootContext();
|
||||
|
||||
const labels = getLabels(selectedOptions, options);
|
||||
const labels = getLabels(value, options);
|
||||
const label =
|
||||
children ?? (labels.length > 1 ? `${labels.length} valgt` : labels[0]);
|
||||
|
||||
@@ -45,7 +45,7 @@ const ComboboxField = forwardRef<HTMLButtonElement, ComboboxFieldProps>(
|
||||
);
|
||||
|
||||
const getLabels = (
|
||||
selectedOptions: ComboboxRootProps["selectedOptions"],
|
||||
value: ComboboxRootProps["value"],
|
||||
options: ComboboxRootProps["options"],
|
||||
) => {
|
||||
const labels: string[] = [];
|
||||
@@ -54,13 +54,13 @@ const getLabels = (
|
||||
if ("options" in optOrGroup) {
|
||||
// It's a group
|
||||
for (const option of optOrGroup.options) {
|
||||
if (selectedOptions.includes(option.value)) {
|
||||
if (value.includes(option.value)) {
|
||||
labels.push(option.label);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// It's a single option
|
||||
if (selectedOptions.includes(optOrGroup.value)) {
|
||||
if (value.includes(optOrGroup.value)) {
|
||||
labels.push(optOrGroup.label);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,28 +47,22 @@ function ComboboxList<
|
||||
function ComboboxListContent<
|
||||
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
|
||||
>({ children }: ComboboxListProps<T>) {
|
||||
const { virtuallyFocusedOptionValue, filterString } =
|
||||
const { virtuallyFocusedOptionValue, filterString, setAnnounceText } =
|
||||
useComboboxPopupContext();
|
||||
const {
|
||||
setOpen,
|
||||
options,
|
||||
selectedOptions,
|
||||
onToggleOption,
|
||||
multiselect,
|
||||
triggerRef,
|
||||
} = useComboboxRootContext();
|
||||
const { setOpen, options, value, onToggleOption, multiple, triggerRef } =
|
||||
useComboboxRootContext();
|
||||
const memoizedOnToggleOption = useEventCallback(onToggleOption);
|
||||
const deferredFilterString = useDeferredValue(filterString);
|
||||
|
||||
const localOnToggleOption = useCallback(
|
||||
(option: ComboboxOptionData, isSelected: boolean) => {
|
||||
memoizedOnToggleOption(option, isSelected);
|
||||
if (!multiselect) {
|
||||
if (!multiple) {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
},
|
||||
[memoizedOnToggleOption, multiselect, triggerRef, setOpen],
|
||||
[memoizedOnToggleOption, multiple, triggerRef, setOpen],
|
||||
);
|
||||
|
||||
if (children && typeof children !== "function") {
|
||||
@@ -80,6 +74,7 @@ function ComboboxListContent<
|
||||
: options;
|
||||
|
||||
if (filteredOptions.length === 0) {
|
||||
setAnnounceText("Ingen treff");
|
||||
return (
|
||||
<div className="aksel-combobox2__no-results" role="status">
|
||||
Ingen treff
|
||||
@@ -87,6 +82,8 @@ function ComboboxListContent<
|
||||
);
|
||||
}
|
||||
|
||||
setAnnounceText("");
|
||||
|
||||
return filteredOptions.map((optOrGroup) =>
|
||||
"options" in optOrGroup ? (
|
||||
<ComboboxGroup
|
||||
@@ -99,7 +96,7 @@ function ComboboxListContent<
|
||||
key={option.value}
|
||||
option={option}
|
||||
onToggleOption={localOnToggleOption}
|
||||
isSelected={selectedOptions.includes(option.value)}
|
||||
isSelected={value.includes(option.value)}
|
||||
hasVirtualFocus={virtuallyFocusedOptionValue === option.value}
|
||||
highlightText={deferredFilterString}
|
||||
>
|
||||
@@ -112,7 +109,7 @@ function ComboboxListContent<
|
||||
key={optOrGroup.value}
|
||||
option={optOrGroup}
|
||||
onToggleOption={localOnToggleOption}
|
||||
isSelected={selectedOptions.includes(optOrGroup.value)}
|
||||
isSelected={value.includes(optOrGroup.value)}
|
||||
hasVirtualFocus={virtuallyFocusedOptionValue === optOrGroup.value}
|
||||
highlightText={deferredFilterString} // TODO: Vurder å kunne slå av dette for bedre ytelse
|
||||
>
|
||||
|
||||
@@ -103,6 +103,7 @@ 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,17 +57,15 @@ const groupedOptions: (MyGroup | MyOption)[] = [
|
||||
];
|
||||
|
||||
export const Default = () => {
|
||||
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
|
||||
"opt-3",
|
||||
]);
|
||||
const [value, setValue] = useState<MyOption["value"][]>(["opt-3"]);
|
||||
|
||||
return (
|
||||
<ComboboxRoot
|
||||
defaultOpen
|
||||
options={options}
|
||||
selectedOptions={selectedOptions}
|
||||
value={value}
|
||||
onToggleOption={(option, newSelected) => {
|
||||
setSelectedOptions((prev) =>
|
||||
setValue((prev) =>
|
||||
newSelected
|
||||
? [...prev, option.value]
|
||||
: prev.filter((v) => v !== option.value),
|
||||
@@ -89,7 +87,7 @@ export const SingleSelect = () => {
|
||||
<ComboboxRoot
|
||||
defaultOpen
|
||||
options={options}
|
||||
selectedOptions={[selectedOption.value]}
|
||||
value={[selectedOption.value]}
|
||||
onToggleOption={setSelectedOption}
|
||||
>
|
||||
<ComboboxPopup>
|
||||
@@ -108,7 +106,7 @@ export const Groups = () => {
|
||||
<ComboboxRoot
|
||||
defaultOpen
|
||||
options={groupedOptions}
|
||||
selectedOptions={[selectedOption.value]}
|
||||
value={[selectedOption.value]}
|
||||
onToggleOption={setSelectedOption}
|
||||
>
|
||||
<ComboboxPopup>
|
||||
@@ -121,7 +119,7 @@ export const Groups = () => {
|
||||
size="small"
|
||||
defaultOpen
|
||||
options={groupedOptions}
|
||||
selectedOptions={[selectedOption.value]}
|
||||
value={[selectedOption.value]}
|
||||
onToggleOption={setSelectedOption}
|
||||
>
|
||||
<ComboboxPopup>
|
||||
@@ -147,7 +145,7 @@ export const WithoutFilter = () => {
|
||||
options: options.slice(0, 3),
|
||||
},
|
||||
]}
|
||||
selectedOptions={[selectedOption.value]}
|
||||
value={[selectedOption.value]}
|
||||
onToggleOption={setSelectedOption}
|
||||
>
|
||||
<ComboboxPopup>
|
||||
@@ -158,7 +156,7 @@ export const WithoutFilter = () => {
|
||||
<ComboboxRoot
|
||||
defaultOpen
|
||||
options={options.slice(0, 3)}
|
||||
selectedOptions={[selectedOption.value]}
|
||||
value={[selectedOption.value]}
|
||||
onToggleOption={setSelectedOption}
|
||||
>
|
||||
<ComboboxPopup>
|
||||
@@ -170,20 +168,18 @@ export const WithoutFilter = () => {
|
||||
};
|
||||
|
||||
export const CustomOptionRendering = () => {
|
||||
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
|
||||
"opt-1",
|
||||
]);
|
||||
const [value, setValue] = useState<MyOption["value"][]>(["opt-1"]);
|
||||
|
||||
return (
|
||||
<ComboboxRoot
|
||||
defaultOpen
|
||||
options={options}
|
||||
selectedOptions={selectedOptions}
|
||||
value={value}
|
||||
onToggleOption={(option, isSelected) => {
|
||||
setSelectedOptions(
|
||||
setValue(
|
||||
isSelected
|
||||
? [...selectedOptions, option.value]
|
||||
: selectedOptions.filter((v) => v !== option.value),
|
||||
? [...value, option.value]
|
||||
: value.filter((v) => v !== option.value),
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -212,7 +208,7 @@ export const CustomOptionRenderingGroups = () => {
|
||||
<ComboboxRoot
|
||||
defaultOpen
|
||||
options={groupedOptions}
|
||||
selectedOptions={[selectedOption.value]}
|
||||
value={[selectedOption.value]}
|
||||
onToggleOption={setSelectedOption}
|
||||
>
|
||||
<ComboboxPopup>
|
||||
@@ -239,9 +235,7 @@ 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 [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
|
||||
"opt-1",
|
||||
]);
|
||||
const [value, setValue] = useState<MyOption["value"][]>(["opt-1"]);
|
||||
const [filterString, setFilterString] = useState("");
|
||||
const filterStringLowerCase = filterString.toLocaleLowerCase();
|
||||
|
||||
@@ -266,9 +260,9 @@ export const ControlledInput = () => {
|
||||
<ComboboxRoot
|
||||
defaultOpen
|
||||
options={filteredOptions}
|
||||
selectedOptions={selectedOptions}
|
||||
value={value}
|
||||
onToggleOption={(option) => {
|
||||
setSelectedOptions((prev) =>
|
||||
setValue((prev) =>
|
||||
prev.includes(option.value)
|
||||
? prev.filter((v) => v !== option.value)
|
||||
: [...prev, option.value],
|
||||
@@ -290,7 +284,7 @@ export const States: StoryFn = () => {
|
||||
<ComboboxRoot
|
||||
defaultOpen
|
||||
options={options.slice(0, 3)}
|
||||
selectedOptions={[selectedOption.value]}
|
||||
value={[selectedOption.value]}
|
||||
onToggleOption={setSelectedOption}
|
||||
>
|
||||
<ComboboxPopup>
|
||||
|
||||
@@ -7,6 +7,7 @@ interface ComboboxPopupContextProps {
|
||||
virtuallyFocusedOptionValue: string;
|
||||
filterString: string;
|
||||
setFilterString: (value: string) => void;
|
||||
setAnnounceText: (text: string) => void;
|
||||
}
|
||||
|
||||
export const {
|
||||
@@ -27,6 +28,7 @@ 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() {
|
||||
@@ -82,8 +84,12 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
|
||||
virtuallyFocusedOptionValue={virtuallyFocusedOptionValue}
|
||||
filterString={filterString}
|
||||
setFilterString={setFilterString}
|
||||
setAnnounceText={setAnnounceText}
|
||||
>
|
||||
{children}
|
||||
<div className="aksel-sr-only" role="status">
|
||||
{announceText}
|
||||
</div>
|
||||
</ComboboxPopupContextProvider>
|
||||
</Listbox>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,26 @@ 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>,
|
||||
@@ -28,9 +48,7 @@ export interface ComboboxRootProps<
|
||||
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData> =
|
||||
ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
|
||||
>
|
||||
extends
|
||||
Pick<FormFieldProps, "disabled">,
|
||||
React.HTMLAttributes<HTMLDivElement> {
|
||||
extends Pick<FormFieldProps, "disabled" | "size">, HtmlAttributes {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Can be either an array of options ({@link ComboboxOptionData})
|
||||
@@ -40,14 +58,25 @@ export interface ComboboxRootProps<
|
||||
* put the single options first.
|
||||
*/
|
||||
options: T[];
|
||||
selectedOptions: ResolveOption<T>["value"][]; // TODO: Vurder navn: value(s)
|
||||
onToggleOption: ComboboxOptionProps<ResolveOption<T>>["onToggleOption"];
|
||||
defaultOpen?: boolean;
|
||||
multiselect?: boolean; // TODO: Hva skal være default? Vurder navn
|
||||
/**
|
||||
* @default "medium"
|
||||
* Value of the selected options.
|
||||
*/
|
||||
size?: "small" | "medium";
|
||||
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.
|
||||
*/
|
||||
onToggleOption: ComboboxOptionProps<ResolveOption<T>>["onToggleOption"];
|
||||
/**
|
||||
* Whether the combobox is open by default.
|
||||
*/
|
||||
defaultOpen?: boolean;
|
||||
/**
|
||||
* Whether multiple options can be selected.
|
||||
* @default true
|
||||
*/
|
||||
multiple?: boolean; // TODO: Hva skal være default?
|
||||
}
|
||||
|
||||
export interface ComboboxRootContextProps {
|
||||
@@ -55,9 +84,9 @@ export interface ComboboxRootContextProps {
|
||||
setOpen: (newOpen: boolean) => void;
|
||||
triggerRef: React.RefObject<HTMLDivElement | null>;
|
||||
options: ComboboxRootProps["options"];
|
||||
selectedOptions: ComboboxRootProps["selectedOptions"];
|
||||
value: ComboboxRootProps["value"];
|
||||
onToggleOption: ComboboxRootProps["onToggleOption"];
|
||||
multiselect: boolean;
|
||||
multiple: boolean;
|
||||
size: Exclude<ComboboxRootProps["size"], undefined>;
|
||||
disabled: ComboboxRootProps["disabled"];
|
||||
}
|
||||
@@ -75,10 +104,10 @@ export function ComboboxRoot<
|
||||
>({
|
||||
children,
|
||||
options,
|
||||
selectedOptions,
|
||||
value,
|
||||
onToggleOption,
|
||||
defaultOpen = false,
|
||||
multiselect = true,
|
||||
multiple = true,
|
||||
size = "medium",
|
||||
disabled,
|
||||
className,
|
||||
@@ -99,11 +128,11 @@ export function ComboboxRoot<
|
||||
setOpen={setOpen}
|
||||
triggerRef={triggerRef}
|
||||
options={options}
|
||||
selectedOptions={selectedOptions}
|
||||
value={value}
|
||||
onToggleOption={
|
||||
onToggleOption as ComboboxRootContextProps["onToggleOption"]
|
||||
}
|
||||
multiselect={multiselect}
|
||||
multiple={multiple}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
@@ -29,7 +29,10 @@ const ComboboxTrigger = ({
|
||||
if (!readOnly && !rootContext.open) event.preventDefault();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
if (
|
||||
!readOnly &&
|
||||
(event.key === "ArrowDown" || event.key === "ArrowUp")
|
||||
) {
|
||||
rootContext.setOpen(true);
|
||||
// Stop keydown from scrolling window
|
||||
event.preventDefault();
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface FormFieldProps {
|
||||
errorId?: string;
|
||||
/**
|
||||
* Changes font-size, padding and gaps.
|
||||
* @default "medium"
|
||||
*/
|
||||
size?: "medium" | "small";
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
LinkCardIcon,
|
||||
LinkCardImage,
|
||||
LinkCardTitle,
|
||||
} from "./LinkCard";
|
||||
} from "./index";
|
||||
|
||||
type Story = StoryObj<typeof LinkCard>;
|
||||
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
LinkAnchor,
|
||||
type LinkAnchorProps,
|
||||
} from "../../utils/components/link-anchor";
|
||||
|
||||
type LinkCardAnchorProps = LinkAnchorProps;
|
||||
|
||||
/**
|
||||
* @see 🏷️ {@link LinkCardAnchorProps}
|
||||
*/
|
||||
const LinkCardAnchor = LinkAnchor;
|
||||
|
||||
export { LinkCardAnchor };
|
||||
export type { LinkCardAnchorProps };
|
||||
@@ -0,0 +1,32 @@
|
||||
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 };
|
||||
@@ -0,0 +1,29 @@
|
||||
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 };
|
||||
@@ -0,0 +1,27 @@
|
||||
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 };
|
||||
@@ -0,0 +1,46 @@
|
||||
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 };
|
||||
@@ -7,7 +7,7 @@ export {
|
||||
LinkCardAnchor,
|
||||
LinkCardIcon,
|
||||
LinkCardImage,
|
||||
} from "./LinkCard";
|
||||
} from "./root/LinkCardRoot";
|
||||
|
||||
export type {
|
||||
LinkCardProps,
|
||||
@@ -17,4 +17,4 @@ export type {
|
||||
LinkCardAnchorProps,
|
||||
LinkCardIconProps,
|
||||
LinkCardImageProps,
|
||||
} from "./LinkCard";
|
||||
} from "./root/LinkCardRoot";
|
||||
@@ -0,0 +1,14 @@
|
||||
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 };
|
||||
@@ -0,0 +1,177 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
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 };
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { Box } from "../primitives/box";
|
||||
import { VStack } from "../primitives/stack";
|
||||
import { BodyLong } from "../typography";
|
||||
import List from "./List";
|
||||
import { List } from "./index";
|
||||
|
||||
export default {
|
||||
title: "ds-react/List",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface ListProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
interface ListProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* HTML list element to render.
|
||||
@@ -16,7 +16,7 @@ export interface ListProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
size?: "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
export interface ListItemProps extends React.HTMLAttributes<HTMLLIElement> {
|
||||
interface ListItemProps extends React.HTMLAttributes<HTMLLIElement> {
|
||||
/**
|
||||
* List item content.
|
||||
*/
|
||||
@@ -30,3 +30,5 @@ export interface ListItemProps extends React.HTMLAttributes<HTMLLIElement> {
|
||||
*/
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export type { ListProps, ListItemProps };
|
||||
@@ -1,3 +1,3 @@
|
||||
"use client";
|
||||
export { default as List, ListItem } from "./List";
|
||||
export type { ListProps, ListItemProps } from "./List";
|
||||
export { List, ListItem } from "./root/ListRoot";
|
||||
export type { ListProps, ListItemProps } from "./root/ListRoot";
|
||||
+9
-7
@@ -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 { ListContext } from "./List.context";
|
||||
import type { ListItemProps } from "./List.types";
|
||||
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";
|
||||
|
||||
/**
|
||||
* @see 🏷️ {@link ListItemProps}
|
||||
*/
|
||||
export const ListItem = forwardRef<HTMLLIElement, ListItemProps>(
|
||||
const ListItem = forwardRef<HTMLLIElement, ListItemProps>(
|
||||
({ className, children, title, icon, ...rest }, ref) => {
|
||||
const { listType, size } = useContext(ListContext);
|
||||
|
||||
@@ -57,4 +57,6 @@ export const ListItem = forwardRef<HTMLLIElement, ListItemProps>(
|
||||
);
|
||||
|
||||
ListItem.displayName = "List.Item";
|
||||
export default ListItem;
|
||||
|
||||
export { ListItem };
|
||||
export type { ListItemProps };
|
||||
+4
-2
@@ -1,12 +1,14 @@
|
||||
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>;
|
||||
}
|
||||
|
||||
export const ListContext = createContext<ListContextProps>({
|
||||
const ListContext = createContext<ListContextProps>({
|
||||
listType: "ul",
|
||||
size: "medium",
|
||||
});
|
||||
|
||||
export { ListContext };
|
||||
+29
-34
@@ -1,35 +1,11 @@
|
||||
import React, { forwardRef, useContext } from "react";
|
||||
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";
|
||||
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";
|
||||
|
||||
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>(
|
||||
const ListRoot = forwardRef<HTMLDivElement, ListProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
@@ -71,10 +47,29 @@ export const List = forwardRef<HTMLDivElement, ListProps>(
|
||||
</ListContext.Provider>
|
||||
);
|
||||
},
|
||||
) as ListComponent;
|
||||
);
|
||||
|
||||
List.Item = 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>
|
||||
* ```
|
||||
*/
|
||||
const List = Object.assign(ListRoot, {
|
||||
/**
|
||||
* @see 🏷️ {@link ListItemProps}
|
||||
*/
|
||||
Item: ListItem,
|
||||
});
|
||||
|
||||
export default List;
|
||||
export { ListItem };
|
||||
export { List, ListItem };
|
||||
export type { ListProps, ListItemProps };
|
||||
@@ -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 "./Modal";
|
||||
import { Modal } from "./index";
|
||||
|
||||
const meta: Meta<typeof Modal> = {
|
||||
title: "ds-react/Modal",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { expectTypeOf } from "vitest";
|
||||
import type { ModalProps } from "./types";
|
||||
import type { ModalProps } from "./Modal.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
|
||||
export type ModalProps = ModalPropsBase &
|
||||
type ModalProps = ModalPropsBase &
|
||||
(
|
||||
| { onClose: ModalPropsBase["onClose"]; open: boolean | undefined }
|
||||
| { onClose?: ModalPropsBase["onClose"]; open?: never }
|
||||
@@ -117,3 +117,5 @@ export type ModalProps = ModalPropsBase &
|
||||
| { "aria-labelledby": string; "aria-label"?: never }
|
||||
| { "aria-labelledby"?: never; "aria-label": string }
|
||||
);
|
||||
|
||||
export type { ModalProps };
|
||||
@@ -1,57 +0,0 @@
|
||||
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 };
|
||||
+4
-3
@@ -1,7 +1,7 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { cl } from "../utils/helpers";
|
||||
import { cl } from "../../utils/helpers";
|
||||
|
||||
export interface ModalBodyProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
interface ModalBodyProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -13,4 +13,5 @@ const ModalBody = forwardRef<HTMLDivElement, ModalBodyProps>(
|
||||
},
|
||||
);
|
||||
|
||||
export default ModalBody;
|
||||
export { ModalBody };
|
||||
export type { ModalBodyProps };
|
||||
+4
-3
@@ -1,7 +1,7 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { cl } from "../utils/helpers";
|
||||
import { cl } from "../../utils/helpers";
|
||||
|
||||
export interface ModalFooterProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
interface ModalFooterProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -17,4 +17,5 @@ const ModalFooter = forwardRef<HTMLDivElement, ModalFooterProps>(
|
||||
},
|
||||
);
|
||||
|
||||
export default ModalFooter;
|
||||
export { ModalFooter };
|
||||
export type { ModalFooterProps };
|
||||
+7
-6
@@ -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 "./Modal.context";
|
||||
import { Button } from "../../button";
|
||||
import { cl } from "../../utils/helpers";
|
||||
import { useI18n } from "../../utils/i18n/i18n.hooks";
|
||||
import { useModalContext } from "../root/ModalRoot.context";
|
||||
|
||||
export interface ModalHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
interface ModalHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode;
|
||||
/**
|
||||
* Removes close-button (X) when false
|
||||
@@ -43,4 +43,5 @@ const ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(
|
||||
},
|
||||
);
|
||||
|
||||
export default ModalHeader;
|
||||
export { ModalHeader };
|
||||
export type { ModalHeaderProps };
|
||||
+6
-6
@@ -2,7 +2,7 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
|
||||
export const needPolyfill =
|
||||
const needPolyfill =
|
||||
typeof window !== "undefined" &&
|
||||
(window.HTMLDialogElement === undefined ||
|
||||
navigator.userAgent.includes("jsdom"));
|
||||
@@ -503,7 +503,10 @@ dialogPolyfillInfo.prototype = /** @type {HTMLDialogElement.prototype} */ {
|
||||
},
|
||||
};
|
||||
|
||||
var dialogPolyfill = {};
|
||||
var dialogPolyfill = {} as {
|
||||
registerDialog(dialog: HTMLDialogElement): void;
|
||||
forceRegisterDialog(dialog: HTMLDialogElement): void;
|
||||
};
|
||||
|
||||
dialogPolyfill.reposition = function (element) {
|
||||
var scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
|
||||
@@ -947,7 +950,4 @@ if (needPolyfill) {
|
||||
HTMLFormElement.prototype.submit = replacementFormSubmit;
|
||||
}
|
||||
|
||||
export default dialogPolyfill as {
|
||||
registerDialog(dialog: HTMLDialogElement): void;
|
||||
forceRegisterDialog(dialog: HTMLDialogElement): void;
|
||||
};
|
||||
export { dialogPolyfill, needPolyfill };
|
||||
@@ -0,0 +1,31 @@
|
||||
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 };
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
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";
|
||||
export { Modal, ModalBody, ModalFooter, ModalHeader } from "./root/ModalRoot";
|
||||
export type {
|
||||
ModalProps,
|
||||
ModalBodyProps,
|
||||
ModalFooterProps,
|
||||
ModalHeaderProps,
|
||||
} from "./root/ModalRoot";
|
||||
+4
-2
@@ -1,13 +1,15 @@
|
||||
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>;
|
||||
}
|
||||
|
||||
export const { Provider: ModalContextProvider, useContext: useModalContext } =
|
||||
const { Provider: ModalContextProvider, useContext: useModalContext } =
|
||||
createStrictContext<ModalContextProps>({
|
||||
name: "ModalContext",
|
||||
errorMessage: "<Modal.Header> must be used within a <Modal>",
|
||||
});
|
||||
|
||||
export { ModalContextProvider, useModalContext };
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import React, { useState } from "react";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { Button, Modal } from "..";
|
||||
import { Button } from "../../button";
|
||||
import { Modal } from "../index";
|
||||
|
||||
const Test = () => {
|
||||
const [open, setOpen] = useState(true);
|
||||
+78
-74
@@ -1,82 +1,28 @@
|
||||
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 { ModalContextProvider, useModalContext } from "./Modal.context";
|
||||
import ModalBody from "./ModalBody";
|
||||
import ModalFooter from "./ModalFooter";
|
||||
import ModalHeader from "./ModalHeader";
|
||||
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 {
|
||||
type MouseCoordinates,
|
||||
coordsAreInside,
|
||||
getCloseHandler,
|
||||
useIsModalOpen,
|
||||
} from "./ModalUtils";
|
||||
import dialogPolyfill, { needPolyfill } from "./dialog-polyfill";
|
||||
import type { ModalProps } from "./types";
|
||||
} from "../helpers/modal-utils";
|
||||
import { ModalContextProvider, useModalContext } from "./ModalRoot.context";
|
||||
import { useIsModalOpen } from "./useIsModalOpen";
|
||||
|
||||
const polyfillClassName = "aksel-modal--polyfilled";
|
||||
|
||||
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>(
|
||||
const ModalRoot = forwardRef<HTMLDialogElement, ModalProps>(
|
||||
(
|
||||
{
|
||||
header,
|
||||
@@ -285,10 +231,68 @@ export const Modal = forwardRef<HTMLDialogElement, ModalProps>(
|
||||
}
|
||||
return component;
|
||||
},
|
||||
) as ModalComponent;
|
||||
);
|
||||
|
||||
Modal.Header = ModalHeader;
|
||||
Modal.Body = ModalBody;
|
||||
Modal.Footer = 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>
|
||||
* ```
|
||||
*/
|
||||
const Modal = Object.assign(ModalRoot, {
|
||||
/**
|
||||
* @see 🏷️ {@link ModalHeaderProps}
|
||||
*/
|
||||
Header: ModalHeader,
|
||||
/**
|
||||
* @see 🏷️ {@link ModalBodyProps}
|
||||
*/
|
||||
Body: ModalBody,
|
||||
/**
|
||||
* @see 🏷️ {@link ModalFooterProps}
|
||||
*/
|
||||
Footer: ModalFooter,
|
||||
});
|
||||
|
||||
export default Modal;
|
||||
export { Modal, ModalBody, ModalFooter, ModalHeader };
|
||||
export type { ModalProps, ModalBodyProps, ModalFooterProps, ModalHeaderProps };
|
||||
@@ -0,0 +1,30 @@
|
||||
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,6 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "../button";
|
||||
import Popover from "./Popover";
|
||||
import { Popover } from "./root/PopoverRoot";
|
||||
|
||||
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 "./Popover";
|
||||
import { Popover } from "./root/PopoverRoot";
|
||||
|
||||
export default {
|
||||
title: "ds-react/Popover/tests",
|
||||
|
||||
+5
-4
@@ -1,11 +1,11 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { cl } from "../utils/helpers";
|
||||
import { cl } from "../../utils/helpers";
|
||||
|
||||
export interface PopoverContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
interface PopoverContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export type PopoverContentType = React.ForwardRefExoticComponent<
|
||||
type PopoverContentType = React.ForwardRefExoticComponent<
|
||||
PopoverContentProps & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
@@ -21,4 +21,5 @@ const PopoverContent: PopoverContentType = forwardRef(
|
||||
},
|
||||
);
|
||||
|
||||
export default PopoverContent;
|
||||
export { PopoverContent };
|
||||
export type { PopoverContentProps, PopoverContentType };
|
||||
@@ -1,6 +1,3 @@
|
||||
"use client";
|
||||
export { default as Popover, type PopoverProps } from "./Popover";
|
||||
export {
|
||||
default as PopoverContent,
|
||||
type PopoverContentProps,
|
||||
} from "./PopoverContent";
|
||||
export { Popover, PopoverContent } from "./root/PopoverRoot";
|
||||
export type { PopoverProps, PopoverContentProps } from "./root/PopoverRoot";
|
||||
+40
-37
@@ -9,14 +9,17 @@ import {
|
||||
useFloating,
|
||||
} from "@floating-ui/react";
|
||||
import React, { type HTMLAttributes, forwardRef } from "react";
|
||||
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";
|
||||
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";
|
||||
|
||||
export interface PopoverProps extends HTMLAttributes<HTMLDivElement> {
|
||||
interface PopoverProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Popover content
|
||||
*/
|
||||
@@ -74,33 +77,7 @@ export interface PopoverProps extends HTMLAttributes<HTMLDivElement> {
|
||||
flip?: boolean;
|
||||
}
|
||||
|
||||
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>(
|
||||
const PopoverRoot = forwardRef<HTMLDivElement, PopoverProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
@@ -186,7 +163,7 @@ export const Popover = forwardRef<HTMLDivElement, PopoverProps>(
|
||||
</DismissableLayer>
|
||||
);
|
||||
},
|
||||
) as PopoverComponent;
|
||||
);
|
||||
|
||||
const oppositeSideMap: Record<Side, Side> = {
|
||||
top: "bottom",
|
||||
@@ -214,6 +191,32 @@ function getOppositePlacement(placement: Placement): Placement[] {
|
||||
return [alignment ? `${oppositeSide}-${alignment}` : oppositeSide];
|
||||
}
|
||||
|
||||
Popover.Content = PopoverContent;
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
|
||||
export default Popover;
|
||||
export { Popover, PopoverContent };
|
||||
export type { PopoverProps, PopoverContentProps };
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
export interface TabsProps extends Omit<
|
||||
interface TabsProps extends Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"onChange" | "dir"
|
||||
> {
|
||||
@@ -43,3 +43,5 @@ export interface TabsProps extends Omit<
|
||||
*/
|
||||
fill?: boolean;
|
||||
}
|
||||
|
||||
export type { TabsProps };
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
export { default as Tabs, TabsTab, TabsList, TabsPanel } from "./Tabs";
|
||||
export { Tabs, TabsTab, TabsList, TabsPanel } from "./root/TabsRoot";
|
||||
export type {
|
||||
TabsProps,
|
||||
TabsTabProps,
|
||||
TabsListProps,
|
||||
TabsPanelProps,
|
||||
} from "./Tabs";
|
||||
} from "./root/TabsRoot";
|
||||
+9
-8
@@ -1,18 +1,18 @@
|
||||
import React, { forwardRef, useRef } from "react";
|
||||
import { cl, composeEventHandlers } from "../../../utils/helpers";
|
||||
import { useMergeRefs } from "../../../utils/hooks";
|
||||
import ScrollButton from "./ScrollButtons";
|
||||
import { cl, composeEventHandlers } from "../../utils/helpers";
|
||||
import { useMergeRefs } from "../../utils/hooks";
|
||||
import { TabsScrollButtonInternal } from "../scroll-button/TabsScrollButtonInternal";
|
||||
import { useScrollButtons } from "./useScrollButtons";
|
||||
import { useTabList } from "./useTabList";
|
||||
|
||||
export interface TabListProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
interface TabsListProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* <Tabs.Tab /> elements.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TabList = forwardRef<HTMLDivElement, TabListProps>(
|
||||
const TabsList = forwardRef<HTMLDivElement, TabsListProps>(
|
||||
({ className, onKeyDown, ...rest }, ref) => {
|
||||
const { onKeyDown: _onKeyDown } = useTabList();
|
||||
|
||||
@@ -24,7 +24,7 @@ export const TabList = forwardRef<HTMLDivElement, TabListProps>(
|
||||
return (
|
||||
<div className="aksel-tabs__tablist-wrapper">
|
||||
{scrollCtx.show && (
|
||||
<ScrollButton
|
||||
<TabsScrollButtonInternal
|
||||
dir="left"
|
||||
hidden={!scrollCtx.start}
|
||||
onClick={scrollCtx.scrollLeft}
|
||||
@@ -40,7 +40,7 @@ export const TabList = forwardRef<HTMLDivElement, TabListProps>(
|
||||
onKeyDown={composeEventHandlers(onKeyDown, _onKeyDown)}
|
||||
/>
|
||||
{scrollCtx.show && (
|
||||
<ScrollButton
|
||||
<TabsScrollButtonInternal
|
||||
dir="right"
|
||||
hidden={!scrollCtx.end}
|
||||
onClick={scrollCtx.scrollRight}
|
||||
@@ -51,4 +51,5 @@ export const TabList = forwardRef<HTMLDivElement, TabListProps>(
|
||||
},
|
||||
);
|
||||
|
||||
export default TabList;
|
||||
export { TabsList };
|
||||
export type { TabsListProps };
|
||||
+5
-5
@@ -1,10 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { debounce } from "../../../utils-external";
|
||||
import { ownerDocument } from "../../../utils/helpers";
|
||||
import { debounce } from "../../utils-external";
|
||||
import { ownerDocument } from "../../utils/helpers";
|
||||
|
||||
export function useScrollButtons(
|
||||
listRef: React.RefObject<HTMLDivElement | null>,
|
||||
) {
|
||||
function useScrollButtons(listRef: React.RefObject<HTMLDivElement | null>) {
|
||||
const [displayScroll, setDisplayScroll] = useState({
|
||||
start: false,
|
||||
end: false,
|
||||
@@ -70,3 +68,5 @@ export function useScrollButtons(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { useScrollButtons };
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { rovingFocus } from "../../../utils/helpers/roving-focus";
|
||||
import { useTabsContext } from "../../Tabs.context";
|
||||
import { rovingFocus } from "../../utils/helpers/roving-focus";
|
||||
import { useTabsContext } from "../root/TabsRoot.context";
|
||||
|
||||
const TAB_SELECTOR = "[data-aksel-tab]:not([data-disabled])";
|
||||
|
||||
@@ -8,7 +8,7 @@ const TAB_SELECTOR = "[data-aksel-tab]:not([data-disabled])";
|
||||
* TabList hook to manage multiple tab buttons,
|
||||
* and ensures only one tab is selected at a time.
|
||||
*/
|
||||
export function useTabList() {
|
||||
function useTabList() {
|
||||
const { loop, selectedValue, setFocusedValue } = useTabsContext();
|
||||
|
||||
/**
|
||||
@@ -52,3 +52,5 @@ export function useTabList() {
|
||||
|
||||
return { onKeyDown };
|
||||
}
|
||||
|
||||
export { useTabList };
|
||||
+5
-4
@@ -1,8 +1,8 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { cl } from "../../../utils/helpers";
|
||||
import { cl } from "../../utils/helpers";
|
||||
import { useTabPanel } from "./useTabPanel";
|
||||
|
||||
export interface TabPanelProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
interface TabsPanelProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Tab panel content.
|
||||
*/
|
||||
@@ -25,7 +25,7 @@ export interface TabPanelProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const TabPanel = forwardRef<HTMLDivElement, TabPanelProps>(
|
||||
const TabsPanel = forwardRef<HTMLDivElement, TabsPanelProps>(
|
||||
({ className, value, children, lazy = true, id, ...rest }, ref) => {
|
||||
const ctx = useTabPanel({ value });
|
||||
|
||||
@@ -48,4 +48,5 @@ const TabPanel = forwardRef<HTMLDivElement, TabPanelProps>(
|
||||
},
|
||||
);
|
||||
|
||||
export default TabPanel;
|
||||
export { TabsPanel };
|
||||
export type { TabsPanelProps };
|
||||
+5
-3
@@ -1,13 +1,13 @@
|
||||
import { useTabsContext } from "../../Tabs.context";
|
||||
import { useTabsContext } from "../root/TabsRoot.context";
|
||||
|
||||
type TabPanelProps = {
|
||||
type UseTabPanelProps = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tabs hook for managing the visible/hidden state of Tabs.Panel
|
||||
*/
|
||||
export function useTabPanel({ value }: TabPanelProps) {
|
||||
function useTabPanel({ value }: UseTabPanelProps) {
|
||||
const { id, selectedValue, makeTabId, makeTabPanelId } = useTabsContext();
|
||||
|
||||
return {
|
||||
@@ -16,3 +16,5 @@ export function useTabPanel({ value }: TabPanelProps) {
|
||||
id: makeTabPanelId(id, value),
|
||||
};
|
||||
}
|
||||
|
||||
export { useTabPanel };
|
||||
+5
-3
@@ -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,9 +9,11 @@ type TabsProviderProps = ReturnType<typeof useTabs> &
|
||||
>;
|
||||
|
||||
/* State context */
|
||||
export const { Provider: TabsProvider, useContext: useTabsContext } =
|
||||
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 };
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { Tabs } from "./Tabs";
|
||||
import { Tabs } from "./TabsRoot";
|
||||
|
||||
const TestTabs = ({
|
||||
value,
|
||||
+51
-63
@@ -1,57 +1,13 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { cl } from "../utils/helpers";
|
||||
import { TabsProvider } from "./Tabs.context";
|
||||
import type { TabsProps } from "./Tabs.types";
|
||||
import Tab, { type TabProps } from "./parts/tab/Tab";
|
||||
import TabList, { type TabListProps } from "./parts/tablist/TabList";
|
||||
import TabPanel, { type TabPanelProps } from "./parts/tabpanel/TabPanel";
|
||||
import { cl } from "../../utils/helpers";
|
||||
import type { TabsProps } from "../Tabs.types";
|
||||
import { TabsList, type TabsListProps } from "../list/TabsList";
|
||||
import { TabsPanel, type TabsPanelProps } from "../panel/TabsPanel";
|
||||
import { TabsTab, type TabsTabProps } from "../tab/TabsTab";
|
||||
import { TabsProvider } from "./TabsRoot.context";
|
||||
import { useTabs } from "./useTabs";
|
||||
|
||||
interface TabsComponent extends React.ForwardRefExoticComponent<
|
||||
TabsProps & React.RefAttributes<HTMLDivElement>
|
||||
> {
|
||||
/**
|
||||
* @see 🏷️ {@link TabProps}
|
||||
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
|
||||
*/
|
||||
Tab: typeof Tab;
|
||||
/**
|
||||
* @see 🏷️ {@link TabListProps}
|
||||
*/
|
||||
List: typeof TabList;
|
||||
/**
|
||||
* @see 🏷️ {@link TabPanelProps}
|
||||
*/
|
||||
Panel: typeof TabPanel;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that displays a set of tabs that can be used to switch between different views.
|
||||
*
|
||||
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/tabs)
|
||||
* @see 🏷️ {@link TabsProps}
|
||||
*
|
||||
* @example
|
||||
* ```jsx
|
||||
* <Tabs defaultValue="logg">
|
||||
* <Tabs.List>
|
||||
* <Tabs.Tab value="logg" label="Logg" />
|
||||
* <Tabs.Tab value="inbox" label="Inbox" />
|
||||
* <Tabs.Tab value="sendt" label="Sendt" />
|
||||
* </Tabs.List>
|
||||
* <Tabs.Panel value="logg">
|
||||
* Logg-tab
|
||||
* </Tabs.Panel>
|
||||
* <Tabs.Panel value="inbox">
|
||||
* Inbox-tab
|
||||
* </Tabs.Panel>
|
||||
* <Tabs.Panel value="sendt">
|
||||
* Sendt-tab
|
||||
* </Tabs.Panel>
|
||||
* </Tabs>
|
||||
* ```
|
||||
*/
|
||||
export const Tabs = forwardRef<HTMLDivElement, TabsProps>(
|
||||
const TabsRoot = forwardRef<HTMLDivElement, TabsProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
@@ -96,17 +52,49 @@ export const Tabs = forwardRef<HTMLDivElement, TabsProps>(
|
||||
</TabsProvider>
|
||||
);
|
||||
},
|
||||
) as TabsComponent;
|
||||
);
|
||||
|
||||
Tabs.Tab = Tab;
|
||||
Tabs.List = TabList;
|
||||
Tabs.Panel = TabPanel;
|
||||
/**
|
||||
* A component that displays a set of tabs that can be used to switch between different views.
|
||||
*
|
||||
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/tabs)
|
||||
* @see 🏷️ {@link TabsProps}
|
||||
*
|
||||
* @example
|
||||
* ```jsx
|
||||
* <Tabs defaultValue="logg">
|
||||
* <Tabs.List>
|
||||
* <Tabs.Tab value="logg" label="Logg" />
|
||||
* <Tabs.Tab value="inbox" label="Inbox" />
|
||||
* <Tabs.Tab value="sendt" label="Sendt" />
|
||||
* </Tabs.List>
|
||||
* <Tabs.Panel value="logg">
|
||||
* Logg-tab
|
||||
* </Tabs.Panel>
|
||||
* <Tabs.Panel value="inbox">
|
||||
* Inbox-tab
|
||||
* </Tabs.Panel>
|
||||
* <Tabs.Panel value="sendt">
|
||||
* Sendt-tab
|
||||
* </Tabs.Panel>
|
||||
* </Tabs>
|
||||
* ```
|
||||
*/
|
||||
const Tabs = Object.assign(TabsRoot, {
|
||||
/**
|
||||
* @see 🏷️ {@link TabsTabProps}
|
||||
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
|
||||
*/
|
||||
Tab: TabsTab,
|
||||
/**
|
||||
* @see 🏷️ {@link TabsListProps}
|
||||
*/
|
||||
List: TabsList,
|
||||
/**
|
||||
* @see 🏷️ {@link TabsPanelProps}
|
||||
*/
|
||||
Panel: TabsPanel,
|
||||
});
|
||||
|
||||
export default Tabs;
|
||||
export { Tab as TabsTab, TabList as TabsList, TabPanel as TabsPanel };
|
||||
export type {
|
||||
TabsProps,
|
||||
TabProps as TabsTabProps,
|
||||
TabListProps as TabsListProps,
|
||||
TabPanelProps as TabsPanelProps,
|
||||
};
|
||||
export { Tabs, TabsTab, TabsList, TabsPanel };
|
||||
export type { TabsProps, TabsTabProps, TabsListProps, TabsPanelProps };
|
||||
+6
-4
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useId } from "../utils-external";
|
||||
import { useControllableState } from "../utils/hooks";
|
||||
import type { TabsProps } from "./Tabs.types";
|
||||
import { useId } from "../../utils-external";
|
||||
import { useControllableState } from "../../utils/hooks";
|
||||
import type { TabsProps } from "../Tabs.types";
|
||||
|
||||
export function useTabs({
|
||||
function useTabs({
|
||||
onChange,
|
||||
value,
|
||||
defaultValue = "",
|
||||
@@ -45,3 +45,5 @@ function makeTabId(id: string, value: string) {
|
||||
function makeTabPanelId(id: string, value: string) {
|
||||
return `${id}--tabpanel-${value}`;
|
||||
}
|
||||
|
||||
export { useTabs };
|
||||
+9
-4
@@ -1,14 +1,18 @@
|
||||
import React from "react";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@navikt/aksel-icons";
|
||||
import { cl } from "../../../utils/helpers";
|
||||
import { cl } from "../../utils/helpers";
|
||||
|
||||
interface ScrollButtonProps {
|
||||
interface TabsScrollButtonInternalProps {
|
||||
hidden: boolean;
|
||||
onClick: () => void;
|
||||
dir: "left" | "right";
|
||||
}
|
||||
|
||||
function ScrollButton({ hidden, onClick, dir }: ScrollButtonProps) {
|
||||
function TabsScrollButtonInternal({
|
||||
hidden,
|
||||
onClick,
|
||||
dir,
|
||||
}: TabsScrollButtonInternalProps) {
|
||||
return (
|
||||
<div
|
||||
className={cl("aksel-tabs__scroll-button", {
|
||||
@@ -22,4 +26,5 @@ function ScrollButton({ hidden, onClick, dir }: ScrollButtonProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default ScrollButton;
|
||||
export { TabsScrollButtonInternal };
|
||||
export type { TabsScrollButtonInternalProps };
|
||||
+8
-7
@@ -1,11 +1,11 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { BodyShort } from "../../../typography";
|
||||
import type { OverridableComponent } from "../../../utils-external";
|
||||
import { cl } from "../../../utils/helpers";
|
||||
import { useTabsContext } from "../../Tabs.context";
|
||||
import { BodyShort } from "../../typography";
|
||||
import type { OverridableComponent } from "../../utils-external";
|
||||
import { cl } from "../../utils/helpers";
|
||||
import { useTabsContext } from "../root/TabsRoot.context";
|
||||
import { useTab } from "./useTab";
|
||||
|
||||
export interface TabProps extends Omit<
|
||||
interface TabsTabProps extends Omit<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"children"
|
||||
> {
|
||||
@@ -30,7 +30,7 @@ export interface TabProps extends Omit<
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const Tab: OverridableComponent<TabProps, HTMLButtonElement> =
|
||||
const TabsTab: OverridableComponent<TabsTabProps, HTMLButtonElement> =
|
||||
forwardRef(
|
||||
(
|
||||
{
|
||||
@@ -94,4 +94,5 @@ export const Tab: OverridableComponent<TabProps, HTMLButtonElement> =
|
||||
},
|
||||
);
|
||||
|
||||
export default Tab;
|
||||
export { TabsTab };
|
||||
export type { TabsTabProps };
|
||||
+4
-3
@@ -1,7 +1,7 @@
|
||||
import { composeEventHandlers } from "../../../utils/helpers";
|
||||
import { useTabsContext } from "../../Tabs.context";
|
||||
import { composeEventHandlers } from "../../utils/helpers";
|
||||
import { useTabsContext } from "../root/TabsRoot.context";
|
||||
|
||||
export interface UseTabProps {
|
||||
interface UseTabProps {
|
||||
id?: string;
|
||||
/**
|
||||
* If `true`, the `Tab` won't be toggleable
|
||||
@@ -56,3 +56,4 @@ function useTab({
|
||||
}
|
||||
|
||||
export { useTab };
|
||||
export type { UseTabProps };
|
||||
@@ -1,23 +0,0 @@
|
||||
import { createContext as ReactCreateContext } from "react";
|
||||
import { createStrictContext } from "../utils/helpers";
|
||||
import type { ToggleGroupProps } from "./ToggleGroup.types";
|
||||
import type { useToggleGroup } from "./useToggleGroup";
|
||||
|
||||
interface ToggleContextProps {
|
||||
size: "medium" | "small";
|
||||
}
|
||||
|
||||
export const ToggleGroupContext = ReactCreateContext<ToggleContextProps | null>(
|
||||
null,
|
||||
);
|
||||
type ToggleGroupProviderProps = ReturnType<typeof useToggleGroup> &
|
||||
Pick<ToggleGroupProps, "size">;
|
||||
|
||||
/* State context */
|
||||
export const {
|
||||
Provider: ToggleGroupProvider,
|
||||
useContext: useToggleGroupContext,
|
||||
} = createStrictContext<ToggleGroupProviderProps>({
|
||||
name: "ToggleGroupContext",
|
||||
errorMessage: "<ToggleGroup.Item> needs to be wrapped within <ToggleGroup>",
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Meta, StoryFn, StoryObj } from "@storybook/react-vite";
|
||||
import type { Meta, StoryFn } from "@storybook/react-vite";
|
||||
import React, { useState } from "react";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import {
|
||||
EnvelopeClosedIcon,
|
||||
EnvelopeOpenIcon,
|
||||
@@ -9,8 +8,8 @@ import {
|
||||
} from "@navikt/aksel-icons";
|
||||
import { VStack } from "../primitives/stack";
|
||||
import { renderStoriesForChromatic } from "../utils/renderStoriesForChromatic";
|
||||
import ToggleGroup from "./ToggleGroup";
|
||||
import type { ToggleGroupProps } from "./ToggleGroup.types";
|
||||
import { ToggleGroup } from "./index";
|
||||
|
||||
const meta: Meta<typeof ToggleGroup> = {
|
||||
title: "ds-react/ToggleGroup",
|
||||
@@ -22,8 +21,6 @@ const meta: Meta<typeof ToggleGroup> = {
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ToggleGroup>;
|
||||
|
||||
const Items = (icon?: boolean, both?: boolean) => {
|
||||
const hasLabel = both || !icon;
|
||||
|
||||
@@ -147,62 +144,35 @@ export const Small = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const PlayControlled: Story = {
|
||||
render: () => {
|
||||
const [activeValue, setActiveValue] = useState("ny");
|
||||
export const XSmall = () => {
|
||||
const [activeValue, setActiveValue] = useState("ulest");
|
||||
|
||||
return (
|
||||
<ToggleGroup value={activeValue} onChange={setActiveValue}>
|
||||
return (
|
||||
<VStack gap="space-24">
|
||||
<ToggleGroup size="xsmall" value={activeValue} onChange={setActiveValue}>
|
||||
{Items()}
|
||||
</ToggleGroup>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const initialToggle = canvas.getByText("Ny").closest("button");
|
||||
|
||||
expect(initialToggle).toHaveAttribute("aria-checked", "true");
|
||||
expect(initialToggle).toHaveAttribute("tabindex", "0");
|
||||
initialToggle?.focus();
|
||||
expect(initialToggle).toHaveFocus();
|
||||
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
const nextToggle = canvas.getByText("Leste").closest("button");
|
||||
expect(nextToggle).toHaveFocus();
|
||||
expect(nextToggle).toHaveAttribute("aria-checked", "false");
|
||||
expect(nextToggle).toHaveAttribute("tabindex", "0");
|
||||
|
||||
expect(initialToggle).toHaveAttribute("aria-checked", "true");
|
||||
expect(initialToggle).toHaveAttribute("tabindex", "-1");
|
||||
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(nextToggle).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
await userEvent.keyboard("{ArrowLeft}");
|
||||
await userEvent.keyboard("{ArrowLeft}");
|
||||
const firstToggle = canvas.getByText("Uleste").closest("button");
|
||||
expect(firstToggle).toHaveFocus();
|
||||
expect(firstToggle).toHaveAttribute("aria-checked", "false");
|
||||
expect(firstToggle).toHaveAttribute("tabindex", "0");
|
||||
|
||||
expect(nextToggle).toHaveAttribute("aria-checked", "true");
|
||||
expect(nextToggle).toHaveAttribute("tabindex", "-1");
|
||||
},
|
||||
};
|
||||
|
||||
export const PlayUnControlled: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<ToggleGroup defaultValue="ny" onChange={() => null}>
|
||||
{Items()}
|
||||
<ToggleGroup size="xsmall" value={activeValue} onChange={setActiveValue}>
|
||||
{Items(true, true)}
|
||||
</ToggleGroup>
|
||||
);
|
||||
},
|
||||
play: PlayControlled.play,
|
||||
<ToggleGroup size="xsmall" value={activeValue} onChange={setActiveValue}>
|
||||
{Items(true)}
|
||||
</ToggleGroup>
|
||||
<ToggleGroup
|
||||
size="xsmall"
|
||||
fill
|
||||
value={activeValue}
|
||||
onChange={setActiveValue}
|
||||
>
|
||||
{Items(true)}
|
||||
</ToggleGroup>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export const Chromatic = renderStoriesForChromatic({
|
||||
Label,
|
||||
Compositions,
|
||||
Small,
|
||||
XSmall,
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React, { useState } from "react";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { ToggleGroup } from "./index";
|
||||
|
||||
const meta: Meta<typeof ToggleGroup> = {
|
||||
title: "ds-react/ToggleGroup/Tests",
|
||||
component: ToggleGroup,
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ToggleGroup>;
|
||||
|
||||
const Items = () => (
|
||||
<>
|
||||
<ToggleGroup.Item value="ulest" label="Uleste" />
|
||||
<ToggleGroup.Item value="ny" label="Ny" />
|
||||
<ToggleGroup.Item value="lest" label="Leste" />
|
||||
<ToggleGroup.Item value="sendt" label="Sendt" />
|
||||
</>
|
||||
);
|
||||
|
||||
export const PlayControlled: Story = {
|
||||
render: () => {
|
||||
const [activeValue, setActiveValue] = useState("ny");
|
||||
|
||||
return (
|
||||
<ToggleGroup value={activeValue} onChange={setActiveValue}>
|
||||
<Items />
|
||||
</ToggleGroup>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const initialToggle = canvas.getByRole("radio", { name: "Ny" });
|
||||
|
||||
expect(initialToggle).toHaveAttribute("aria-checked", "true");
|
||||
expect(initialToggle).toHaveAttribute("tabindex", "0");
|
||||
initialToggle.focus();
|
||||
expect(initialToggle).toHaveFocus();
|
||||
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
const nextToggle = canvas.getByRole("radio", { name: "Leste" });
|
||||
expect(nextToggle).toHaveFocus();
|
||||
expect(nextToggle).toHaveAttribute("aria-checked", "false");
|
||||
expect(nextToggle).toHaveAttribute("tabindex", "0");
|
||||
|
||||
expect(initialToggle).toHaveAttribute("aria-checked", "true");
|
||||
expect(initialToggle).toHaveAttribute("tabindex", "-1");
|
||||
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(nextToggle).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
await userEvent.keyboard("{ArrowLeft}");
|
||||
await userEvent.keyboard("{ArrowLeft}");
|
||||
const firstToggle = canvas.getByRole("radio", { name: "Uleste" });
|
||||
expect(firstToggle).toHaveFocus();
|
||||
expect(firstToggle).toHaveAttribute("aria-checked", "false");
|
||||
expect(firstToggle).toHaveAttribute("tabindex", "0");
|
||||
|
||||
expect(nextToggle).toHaveAttribute("aria-checked", "true");
|
||||
expect(nextToggle).toHaveAttribute("tabindex", "-1");
|
||||
},
|
||||
};
|
||||
|
||||
export const PlayUnControlled: Story = {
|
||||
render: () => (
|
||||
<ToggleGroup defaultValue="ny" onChange={() => null}>
|
||||
<Items />
|
||||
</ToggleGroup>
|
||||
),
|
||||
play: PlayControlled.play,
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import type { HTMLAttributes } from "react";
|
||||
import type { AkselStatusColorRole } from "@navikt/ds-tokens/types";
|
||||
import type { AkselColor } from "../types";
|
||||
|
||||
export interface ToggleGroupProps extends Omit<
|
||||
interface ToggleGroupProps extends Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"onChange" | "dir"
|
||||
> {
|
||||
@@ -14,7 +14,7 @@ export interface ToggleGroupProps extends Omit<
|
||||
* Changes padding and font-size.
|
||||
* @default "medium"
|
||||
*/
|
||||
size?: "medium" | "small";
|
||||
size?: "medium" | "small" | "xsmall";
|
||||
/**
|
||||
* Controlled selected value.
|
||||
*/
|
||||
@@ -49,3 +49,5 @@ export interface ToggleGroupProps extends Omit<
|
||||
*/
|
||||
fill?: boolean;
|
||||
}
|
||||
|
||||
export type { ToggleGroupProps };
|
||||
@@ -1,3 +1,6 @@
|
||||
"use client";
|
||||
export { default as ToggleGroup, ToggleGroupItem } from "./ToggleGroup";
|
||||
export type { ToggleGroupProps, ToggleGroupItemProps } from "./ToggleGroup";
|
||||
export { ToggleGroup, ToggleGroupItem } from "./root/ToggleGroupRoot";
|
||||
export type {
|
||||
ToggleGroupProps,
|
||||
ToggleGroupItemProps,
|
||||
} from "./root/ToggleGroupRoot";
|
||||
+7
-5
@@ -1,7 +1,7 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { BodyShort } from "../../typography/BodyShort";
|
||||
import { cl } from "../../utils/helpers";
|
||||
import { useToggleGroupContext } from "../ToggleGroup.context";
|
||||
import { useToggleGroupContext } from "../root/ToggleGroupRoot.context";
|
||||
import { useToggleItem } from "./useToggleItem";
|
||||
|
||||
type BaseProps = Omit<
|
||||
@@ -47,10 +47,10 @@ type IconProps = {
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
export type ToggleGroupItemProps = BaseProps &
|
||||
type ToggleGroupItemProps = BaseProps &
|
||||
(ChildrenProps | LabelProps | IconProps);
|
||||
|
||||
const ToggleItem = forwardRef<HTMLButtonElement, ToggleGroupItemProps>(
|
||||
const ToggleGroupItem = forwardRef<HTMLButtonElement, ToggleGroupItemProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
@@ -74,6 +74,7 @@ const ToggleItem = forwardRef<HTMLButtonElement, ToggleGroupItemProps>(
|
||||
onKeyDown,
|
||||
});
|
||||
const ctx = useToggleGroupContext();
|
||||
const size = ctx?.size === "xsmall" ? "small" : ctx?.size;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -86,7 +87,7 @@ const ToggleItem = forwardRef<HTMLButtonElement, ToggleGroupItemProps>(
|
||||
<BodyShort
|
||||
as="span"
|
||||
className="aksel-toggle-group__button-inner"
|
||||
size={ctx?.size}
|
||||
size={size}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
@@ -100,4 +101,5 @@ const ToggleItem = forwardRef<HTMLButtonElement, ToggleGroupItemProps>(
|
||||
},
|
||||
);
|
||||
|
||||
export default ToggleItem;
|
||||
export { ToggleGroupItem };
|
||||
export type { ToggleGroupItemProps };
|
||||
+6
-3
@@ -1,11 +1,11 @@
|
||||
import { useCallback } from "react";
|
||||
import { composeEventHandlers } from "../../utils/helpers";
|
||||
import { rovingFocus } from "../../utils/helpers/roving-focus";
|
||||
import { useToggleGroupContext } from "../ToggleGroup.context";
|
||||
import { useToggleGroupContext } from "../root/ToggleGroupRoot.context";
|
||||
|
||||
const TOGGLE_ITEM_SELECTOR = "[data-aksel-toggle-item]:not([data-disabled])";
|
||||
|
||||
export interface UseToggleItemProps {
|
||||
interface UseToggleItemProps {
|
||||
/**
|
||||
* If `true`, the `ToggleItem` won't be toggleable
|
||||
* @default false
|
||||
@@ -17,7 +17,7 @@ export interface UseToggleItemProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function useToggleItem<P extends UseToggleItemProps>({
|
||||
function useToggleItem<P extends UseToggleItemProps>({
|
||||
value,
|
||||
disabled = false,
|
||||
onFocus: _onFocus,
|
||||
@@ -92,3 +92,6 @@ export function useToggleItem<P extends UseToggleItemProps>({
|
||||
"data-disabled": disabled ? "" : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export { useToggleItem };
|
||||
export type { UseToggleItemProps };
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createContext as ReactCreateContext } from "react";
|
||||
import { createStrictContext } from "../../utils/helpers";
|
||||
import type { ToggleGroupProps } from "../ToggleGroup.types";
|
||||
import type { useToggleGroup } from "./useToggleGroup";
|
||||
|
||||
interface ToggleContextProps {
|
||||
size: "medium" | "small" | "xsmall";
|
||||
}
|
||||
|
||||
const ToggleGroupContext = ReactCreateContext<ToggleContextProps | null>(null);
|
||||
type ToggleGroupProviderProps = ReturnType<typeof useToggleGroup> &
|
||||
Pick<ToggleGroupProps, "size">;
|
||||
|
||||
/* State context */
|
||||
const { Provider: ToggleGroupProvider, useContext: useToggleGroupContext } =
|
||||
createStrictContext<ToggleGroupProviderProps>({
|
||||
name: "ToggleGroupContext",
|
||||
errorMessage: "<ToggleGroup.Item> needs to be wrapped within <ToggleGroup>",
|
||||
});
|
||||
|
||||
export { ToggleGroupContext, ToggleGroupProvider, useToggleGroupContext };
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { ToggleGroup } from "./ToggleGroup";
|
||||
import { ToggleGroup } from "./ToggleGroupRoot";
|
||||
|
||||
const TestToggleGroup = ({ value, onChange, defaultValue }: any) => (
|
||||
<ToggleGroup value={value} onChange={onChange} defaultValue={defaultValue}>
|
||||
+37
-37
@@ -1,38 +1,17 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import type { AkselColor } from "../types";
|
||||
import { Label } from "../typography";
|
||||
import { useId } from "../utils-external";
|
||||
import { cl } from "../utils/helpers";
|
||||
import { ToggleGroupProvider } from "./ToggleGroup.context";
|
||||
import type { ToggleGroupProps } from "./ToggleGroup.types";
|
||||
import ToggleItem, { type ToggleGroupItemProps } from "./parts/ToggleItem";
|
||||
import type { AkselColor } from "../../types";
|
||||
import { Label } from "../../typography";
|
||||
import { useId } from "../../utils-external";
|
||||
import { cl } from "../../utils/helpers";
|
||||
import type { ToggleGroupProps } from "../ToggleGroup.types";
|
||||
import {
|
||||
ToggleGroupItem,
|
||||
type ToggleGroupItemProps,
|
||||
} from "../item/ToggleGroupItem";
|
||||
import { ToggleGroupProvider } from "./ToggleGroupRoot.context";
|
||||
import { useToggleGroup } from "./useToggleGroup";
|
||||
|
||||
interface ToggleGroupComponent extends React.ForwardRefExoticComponent<
|
||||
ToggleGroupProps & React.RefAttributes<HTMLDivElement>
|
||||
> {
|
||||
/**
|
||||
* @see 🏷️ {@link ToggleGroupItemProps}
|
||||
*/
|
||||
Item: typeof ToggleItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that displays a group of toggle buttons.
|
||||
*
|
||||
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/toggle-group)
|
||||
* @see 🏷️ {@link ToggleGroupProps}
|
||||
*
|
||||
* @example
|
||||
* ```jsx
|
||||
* <ToggleGroup defaultValue="lest" onChange={console.log} size="small">
|
||||
* <ToggleGroup.Item value="ulest" label="Ulest" />
|
||||
* <ToggleGroup.Item value="lest" label="Leste" />
|
||||
* <ToggleGroup.Item value="sendt" label="Sendt" />
|
||||
* </ToggleGroup>
|
||||
* ```
|
||||
*/
|
||||
export const ToggleGroup = forwardRef<HTMLDivElement, ToggleGroupProps>(
|
||||
const ToggleGroupRoot = forwardRef<HTMLDivElement, ToggleGroupProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
@@ -65,6 +44,8 @@ export const ToggleGroup = forwardRef<HTMLDivElement, ToggleGroupProps>(
|
||||
|
||||
const labelId = useId();
|
||||
|
||||
const labelSize = size === "xsmall" ? "small" : size;
|
||||
|
||||
if (!value && !defaultValue) {
|
||||
console.error("ToggleGroup without value or defaultvalue is not allowed");
|
||||
}
|
||||
@@ -84,7 +65,7 @@ export const ToggleGroup = forwardRef<HTMLDivElement, ToggleGroupProps>(
|
||||
{label && (
|
||||
<Label
|
||||
as="div"
|
||||
size={size}
|
||||
size={labelSize}
|
||||
className="aksel-toggle-group__label"
|
||||
id={labelId}
|
||||
>
|
||||
@@ -105,7 +86,7 @@ export const ToggleGroup = forwardRef<HTMLDivElement, ToggleGroupProps>(
|
||||
</ToggleGroupProvider>
|
||||
);
|
||||
},
|
||||
) as ToggleGroupComponent;
|
||||
);
|
||||
|
||||
function variantToColor(
|
||||
variant?: ToggleGroupProps["variant"],
|
||||
@@ -120,8 +101,27 @@ function variantToColor(
|
||||
}
|
||||
}
|
||||
|
||||
ToggleGroup.Item = ToggleItem;
|
||||
/**
|
||||
* A component that displays a group of toggle buttons.
|
||||
*
|
||||
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/toggle-group)
|
||||
* @see 🏷️ {@link ToggleGroupProps}
|
||||
*
|
||||
* @example
|
||||
* ```jsx
|
||||
* <ToggleGroup defaultValue="lest" onChange={console.log} size="small">
|
||||
* <ToggleGroup.Item value="ulest" label="Ulest" />
|
||||
* <ToggleGroup.Item value="lest" label="Leste" />
|
||||
* <ToggleGroup.Item value="sendt" label="Sendt" />
|
||||
* </ToggleGroup>
|
||||
* ```
|
||||
*/
|
||||
const ToggleGroup = Object.assign(ToggleGroupRoot, {
|
||||
/**
|
||||
* @see 🏷️ {@link ToggleGroupItemProps}
|
||||
*/
|
||||
Item: ToggleGroupItem,
|
||||
});
|
||||
|
||||
export default ToggleGroup;
|
||||
export { ToggleItem as ToggleGroupItem };
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
export type { ToggleGroupProps, ToggleGroupItemProps };
|
||||
+5
-3
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useControllableState } from "../utils/hooks";
|
||||
import type { ToggleGroupProps } from "./ToggleGroup.types";
|
||||
import { useControllableState } from "../../utils/hooks";
|
||||
import type { ToggleGroupProps } from "../ToggleGroup.types";
|
||||
|
||||
export function useToggleGroup({
|
||||
function useToggleGroup({
|
||||
onChange,
|
||||
value,
|
||||
defaultValue = "",
|
||||
@@ -28,3 +28,5 @@ export function useToggleGroup({
|
||||
setFocusedValue,
|
||||
};
|
||||
}
|
||||
|
||||
export { useToggleGroup };
|
||||
@@ -17,7 +17,7 @@ import React, {
|
||||
forwardRef,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useModalContext } from "../modal/Modal.context";
|
||||
import { useModalContext } from "../modal/root/ModalRoot.context";
|
||||
import { Portal } from "../portal";
|
||||
import { HStack } from "../primitives/stack";
|
||||
import { Detail } from "../typography";
|
||||
@@ -166,7 +166,7 @@ export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
|
||||
whileElementsMounted: modalContext
|
||||
? (reference, floating, update) =>
|
||||
// Reduces jumping in Chrome when used in a Modal and it's the first focusable element.
|
||||
// Can be removed when autofocus starts working on <dialog> in Chrome. See also Modal.tsx
|
||||
// Can be removed when autofocus starts working on <dialog> in Chrome. See also ModalRoot.tsx
|
||||
autoUpdate(reference, floating, update, { animationFrame: true })
|
||||
: autoUpdate,
|
||||
strategy: modalContext ? "fixed" : undefined,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Meta, StoryFn } from "@storybook/react-vite";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Search } from "../../../form/search";
|
||||
import { Box } from "../../../primitives/box";
|
||||
import { BodyShort } from "../../../typography";
|
||||
import { useDeferredValue } from "../../hooks/useDeferredValue";
|
||||
import { HighlightText } from "../HighlightText/HighlightText";
|
||||
import { DismissableLayer } from "../dismissablelayer/DismissableLayer";
|
||||
@@ -17,84 +18,95 @@ const meta: Meta<typeof Listbox> = {
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type MyItem = {
|
||||
type MyOption = {
|
||||
label: string;
|
||||
value: `item-${number}`;
|
||||
value: `opt-${number}`;
|
||||
metadata?: string;
|
||||
};
|
||||
const myItems: MyItem[] = [
|
||||
{ label: "Norway", value: "item-1", metadata: "foo1" },
|
||||
{ label: "Finland", value: "item-2" },
|
||||
{ label: "Sweden", value: "item-3", metadata: "foo3" },
|
||||
{ label: "Denmark", value: "item-4" },
|
||||
{ label: "Iceland", value: "item-5" },
|
||||
{ label: "Faroe Islands", value: "item-6" },
|
||||
{ label: "Åland Islands", value: "item-7" },
|
||||
{ label: "Estonia", value: "item-8" },
|
||||
{ label: "Latvia", value: "item-9" },
|
||||
{ label: "Lithuania", value: "item-10" },
|
||||
|
||||
const nordicCountries: MyOption[] = [
|
||||
{ label: "Norge", value: "opt-1", metadata: "foo1" },
|
||||
{ label: "Finland", value: "opt-2" },
|
||||
{ label: "Sverige", value: "opt-3", metadata: "foo3" },
|
||||
{ label: "Danmark", value: "opt-4" },
|
||||
{ label: "Island", value: "opt-5" },
|
||||
{ label: "Grønland", value: "opt-6" },
|
||||
{ label: "Færøyene", value: "opt-7" },
|
||||
{ label: "Åland", value: "opt-8" },
|
||||
];
|
||||
const centralEuropeanCountries: MyOption[] = [
|
||||
{ label: "Tyskland", value: "opt-9" },
|
||||
{ label: "Østerrike", value: "opt-10" },
|
||||
{ label: "Sveits", value: "opt-11" },
|
||||
{ label: "Liechtenstein", value: "opt-12" },
|
||||
{ label: "Polen", value: "opt-13" },
|
||||
{ label: "Tsjekkia", value: "opt-14" },
|
||||
{ label: "Slovakia", value: "opt-15" },
|
||||
{ label: "Ungarn", value: "opt-16" },
|
||||
{ label: "Slovenia", value: "opt-17" },
|
||||
];
|
||||
const countries: MyOption[] = [...nordicCountries, ...centralEuropeanCountries];
|
||||
|
||||
type MyGroup = {
|
||||
label: string;
|
||||
id: `group-${number}`;
|
||||
items: MyItem[];
|
||||
options: MyOption[];
|
||||
};
|
||||
const groupedItems: (MyGroup | MyItem)[] = [
|
||||
{ label: "Single item first", value: "item-01" },
|
||||
|
||||
const groupedCountries: (MyGroup | MyOption)[] = [
|
||||
{
|
||||
label: "Nordic countries",
|
||||
label: "Norden",
|
||||
id: "group-1",
|
||||
items: myItems.slice(0, 6),
|
||||
options: nordicCountries,
|
||||
},
|
||||
{
|
||||
label: "Baltic countries",
|
||||
label: "Sentral-Europa",
|
||||
id: "group-2",
|
||||
items: myItems.slice(6),
|
||||
options: centralEuropeanCountries,
|
||||
},
|
||||
{ label: "Single item last", value: "item-02" },
|
||||
{ label: "Annet (ikke gruppert)", value: "opt-01" },
|
||||
];
|
||||
|
||||
interface RenderItemsProps {
|
||||
items: (MyGroup | MyItem)[];
|
||||
selectedItems: MyItem["value"][];
|
||||
interface RenderOptionsProps {
|
||||
options: (MyGroup | MyOption)[];
|
||||
selectedOptions: MyOption["value"][];
|
||||
filterString: string;
|
||||
virtuallyFocusedOptionId: string;
|
||||
onSelect: (item: MyItem) => void;
|
||||
onSelect: (option: MyOption) => void;
|
||||
}
|
||||
const RenderItems = ({
|
||||
items,
|
||||
selectedItems,
|
||||
const RenderOptions = ({
|
||||
options,
|
||||
selectedOptions,
|
||||
filterString,
|
||||
virtuallyFocusedOptionId,
|
||||
onSelect,
|
||||
}: RenderItemsProps) =>
|
||||
items.map((itemOrGroup) =>
|
||||
"items" in itemOrGroup ? (
|
||||
<Listbox.Group key={itemOrGroup.label} label={itemOrGroup.label}>
|
||||
{itemOrGroup.items.map((item) => (
|
||||
}: RenderOptionsProps) =>
|
||||
options.map((optionOrGroup) =>
|
||||
"options" in optionOrGroup ? (
|
||||
<Listbox.Group key={optionOrGroup.label} label={optionOrGroup.label}>
|
||||
{optionOrGroup.options.map((option) => (
|
||||
<Listbox.Option
|
||||
key={item.value}
|
||||
id={item.value}
|
||||
onClick={() => onSelect(item)}
|
||||
aria-selected={selectedItems.includes(item.value)}
|
||||
hasVirtualFocus={virtuallyFocusedOptionId === item.value}
|
||||
key={option.value}
|
||||
id={option.value}
|
||||
onClick={() => onSelect(option)}
|
||||
aria-selected={selectedOptions.includes(option.value)}
|
||||
hasVirtualFocus={virtuallyFocusedOptionId === option.value}
|
||||
>
|
||||
<HighlightText text={filterString}>{item.label}</HighlightText>
|
||||
{selectedItems.includes(item.value) && <Checkmark />}
|
||||
<HighlightText text={filterString}>{option.label}</HighlightText>
|
||||
{selectedOptions.includes(option.value) && <Checkmark />}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Group>
|
||||
) : (
|
||||
<Listbox.Option
|
||||
key={itemOrGroup.value}
|
||||
id={itemOrGroup.value}
|
||||
onClick={() => onSelect(itemOrGroup)}
|
||||
aria-selected={selectedItems.includes(itemOrGroup.value)}
|
||||
hasVirtualFocus={virtuallyFocusedOptionId === itemOrGroup.value}
|
||||
key={optionOrGroup.value}
|
||||
id={optionOrGroup.value}
|
||||
onClick={() => onSelect(optionOrGroup)}
|
||||
aria-selected={selectedOptions.includes(optionOrGroup.value)}
|
||||
hasVirtualFocus={virtuallyFocusedOptionId === optionOrGroup.value}
|
||||
>
|
||||
<HighlightText text={filterString}>{itemOrGroup.label}</HighlightText>
|
||||
{selectedItems.includes(itemOrGroup.value) && <Checkmark />}
|
||||
<HighlightText text={filterString}>{optionOrGroup.label}</HighlightText>
|
||||
{selectedOptions.includes(optionOrGroup.value) && <Checkmark />}
|
||||
</Listbox.Option>
|
||||
),
|
||||
);
|
||||
@@ -102,23 +114,23 @@ const Checkmark = () => <div style={{ float: "right" }}>✓</div>;
|
||||
|
||||
export const Default = () => {
|
||||
const [filterString, setFilterString] = useState("");
|
||||
const [selectedItem, setSelectedItem] = useState<MyItem["value"] | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedOption, setSelectedOption] = useState<
|
||||
MyOption["value"] | null
|
||||
>(null);
|
||||
const [virtuallyFocusedOptionId, setVirtuallyFocusedOptionId] = useState("");
|
||||
|
||||
const filteredItems = filterString
|
||||
? filterItems(groupedItems, filterString)
|
||||
: groupedItems;
|
||||
const filteredOptions = filterString
|
||||
? filterOptions(groupedCountries, filterString)
|
||||
: groupedCountries;
|
||||
|
||||
const onSelect = (item: MyItem) => {
|
||||
setSelectedItem(item.value);
|
||||
console.log(item);
|
||||
const onSelect = (option: MyOption) => {
|
||||
setSelectedOption(option.value);
|
||||
console.log(option);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>Selected: {selectedItem}</div>
|
||||
<div>Selected: {selectedOption}</div>
|
||||
|
||||
<Listbox setVirtuallyFocusedOptionId={setVirtuallyFocusedOptionId}>
|
||||
<Listbox.InputSlot virtuallyFocusedOptionId={virtuallyFocusedOptionId}>
|
||||
@@ -132,9 +144,9 @@ export const Default = () => {
|
||||
</Listbox.InputSlot>
|
||||
|
||||
<Listbox.Options>
|
||||
<RenderItems
|
||||
items={filteredItems}
|
||||
selectedItems={selectedItem ? [selectedItem] : []}
|
||||
<RenderOptions
|
||||
options={filteredOptions}
|
||||
selectedOptions={selectedOption ? [selectedOption] : []}
|
||||
filterString={filterString}
|
||||
virtuallyFocusedOptionId={virtuallyFocusedOptionId}
|
||||
onSelect={onSelect}
|
||||
@@ -152,14 +164,14 @@ interface OptimizedProps {
|
||||
export const Optimized: StoryFn<OptimizedProps> = ({ count, highlight }) => {
|
||||
const [filterString, setFilterString] = useState("");
|
||||
const deferredFilterString = useDeferredValue(filterString);
|
||||
const [selectedItem, setSelectedItem] = useState<string>("");
|
||||
const [selectedOption, setSelectedOption] = useState<string>("");
|
||||
const [virtuallyFocusedOptionId, setVirtuallyFocusedOptionId] = useState("");
|
||||
|
||||
const allOptions = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
{ length: count },
|
||||
(_, i) => `Item ${String(i + 1).padStart(4, "0")}`,
|
||||
(_, i) => `Option ${String(i + 1).padStart(4, "0")}`,
|
||||
),
|
||||
[count],
|
||||
);
|
||||
@@ -174,7 +186,7 @@ export const Optimized: StoryFn<OptimizedProps> = ({ count, highlight }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>Selected: {selectedItem}</div>
|
||||
<div>Selected: {selectedOption}</div>
|
||||
|
||||
<Listbox setVirtuallyFocusedOptionId={setVirtuallyFocusedOptionId}>
|
||||
<Listbox.InputSlot virtuallyFocusedOptionId={virtuallyFocusedOptionId}>
|
||||
@@ -194,9 +206,9 @@ export const Optimized: StoryFn<OptimizedProps> = ({ count, highlight }) => {
|
||||
key={option}
|
||||
value={option}
|
||||
filterString={highlight ? deferredFilterString : ""}
|
||||
isSelected={selectedItem === option}
|
||||
isSelected={selectedOption === option}
|
||||
hasVirtualFocus={virtuallyFocusedOptionId === option}
|
||||
onSelect={setSelectedItem}
|
||||
onSelect={setSelectedOption}
|
||||
/>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
@@ -246,26 +258,26 @@ const MyMemoizedOption = React.memo(
|
||||
|
||||
export const WithFloating = () => {
|
||||
const [filterString, setFilterString] = useState("");
|
||||
const [selectedItem, setSelectedItem] = useState<MyItem["value"] | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedOption, setSelectedOption] = useState<
|
||||
MyOption["value"] | null
|
||||
>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [virtuallyFocusedOptionId, setVirtuallyFocusedOptionId] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const filteredItems = filterString
|
||||
? filterItems(groupedItems, filterString)
|
||||
: groupedItems;
|
||||
const filteredOptions = filterString
|
||||
? filterOptions(groupedCountries, filterString)
|
||||
: groupedCountries;
|
||||
|
||||
const onSelect = (item: MyItem) => {
|
||||
setSelectedItem(item.value);
|
||||
const onSelect = (option: MyOption) => {
|
||||
setSelectedOption(option.value);
|
||||
//setOpen(false);
|
||||
console.log(item);
|
||||
console.log(option);
|
||||
};
|
||||
|
||||
return (
|
||||
<Floating>
|
||||
<div>Selected: {selectedItem}</div>
|
||||
<div>Selected: {selectedOption}</div>
|
||||
|
||||
<Listbox setVirtuallyFocusedOptionId={setVirtuallyFocusedOptionId}>
|
||||
<Floating.Anchor>
|
||||
@@ -304,9 +316,9 @@ export const WithFloating = () => {
|
||||
width="var(--__axc-floating-anchor-width)"
|
||||
>
|
||||
<Listbox.Options>
|
||||
<RenderItems
|
||||
items={filteredItems}
|
||||
selectedItems={selectedItem ? [selectedItem] : []}
|
||||
<RenderOptions
|
||||
options={filteredOptions}
|
||||
selectedOptions={selectedOption ? [selectedOption] : []}
|
||||
filterString={filterString}
|
||||
virtuallyFocusedOptionId={virtuallyFocusedOptionId}
|
||||
onSelect={onSelect}
|
||||
@@ -321,40 +333,145 @@ export const WithFloating = () => {
|
||||
);
|
||||
};
|
||||
|
||||
type Item = {
|
||||
export const UseCaseTeamkatalogen = () => {
|
||||
const [filterString, setFilterString] = useState("");
|
||||
const [selectedOption, setSelectedOption] = useState<
|
||||
MyOption["value"] | null
|
||||
>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [virtuallyFocusedOptionId, setVirtuallyFocusedOptionId] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const filteredOptions =
|
||||
filterString.length > 2 ? filterOptions(countries, filterString) : null;
|
||||
|
||||
const onSelect = (option: MyOption) => {
|
||||
setSelectedOption(option.value);
|
||||
close();
|
||||
console.log(option);
|
||||
};
|
||||
|
||||
function close() {
|
||||
setVirtuallyFocusedOptionId("");
|
||||
setFilterString("");
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: "500px" }}>
|
||||
<Floating>
|
||||
<BodyShort spacing>
|
||||
Søkefelt der resultatene vises som alternativer i en listbox. Omtrent
|
||||
som dagens Combobox, men der valg fører til navigering og ikke huskes
|
||||
(i feltet).
|
||||
</BodyShort>
|
||||
|
||||
<BodyShort spacing>Valgt: {selectedOption}</BodyShort>
|
||||
|
||||
<Listbox setVirtuallyFocusedOptionId={setVirtuallyFocusedOptionId}>
|
||||
<Floating.Anchor>
|
||||
<Listbox.InputSlot
|
||||
virtuallyFocusedOptionId={virtuallyFocusedOptionId}
|
||||
>
|
||||
<Search
|
||||
label="Søk etter team, seksjon, person eller tagg"
|
||||
placeholder="Søk etter team, seksjon, person eller tagg"
|
||||
variant="simple"
|
||||
ref={inputRef}
|
||||
value={filterString}
|
||||
onChange={(newValue) => {
|
||||
setFilterString(newValue);
|
||||
if (!open && newValue.length > 2) {
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
onClear={close}
|
||||
onClick={() => setOpen(true)}
|
||||
onFocus={() => setOpen(true)}
|
||||
/>
|
||||
</Listbox.InputSlot>
|
||||
</Floating.Anchor>
|
||||
{open && (
|
||||
<DismissableLayer
|
||||
asChild
|
||||
onDismiss={close}
|
||||
safeZone={{ anchor: inputRef.current }}
|
||||
>
|
||||
<Floating.Content
|
||||
align="start"
|
||||
side="bottom"
|
||||
fallbackPlacements={[]}
|
||||
sideOffset={4}
|
||||
>
|
||||
<Box
|
||||
background="default"
|
||||
borderWidth="1"
|
||||
maxHeight="calc(var(--__axc-floating-available-height) - 4px)"
|
||||
width="var(--__axc-floating-anchor-width)"
|
||||
>
|
||||
{filteredOptions && filteredOptions.length > 0 && (
|
||||
<Listbox.Options>
|
||||
<RenderOptions
|
||||
options={filteredOptions}
|
||||
selectedOptions={[]}
|
||||
filterString={filterString}
|
||||
virtuallyFocusedOptionId={virtuallyFocusedOptionId}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</Listbox.Options>
|
||||
)}
|
||||
{filteredOptions && filteredOptions.length === 0 && (
|
||||
<Box padding="space-20" role="status">
|
||||
Ingen resultater
|
||||
</Box>
|
||||
)}
|
||||
{!filteredOptions && (
|
||||
<Box padding="space-20">Skriv minst 3 tegn for å søke</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Floating.Content>
|
||||
</DismissableLayer>
|
||||
)}
|
||||
</Listbox>
|
||||
</Floating>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type Option = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type Group<T extends Item> = {
|
||||
type Group<T extends Option> = {
|
||||
label: string;
|
||||
id: string;
|
||||
items: T[];
|
||||
options: T[];
|
||||
};
|
||||
|
||||
function filterItems<T extends (Item | Group<Item>)[]>(
|
||||
items: T,
|
||||
function filterOptions<T extends (Option | Group<Option>)[]>(
|
||||
options: T,
|
||||
filterString: string,
|
||||
): T {
|
||||
const filterStringLowerCase = filterString.toLocaleLowerCase();
|
||||
|
||||
return items
|
||||
.map((itemOrGroup) => {
|
||||
if ("items" in itemOrGroup) {
|
||||
const matchingItems = itemOrGroup.items.filter((item) =>
|
||||
item.label.toLocaleLowerCase().includes(filterStringLowerCase),
|
||||
return options
|
||||
.map((optionOrGroup) => {
|
||||
if ("options" in optionOrGroup) {
|
||||
const matchingOptions = optionOrGroup.options.filter((option) =>
|
||||
option.label.toLocaleLowerCase().includes(filterStringLowerCase),
|
||||
);
|
||||
if (matchingItems.length > 0) {
|
||||
return { ...itemOrGroup, items: matchingItems };
|
||||
if (matchingOptions.length > 0) {
|
||||
return { ...optionOrGroup, options: matchingOptions };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
itemOrGroup.label.toLocaleLowerCase().includes(filterStringLowerCase)
|
||||
optionOrGroup.label.toLocaleLowerCase().includes(filterStringLowerCase)
|
||||
) {
|
||||
return itemOrGroup;
|
||||
return optionOrGroup;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((item) => item !== null) as T;
|
||||
.filter((option) => option !== null) as T;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import React, { useRef } from "react";
|
||||
import { Button } from "../../../../button";
|
||||
import { useDialogContext } from "../../../../dialog/root/DialogRoot.context";
|
||||
import { Modal } from "../../../../modal";
|
||||
import { useModalContext } from "../../../../modal/Modal.context";
|
||||
import { useModalContext } from "../../../../modal/root/ModalRoot.context";
|
||||
import { Popover } from "../../../../popover";
|
||||
import { cl } from "../../../helpers";
|
||||
import { focusElement } from "../../../helpers/focus";
|
||||
|
||||
@@ -18,7 +18,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useModalContext } from "../../../modal/Modal.context";
|
||||
import { useModalContext } from "../../../modal/root/ModalRoot.context";
|
||||
import { useClientLayoutEffect } from "../../../utils-external";
|
||||
import { createStrictContext } from "../../helpers";
|
||||
import {
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
"**",
|
||||
"!**/public",
|
||||
"!**/codemod/**/*.js",
|
||||
"!**/modal/dialog-polyfill.ts",
|
||||
"!**/modal/helpers/dialog-polyfill.ts",
|
||||
"!**/_sanity/query-types.ts",
|
||||
"!**/component-names.generated.ts",
|
||||
"!**/transforms/**/*.input.*",
|
||||
|
||||
Reference in new issue
Block a user