diff --git a/.changeset/sweet-snails-sneeze.md b/.changeset/sweet-snails-sneeze.md new file mode 100644 index 00000000000..a1390689500 --- /dev/null +++ b/.changeset/sweet-snails-sneeze.md @@ -0,0 +1,16 @@ +--- +"@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. + +```tsx + + + + + + +``` 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..668f3cd35ab --- /dev/null +++ b/packages/lab/src/__tests__/__e2e__/side-panel/SidePanel.cy.tsx @@ -0,0 +1,544 @@ +import { SidePanel } from "@salt-ds/lab"; +import * as sidePanel from "@stories/side-panel/side-panel.stories"; +import { composeStories } from "@storybook/react-vite"; + +const { Left, Default, ManualTrigger, Variants, WithTable } = + composeStories(sidePanel); + +describe("GIVEN a SidePanel component", () => { + describe("Rendering & Position Variants", () => { + it("WHEN Left panel is opened, THEN displays correctly with ARIA attributes", () => { + cy.mount(); + + cy.findByRole("button", { name: "Open Left Panel" }) + .should("have.attr", "aria-expanded", "false") + .and("have.attr", "aria-controls"); + + 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" }).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"); + }); + + it("WHEN Default panel is opened, THEN displays with correct position class and ARIA attributes", () => { + cy.mount(); + + cy.findByRole("button", { name: "Open Default Panel" }) + .should("have.attr", "aria-expanded", "false") + .and("have.attr", "aria-controls"); + + cy.findByRole("button", { name: "Open Default Panel" }).click(); + cy.findByRole("button", { name: "Open Default Panel" }).should( + "have.attr", + "aria-expanded", + "true", + ); + cy.findByRole("region") + .should("have.class", "saltSidePanel-right") + .and("be.visible"); + + cy.findByRole("button", { name: "Close" }).click(); + cy.findByRole("button", { name: "Open Default Panel" }).should( + "have.attr", + "aria-expanded", + "false", + ); + + cy.findByRole("region").should("not.exist"); + }); + + it("WHEN ManualTrigger is used, THEN aria-expanded and aria-controls are managed correctly", () => { + cy.mount(); + + cy.findByRole("button", { name: "Open Manual Panel" }) + .should("have.attr", "aria-expanded", "false") + .and("have.attr", "aria-controls"); + + cy.findByRole("button", { name: "Open Manual Panel" }).click(); + + cy.findByRole("button", { name: "Open Manual Panel" }).should( + "have.attr", + "aria-expanded", + "true", + ); + }); + }); + + describe("State Management", () => { + describe("WHEN mounted as an uncontrolled component", () => { + it("AND panel closes via button or Escape and reopens, THEN maintains state correctly", () => { + cy.mount(); + + 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("button", { name: "Close" }).click(); + 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"); + }); + }); + + describe("WHEN mounted as a controlled component", () => { + it("AND using manual trigger with onOpenChange, THEN callback fires with correct value", () => { + const onOpenChange = cy.stub().as("onOpenChange"); + + cy.mount( + <> + + Content + + + , + ); + + cy.findByRole("region").should("not.exist"); + + cy.findByRole("button", { name: "Open Panel" }).click(); + + cy.get("@onOpenChange").should("have.been.calledWith", true); + }); + + it("AND panel onOpenChange is called with false on Escape", () => { + const onOpenChange = cy.stub().as("onOpenChange"); + + cy.mount( + + Content + , + ); + + cy.findByRole("region", { name: "Test Panel" }) + .should("be.visible") + .focus(); + + cy.realPress("Escape"); + + cy.get("@onOpenChange").should("have.been.calledWith", false); + }); + }); + + describe("WHEN checking ARIA attributes and accessibility", () => { + it("THEN aria-controls attribute links button to panel id correctly", () => { + cy.mount(); + + cy.findByRole("button", { name: "Open Left Panel" }) + .invoke("attr", "aria-controls") + .then((panelId) => { + cy.findByRole("button", { name: "Open Left Panel" }).click(); + + cy.findByRole("region").should("have.attr", "id", panelId); + }); + }); + + it("AND panel uses aria-label, THEN label is accessible via role query", () => { + cy.mount( + {}} + aria-label="Test Panel" + > + Content + , + ); + + cy.findByRole("region", { name: "Test Panel" }) + .should("be.visible") + .and("have.attr", "aria-label", "Test Panel"); + }); + }); + }); + + describe("Focus Management", () => { + describe("WHEN panel is opened via trigger", () => { + it("THEN initial focus moves to first button", () => { + cy.mount(); + + cy.findByRole("button", { name: "Open Left Panel" }).click(); + + cy.findByRole("button", { name: "Close" }).should("have.focus"); + }); + + it("AND user tabs through content and presses Escape, THEN panel closes and focus returns to trigger", () => { + cy.mount(); + + cy.findByRole("button", { name: "Open Left Panel" }).click(); + + cy.findByRole("button", { name: "Close" }).should("have.focus"); + + cy.realPress("Tab"); + cy.findByRole("region").should("be.visible"); + + cy.realPress("Tab"); + cy.findByRole("region").should("be.visible"); + + cy.realPress("Tab"); + cy.findByRole("region").should("be.visible"); + + cy.realPress("Escape"); + + cy.findByRole("region").should("not.exist"); + cy.focused().should("have.text", "Open Left Panel"); + }); + + it("AND user navigates to form field and closes, THEN panel closes and focus returns to trigger", () => { + cy.mount(); + + cy.findByRole("button", { name: "Open Left Panel" }).click(); + + cy.findByRole("button", { name: "Close" }).should("have.focus"); + + cy.realPress("Tab"); + cy.realPress("Tab"); + + cy.findByRole("button", { name: "Close" }).click(); + + cy.findByRole("region").should("not.exist"); + cy.focused().should("have.text", "Open Left Panel"); + }); + }); + + describe("WHEN multiple panels are open simultaneously", () => { + it("AND Escape is pressed in each panel sequentially, THEN each closes and focus returns to its trigger", () => { + cy.mount(); + + cy.findByRole("button", { name: "Toggle Primary Panel" }).click(); + cy.findByRole("button", { name: "Toggle Secondary Panel" }).click(); + cy.findByRole("button", { name: "Toggle Tertiary Panel" }).click(); + + cy.findByRole("region", { name: "Primary Variant" }).should( + "be.visible", + ); + cy.findByRole("region", { name: "Secondary Variant" }).should( + "be.visible", + ); + cy.findByRole("region", { name: "Tertiary Variant" }).should( + "be.visible", + ); + + cy.findByRole("region", { name: "Secondary Variant" }).within(() => { + cy.findAllByRole("textbox").first().focus(); + }); + cy.realPress("Escape"); + cy.findByRole("region", { name: "Secondary Variant" }).should( + "not.exist", + ); + cy.findByRole("region", { name: "Primary Variant" }).should( + "be.visible", + ); + cy.findByRole("region", { name: "Tertiary Variant" }).should( + "be.visible", + ); + cy.focused().should("have.text", "Toggle Secondary Panel"); + + cy.findByRole("region", { name: "Primary Variant" }).within(() => { + cy.findAllByRole("textbox").first().focus(); + }); + cy.realPress("Escape"); + cy.findByRole("region", { name: "Primary Variant" }).should( + "not.exist", + ); + cy.findByRole("region", { name: "Tertiary Variant" }).should( + "be.visible", + ); + cy.focused().should("have.text", "Toggle Primary Panel"); + + cy.findByRole("region", { name: "Tertiary Variant" }).within(() => { + cy.findAllByRole("textbox").first().focus(); + }); + cy.realPress("Escape"); + cy.findByRole("region", { name: "Tertiary Variant" }).should( + "not.exist", + ); + cy.focused().should("have.text", "Toggle Tertiary Panel"); + }); + + it("AND trigger is clicked while another panel focused, THEN focus returns to its trigger", () => { + cy.mount(); + + cy.findByRole("button", { name: "Toggle Primary Panel" }).click(); + cy.findByRole("button", { name: "Toggle Secondary Panel" }).click(); + + cy.findByRole("region", { name: "Primary Variant" }).should( + "be.visible", + ); + cy.findByRole("region", { name: "Secondary Variant" }).should( + "be.visible", + ); + + cy.findByRole("region", { name: "Secondary Variant" }).within(() => { + cy.findAllByRole("textbox").first().focus(); + }); + + cy.findByRole("button", { name: "Toggle Secondary Panel" }).click(); + + cy.findByRole("region", { name: "Secondary Variant" }).should( + "not.exist", + ); + cy.findByRole("region", { name: "Primary Variant" }).should( + "be.visible", + ); + + cy.focused().should("have.text", "Toggle Secondary Panel"); + }); + }); + }); + + describe("Variants", () => { + it("WHEN variant panels are toggled, THEN each renders with correct style class", () => { + cy.mount(); + + cy.findByRole("button", { name: "Toggle Primary Panel" }).click(); + cy.findByRole("region", { name: "Primary Variant" }) + .should("be.visible") + .and("have.class", "saltSidePanel-primary"); + cy.findByRole("button", { name: "Toggle Primary Panel" }).click(); + cy.findByRole("region", { name: "Primary Variant" }).should("not.exist"); + + cy.findByRole("button", { name: "Toggle Secondary Panel" }).click(); + cy.findByRole("region", { name: "Secondary Variant" }) + .should("be.visible") + .and("have.class", "saltSidePanel-secondary"); + cy.findByRole("button", { name: "Toggle Secondary Panel" }).click(); + cy.findByRole("region", { name: "Secondary Variant" }).should( + "not.exist", + ); + + cy.findByRole("button", { name: "Toggle Tertiary Panel" }).click(); + cy.findByRole("region", { name: "Tertiary Variant" }) + .should("be.visible") + .and("have.class", "saltSidePanel-tertiary"); + cy.findByRole("button", { name: "Toggle Tertiary Panel" }).click(); + cy.findByRole("region", { name: "Tertiary Variant" }).should("not.exist"); + }); + + it("AND all variants are toggled sequentially, THEN each maintains independent state", () => { + cy.mount(); + + cy.findByRole("button", { name: "Toggle Primary Panel" }).click(); + cy.findByRole("button", { name: "Toggle Secondary Panel" }).click(); + cy.findByRole("button", { name: "Toggle Tertiary Panel" }).click(); + + cy.findByRole("region", { name: "Primary Variant" }).should("be.visible"); + cy.findByRole("region", { name: "Secondary Variant" }).should( + "be.visible", + ); + cy.findByRole("region", { name: "Tertiary Variant" }).should( + "be.visible", + ); + + cy.findByRole("button", { name: "Toggle Secondary Panel" }).click(); + + cy.findByRole("region", { name: "Secondary Variant" }).should( + "not.exist", + ); + cy.findByRole("region", { name: "Primary Variant" }).should("be.visible"); + cy.findByRole("region", { name: "Tertiary Variant" }).should( + "be.visible", + ); + + cy.findByRole("button", { name: "Toggle Primary Panel" }).click(); + + cy.findByRole("region", { name: "Primary Variant" }).should("not.exist"); + cy.findByRole("region", { name: "Tertiary Variant" }).should( + "be.visible", + ); + + cy.findByRole("button", { name: "Toggle Tertiary Panel" }).click(); + + cy.findByRole("region", { name: "Tertiary Variant" }).should("not.exist"); + }); + }); + + it("WHEN table row View Details button is clicked, THEN panel opens with correct employee details", () => { + cy.mount(); + + cy.findByRole("table").should("be.visible"); + cy.findByRole("columnheader", { name: "Name" }).should("be.visible"); + + cy.findAllByRole("button", { name: "View Details" }).first().click(); + + cy.findByRole("region", { name: "Employee Details" }).should("be.visible"); + cy.findByRole("region", { name: "Employee Details" }).within(() => { + cy.findByText("Alice Johnson").should("be.visible"); + cy.findByText("alice.johnson@example.com").should("be.visible"); + cy.findByText("Engineering").should("be.visible"); + }); + }); + + it("WHEN panel is open and Close button clicked, THEN panel is removed and table remains visible", () => { + cy.mount(); + + cy.findAllByRole("button", { name: "View Details" }).first().click(); + + cy.findByRole("region", { name: "Employee Details" }).should("be.visible"); + + cy.findByRole("button", { name: "Close" }).click(); + + cy.findByRole("region", { name: "Employee Details" }).should("not.exist"); + cy.findByRole("table").should("be.visible"); + }); + + it("WHEN different rows are clicked sequentially, THEN panel content updates with new employee data", () => { + cy.mount(); + + cy.findAllByRole("button", { name: "View Details" }).first().click(); + + cy.findByRole("region", { name: "Employee Details" }).should("be.visible"); + cy.findByRole("region", { name: "Employee Details" }).within(() => { + cy.findByText("Alice Johnson").should("be.visible"); + }); + + cy.findByRole("button", { name: "Close" }).click(); + + cy.findByRole("region", { name: "Employee Details" }).should("not.exist"); + + cy.findAllByRole("button", { name: "View Details" }).eq(2).click(); + + cy.findByRole("region", { name: "Employee Details" }).should("be.visible"); + cy.findByRole("region", { name: "Employee Details" }).within(() => { + cy.findByText("Carol Williams").should("be.visible"); + cy.findByText("Product").should("be.visible"); + }); + }); + + it("WHEN panel is open and Escape is pressed, THEN panel closes and onOpenChange fires", () => { + const onOpenChange = cy.stub().as("onOpenChange"); + + cy.mount( + <> + + Employee Details + + + + + + + +
Test Row
+ , + ); + + cy.findByRole("region", { name: "Table Test" }) + .should("be.visible") + .focus(); + + cy.realPress("Escape"); + + cy.get("@onOpenChange").should("have.been.calledWith", false); + }); + + it("AND multiple employee rows are viewed in sequence, THEN panel content updates each time", () => { + cy.mount(); + + const scenarios = [ + { index: 0, name: "Alice Johnson", dept: "Engineering" }, + { index: 1, name: "Bob Smith", dept: "Design" }, + { index: 3, name: "David Brown", dept: "Sales" }, + ]; + + scenarios.forEach(({ index, name, dept }) => { + cy.findAllByRole("button", { name: "View Details" }).eq(index).click(); + + cy.findByRole("region", { name: "Employee Details" }).should( + "be.visible", + ); + cy.findByRole("region", { name: "Employee Details" }).within(() => { + cy.findByText(name).should("be.visible"); + cy.findByText(dept).should("be.visible"); + }); + + cy.findByRole("button", { name: "Close" }).click(); + + cy.findByRole("region", { name: "Employee Details" }).should("not.exist"); + }); + }); + + it("WHEN different View Details buttons clicked sequentially, THEN aria-expanded moves to active trigger only", () => { + cy.mount(); + + cy.findAllByRole("button", { name: "View Details" }) + .eq(0) + .should("have.attr", "aria-expanded", "false"); + cy.findAllByRole("button", { name: "View Details" }) + .eq(1) + .should("have.attr", "aria-expanded", "false"); + cy.findAllByRole("button", { name: "View Details" }) + .eq(2) + .should("have.attr", "aria-expanded", "false"); + cy.findAllByRole("button", { name: "View Details" }) + .eq(3) + .should("have.attr", "aria-expanded", "false"); + + cy.findAllByRole("button", { name: "View Details" }).eq(0).click(); + cy.findAllByRole("button", { name: "View Details" }) + .eq(0) + .should("have.attr", "aria-expanded", "true"); + cy.findAllByRole("button", { name: "View Details" }) + .eq(1) + .should("have.attr", "aria-expanded", "false"); + cy.findAllByRole("button", { name: "View Details" }) + .eq(2) + .should("have.attr", "aria-expanded", "false"); + cy.findAllByRole("button", { name: "View Details" }) + .eq(3) + .should("have.attr", "aria-expanded", "false"); + + cy.findAllByRole("button", { name: "View Details" }).eq(2).click(); + cy.findByRole("region", { name: "Employee Details" }).should("be.visible"); + cy.findByRole("region", { name: "Employee Details" }).within(() => { + cy.findByText("Carol Williams").should("be.visible"); + }); + cy.findAllByRole("button", { name: "View Details" }) + .eq(0) + .should("have.attr", "aria-expanded", "false"); + cy.findAllByRole("button", { name: "View Details" }) + .eq(2) + .should("have.attr", "aria-expanded", "true"); + + cy.findByRole("button", { name: "Close" }).click(); + cy.findAllByRole("button", { name: "View Details" }) + .eq(0) + .should("have.attr", "aria-expanded", "false"); + cy.findAllByRole("button", { name: "View Details" }) + .eq(2) + .should("have.attr", "aria-expanded", "false"); + }); +}); 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..235da42f8fa --- /dev/null +++ b/packages/lab/src/side-panel/SidePanel.css @@ -0,0 +1,77 @@ +.saltSidePanel { + overflow: hidden; + background-color: var(--saltSidePanel-background, var(--salt-container-primary-background)); + --saltSidePanel-width: 300px; + --saltSidePanel-border: var(--salt-size-fixed-100) var(--salt-borderStyle-solid) var(--salt-container-primary-borderColor); +} + +.saltSidePanel-primary { + --saltSidePanel-background: var(--salt-container-primary-background); +} + +.saltSidePanel-secondary { + --saltSidePanel-background: var(--salt-container-secondary-background); +} + +.saltSidePanel-tertiary { + --saltSidePanel-background: var(--salt-container-tertiary-background); +} + +.saltSidePanel-left { + border-right: var(--saltSidePanel-border); +} +.saltSidePanel-right { + border-left: var(--saltSidePanel-border); +} + +.saltSidePanel-left, +.saltSidePanel-right { + width: var(--saltSidePanel-width); + height: 100%; +} + +.saltSidePanel-left.saltSidePanel-enterAnimation, +.saltSidePanel-right.saltSidePanel-enterAnimation { + animation: saltSidePanel-expandWidth var(--salt-animation-duration) var(--salt-animation-timing-function); +} + +.saltSidePanel-left.saltSidePanel-exitAnimation, +.saltSidePanel-right.saltSidePanel-exitAnimation { + animation: saltSidePanel-collapseWidth var(--salt-animation-duration) var(--salt-animation-timing-function) both; +} + +.saltSidePanel-exitAnimation { + pointer-events: none; +} + +.saltSidePanel-inner { + box-sizing: border-box; + overflow: auto; + 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..48b56dbe331 --- /dev/null +++ b/packages/lab/src/side-panel/SidePanel.tsx @@ -0,0 +1,163 @@ +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 ComponentPropsWithRef, + forwardRef, + type KeyboardEvent, + type MutableRefObject, + useEffect, + useState, +} from "react"; +import sidePanelCss from "./SidePanel.css"; +import { useSidePanelGroup } from "./SidePanelGroupContext"; + +const withBaseName = makePrefixer("saltSidePanel"); + +export interface SidePanelProps extends ComponentPropsWithRef<"div"> { + /** + * Edge the panel is anchored to; controls animation direction and divider side. + * @default "right" + */ + position?: "right" | "left"; + /** + * Which element to focus when the panel opens. + * @default 0 + */ + initialFocus?: number | MutableRefObject; + /** + * Whether the panel is open. + */ + open?: boolean; + /** + * Callback when open state should change + */ + onOpenChange?: (newOpen: boolean) => void; + /** + * Change background color palette + * @default "primary" + */ + variant?: "primary" | "secondary" | "tertiary"; + /** + * Reference to the trigger element for manual mode. Used to return focus when panel closes. + * When inside SidePanelGroup, this is automatically managed via SidePanelTrigger. + */ + triggerRef?: MutableRefObject; +} + +export const SidePanel = forwardRef( + function SidePanel(props, ref) { + const { + position = "right", + initialFocus = 0, + open: openProp = false, + onOpenChange: onOpenChangeProp, + variant = "primary", + children, + className, + id: idProp, + onKeyDownCapture, + triggerRef: manualTriggerRef, + ...rest + } = props; + const [showComponent, setShowComponent] = useState(false); + const targetWindow = useWindow(); + const { + open: groupOpen, + setOpen: setGroupOpen, + panelId, + triggerRef: groupTriggerRef, + } = useSidePanelGroup(); + + useComponentCssInjection({ + testId: "salt-side-panel", + css: sidePanelCss, + window: targetWindow, + }); + + const id = useId(idProp || panelId); + + // Use SidePanelGroup props if available + const open = groupOpen ?? openProp; + const onOpenChange = setGroupOpen ?? onOpenChangeProp; + const focusReturnTriggerRef = groupTriggerRef ?? manualTriggerRef; + + const { context, refs } = useFloatingUI({ + open, + onOpenChange, + }); + const { setReference, setFloating } = refs; + const handleRef = useForkRef(setFloating, ref); + + useEffect(() => { + if (focusReturnTriggerRef?.current) { + setReference(focusReturnTriggerRef.current); + } + }, [focusReturnTriggerRef, setReference]); + + useEffect(() => { + if (open) { + setShowComponent(true); + return; + } + const animate = setTimeout(() => { + setShowComponent(false); + }, 300); // var(--salt-duration-perceptible) + return () => clearTimeout(animate); + }, [open]); + + const handleKeyDownCapture = (event: KeyboardEvent) => { + onKeyDownCapture?.(event); + + if (event.defaultPrevented || event.key !== "Escape") { + return; + } + + event.stopPropagation(); + onOpenChange?.(false); + }; + + if (!showComponent) return null; + + const panelDiv = ( +
+
{children}
+
+ ); + + if (open) { + return ( + + {panelDiv} + + ); + } + + return panelDiv; + }, +); diff --git a/packages/lab/src/side-panel/SidePanelCloseTrigger.tsx b/packages/lab/src/side-panel/SidePanelCloseTrigger.tsx new file mode 100644 index 00000000000..12c9dfb0009 --- /dev/null +++ b/packages/lab/src/side-panel/SidePanelCloseTrigger.tsx @@ -0,0 +1,57 @@ +import { + type ButtonProps, + getRefFromChildren, + mergeProps, + useForkRef, +} from "@salt-ds/core"; +import { + cloneElement, + forwardRef, + isValidElement, + type ReactNode, +} from "react"; +import { useSidePanelGroup } from "./SidePanelGroupContext"; + +export interface SidePanelCloseButtonProps extends ButtonProps { + children: ReactNode; +} + +export const SidePanelCloseTrigger = forwardRef< + HTMLElement, + SidePanelCloseButtonProps +>(function SidePanelCloseTrigger({ children, onClick, ...rest }, ref) { + const { setOpen } = useSidePanelGroup(); + + const handleClick: ButtonProps["onClick"] = (event) => { + onClick?.(event); + + if (event.defaultPrevented) { + return; + } + + setOpen?.(false); + }; + + const handleRef = useForkRef(getRefFromChildren(children), ref); + + if (!children || !isValidElement(children)) { + return <>{children}; + } + + const mergedProps = mergeProps( + { + onClick: handleClick, + ...rest, + }, + children.props, + ); + + return ( + <> + {cloneElement(children, { + ...mergedProps, + ref: handleRef, + })} + + ); +}); diff --git a/packages/lab/src/side-panel/SidePanelGroup.tsx b/packages/lab/src/side-panel/SidePanelGroup.tsx new file mode 100644 index 00000000000..b1c9d24e97b --- /dev/null +++ b/packages/lab/src/side-panel/SidePanelGroup.tsx @@ -0,0 +1,96 @@ +import { useControlled, useId } from "@salt-ds/core"; +import { + type MutableRefObject, + type ReactNode, + useCallback, + useMemo, + useState, +} from "react"; +import { + SidePanelGroupContext, + type SidePanelGroupContextValue, +} from "./SidePanelGroupContext"; + +export interface SidePanelGroupProps { + /** + * Whether the panel is open + */ + open?: boolean; + /** + * Default open state when initially rendered + */ + defaultOpen?: boolean; + /** + * Callback when open state should change + */ + onOpenChange?: (open: boolean) => void; + /** + * SidePanelGroup children, should include SidePanel and SidePanelTrigger + */ + children: ReactNode; +} + +export function SidePanelGroup(props: SidePanelGroupProps) { + const [activeTriggerId, setActiveTriggerId] = useState( + undefined, + ); + const [triggerRef, setTriggerRef] = useState< + MutableRefObject | undefined + >(undefined); + + const { children, open: openProp, defaultOpen, onOpenChange } = props; + + const [open, setOpenState] = useControlled({ + default: Boolean(defaultOpen), + controlled: openProp, + name: "SidePanelGroup", + state: "open", + }); + + const panelId = useId(); + + const setOpen = useCallback( + (newOpen: boolean) => { + if (newOpen === open) { + return; + } + + setOpenState(newOpen); + if (!newOpen) { + setActiveTriggerId(undefined); + } + onOpenChange?.(newOpen); + }, + [open, onOpenChange], + ); + + const activateTrigger = useCallback( + ( + triggerId: string, + triggerElement: MutableRefObject, + ) => { + setActiveTriggerId(triggerId); + setTriggerRef(triggerElement); + setOpen(true); + }, + [setOpen], + ); + + const contextValue = useMemo( + () => ({ + open, + setOpen, + panelId, + activeTriggerId, + triggerRef, + activateTrigger, + }), + [open, setOpen, panelId, activeTriggerId, triggerRef, activateTrigger], + ); + + return ( + + {children} + + ); +} diff --git a/packages/lab/src/side-panel/SidePanelGroupContext.tsx b/packages/lab/src/side-panel/SidePanelGroupContext.tsx new file mode 100644 index 00000000000..86b6be68434 --- /dev/null +++ b/packages/lab/src/side-panel/SidePanelGroupContext.tsx @@ -0,0 +1,45 @@ +import { createContext } from "@salt-ds/core"; +import { type MutableRefObject, useContext } from "react"; + +export interface SidePanelGroupContextValue { + /** + * Whether the side panel is currently open. + */ + open?: boolean; + /** + * Function to set the open state of the panel. + */ + setOpen?: (open: boolean) => void; + /** + * ID of the panel. + */ + panelId?: string; + /** + * ID of the active trigger, used to identify which trigger should receive focus when the panel closes. + */ + activeTriggerId?: string; + /** + * DOM reference of the active trigger, used to restore focus when the panel closes. + */ + triggerRef?: MutableRefObject; + /** + * Activates a trigger: sets its ID and ref, keeps the panel open, and prepares for focus restoration on close. + */ + activateTrigger: ( + triggerId: string, + triggerElement: MutableRefObject, + ) => void; +} + +export const SidePanelGroupContext = createContext( + "SidePanelGroupContext", + { + open: undefined, + setOpen: undefined, + activateTrigger: () => undefined, + }, +); + +export function useSidePanelGroup() { + return useContext(SidePanelGroupContext); +} diff --git a/packages/lab/src/side-panel/SidePanelTrigger.tsx b/packages/lab/src/side-panel/SidePanelTrigger.tsx new file mode 100644 index 00000000000..5fe0c200d49 --- /dev/null +++ b/packages/lab/src/side-panel/SidePanelTrigger.tsx @@ -0,0 +1,68 @@ +import { mergeProps, useForkRef, useId } from "@salt-ds/core"; +import { + type ComponentPropsWithoutRef, + cloneElement, + forwardRef, + isValidElement, + type MouseEvent, + type ReactNode, + useRef, +} from "react"; +import { useSidePanelGroup } from "./SidePanelGroupContext"; + +export interface SidePanelTriggerProps + extends Omit< + ComponentPropsWithoutRef<"button">, + "aria-controls" | "aria-expanded" + > { + children: ReactNode; +} + +export const SidePanelTrigger = forwardRef< + HTMLButtonElement, + SidePanelTriggerProps +>(function SidePanelTrigger(props, ref) { + const { children, onClick, ...rest } = props; + const { open, activeTriggerId, setOpen, activateTrigger, panelId } = + useSidePanelGroup(); + const triggerRef = useRef(null); + const triggerId = useId(); + const handleRef = useForkRef(triggerRef, ref); + + const handleClick = (event: MouseEvent) => { + onClick?.(event); + + if (!triggerRef.current || !triggerId) { + return; + } + + const isActiveTriggerOpen = open && activeTriggerId === triggerId; + + if (isActiveTriggerOpen) { + setOpen?.(false); + return; + } + + activateTrigger(triggerId, triggerRef); + }; + + if (!children || !isValidElement<{ ref?: unknown }>(children)) { + return <>{children}; + } + + const mergedProps = mergeProps( + { + onClick: handleClick, + ...rest, + }, + children.props, + ); + + mergedProps["aria-expanded"] = open && activeTriggerId === triggerId; + mergedProps["aria-controls"] = panelId; + + 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..f54df9083e2 --- /dev/null +++ b/packages/lab/src/side-panel/index.ts @@ -0,0 +1,4 @@ +export * from "./SidePanel"; +export * from "./SidePanelCloseTrigger"; +export * from "./SidePanelGroup"; +export * from "./SidePanelTrigger"; diff --git a/packages/lab/stories/side-panel/images/article1.png b/packages/lab/stories/side-panel/images/article1.png new file mode 100644 index 00000000000..16cb9816068 Binary files /dev/null and b/packages/lab/stories/side-panel/images/article1.png differ diff --git a/packages/lab/stories/side-panel/images/article2.png b/packages/lab/stories/side-panel/images/article2.png new file mode 100644 index 00000000000..3b98f2e6893 Binary files /dev/null and b/packages/lab/stories/side-panel/images/article2.png differ diff --git a/packages/lab/stories/side-panel/images/card.png b/packages/lab/stories/side-panel/images/card.png new file mode 100644 index 00000000000..0902d1be330 Binary files /dev/null and b/packages/lab/stories/side-panel/images/card.png differ diff --git a/packages/lab/stories/side-panel/images/mobile.png b/packages/lab/stories/side-panel/images/mobile.png new file mode 100644 index 00000000000..ebe32b48436 Binary files /dev/null and b/packages/lab/stories/side-panel/images/mobile.png differ 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..d2d9482480f --- /dev/null +++ b/packages/lab/stories/side-panel/side-panel.qa.stories.tsx @@ -0,0 +1,79 @@ +import { H2, StackLayout, Text } from "@salt-ds/core"; +import { SidePanel, SidePanelGroup, type SidePanelProps } 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 SidePanelTemplate: StoryFn = ({ + variant = "primary", + position = "right", +}) => { + return ( +
+ + +

Title

+ + 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, when an unknown printer took a galley of type + and scrambled it to make a type specimen book. + +
+
+
+ ); +}; + +export const SidePanelExamples: StoryFn = (props) => { + const { ...rest } = props; + + return ( + + + + ); +}; + +export const SidePanelVariants: StoryFn = (props) => { + const { ...rest } = props; + + return ( + + + + + + ); +}; + +SidePanelExamples.parameters = { + chromatic: { disableSnapshot: false }, +}; + +SidePanelVariants.parameters = { + chromatic: { disableSnapshot: false }, +}; 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..6f714663a8b --- /dev/null +++ b/packages/lab/stories/side-panel/side-panel.stories.tsx @@ -0,0 +1,786 @@ +import { + BorderItem, + BorderLayout, + Button, + Card, + Divider, + FlexItem, + FlexLayout, + FormField, + FormFieldHelperText, + FormFieldLabel, + H2, + Input, + Link, + NavigationItem, + StackLayout, + Table, + TableContainer, + TBody, + TD, + Text, + TH, + THead, + ToggleButton, + ToggleButtonGroup, + TR, + useId, +} from "@salt-ds/core"; +import { + ArrowRightIcon, + CloseIcon, + DoubleChevronRightIcon, + GithubIcon, + GuideClosedIcon, + HelpCircleIcon, + MapIcon, + StackoverflowIcon, + TextUnorderedListIcon, +} from "@salt-ds/icons"; +import { + SidePanel, + SidePanelCloseTrigger, + SidePanelGroup, + type SidePanelGroupProps, + type SidePanelProps, + SidePanelTrigger, +} from "@salt-ds/lab"; +import type { Meta, StoryFn } from "@storybook/react-vite"; +import { useEffect, useState } from "react"; +import Article1 from "./images/article1.png"; +import Article2 from "./images/article2.png"; +import CardImg from "./images/card.png"; +import PhoneImg from "./images/mobile.png"; + +export default { + title: "Lab/Side Panel", + component: SidePanel, + parameters: { + layout: "fullscreen", + }, +} as Meta; + +const FormFieldExample = () => ( + + Label + + Help text appears here + +); + +export const Default: StoryFn = (args) => { + const headingId = useId(); + + return ( + + + + + + + + + + + + +

Section Title

+ + This placeholder text is provided to illustrate how content will + appear within the component. The sentences are intended for + demonstration only and do not convey specific information. Generic + examples like this help review layout, spacing, and overall + design. Adjust the wording as needed to fit your use case or + display requirements. + + {Array.from({ length: 7 }, (_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: Acceptable in this case since content is static and not re-orderable + + ))} +
+
+
+
+ ); +}; + +export const Left: StoryFn = (args) => { + const headingId = useId(); + + return ( + + + + + + + +

Section Title

+ + This placeholder text is provided to illustrate how content will + appear within the component. The sentences are intended for + demonstration only and do not convey specific information. Generic + examples like this help review layout, spacing, and overall + design. Adjust the wording as needed to fit your use case or + display requirements. + + {Array.from({ length: 7 }, (_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: Acceptable in this case since content is static and not re-orderable + + ))} +
+
+ + + + + +
+
+ ); +}; + +export const Controlled: StoryFn = (args) => { + const [open, setOpen] = useState(false); + const headingId = useId(); + + return ( + + + + + + + + + + setOpen(false)}> + + +

Section Title

+ + This placeholder text is provided to illustrate how content will + appear within the component. The sentences are intended for + demonstration only and do not convey specific information. Generic + examples like this help review layout, spacing, and overall + design. Adjust the wording as needed to fit your use case or + display requirements. + + {Array.from({ length: 7 }, (_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: Acceptable in this case since content is static and not re-orderable + + ))} +
+
+
+
+ ); +}; + +export const ManualTrigger: StoryFn = (args) => { + const [open, setOpen] = useState(false); + const id = useId(); + const headingId = useId(); + + return ( + + + + + + + + setOpen(false)} + style={{ marginLeft: "auto" }} + > + + +

Manual Trigger Link

+ + This example shows a trigger outside SidePanelGroup. The user + manually provides aria-expanded and aria-controls. + +
+
+
+
+ ); +}; + +export const Variants: StoryFn = (args) => { + const primaryHeadingId = useId(); + const secondaryHeadingId = useId(); + const tertiaryHeadingId = useId(); + + return ( + + + + + + + + + + + +

Primary Variant

+ + This panel uses the primary variant, which is the default + background color for containers. + + + +
+
+
+ + + + + + + + + +

Secondary Variant

+ + This panel uses the secondary variant with a different + background color. + + + +
+
+
+ + + + + + + + + +

Tertiary Variant

+ + This panel uses the tertiary variant with yet another background + color. + + + +
+
+
+
+
+ ); +}; + +interface TeamMember { + id: string; + name: string; + email: string; + department: string; + status: string; +} + +const tableData: TeamMember[] = [ + { + id: "1", + name: "Alice Johnson", + email: "alice.johnson@example.com", + department: "Engineering", + status: "Active", + }, + { + id: "2", + name: "Bob Smith", + email: "bob.smith@example.com", + department: "Design", + status: "Active", + }, + { + id: "3", + name: "Carol Williams", + email: "carol.williams@example.com", + department: "Product", + status: "On Leave", + }, + { + id: "4", + name: "David Brown", + email: "david.brown@example.com", + department: "Sales", + status: "Active", + }, + { + id: "5", + name: "Eve Martinez", + email: "eve.martinez@example.com", + department: "Engineering", + status: "Active", + }, +]; + +export const WithTable: StoryFn = (args) => { + const [selectedRow, setSelectedRow] = useState(null); + const panelHeadingId = useId(); + + const handleRowClick = (row: TeamMember) => { + setSelectedRow(row); + }; + + return ( + + +
+ + + + + + + + + + + + + + {tableData.map((row) => ( + + + + + + + + ))} + +
Team members
NameEmailDepartmentStatusAction
{row.name}{row.email}{row.department}{row.status} + + + +
+
+
+ + + + + + + {selectedRow && ( + <> +

Employee Details

+ +
+ Name + {selectedRow.name} +
+
+ Email + {selectedRow.email} +
+
+ Department + {selectedRow.department} +
+
+ Status + {selectedRow.status} +
+ +
+ + )} +
+
+
+
+ ); +}; + +const DesktopAppHeader = ({ items }: { items?: string[] }) => { + const [active, setActive] = useState(items?.[0]); + const [offset, setOffset] = useState(0); + useEffect(() => { + const setScroll = () => { + setOffset(window.scrollY); + }; + + window.addEventListener("scroll", setScroll); + return () => { + window.removeEventListener("scroll", setScroll); + }; + }, []); + + return ( +
+ 0 ? "var(--salt-overlayable-shadow-scroll)" : "none", + borderBottom: + "var(--salt-size-fixed-100) var(--salt-borderStyle-solid) var(--salt-separable-primary-borderColor)", + }} + justify="space-between" + gap={3} + > + + Logo + + + + + + + + + + + + +
+ ); +}; + +export const WithAppHeader: StoryFn = (args) => { + const items = ["Home", "About", "Services", "Contact", "Blog"]; + const headingId = useId(); + + return ( + + + + + + + {Array.from({ length: 12 }, (_, index) => ( +
+ ))} + + + + + + + Help and support + + + + + + + toggle + toggle + toggle + + {/* Header */} + {/* Content */} + + + + + FAQs + + + FAQ article title/questions + FAQ article title/questions + FAQ article title/questions + + + + + + + + + + + + Terms + + + Terms title + + Lorem ipsum dolor sit amet consectetur adipisicing elit. + Consequatur culpa enim. + + Terms title + + Lorem ipsum dolor sit amet consectetur adipisicing elit. + Consequatur culpa enim. + + Terms title + + Lorem ipsum dolor sit amet consectetur adipisicing elit. + Consequatur culpa enim. + + + + + + + + + + + + + + Tours + + + + + + phone + + Title + + Lorem ipsum dolor sit amet, consectetuer adipiscing + elit. In in nunc. + + + + + + + + + card + + Title + + Lorem ipsum dolor sit amet, consectetuer adipiscing + elit. In in nunc. + + + + + + + + + + + + + + + + + Articles + + + article + Article title + + Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + In in nunc. + + + + article + Article title + + Lorem ipsum dolor sit amet, consectetuer adipiscing elit. + In in nunc. + + + + + + + + +
+ Footer +
+
+ + + ); +}; diff --git a/site/docs/components/side-panel/accessibility.mdx b/site/docs/components/side-panel/accessibility.mdx new file mode 100644 index 00000000000..9b5b00376c3 --- /dev/null +++ b/site/docs/components/side-panel/accessibility.mdx @@ -0,0 +1,44 @@ +--- +title: + $ref: ./#/title +layout: DetailComponent +sidebar: + exclude: true +data: + $ref: ./#/data +--- + +## Best practices + +**Provide an accessible name for the `SidePanel`** + +Use `aria-labelledby` to link to a heading within the panel, or `aria-label` to provide a descriptive name for the `region`. + +## Keyboard interactions + + + + +- Tab moves focus through focusable content elements in the panel in reading order. +- Because the panel is non‑modal, after the last focusable element in the panel, focus continues to the next focusable element that follows the trigger in page order. Focus can leave the panel. + + + + +- This action moves focus to the previous focusable content element in the panel. +- From the first focusable element in the panel, focus returns to the trigger (and can continue backward to earlier page elements). Focus can leave the panel. + + + + +- If focus is on the trigger element, this action activates the element and opens the side panel. Focus moves into the panel (to the container or first focusable element). Panel items appear immediately after the trigger in the tab sequence. +- If focus is on the Close button, this action closes the panel. Focus returns to the trigger element. +- If focus is on a content element configured to close the panel (for example, a Submit button), this action closes the panel and returns focus to the trigger. + + + + +Escape closes the side panel and returns focus to the trigger element. + + + diff --git a/site/docs/components/side-panel/examples.mdx b/site/docs/components/side-panel/examples.mdx new file mode 100644 index 00000000000..4d48603d9af --- /dev/null +++ b/site/docs/components/side-panel/examples.mdx @@ -0,0 +1,64 @@ +--- +title: + $ref: ./#/title +layout: DetailComponent +sidebar: + exclude: true +data: + $ref: ./#/data +--- + +## Right side panel + +By default, a side panel anchors to the right edge of the screen. Consider using a right‑anchored side panel to surface contextual details and secondary tasks related to the current view—such as item details, comments, history, or quick edits. Always set the panel’s width to suit its content and ensure the main page stays visible and focusable while the panel is open. + + + +## Left side panel + +Consider using a left‑anchored side panel for structure and exploration—such as hierarchical filters, folders, outlines, or multi‑facet filtering. + + + +## Panel variants + +By default the side panel styling uses `variant=”primary”`. Set `variant=”secondary”` for a secondary side panel and `variant=”tertiary”` for a tertiary side panel. + + + +## In-context triggers + +Local affordances are in-context triggers that open side panels for quick tasks, typically placed close to what they affect so settings can be adjusted without losing sight of the primary page. + +A side panel can be triggered via a page-level utility, for example a control in the app header that acts on the current page’s primary content: + + + +A side panel can be also triggered via a control embedded within the main content area, usually scoped to a specific component or subset of data (e.g., a grid toolbar button, row‑level action, card menu): + + + +## Controlled + +You can control the open state of the side panel component by using the `open` and `onOpenChange` props. This allows you to programmatically show or hide the content. + + + +## Complex layouts + +For layouts that need more control over the side panel’s open/close behavior, use manual trigger management instead of SidePanelGroup. This is useful when the trigger and the panel can’t sit together in the component tree, or when open/close behavior needs to be driven by custom logic. + +Manual trigger management makes it possible to: + +- Place the trigger and the panel in different layout areas. +- Manage the panel’s open state with application state. +- Handle multiple panels with custom rules. +- Integrate with other components that control the panel state. + +This approach trades convenience for flexibility: it requires more wiring, but enables more control over placement and behavior. + +### How to wire it up + +When using manual triggers, pass `open` and `onOpenChange` directly to SidePanel, and set `aria-expanded` and `aria-controls` on the trigger button. Additionally, pass `triggerRef` to the panel so focus returns to the trigger when the panel closes. + + diff --git a/site/docs/components/side-panel/index.mdx b/site/docs/components/side-panel/index.mdx new file mode 100644 index 00000000000..f3eefc5776c --- /dev/null +++ b/site/docs/components/side-panel/index.mdx @@ -0,0 +1,14 @@ +--- +title: Side panel +data: + description: "A side panel is a persistent, side-by-side workspace that keeps supporting information and controls available while users continue their main task. It’s intended for ongoing parallel work—such as referencing details, inspecting data, filtering results, or editing attributes—where maintaining visibility and context improves accuracy and efficiency." + sourceCodeUrl: "https://github.com/jpmorganchase/salt-ds/tree/main/packages/lab/src/side-panel" + package: + name: "@salt-ds/lab" + initialVersion: "" + alsoKnownAs: ["Flyout", "Utility panel", "Details panel"] + relatedComponents: [] +keywords: + $ref: "#/data/alsoKnownAs" +layout: DetailComponent +--- diff --git a/site/docs/components/side-panel/usage.mdx b/site/docs/components/side-panel/usage.mdx new file mode 100644 index 00000000000..e1a1d46110c --- /dev/null +++ b/site/docs/components/side-panel/usage.mdx @@ -0,0 +1,48 @@ +--- +title: + $ref: ./#/title +layout: DetailComponent +sidebar: + exclude: true +data: + $ref: ./#/data +--- + +## Using the component + +A side panel spans the full height of its container and is always non-modal. This means the content beside it remains interactive when the panel is expanded. + +Always choose a width that suits the content in the side panel. Refer to Salt's Forms pattern for information on logical forms width, and the [typography foundation](/salt/foundations/typography) for guidance on paragraph text width. + +### When to use + +- To view supporting details alongside the primary content (e.g., record details, metadata, history, related items) while keeping the main view visible and interactive. +- To edit or update information in-context (e.g., update attributes, adjust settings, annotate) while referencing the main view for confirmation. +- To apply and refine filters (e.g., facets, query parameters) while keeping results visible and interactive next to the filtering controls. +- To support multi-step workflows (e.g., review → decide → update → confirm) where context and supporting controls must remain available across steps. +- To keep supplementary information and actions persistently available during longer parallel tasks (e.g., compare items, validate fields, cross-check data). + +### When not to use + +For a temporary, focused workspace that lets users handle a small, self-contained action. Instead, use [Drawer](/salt/components/drawer). +For a background that organizes content in an application. Instead, use [Panel](/salt/components/panel). + +## Import + +To import `SidePanel`, `SidePanelCloseTrigger`, `SidePanelGroup` and `SidePanelTrigger` from the lab Salt package, use: + +```js +import { + SidePanel, + SidePanelCloseTrigger, + SidePanelGroup, + SidePanelTrigger, +} from "@salt-ds/lab"; +``` + +## Props + + + + + diff --git a/site/src/examples/side-panel/Controlled.tsx b/site/src/examples/side-panel/Controlled.tsx new file mode 100644 index 00000000000..e4b02cbf36f --- /dev/null +++ b/site/src/examples/side-panel/Controlled.tsx @@ -0,0 +1,55 @@ +import { + Button, + FlexItem, + FlexLayout, + H2, + StackLayout, + Text, + useId, +} from "@salt-ds/core"; +import { CloseIcon } from "@salt-ds/icons"; +import { + SidePanel, + SidePanelCloseTrigger, + SidePanelGroup, + SidePanelTrigger, +} from "@salt-ds/lab"; +import { useState } from "react"; + +export const Controlled = () => { + const [open, setOpen] = useState(false); + const headingId = useId(); + + return ( + + + + + + + + + + + setOpen(false)}> + + +

