Compare commits

...
5 changed files with 129 additions and 73 deletions

No files matched your search

@@ -1,5 +1,5 @@
import type { Meta, StoryFn, StoryObj } from "@storybook/react-vite";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { ChevronDownIcon, ChevronUpIcon } from "@navikt/aksel-icons";
import { Button } from "../button";
import { Box } from "../primitives/box";
@@ -19,22 +19,40 @@ export default meta;
type Story = StoryObj<typeof Collapsible>;
function DemoContent(props: React.HTMLAttributes<HTMLDivElement>) {
useEffect(() => {
console.log("Content mounted");
return () => console.log("Content unmounted");
}, []);
return (
<Box {...props} padding="space-16" background="info-soft">
<div>lorem ipsum</div>
</Box>
);
}
export const Default: Story = {
render: (...props) => (
render: (props) => (
<Collapsible {...props}>
<Collapsible.Trigger>Trigger</Collapsible.Trigger>
<Collapsible.Content>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Recusandae
corporis maxime aliquam, voluptates nobis numquam, non odit optio
architecto iure laborum possimus! Quibusdam sit ullam, consequatur sunt
tempore optio aliquid!
<DemoContent />
</Collapsible.Content>
</Collapsible>
),
args: {
open: false,
defaultOpen: false,
},
argTypes: {
open: {
control: "boolean",
},
closedBehavior: {
control: "select",
options: ["unmount", "hidden", "hiddenUntilFound"],
},
},
};
export const InContext: StoryFn<typeof Collapsible> = () => {
@@ -67,11 +85,11 @@ export const InContext: StoryFn<typeof Collapsible> = () => {
);
};
export const Animated: StoryFn<typeof Collapsible> = () => (
<Collapsible lazy>
export const Animated: StoryFn<typeof Collapsible> = (props) => (
<Collapsible {...props}>
<Collapsible.Trigger asChild>
<Button size="small" variant="secondary">
Animer open/lukk
Animer åpne/lukk
</Button>
</Collapsible.Trigger>
<Collapsible.Content className="collapsible">
@@ -83,7 +101,15 @@ export const Animated: StoryFn<typeof Collapsible> = () => (
</Collapsible.Content>
</Collapsible>
);
Animated.argTypes = {
closedBehavior: {
control: "radio",
options: ["hidden", "hiddenUntilFound"],
},
};
Animated.args = {
closedBehavior: "hiddenUntilFound",
};
Animated.decorators = [
(Story) => (
<div style={{ height: 300 }}>
@@ -92,55 +118,39 @@ Animated.decorators = [
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows 1s ease-in-out;
}
transition-duration: 1s;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
transition-property: visibility, content-visibility, grid-template-rows;
.collapsible[data-state="closed"] {
width: 0;
}
&[hidden]:not([hidden="until-found"]) {
visibility: hidden; /* hidden=until-found uses content-visibility (we break the "until-found" part if we override it) */
}
.collapsible[data-state="open"] {
grid-template-rows: 1fr;
&[data-state="open"] {
grid-template-rows: 1fr;
visibility: visible;
}
}
.collapsibleContent {
min-height: 0;
transition: visibility 1s;
visibility: hidden;
}
.collapsible[data-state="open"] .collapsibleContent {
visibility: visible;
}
`}</style>
{Story()}
</div>
),
];
export const Lazy: StoryFn<typeof Collapsible> = () => (
<Collapsible lazy>
<Collapsible.Trigger>Trigger</Collapsible.Trigger>
<Collapsible.Content>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Recusandae
corporis maxime aliquam, voluptates nobis numquam, non odit optio
architecto iure laborum possimus! Quibusdam sit ullam, consequatur sunt
tempore optio aliquid!
</Collapsible.Content>
</Collapsible>
);
export const AsChild: StoryFn<typeof Collapsible> = () => (
<Collapsible>
<Collapsible.Trigger asChild>
<Button>Button</Button>
</Collapsible.Trigger>
<Collapsible.Content asChild>
<Box padding="space-16" background="info-soft">
<div>lorem ipsum</div>
</Box>
</Collapsible.Content>
<Collapsible closedBehavior="unmount" asChild>
<div style={{ minHeight: "110px", minWidth: "130px" }}>
<Collapsible.Trigger asChild>
<Button>Button</Button>
</Collapsible.Trigger>
<Collapsible.Content asChild>
<DemoContent />
</Collapsible.Content>
</div>
</Collapsible>
);
@@ -150,9 +160,7 @@ export const DefaultOpen: StoryFn<typeof Collapsible> = () => (
<Button>Button</Button>
</Collapsible.Trigger>
<Collapsible.Content asChild>
<Box padding="space-16" background="info-soft">
<div>lorem ipsum</div>
</Box>
<DemoContent />
</Collapsible.Content>
</Collapsible>
);
@@ -163,9 +171,7 @@ export const ControlledOpen: StoryFn<typeof Collapsible> = () => (
<Button>Button</Button>
</Collapsible.Trigger>
<Collapsible.Content asChild>
<Box padding="space-16" background="info-soft">
<div>lorem ipsum</div>
</Box>
<DemoContent />
</Collapsible.Content>
</Collapsible>
);
@@ -175,10 +181,8 @@ export const Disabled = ({ open = false }) => (
<Collapsible.Trigger asChild disabled>
<Button>Button</Button>
</Collapsible.Trigger>
<Collapsible.Content asChild>
<Box padding="space-16" background="info-soft">
<div>lorem ipsum</div>
</Box>
<Collapsible.Content>
<DemoContent />
</Collapsible.Content>
</Collapsible>
);
@@ -186,7 +190,6 @@ export const Disabled = ({ open = false }) => (
export const Chromatic = renderStoriesForChromatic({
InContext,
Animated,
Lazy,
AsChild,
DefaultOpen,
ControlledOpen,
@@ -1,5 +1,6 @@
import React, { forwardRef } from "react";
import React, { forwardRef, useEffect, useRef } from "react";
import { Slot } from "../../utils/components/slot/Slot";
import { useMergeRefs } from "../../utils/hooks";
import { useCollapsibleContext } from "../root/CollapsibleRoot.context";
export interface CollapsibleContentProps extends Omit<
@@ -16,11 +17,36 @@ export interface CollapsibleContentProps extends Omit<
export const CollapsibleContent = forwardRef<
HTMLDivElement,
CollapsibleContentProps
>(({ children, asChild, ...rest }, ref) => {
>(({ children, asChild, ...rest }, forwardedRef) => {
const ctx = useCollapsibleContext();
const { closedBehavior, open, onOpenToggle } = ctx;
const localRef = useRef<HTMLDivElement>(null);
const ref = useMergeRefs(forwardedRef, localRef);
const Comp = asChild ? Slot : "div";
useEffect(() => {
if (
!localRef.current ||
open ||
!("onbeforematch" in HTMLElement.prototype)
) {
return;
}
const element = localRef.current;
if (closedBehavior !== "hiddenUntilFound") {
// Just in case closedBehavior changes from "hiddenUntilFound" to something else while closed
if (element.hidden === "until-found") {
element.hidden = true;
}
return;
}
element.hidden = "until-found";
element.addEventListener("beforematch", onOpenToggle);
return () => element.removeEventListener("beforematch", onOpenToggle);
}, [closedBehavior, open, onOpenToggle]);
return (
<Comp
ref={ref}
@@ -30,7 +56,7 @@ export const CollapsibleContent = forwardRef<
aria-controls={ctx.open ? ctx.triggerId : undefined}
id={ctx.contentId}
>
{ctx.lazy || ctx.open ? children : null}
{ctx.closedBehavior === "unmount" && !ctx.open ? null : children}
</Comp>
);
});
@@ -1,4 +1,5 @@
import { createStrictContext } from "../../utils/helpers";
import type { CollapsibleProps } from "./CollapsibleRoot";
export type CollapsibleContextProps = {
/**
@@ -17,15 +18,11 @@ export type CollapsibleContextProps = {
* Callback for toggling open state
*/
onOpenToggle: () => void;
/**
* Allows always rendering children in content when closed
*/
lazy: boolean;
/**
* Utility for getting state as string. Usefull for data-attributes.
*/
state: "open" | "closed";
};
} & Required<Pick<CollapsibleProps, "closedBehavior">>;
export const {
Provider: CollapsibleContextProvider,
@@ -1,5 +1,6 @@
import React, { forwardRef, useCallback } from "react";
import { useId } from "../../utils-external";
import { Slot } from "../../utils/components/slot/Slot";
import { useControllableState } from "../../utils/hooks";
import CollapsibleContent, {
type CollapsibleContentProps,
@@ -23,10 +24,33 @@ interface CollapsibleProps extends React.HTMLAttributes<HTMLDivElement> {
*/
onOpenChange?: (open: boolean) => void;
/**
* Render the collapsible content lazily. This means that the content will not be rendered until the collapsible is open.
* ### unmount
* The content will not be rendered until the collapsible is opened.
*
* ### hidden
* The content will be rendered but hidden with the `hidden` attribute (`display: none`).
*
* ### hiddenUntilFound
* Same as `hidden`, except that `hidden="until-found"` will be used if the browser supports it.
*
* The content will expand if a `beforematch` event is triggered, which happens when
* fragment navigation or the browser's "Find in page" feature causes a scroll to the content.
*
* **NB:** Since React currently doesn't support setting `hidden="until-found"`, we have to set it
* manually in a `useEffect`. This means that fragment navigation will not work on the first render.
* (I.e. `<a href="/different-page#elm-inside-closed-collapsible">` won't work, but
* `<a href="#elm-inside-closed-collapsible">` should work.)
*
* @see [MDN docs about the `hidden` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/hidden#the_hidden_until_found_state)
*
* @default "unmount"
*/
closedBehavior?: "unmount" | "hidden" | "hiddenUntilFound";
/**
* When true, will render element as its child. This merges classes, styles and event handlers.
* @default false
*/
lazy?: boolean;
asChild?: boolean;
}
interface CollapsibleComponent extends React.ForwardRefExoticComponent<
@@ -79,7 +103,8 @@ export const Collapsible = forwardRef<HTMLDivElement, CollapsibleProps>(
open,
defaultOpen = false,
onOpenChange,
lazy = false,
closedBehavior = "unmount",
asChild,
...rest
},
ref,
@@ -92,6 +117,7 @@ export const Collapsible = forwardRef<HTMLDivElement, CollapsibleProps>(
const internalId = useId();
const state = _open ? "open" : "closed";
const Comp = asChild ? Slot : "div";
return (
<CollapsibleContextProvider
@@ -102,12 +128,12 @@ export const Collapsible = forwardRef<HTMLDivElement, CollapsibleProps>(
)}
contentId={`collapsible-content-${internalId}`}
triggerId={`collapsible-trigger-${internalId}`}
lazy={lazy}
closedBehavior={closedBehavior}
state={state}
>
<div ref={ref} data-state={state} {...rest}>
<Comp ref={ref} data-state={state} {...rest}>
{children}
</div>
</Comp>
</CollapsibleContextProvider>
);
},
@@ -100,7 +100,11 @@ export const FormProgress = forwardRef<HTMLDivElement, FormProgressProps>(
className="aksel-form-progress__bar"
/>
<Collapsible lazy open={open} onOpenChange={onOpenChange}>
<Collapsible
closedBehavior="hidden"
open={open}
onOpenChange={onOpenChange}
>
<HStack justify="space-between" align="center">
<BodyShort as="span">
{translate("step", { activeStep, totalSteps })}