diff --git a/.changeset/sweet-snails-sneeze.md b/.changeset/sweet-snails-sneeze.md new file mode 100644 index 00000000000..4834bb4bd9f --- /dev/null +++ b/.changeset/sweet-snails-sneeze.md @@ -0,0 +1,42 @@ +--- +"@salt-ds/lab": minor +--- + +Added `SidePanel`. + +`SidePanel` is a collapsible container that slides in from an edge of its parent, providing supplementary content or controls without disrupting the main layout. + +Use `SidePanelProvider` to manage open state, `SidePanelTrigger` to toggle the panel, `SidePanelTitle` provides the accessible name for the panel region automatically and `useSidePanel` to access `setOpen` for programmatic close. + +```tsx +const PanelContent = () => { + const { CloseIcon } = useIcon(); + const { setOpen } = useSidePanel(); + return ( + + + +

Panel Title

+
+ +
+ + Panel body content. + +
+ ); +}; + + + + + + +; +``` diff --git a/docs/components/QAContainer.tsx b/docs/components/QAContainer.tsx index 098146e1256..45e3c88ebd3 100644 --- a/docs/components/QAContainer.tsx +++ b/docs/components/QAContainer.tsx @@ -21,13 +21,13 @@ const withBaseName = makePrefixer("saltQAContainer"); export interface QAContainerProps extends ComponentPropsWithoutRef<"div"> { cols?: number; densities?: Array<(typeof DensityValues)[number]>; - height?: number; + height?: number | string; enableStyleInjection?: boolean; itemPadding?: number; itemWidthAuto?: boolean; transposeDensity?: boolean; vertical?: boolean; - width?: number; + width?: number | string; } const BackgroundBlock = ({ children }: ComponentPropsWithoutRef<"div">) => ( @@ -82,8 +82,18 @@ export const QAContainer = ({ }: QAContainerProps) => { const style = { "--qaContainer-cols": cols, - "--qaContainer-height": height === undefined ? undefined : `${height}px`, - "--qaContainer-width": width === undefined ? undefined : `${width}px`, + "--qaContainer-height": + height === undefined + ? undefined + : typeof height === "number" + ? `${height}px` + : height, + "--qaContainer-width": + width === undefined + ? undefined + : typeof width === "number" + ? `${width}px` + : width, "--qaContainer-item-padding": itemPadding === undefined ? undefined : `${itemPadding}px`, "--qaContainer-item-width": itemWidthAuto ? "auto" : undefined, diff --git a/packages/lab/src/__tests__/__e2e__/side-panel/SidePanel.cy.tsx b/packages/lab/src/__tests__/__e2e__/side-panel/SidePanel.cy.tsx new file mode 100644 index 00000000000..d92ed1d0dba --- /dev/null +++ b/packages/lab/src/__tests__/__e2e__/side-panel/SidePanel.cy.tsx @@ -0,0 +1,335 @@ +import { + SidePanel, + SidePanelContent, + SidePanelHeader, + SidePanelProvider, + SidePanelTitle, + SidePanelTrigger, +} from "@salt-ds/lab"; +import * as sidePanel from "@stories/side-panel/side-panel.stories"; +import { composeStories } from "@storybook/react-vite"; +import { checkAccessibility } from "../../../../../../cypress/tests/checkAccessibility"; + +const composedStories = composeStories(sidePanel); +const { Left, Default, ManualTrigger, WithTable, Scrollable, WithNav } = + composedStories; +describe("GIVEN a SidePanel component", () => { + checkAccessibility(composedStories); + + describe("Rendering & Position Variants", () => { + it("Left story - WHEN opened and closed via button and Escape, THEN lifecycle and ARIA state are correct", () => { + cy.mount(); + + cy.findByRole("region").should("not.exist"); + + cy.findByRole("button", { name: "Open left panel" }).should( + "have.attr", + "aria-expanded", + "false", + ); + + cy.findByRole("button", { name: "Open left panel" }).click(); + cy.findByRole("button", { name: "Open left panel" }).should( + "have.attr", + "aria-expanded", + "true", + ); + + cy.findByRole("region", { name: "Section Title" }) + .invoke("attr", "id") + .then((panelId) => { + cy.findByRole("button", { name: "Open left panel" }).should( + "have.attr", + "aria-controls", + panelId, + ); + }); + + cy.findByRole("region", { name: "Section Title" }).should("be.visible"); + cy.findByRole("region").should("have.class", "saltSidePanel-left"); + + cy.findByRole("button", { name: "Close" }).click(); + cy.findByRole("button", { name: "Open left panel" }).should( + "have.attr", + "aria-expanded", + "false", + ); + + cy.findByRole("region").should("not.exist"); + + cy.findByRole("button", { name: "Open left panel" }).click(); + cy.findByRole("region", { name: "Section Title" }).should("be.visible"); + + cy.realPress("Escape"); + cy.findByRole("region").should("not.exist"); + + cy.findByRole("button", { name: "Open left panel" }).click(); + cy.findByRole("region", { name: "Section Title" }).should("be.visible"); + cy.findByRole("region").should("have.attr", "role", "region"); + }); + + it("Default story - WHEN opened and closed, THEN ARIA, class, and focus behavior are correct", () => { + cy.mount(); + + cy.findByRole("button", { name: "Open right panel" }).should( + "have.attr", + "aria-expanded", + "false", + ); + + cy.findByRole("button", { name: "Open right panel" }).click(); + cy.findByRole("button", { name: "Open right panel" }).should( + "have.attr", + "aria-expanded", + "true", + ); + cy.findByRole("region") + .should("have.class", "saltSidePanel-right") + .and("be.visible"); + + cy.findByRole("region", { name: "Section Title" }) + .invoke("attr", "id") + .then((panelId) => { + cy.findByRole("button", { name: "Open right panel" }).should( + "have.attr", + "aria-controls", + panelId, + ); + }); + + cy.findByRole("button", { name: "Close" }).should("have.focus"); + + cy.realPress("Escape"); + cy.findByRole("region").should("not.exist"); + cy.focused().should("have.text", "Open right panel"); + + cy.findByRole("button", { name: "Open right panel" }).click(); + cy.findByRole("button", { name: "Close" }).should("have.focus"); + + cy.findByRole("button", { name: "Close" }).click(); + cy.findByRole("button", { name: "Open right panel" }).should( + "have.attr", + "aria-expanded", + "false", + ); + + cy.findByRole("region").should("not.exist"); + cy.focused().should("have.text", "Open right panel"); + }); + + it("ManualTrigger - WHEN used, THEN aria-expanded and aria-controls are managed correctly", () => { + cy.mount(); + + cy.findByRole("button", { name: "Toggle left panel" }).should( + "have.attr", + "aria-expanded", + "false", + ); + + cy.findByRole("button", { name: "Toggle left panel" }).click(); + + cy.findByRole("button", { name: "Toggle left panel" }).should( + "have.attr", + "aria-expanded", + "true", + ); + + cy.findByRole("region", { name: "Left Panel" }) + .invoke("attr", "id") + .then((panelId) => { + cy.findByRole("button", { name: "Toggle left panel" }).should( + "have.attr", + "aria-controls", + panelId, + ); + }); + + cy.findByRole("region", { name: "Left Panel" }).should("be.visible"); + }); + }); + + describe("State Management", () => { + it("SHOULD manage open state and focus correctly", () => { + const onOpenChange = cy.stub().as("onOpenChange"); + + cy.mount( + + + + + + + Test Panel + + + Content + + , + ); + + cy.findByRole("region").should("not.exist"); + cy.findByRole("button", { name: "Open Panel" }).click(); + + cy.get("@onOpenChange").should("have.been.calledWith", true); + + cy.findByRole("region", { name: "Test Panel" }).should("be.visible"); + cy.findByRole("button", { name: "Close" }).should("have.focus"); + + cy.realPress("Escape"); + + cy.get("@onOpenChange").should("have.been.calledWith", false); + cy.findByRole("region").should("not.exist"); + }); + }); + + describe("WithTable", () => { + it("WHEN different row Edit buttons are clicked sequentially, THEN details update and table remains visible after close", () => { + cy.mount(); + + cy.findByRole("table").should("be.visible"); + cy.findByRole("columnheader", { name: "Name" }).should("be.visible"); + + cy.findByRole("button", { + name: "Edit details for Alex Morgan", + }).click(); + + cy.findByRole("region", { + name: "Alex Morgan Employee Details", + }).should("be.visible"); + cy.findByRole("region", { + name: "Alex Morgan Employee Details", + }).within(() => { + cy.findByDisplayValue("Alex Morgan").should("be.visible"); + cy.findByDisplayValue("alex.morgan@example.com").should("be.visible"); + cy.findByDisplayValue("+1 212 555 0101").should("be.visible"); + }); + + cy.findByRole("button", { name: "Close" }).click(); + cy.findByRole("region", { + name: "Alex Morgan Employee Details", + }).should("not.exist"); + cy.findByRole("table").should("be.visible"); + + cy.findByRole("button", { + name: "Edit details for Jordan Lee", + }).click(); + + cy.findByRole("region", { + name: "Jordan Lee Employee Details", + }).should("be.visible"); + cy.findByRole("region", { + name: "Jordan Lee Employee Details", + }).within(() => { + cy.findByDisplayValue("Jordan Lee").should("be.visible"); + cy.findByDisplayValue("jordan.lee@example.com").should("be.visible"); + }); + }); + + it("WHEN Edit buttons are activated and panel is closed in different ways, THEN focus moves into panel on open and returns to trigger on close", () => { + cy.mount(); + + // First Edit click — focus moves into the panel + cy.findByRole("button", { + name: "Edit details for Alex Morgan", + }).click(); + cy.findByRole("region", { + name: "Alex Morgan Employee Details", + }).should("be.visible"); + cy.findByRole("button", { name: "Close" }).should("have.focus"); + + // Switch rows while panel is open — focus moves into the new panel + cy.findByRole("button", { + name: "Edit details for Taylor Reed", + }).click(); + cy.findByRole("region", { + name: "Taylor Reed Employee Details", + }).should("be.visible"); + cy.findByRole("button", { name: "Close" }).should("have.focus"); + + // Close via Escape — focus returns to the trigger + cy.realPress("Escape"); + cy.findByRole("region").should("not.exist"); + cy.focused().should( + "have.attr", + "aria-label", + "Edit details for Taylor Reed", + ); + + // Reopen a row — close via Close button — focus returns to the trigger + cy.findByRole("button", { + name: "Edit details for Alex Morgan", + }).click(); + cy.findByRole("button", { name: "Close" }).click(); + cy.findByRole("region").should("not.exist"); + cy.focused().should( + "have.attr", + "aria-label", + "Edit details for Alex Morgan", + ); + + // Reopen — close via Cancel button — focus returns to the trigger + cy.findByRole("button", { + name: "Edit details for Jordan Lee", + }).click(); + cy.findByRole("button", { name: "Cancel" }).click(); + cy.findByRole("region").should("not.exist"); + cy.focused().should( + "have.attr", + "aria-label", + "Edit details for Jordan Lee", + ); + }); + }); + + describe("Scrollable", () => { + it("WHEN panel content scrollability differs, THEN body focusability attributes match configuration", () => { + cy.mount(); + + cy.findByRole("button", { name: "Open right panel" }).click(); + + cy.get(".saltSidePanelContent-body").should("be.visible"); + cy.get(".saltSidePanelContent-body").should("not.have.attr", "tabindex"); + cy.get(".saltSidePanelContent-body").should("not.have.attr", "role"); + + cy.mount(); + + cy.findByRole("button", { name: "Toggle right panel" }).click(); + + cy.get(".saltSidePanelContent-body") + .should("be.visible") + .and("have.attr", "tabindex", "0") + .and("have.attr", "role", "region"); + }); + + it("WHEN scrollable panel is opened and closed, THEN focus, visibility, and toggle behavior are correct", () => { + cy.mount(); + + cy.findByRole("button", { name: "Toggle right panel" }).click(); + + cy.findByRole("region", { name: "Section Title" }).should("be.visible"); + cy.findByRole("region", { name: "Main content" }).should("be.visible"); + + cy.findByRole("button", { name: "Close" }).should("have.focus"); + + cy.findByRole("button", { name: "Close" }).click(); + cy.findByRole("region", { name: "Section Title" }).should("not.exist"); + + cy.findByRole("button", { name: "Toggle right panel" }).click(); + cy.findByRole("region", { name: "Section Title" }).should("be.visible"); + }); + }); + + describe("WithNav", () => { + it("WHEN panel is opened and then closed, THEN nav remains visible throughout", () => { + cy.mount(); + + cy.findByRole("button", { name: "Open side panel" }).click(); + cy.findByRole("region", { name: "Section Title" }).should("be.visible"); + cy.findByRole("navigation").should("be.visible"); + + cy.findByRole("button", { name: "Close" }).click(); + cy.findByRole("region", { name: "Section Title" }).should("not.exist"); + cy.findByRole("navigation").should("be.visible"); + }); + }); +}); diff --git a/packages/lab/src/index.ts b/packages/lab/src/index.ts index 3662ed000ad..9b375f07bde 100644 --- a/packages/lab/src/index.ts +++ b/packages/lab/src/index.ts @@ -53,6 +53,7 @@ export * from "./query-input"; export * from "./rating"; export * from "./responsive"; export * from "./search-input"; +export * from "./side-panel"; export * from "./static-list"; export * from "./system-status"; export * from "./tabs"; diff --git a/packages/lab/src/side-panel/SidePanel.css b/packages/lab/src/side-panel/SidePanel.css new file mode 100644 index 00000000000..3179ede57fe --- /dev/null +++ b/packages/lab/src/side-panel/SidePanel.css @@ -0,0 +1,101 @@ +.saltSidePanel { + background-color: var(--sidePanel-background, var(--salt-container-primary-background)); + --saltSidePanel-width: 300px; + --sidePanel-border: var(--salt-size-fixed-100) var(--salt-borderStyle-solid) var(--sidePanel-borderColor, var(--salt-container-primary-borderColor)); + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.saltSidePanel-primary { + --sidePanel-background: var(--salt-container-primary-background); + --sidePanel-borderColor: var(--salt-container-primary-borderColor); +} + +.saltSidePanel-secondary { + --sidePanel-background: var(--salt-container-secondary-background); + --sidePanel-borderColor: var(--salt-container-secondary-borderColor); +} + +.saltSidePanel-tertiary { + --sidePanel-background: var(--salt-container-tertiary-background); + --sidePanel-borderColor: var(--salt-container-tertiary-borderColor); +} + +.saltSidePanel-left { + border-right: var(--sidePanel-border); +} +.saltSidePanel-right { + border-left: var(--sidePanel-border); +} + +.saltSidePanel-none { + --sidePanel-background: none; + border: none; +} + +.saltSidePanel-none .saltSidePanel-inner { + padding: 0; +} + +.saltSidePanel-left, +.saltSidePanel-right { + width: var(--saltSidePanel-width); + height: 100%; + min-height: 0; + align-self: stretch; +} + +.saltSidePanel-enterAnimation, +.saltSidePanel-exitAnimation { + overflow: hidden; +} + +.saltSidePanel-left.saltSidePanel-enterAnimation, +.saltSidePanel-right.saltSidePanel-enterAnimation { + animation: saltSidePanel-expandWidth var(--salt-duration-perceptible) var(--salt-animation-timing-function); +} + +.saltSidePanel-left.saltSidePanel-exitAnimation, +.saltSidePanel-right.saltSidePanel-exitAnimation { + animation: saltSidePanel-collapseWidth var(--salt-duration-perceptible) var(--salt-animation-timing-function) both; +} + +.saltSidePanel-exitAnimation { + pointer-events: none; +} + +.saltSidePanel-inner { + box-sizing: border-box; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + padding: var(--salt-spacing-300); +} + +.saltSidePanel-left .saltSidePanel-inner, +.saltSidePanel-right .saltSidePanel-inner { + width: var(--saltSidePanel-width); + height: 100%; +} + +@keyframes saltSidePanel-expandWidth { + from { + width: 0; + } +} +@keyframes saltSidePanel-collapseWidth { + to { + width: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .saltSidePanel-left.saltSidePanel-enterAnimation, + .saltSidePanel-right.saltSidePanel-enterAnimation, + .saltSidePanel-left.saltSidePanel-exitAnimation, + .saltSidePanel-right.saltSidePanel-exitAnimation { + animation: none; + } +} diff --git a/packages/lab/src/side-panel/SidePanel.tsx b/packages/lab/src/side-panel/SidePanel.tsx new file mode 100644 index 00000000000..d306d942b09 --- /dev/null +++ b/packages/lab/src/side-panel/SidePanel.tsx @@ -0,0 +1,215 @@ +import { FloatingFocusManager } from "@floating-ui/react"; +import { makePrefixer, useFloatingUI, useForkRef, useId } from "@salt-ds/core"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import { useWindow } from "@salt-ds/window"; +import { clsx } from "clsx"; +import { + type AnimationEvent, + type ComponentProps, + type ComponentPropsWithRef, + forwardRef, + useEffect, + useRef, + useState, +} from "react"; +import { useSidePanelContext } from "./internal"; +import sidePanelCss from "./SidePanel.css"; + +const withBaseName = makePrefixer("saltSidePanel"); + +export interface SidePanelProps extends ComponentPropsWithRef<"div"> { + /** + * Disable the panel's own open/close animation. + * Set to `true` when the parent controls sizing and animation (e.g. inside a splitter). + * @default false + */ + disableAnimation?: boolean; + /** + * Edge the panel is anchored to; controls animation direction and divider side. + * @default "right" + */ + position?: "right" | "left"; + /** + * Which element receives focus when the panel opens. + * Pass a number for the tabbable element index (0 = first), or a ref to a specific element. + * Defaults to the side panel close button. + */ + initialFocus?: ComponentProps["initialFocus"]; + /** + * The background color palette. Options are 'primary', 'secondary', 'tertiary' and 'none'. + * @default "primary" + */ + variant?: "primary" | "secondary" | "tertiary" | "none"; +} + +export const SidePanel = forwardRef( + function SidePanel(props, ref) { + const { + disableAnimation = false, + position = "right", + initialFocus, + variant = "primary", + children, + id: idProp, + className, + "aria-labelledby": ariaLabelledBy, + ...rest + } = props; + + const { openState, floatingRootContext, setFloating, setPanelId, titleId } = + useSidePanelContext(); + + const id = useId(idProp); + + const [showComponent, setShowComponent] = useState(openState); + const [animating, setAnimating] = useState(() => { + if (!openState || disableAnimation) return false; + // Animate on first mount only when the trigger has focus, indicating a + // user-initiated open. + const reference = floatingRootContext.elements.reference; + if (!(reference instanceof Element)) return false; + const activeElement = reference.ownerDocument?.activeElement; + return !!activeElement && reference.contains(activeElement); + }); + // On first mount while open, skip moving focus when focus did not come from the trigger. + const [skipInitialFocus, setSkipInitialFocus] = useState(() => { + if (!openState) return false; + const reference = floatingRootContext.elements.reference; + if (!(reference instanceof Element)) return true; + const activeElement = reference.ownerDocument?.activeElement; + return !activeElement || !reference.contains(activeElement); + }); + // Track whether this is the initial render to skip the open animation. + const initialRender = useRef(true); + const targetWindow = useWindow(); + + useComponentCssInjection({ + testId: "salt-side-panel", + css: sidePanelCss, + window: targetWindow, + }); + + const { context } = useFloatingUI({ + rootContext: floatingRootContext, + }); + + const handleRef = useForkRef(setFloating, ref); + + const handleAnimationEnd = (event: AnimationEvent) => { + if (event.currentTarget !== event.target) return; + setAnimating(false); + if (!openState) { + setShowComponent(false); + } + }; + + useEffect(() => { + // Keep this as state (not ref): setPanelId causes a context re-render and + // this value is consumed as a prop for initial focus behavior. + setPanelId(id); + + return () => { + setPanelId(undefined); + }; + }, [id, setPanelId]); + + useEffect(() => { + if (!openState) { + setSkipInitialFocus(false); + } + }, [openState]); + + useEffect(() => { + if (disableAnimation) { + // When animation is disabled, show/hide immediately since there is + // no exit animation to wait for before unmounting. + setShowComponent(openState); + setAnimating(false); + initialRender.current = false; + return; + } + + // Skip animation on initial render when panel is already open + // without a trigger interaction (i.e. defaultOpen scenario). + if (initialRender.current && openState) { + const reference = floatingRootContext.elements.reference; + if (!(reference instanceof Element)) { + setShowComponent(true); + setAnimating(false); + initialRender.current = false; + return; + } + } + + const prefersReducedMotion = targetWindow?.matchMedia?.( + "(prefers-reduced-motion: reduce)", + )?.matches; + + if (openState) { + setShowComponent(true); + } + + if (prefersReducedMotion) { + setAnimating(false); + if (!openState) { + setShowComponent(false); + } + } else { + setAnimating(true); + } + + initialRender.current = false; + }, [ + openState, + targetWindow, + disableAnimation, + floatingRootContext.elements.reference, + ]); + + if (!showComponent) return null; + + // `-1` skips initial focus movement but preserves focus guards/return focus handling. + const resolvedInitialFocus = skipInitialFocus ? -1 : (initialFocus ?? 0); + + const panelDiv = ( +
+
{children}
+
+ ); + + if (openState || animating) { + return ( + + {panelDiv} + + ); + } + + return panelDiv; + }, +); diff --git a/packages/lab/src/side-panel/SidePanelContent.css b/packages/lab/src/side-panel/SidePanelContent.css new file mode 100644 index 00000000000..b82e6096bb9 --- /dev/null +++ b/packages/lab/src/side-panel/SidePanelContent.css @@ -0,0 +1,12 @@ +.saltSidePanelContent { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + box-sizing: border-box; +} + +.saltSidePanelContent-body { + flex: 1; + overflow: auto; +} diff --git a/packages/lab/src/side-panel/SidePanelContent.tsx b/packages/lab/src/side-panel/SidePanelContent.tsx new file mode 100644 index 00000000000..beedb392a1b --- /dev/null +++ b/packages/lab/src/side-panel/SidePanelContent.tsx @@ -0,0 +1,116 @@ +import { makePrefixer, useId } from "@salt-ds/core"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import { useWindow } from "@salt-ds/window"; +import { clsx } from "clsx"; +import { + type ComponentPropsWithRef, + forwardRef, + useEffect, + useRef, + useState, +} from "react"; +import { useSidePanelContext } from "./internal"; +import sidePanelContentCss from "./SidePanelContent.css"; + +const withBaseName = makePrefixer("saltSidePanelContent"); + +export interface SidePanelContentProps extends ComponentPropsWithRef<"div"> {} + +export const SidePanelContent = forwardRef< + HTMLDivElement, + SidePanelContentProps +>(function SidePanelContent(props, ref) { + const { + children, + className, + "aria-labelledby": ariaLabelledBy, + "aria-label": ariaLabel, + ...rest + } = props; + + const { titleId } = useSidePanelContext(); + const targetWindow = useWindow(); + const contentSuffixId = useId(); + const bodyRef = useRef(null); + const [isScrollable, setIsScrollable] = useState(false); + + // Monitor scrollability of the body element + useEffect(() => { + const bodyElement = bodyRef.current; + if (!bodyElement) { + return; + } + + const checkScrollable = () => { + // Element is scrollable if scrollHeight > clientHeight + setIsScrollable( + bodyElement.scrollHeight > bodyElement.clientHeight || + bodyElement.scrollWidth > bodyElement.clientWidth, + ); + }; + + // Check immediately + checkScrollable(); + + // Use ResizeObserver to detect when the panel resizes + const resizeObserver = new ResizeObserver(() => { + checkScrollable(); + }); + + resizeObserver.observe(bodyElement); + + // Use MutationObserver to detect when dynamic content is added/removed, + // since child size changes don't trigger ResizeObserver on the parent. + const mutationObserver = new MutationObserver(checkScrollable); + mutationObserver.observe(bodyElement, { childList: true, subtree: true }); + + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, []); + + useComponentCssInjection({ + testId: "salt-side-panel-content", + css: sidePanelContentCss, + window: targetWindow, + }); + + const explicitLabelledBy = ariaLabelledBy; + const explicitLabel = ariaLabel; + + let bodyAriaLabelledBy: string | undefined; + let bodyAriaLabel: string | undefined; + + if (isScrollable) { + if (explicitLabelledBy) { + bodyAriaLabelledBy = explicitLabelledBy; + } else if (titleId) { + bodyAriaLabelledBy = clsx(titleId, contentSuffixId) || undefined; + } else { + bodyAriaLabel = explicitLabel ?? "Content"; + } + } + + return ( +
+ {isScrollable ? ( + + ) : null} +
+ {children} +
+
+ ); +}); diff --git a/packages/lab/src/side-panel/SidePanelHeader.css b/packages/lab/src/side-panel/SidePanelHeader.css new file mode 100644 index 00000000000..dd35805ca4f --- /dev/null +++ b/packages/lab/src/side-panel/SidePanelHeader.css @@ -0,0 +1,7 @@ +.saltSidePanelHeader { + display: flex; + align-items: center; + flex-shrink: 0; + gap: var(--salt-spacing-100); + padding: 0 0 var(--salt-spacing-300); +} diff --git a/packages/lab/src/side-panel/SidePanelHeader.tsx b/packages/lab/src/side-panel/SidePanelHeader.tsx new file mode 100644 index 00000000000..def39aff0bf --- /dev/null +++ b/packages/lab/src/side-panel/SidePanelHeader.tsx @@ -0,0 +1,30 @@ +import { makePrefixer } from "@salt-ds/core"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import { useWindow } from "@salt-ds/window"; +import { clsx } from "clsx"; +import { type ComponentPropsWithRef, forwardRef } from "react"; +import sidePanelHeaderCss from "./SidePanelHeader.css"; + +const withBaseName = makePrefixer("saltSidePanelHeader"); + +export interface SidePanelHeaderProps extends ComponentPropsWithRef<"div"> {} + +export const SidePanelHeader = forwardRef( + function SidePanelHeader(props, ref) { + const { children, className, ...rest } = props; + + const targetWindow = useWindow(); + + useComponentCssInjection({ + testId: "salt-side-panel-header", + css: sidePanelHeaderCss, + window: targetWindow, + }); + + return ( +
+ {children} +
+ ); + }, +); diff --git a/packages/lab/src/side-panel/SidePanelProvider.tsx b/packages/lab/src/side-panel/SidePanelProvider.tsx new file mode 100644 index 00000000000..46400970149 --- /dev/null +++ b/packages/lab/src/side-panel/SidePanelProvider.tsx @@ -0,0 +1,104 @@ +import { useFloatingRootContext } from "@floating-ui/react"; +import { useControlled } from "@salt-ds/core"; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import { SidePanelContext } from "./internal"; + +export interface SidePanelProviderProps { + /** + * Whether the panel is open. + */ + open?: boolean; + /** + * Default open state when initially rendered. + */ + defaultOpen?: boolean; + /** + * Callback when open state changes. + */ + onOpenChange?: (open: boolean) => void; + /** + * SidePanelProvider children, should include SidePanel and SidePanelTrigger. + */ + children: ReactNode; +} + +export function SidePanelProvider(props: SidePanelProviderProps) { + const { children, open: openProp, defaultOpen, onOpenChange } = props; + + const [openState, setOpenState] = useControlled({ + default: Boolean(defaultOpen), + controlled: openProp, + name: "SidePanelProvider", + state: "open", + }); + + const handleOpenChange = useCallback( + (newOpen: boolean) => { + setOpenState(newOpen); + onOpenChange?.(newOpen); + }, + [onOpenChange], + ); + + const [reference, setReference] = useState(null); + const [floating, setFloating] = useState(null); + const [panelId, setPanelId] = useState(undefined); + const [titleId, setTitleId] = useState(undefined); + + const floatingRootContext = useFloatingRootContext({ + open: openState, + onOpenChange: handleOpenChange, + elements: { + reference, + floating, + }, + }); + + useEffect(() => { + if (!openState || !floating) { + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") { + return; + } + + event.preventDefault(); + event.stopPropagation(); + handleOpenChange(false); + }; + + floating.addEventListener("keydown", onKeyDown); + return () => { + floating.removeEventListener("keydown", onKeyDown); + }; + }, [floating, openState, handleOpenChange]); + + const context = useMemo( + () => ({ + openState, + floatingRootContext, + setFloating, + setReference, + setOpen: handleOpenChange, + panelId, + setPanelId, + titleId, + setTitleId, + }), + [openState, floatingRootContext, handleOpenChange, panelId, titleId], + ); + + return ( + + {children} + + ); +} diff --git a/packages/lab/src/side-panel/SidePanelTitle.css b/packages/lab/src/side-panel/SidePanelTitle.css new file mode 100644 index 00000000000..527f456162a --- /dev/null +++ b/packages/lab/src/side-panel/SidePanelTitle.css @@ -0,0 +1,4 @@ +.saltSidePanelTitle { + flex: 1; + min-width: 0; +} diff --git a/packages/lab/src/side-panel/SidePanelTitle.tsx b/packages/lab/src/side-panel/SidePanelTitle.tsx new file mode 100644 index 00000000000..8c4b4fcb03b --- /dev/null +++ b/packages/lab/src/side-panel/SidePanelTitle.tsx @@ -0,0 +1,56 @@ +import { + makePrefixer, + Text, + type TextProps, + useId, + useIsomorphicLayoutEffect, +} from "@salt-ds/core"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import { useWindow } from "@salt-ds/window"; +import { clsx } from "clsx"; +import { forwardRef } from "react"; +import { useSidePanelContext } from "./internal"; +import sidePanelTitleCss from "./SidePanelTitle.css"; + +const withBaseName = makePrefixer("saltSidePanelTitle"); + +export interface SidePanelTitleProps extends TextProps<"div"> {} + +export const SidePanelTitle = forwardRef( + function SidePanelTitle(props, ref) { + const { children, className, id, styleAs = "h2", ...rest } = props; + + const { setTitleId } = useSidePanelContext(); + const targetWindow = useWindow(); + + useComponentCssInjection({ + testId: "salt-side-panel-title", + css: sidePanelTitleCss, + window: targetWindow, + }); + + const titleId = useId(id); + + useIsomorphicLayoutEffect(() => { + if (titleId) { + setTitleId(titleId); + } + + return () => { + setTitleId(undefined); + }; + }, [titleId, setTitleId]); + + return ( + + {children} + + ); + }, +); diff --git a/packages/lab/src/side-panel/SidePanelTrigger.tsx b/packages/lab/src/side-panel/SidePanelTrigger.tsx new file mode 100644 index 00000000000..56d0ddc71e1 --- /dev/null +++ b/packages/lab/src/side-panel/SidePanelTrigger.tsx @@ -0,0 +1,51 @@ +import { mergeProps, useForkRef } from "@salt-ds/core"; +import { + type ComponentPropsWithoutRef, + cloneElement, + forwardRef, + isValidElement, + type MouseEvent, + type ReactNode, +} from "react"; +import { useSidePanelContext } from "./internal"; + +export interface SidePanelTriggerProps + extends ComponentPropsWithoutRef<"button"> { + children?: ReactNode; +} + +export const SidePanelTrigger = forwardRef< + HTMLButtonElement, + SidePanelTriggerProps +>(function SidePanelTrigger(props, ref) { + const { children, onClick, ...rest } = props; + const { setReference, openState, setOpen, panelId } = useSidePanelContext(); + + const childRef = (children as { ref?: React.Ref })?.ref; + const combinedRef = useForkRef(setReference, ref); + const handleRef = useForkRef(combinedRef, childRef); + + const handleClick = (event: MouseEvent) => { + onClick?.(event); + setOpen(!openState); + }; + + if (!children || !isValidElement(children)) { + return <>{children}; + } + + const mergedProps = mergeProps( + { + "aria-expanded": openState, + "aria-controls": openState ? panelId : undefined, + ...rest, + onClick: handleClick, + }, + children.props, + ); + + return cloneElement(children, { + ...mergedProps, + ref: handleRef, + }); +}); diff --git a/packages/lab/src/side-panel/index.ts b/packages/lab/src/side-panel/index.ts new file mode 100644 index 00000000000..aa4f38e0712 --- /dev/null +++ b/packages/lab/src/side-panel/index.ts @@ -0,0 +1,7 @@ +export * from "./SidePanel"; +export * from "./SidePanelContent"; +export * from "./SidePanelHeader"; +export * from "./SidePanelProvider"; +export * from "./SidePanelTitle"; +export * from "./SidePanelTrigger"; +export * from "./useSidePanel"; diff --git a/packages/lab/src/side-panel/internal/SidePanelContext.ts b/packages/lab/src/side-panel/internal/SidePanelContext.ts new file mode 100644 index 00000000000..3e223a6b133 --- /dev/null +++ b/packages/lab/src/side-panel/internal/SidePanelContext.ts @@ -0,0 +1,67 @@ +import type { FloatingRootContext } from "@floating-ui/react"; +import { createContext } from "@salt-ds/core"; +import { type Dispatch, type SetStateAction, useContext } from "react"; + +export interface SidePanelContextValue { + /** + * Whether the side panel is currently open. + */ + openState: boolean; + /** + * The floating-ui root context shared between the trigger and the panel. + * Coordinates interactions (click, dismiss, role) across both elements. + */ + floatingRootContext: FloatingRootContext; + /** + * Ref setter for the panel element. + * Registers the panel DOM node with floating-ui. + */ + setFloating: Dispatch>; + /** + * Ref setter for the reference (trigger) element. + * Registers the trigger DOM node with floating-ui for focus return. + */ + setReference: Dispatch>; + /** + * Sets the open state of the panel. + * Called by the close button in SidePanelHeader, or any consumer that needs to close the panel. + */ + setOpen: (open: boolean) => void; + /** + * Side panel id used for aria-controls on the trigger. + */ + panelId?: string; + /** + * Registers or clears the side panel id used for aria-controls/id linkage. + */ + setPanelId: Dispatch>; + /** + * The auto-generated id placed on SidePanelTitle. + * Used for aria-labelledby on the panel region and the scrollable body. + */ + titleId?: string; + /** + * Registers the title id from SidePanelTitle back to the context + * so that SidePanel and SidePanelContent can use it for aria-labelledby. + */ + setTitleId: Dispatch>; +} + +export const SidePanelContext = createContext( + "SidePanelContext", + { + openState: false, + floatingRootContext: {} as FloatingRootContext, + setFloating: () => {}, + setReference: () => {}, + setOpen: () => {}, + panelId: undefined, + setPanelId: () => {}, + titleId: undefined, + setTitleId: () => {}, + }, +); + +export function useSidePanelContext() { + return useContext(SidePanelContext); +} diff --git a/packages/lab/src/side-panel/internal/index.ts b/packages/lab/src/side-panel/internal/index.ts new file mode 100644 index 00000000000..a8076965047 --- /dev/null +++ b/packages/lab/src/side-panel/internal/index.ts @@ -0,0 +1,5 @@ +export { + SidePanelContext, + type SidePanelContextValue, + useSidePanelContext, +} from "./SidePanelContext"; diff --git a/packages/lab/src/side-panel/useSidePanel.ts b/packages/lab/src/side-panel/useSidePanel.ts new file mode 100644 index 00000000000..f7946616346 --- /dev/null +++ b/packages/lab/src/side-panel/useSidePanel.ts @@ -0,0 +1,82 @@ +import { useCallback } from "react"; +import { useSidePanelContext } from "./internal"; + +export interface SidePanelValue { + /** + * Whether the side panel is currently open. + */ + openState: boolean; + /** + * Sets the open state of the panel. + */ + setOpen: (open: boolean) => void; + /** + * Props getter for a single trigger element. + * Merges `aria-expanded`, `aria-controls`, a `ref` callback (to register + * the trigger for focus-return), and user-provided props. + * Best for the common case where one button toggles one panel. + * + * For multi-trigger scenarios (e.g. table rows), use `setTriggerRef` and + * manage ARIA attributes yourself instead. + */ + getTriggerProps: ( + userProps?: Record, + ) => Record; + /** + * Registers the element that should receive focus when the panel closes. + * Use this in multi-trigger scenarios (e.g. table rows) where each trigger + * needs explicit control over which element is the reference. + */ + setTriggerRef: (element: HTMLElement | null) => void; + /** + * The panel's DOM id. Use this for `aria-controls` in multi-trigger + * scenarios where you manage ARIA attributes yourself. + */ + panelId: string | undefined; +} + +export function useSidePanel(): SidePanelValue { + const { openState, setOpen, setReference, panelId } = useSidePanelContext(); + + const getTriggerProps = useCallback( + (userProps?: Record) => { + const userOnClick = userProps?.onClick as + | ((e: React.MouseEvent) => void) + | undefined; + + return { + "aria-expanded": openState, + "aria-controls": openState ? panelId : undefined, + ...userProps, + onClick: (e: React.MouseEvent) => { + userOnClick?.(e); + }, + ref: (node: HTMLElement | null) => { + // Register this element as the trigger for focus return + setReference(node); + // Forward the consumer's ref if provided + const userRef = userProps?.ref; + if (typeof userRef === "function") { + userRef(node); + } else if ( + userRef && + typeof userRef === "object" && + "current" in userRef + ) { + (userRef as React.MutableRefObject).current = + node; + } + }, + }; + }, + [openState, panelId, setReference], + ); + + return { + openState, + setOpen, + getTriggerProps, + setTriggerRef: setReference, + panelId, + }; +} diff --git a/packages/lab/stories/side-panel/side-panel.qa.stories.tsx b/packages/lab/stories/side-panel/side-panel.qa.stories.tsx new file mode 100644 index 00000000000..a4694aba936 --- /dev/null +++ b/packages/lab/stories/side-panel/side-panel.qa.stories.tsx @@ -0,0 +1,151 @@ +import { StackLayout, Text } from "@salt-ds/core"; + +import { + SidePanel, + SidePanelContent, + SidePanelHeader, + type SidePanelProps, + SidePanelProvider, + SidePanelTitle, +} from "@salt-ds/lab"; + +import type { Meta, StoryFn } from "@storybook/react-vite"; +import { QAContainer, type QAContainerProps } from "docs/components"; +import type { ReactNode } from "react"; + +export default { + title: "Lab/Side Panel/Side Panel QA", + component: SidePanel, +} as Meta; + +function FakeSidePanel({ + children, + variant = "primary", + position = "right", +}: { + children: ReactNode; + variant?: SidePanelProps["variant"]; + position?: SidePanelProps["position"]; +}) { + return ( + + + {children} + + + ); +} + +const loremText = + "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s."; + +const SidePanelTemplate: StoryFn = ({ + variant = "primary", + position = "right", +}) => { + return ( +
+ + + Title + + + {loremText} + + +
+ ); +}; + +export const OpenLeft: StoryFn = (props) => { + const { ...rest } = props; + + return ( + + + + + + + ); +}; + +OpenLeft.parameters = { + chromatic: { disableSnapshot: false }, + actions: { disable: true }, +}; + +export const OpenRight: StoryFn = (props) => { + const { ...rest } = props; + + return ( + + + + + + + ); +}; + +OpenRight.parameters = { + chromatic: { disableSnapshot: false }, + actions: { disable: true }, +}; + +export const CustomTitle: StoryFn = () => ( +
+ + + Custom H3 Title + + + {loremText} + + +
+); + +CustomTitle.parameters = { + chromatic: { disableSnapshot: false }, +}; + +export const ScrollableContent: StoryFn = () => ( +
+ + + Scrollable + + + + {Array.from({ length: 10 }, (_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: Static list of identical placeholder items + + Paragraph {i + 1}: {loremText} + + ))} + + + +
+); + +ScrollableContent.parameters = { + chromatic: { disableSnapshot: false }, +}; diff --git a/packages/lab/stories/side-panel/side-panel.stories.css b/packages/lab/stories/side-panel/side-panel.stories.css new file mode 100644 index 00000000000..ebf9bc380e3 --- /dev/null +++ b/packages/lab/stories/side-panel/side-panel.stories.css @@ -0,0 +1,11 @@ +.visuallyHidden { + position: absolute; + height: 1px; + width: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} diff --git a/packages/lab/stories/side-panel/side-panel.stories.tsx b/packages/lab/stories/side-panel/side-panel.stories.tsx new file mode 100644 index 00000000000..e593b3dd551 --- /dev/null +++ b/packages/lab/stories/side-panel/side-panel.stories.tsx @@ -0,0 +1,1309 @@ +import { + BorderItem, + BorderLayout, + Button, + Card, + FlexItem, + FlexLayout, + FormField, + FormFieldLabel, + Input, + Link, + RadioButton, + RadioButtonGroup, + StackLayout, + Table, + TableContainer, + TBody, + TD, + Text, + TH, + THead, + Tooltip, + TR, + useIcon, +} from "@salt-ds/core"; +import { + ChattingIcon, + DoubleChevronRightIcon, + HelpCircleIcon, + NotificationIcon, + SearchIcon, +} from "@salt-ds/icons"; +import { + SidePanel, + SidePanelContent, + SidePanelHeader, + type SidePanelProps, + SidePanelProvider, + SidePanelTitle, + SidePanelTrigger, + type SidePanelValue, + useSidePanel, +} from "@salt-ds/lab"; +import type { Meta, StoryFn } from "@storybook/react-vite"; +import "@salt-ds/react-resizable-panels-theme/index.css"; +import type React from "react"; +import { + type ChangeEvent, + type ChangeEventHandler, + type CSSProperties, + type ReactNode, + useCallback, + useRef, + useState, +} from "react"; +import { + type ImperativePanelHandle, + Panel, + PanelGroup, + PanelResizeHandle, +} from "react-resizable-panels"; +import "./side-panel.stories.css"; + +export default { + title: "Lab/Side Panel", + component: SidePanel, + parameters: { + layout: "fullscreen", + }, +} as Meta; + +const ContentExample = ({ children }: { children?: ReactNode }) => ( +
+ {children} +
+ {Array.from({ length: 6 }, (_, i) => ( +
+ ))} +
+
+); + +export const Default: StoryFn = () => { + return ( + + + + ); +}; + +const DefaultContent = () => { + const { CloseIcon } = useIcon(); + const { setOpen } = useSidePanel(); + + return ( + + + + + + + + + + Section Title + + + + Side panel content goes here. + + + + ); +}; + +export const Left: StoryFn = () => { + return ( + + + + ); +}; + +const LeftContent = () => { + const { CloseIcon } = useIcon(); + const { setOpen } = useSidePanel(); + + return ( + + + + Section Title + + + + Side panel content goes here. + + + + + + + + + + ); +}; + +// --------------------------------------------------------------------------- +// ManualTrigger (left + right panels with independent providers) +// --------------------------------------------------------------------------- + +const manualPanelStyle = { + "--saltSidePanel-width": "200px", +} as CSSProperties; + +const ManualRightPanel = () => { + const { CloseIcon } = useIcon(); + const { setOpen } = useSidePanel(); + return ( + + + Right Panel + + + + Right panel content. + + + ); +}; + +const ManualLeftPanel = () => { + const { CloseIcon } = useIcon(); + const { setOpen } = useSidePanel(); + return ( + + + Left Panel + + + + Left panel content. + + + ); +}; + +const ManualTriggerButton = ({ + children, + context, +}: { + children: string; + context: SidePanelValue; +}) => { + const { openState, setOpen, getTriggerProps, panelId } = context; + + return ( + + ); +}; + +const ManualRightPanelTriggerButton = () => { + const rightPanelContext = useSidePanel(); + + return ( + + Toggle right panel + + ); +}; + +const ManualContentArea = () => { + const leftPanelContext = useSidePanel(); + + return ( + + + + + Toggle left panel + + + + + + + ); +}; + +export const ManualTrigger: StoryFn = () => ( +
+ + + + +
+); + +const variantOptions = ["primary", "secondary", "tertiary", "none"]; + +export const Variants: StoryFn = () => { + return ( + + + + ); +}; + +const VariantsContent = () => { + const [variant, setVariant] = useState("primary"); + const { CloseIcon } = useIcon(); + const { setOpen } = useSidePanel(); + + const handleVariantChange: ChangeEventHandler = (event) => { + setVariant(event.target.value as SidePanelProps["variant"]); + }; + + return ( + + + + + + + + Variant + + {variantOptions.map((option) => ( + + ))} + + + + + + + + Section Title + + + + Side panel content goes here. + + + + ); +}; + +interface TeamMember { + id: string; + name: string; + email: string; + phone: string; +} + +const tableData: TeamMember[] = [ + { + id: "1", + name: "Alex Morgan", + email: "alex.morgan@example.com", + phone: "+1 212 555 0101", + }, + { + id: "2", + name: "Taylor Reed", + email: "taylor.reed@example.com", + phone: "+1 212 555 0102", + }, + { + id: "3", + name: "Jordan Lee", + email: "jordan.lee@example.com", + phone: "+1 212 555 0103", + }, + { + id: "4", + name: "Casey Patel", + email: "casey.patel@example.com", + phone: "+1 212 555 0104", + }, + { + id: "5", + name: "Riley Chen", + email: "riley.chen@example.com", + phone: "+1 212 555 0105", + }, +]; + +const withTablePanelStyle = { + "--saltSidePanel-width": "250px", +} as CSSProperties; + +export const WithTable: StoryFn = () => { + return ( + + + + ); +}; + +const WithTableContent = () => { + const [selectedRow, setSelectedRow] = useState(null); + const [data, setData] = useState(tableData); + const [formValues, setFormValues] = useState(null); + const { CloseIcon } = useIcon(); + const { setOpen, openState, setTriggerRef, panelId } = useSidePanel(); + + const handleRowClick = (row: TeamMember, target: HTMLElement) => { + if (openState && selectedRow?.id === row.id) { + setOpen(false); + return; + } + setTriggerRef(target); + setSelectedRow(row); + setFormValues({ ...row }); + setOpen(true); + }; + + const handleSave = () => { + if (formValues) { + setData((prev) => + prev.map((r) => (r.id === formValues.id ? formValues : r)), + ); + } + setOpen(false); + }; + + const getRowTriggerProps = (row: TeamMember) => { + const isExpanded = openState && selectedRow?.id === row.id; + + return { + "aria-expanded": isExpanded, + "aria-controls": isExpanded ? panelId : undefined, + "aria-label": `Edit details for ${row.name}`, + onClick: (e: React.MouseEvent) => { + handleRowClick(row, e.currentTarget); + }, + }; + }; + + return ( + +
+ + + + + + + + + + + + + {data.map((row) => ( + + + + + + + ))} + +
Users
NameEmailPhoneAction
{row.name}{row.email}{row.phone} + +
+
+
+ + + {formValues && ( + <> + + + {formValues.name} + Employee Details + + + + + + + Name + ) => + setFormValues( + (v) => v && { ...v, name: event.target.value }, + ) + } + /> + + + Email + ) => + setFormValues( + (v) => v && { ...v, email: event.target.value }, + ) + } + /> + + + Phone + ) => + setFormValues( + (v) => v && { ...v, phone: event.target.value }, + ) + } + /> + + + + + + + + + )} + +
+ ); +}; + +const DesktopAppHeader = () => { + return ( +
+ + + App name + + } + placeholder="Search" + style={{ width: 200 }} + /> + + + + + + + + + + + + + + + + + +
+ ); +}; + +const WithAppHeaderPanel = () => { + const { CloseIcon } = useIcon(); + const { setOpen } = useSidePanel(); + return ( + + + Help & support + + + + + The content shown here is for illustrative purposes and does not + contain specific information or advice. Using placeholder text like + this helps review formatting, spacing, and overall presentation in the + user interface. Adjust the wording as needed to suit your particular + requirements or design preferences. + + + + ); +}; + +export const WithAppHeader: StoryFn = () => { + return ( + + + + + + + + Link 1 + Link 2 + Link 3 + + + + + + + + + ); +}; + +// --------------------------------------------------------------------------- +// Scrollable +// --------------------------------------------------------------------------- + +const ScrollableContent = () => ( +
+ + + +
+ {Array.from({ length: 12 }, (_, i) => ( +
+ ))} +
+
+); + +const ScrollablePanel = () => { + const { CloseIcon } = useIcon(); + const { setOpen } = useSidePanel(); + return ( + + + Section Title + + + + + {Array.from({ length: 10 }, (_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: In this case, using index as key is acceptable + + Panel item — This is scrollable content inside the side panel that + demonstrates independent scrolling. + + ))} + + + + ); +}; + +export const Scrollable: StoryFn = () => { + return ( + + + + + + + ); +}; + +/** State, refs, toggle logic and CSS transition for animating a + * react-resizable-panels Panel as a SidePanel. */ +function useResizableSidePanel({ + defaultExpanded = false, + expandedSize = 25, +}: { + defaultExpanded?: boolean; + expandedSize?: number; +} = {}) { + const panelRef = useRef(null); + const [expanded, setExpanded] = useState(defaultExpanded); + const [animating, setAnimating] = useState(false); + const timerRef = useRef>(); + + const toggle = useCallback(() => { + if (!panelRef.current) return; + clearTimeout(timerRef.current); + const willExpand = !expanded; + const duration = + Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue( + "--salt-duration-perceptible", + ), + 10, + ) || 300; // var(--salt-duration-perceptible) + setAnimating(true); + setExpanded(willExpand); + requestAnimationFrame(() => { + panelRef.current?.resize(willExpand ? expandedSize : 0); + }); + timerRef.current = setTimeout(() => setAnimating(false), duration); + }, [expanded, expandedSize]); + + const panelTransition = animating + ? "flex-grow var(--salt-duration-perceptible) var(--salt-animation-timing-function)" + : undefined; + + const handleOpenChange = useCallback((_open: boolean) => toggle(), [toggle]); + + return { + panelRef, + expanded, + animating, + toggle, + panelTransition, + handleOpenChange, + }; +} + +/** Style overrides for a SidePanel inside a resizable Panel (the Panel itself + * controls width so the SidePanel's own width and border are disabled). */ +const resizableSidePanelStyle = { + "--saltSidePanel-width": "100%", +} as CSSProperties; + +const ResizablePanel = ({ style }: { style?: CSSProperties }) => { + const { CloseIcon } = useIcon(); + const { setOpen } = useSidePanel(); + return ( + + + Section Title + + + + Side panel content goes here. + + + ); +}; + +export const Resizable: StoryFn = () => { + const { panelRef, expanded, animating, panelTransition, handleOpenChange } = + useResizableSidePanel({ expandedSize: 30 }); + + return ( + +
+ + + + + + + + + + + + + +
+
+ ); +}; + +const Nav = () => ( + +); + +export const WithNav: StoryFn = () => { + return ( + + + + ); +}; + +const WithNavContent = () => { + const { CloseIcon } = useIcon(); + const { setOpen } = useSidePanel(); + + return ( + +