Section Title

+ Content for the primary side panel +
+
+
+
+
+ ); +}; diff --git a/site/src/examples/side-panel/LeftPanel.tsx b/site/src/examples/side-panel/LeftPanel.tsx new file mode 100644 index 00000000000..f8a3967a633 --- /dev/null +++ b/site/src/examples/side-panel/LeftPanel.tsx @@ -0,0 +1,93 @@ +import { + Button, + Dropdown, + FlexLayout, + FormField, + FormFieldHelperText, + FormFieldLabel, + H2, + Input, + Option, + RadioButton, + RadioButtonGroup, + StackLayout, + useId, +} from "@salt-ds/core"; +import { CloseIcon, SearchIcon } from "@salt-ds/icons"; +import { + SidePanel, + SidePanelCloseTrigger, + SidePanelGroup, + SidePanelTrigger, +} from "@salt-ds/lab"; + +export const LeftPanel = () => { + const headingId = useId(); + + return ( + + + + + + + +

Filters

+ } placeholder="Search" /> + + + Color + + + Pick a color + + + + Location + + + + + + Select one that applies + + + + + +
+
+ + + + + +
+
+ ); +}; diff --git a/site/src/examples/side-panel/ManualTrigger.tsx b/site/src/examples/side-panel/ManualTrigger.tsx new file mode 100644 index 00000000000..840107a8c9a --- /dev/null +++ b/site/src/examples/side-panel/ManualTrigger.tsx @@ -0,0 +1,84 @@ +import { + Button, + FlexLayout, + H2, + StackLayout, + Text, + useId, +} from "@salt-ds/core"; +import { SidePanel } from "@salt-ds/lab"; +import { type CSSProperties, useRef, useState } from "react"; + +const panelStyle = { + "--saltSidePanel-width": "150px", +} as CSSProperties; + +export const ManualTrigger = () => { + const [openLeft, setOpenLeft] = useState(false); + const [openRight, setOpenRight] = useState(false); + const leftPanelId = useId(); + const rightPanelId = useId(); + const leftHeadingId = useId(); + const rightHeadingId = useId(); + + const leftTriggerRef = useRef(null); + const rightTriggerRef = useRef(null); + + return ( + + + +

Left Panel

+ Left panel content. +
+
+ + + + + + + +

Right Panel

+ Right panel content. +
+
+
+ ); +}; diff --git a/site/src/examples/side-panel/RightPanel.tsx b/site/src/examples/side-panel/RightPanel.tsx new file mode 100644 index 00000000000..6aa1cb50f69 --- /dev/null +++ b/site/src/examples/side-panel/RightPanel.tsx @@ -0,0 +1,96 @@ +import { + Button, + Divider, + FlexItem, + FlexLayout, + H2, + H3, + StackLayout, + Text, + useId, +} from "@salt-ds/core"; +import { CloseIcon } from "@salt-ds/icons"; +import { + SidePanel, + SidePanelCloseTrigger, + SidePanelGroup, + SidePanelTrigger, +} from "@salt-ds/lab"; + +const DetailsExample = () => ( + <> +

Metadata

+ + Use case name + + lorem ipsum + + + + Account + + lorem ipsum + + + + Payment type + + lorem ipsum + + + + +); + +export const RightPanel = () => { + const headingId = useId(); + + return ( + + + + + + + + + + +

Use case details

+ + + +
+ + {Array.from({ length: 2 }, (_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: Acceptable in this case since content is static and not re-orderable + + ))} + + + + +
+
+
+
+ ); +}; diff --git a/site/src/examples/side-panel/Variants.tsx b/site/src/examples/side-panel/Variants.tsx new file mode 100644 index 00000000000..0e2a7b2c776 --- /dev/null +++ b/site/src/examples/side-panel/Variants.tsx @@ -0,0 +1,90 @@ +import { + Button, + FlexItem, + FlexLayout, + FormField, + FormFieldLabel, + H2, + RadioButton, + RadioButtonGroup, + StackLayout, + Text, + useId, +} from "@salt-ds/core"; +import { CloseIcon } from "@salt-ds/icons"; +import { + SidePanel, + SidePanelCloseTrigger, + SidePanelGroup, + type SidePanelProps, + SidePanelTrigger, +} from "@salt-ds/lab"; +import { type ChangeEventHandler, useState } from "react"; + +const variantOptions = ["primary", "secondary", "tertiary"]; + +export const Variants = () => { + const [variant, setVariant] = useState("primary"); + const headingId = useId(); + + const handleVariantChange: ChangeEventHandler = (event) => { + const { value } = event.target; + setVariant(value as SidePanelProps["variant"]); + }; + + return ( + + + + + + + + + + + + + +

Section Title

+ Content for the primary side panel +
+
+
+
+ + + + Variant + + {variantOptions.map((alignment) => ( + + ))} + + + +
+ ); +}; diff --git a/site/src/examples/side-panel/WithAppHeader.tsx b/site/src/examples/side-panel/WithAppHeader.tsx new file mode 100644 index 00000000000..463aa9e0da4 --- /dev/null +++ b/site/src/examples/side-panel/WithAppHeader.tsx @@ -0,0 +1,133 @@ +import { + BorderItem, + BorderLayout, + Button, + FlexItem, + FlexLayout, + H2, + Input, + Link, + StackLayout, + Text, + useId, +} from "@salt-ds/core"; +import { + ChattingIcon, + CloseIcon, + HelpCircleIcon, + NotificationIcon, + SearchIcon, +} from "@salt-ds/icons"; +import { + SidePanel, + SidePanelCloseTrigger, + SidePanelGroup, + SidePanelTrigger, +} from "@salt-ds/lab"; + +const DesktopAppHeader = () => { + return ( +
+ + + App name + + } + placeholder="Search" + style={{ width: 200 }} + /> + + + + + + + + + + + +
+ ); +}; + +export const WithAppHeader = () => { + const headingId = useId(); + + return ( + + + + + + + + Link 1 + Link 2 + Link 3 + + {Array.from({ length: 4 }, (_, index) => ( +
+ ))} + + + + + + + +

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. + +
+
+
+ + + ); +}; diff --git a/site/src/examples/side-panel/WithTable.tsx b/site/src/examples/side-panel/WithTable.tsx new file mode 100644 index 00000000000..ca09abd01ba --- /dev/null +++ b/site/src/examples/side-panel/WithTable.tsx @@ -0,0 +1,167 @@ +import { + Button, + FlexItem, + FlexLayout, + FormField, + FormFieldLabel, + H2, + Input, + StackLayout, + Table, + TableContainer, + TBody, + TD, + TH, + THead, + TR, + useId, +} from "@salt-ds/core"; +import { CloseIcon } from "@salt-ds/icons"; +import { + SidePanel, + SidePanelCloseTrigger, + SidePanelGroup, + SidePanelTrigger, +} from "@salt-ds/lab"; +import { useState } from "react"; + +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", + }, +]; + +export const WithTable = () => { + const [selectedRow, setSelectedRow] = useState(null); + const panelHeadingId = useId(); + + const handleRowClick = (row: TeamMember) => { + setSelectedRow(row); + }; + + return ( + + + + + + + + + + + + + + + {tableData.map((row) => ( + + + + + + ))} + +
Users
NameEmailAction
{row.name}{row.email} + + + +
+
+
+ + + + + + + {selectedRow && ( + +

Employee Details

+ + Name + + + + Email + + + + Phone + + + + + + +
+ )} +
+
+
+
+ ); +}; diff --git a/site/src/examples/side-panel/index.ts b/site/src/examples/side-panel/index.ts new file mode 100644 index 00000000000..34273cf5d0b --- /dev/null +++ b/site/src/examples/side-panel/index.ts @@ -0,0 +1,7 @@ +export * from "./Controlled"; +export * from "./LeftPanel"; +export * from "./ManualTrigger"; +export * from "./RightPanel"; +export * from "./Variants"; +export * from "./WithAppHeader"; +export * from "./WithTable";