diff --git a/.changeset/seven-penguins-fly.md b/.changeset/seven-penguins-fly.md new file mode 100644 index 00000000000..b82642ef0c2 --- /dev/null +++ b/.changeset/seven-penguins-fly.md @@ -0,0 +1,32 @@ +--- +"@salt-ds/lab": minor +--- + +# Introducing the Splitter component + +`Splitter` divides window content into separate regions called `SplitPanel`s that can be dragged and resized, allowing users to customize the layout of their workspace. + +The Salt Splitter leverages the popular open-source library react-resizable-panels, whilst making adjustments to component names, adding custom styling, and introducing extra functionality to better align with the rest of the components present in our design system. + +## Remapping + +| salt-ds | react-resizable-panels | +| ------------------------------ | ------------------------------ | +| `` | `` | +| `` | `` | +| `` | `` | +| `SplitterProps["orientation"]` | `PanelGroupProps["direction"]` | + +## Overridden Functionality + +`SplitterProps["orientation"]` - Replaced the `direction` prop with `orientation` to better align with the rest of the components present in our design system. The orientation prop takes one of two options `horizontal` and `vertical`. + +## New Functionality + +`SplitterProps["appearance"]` - Change the appearance of the Splitter component. The appearance prop here acts similarly to `Button["appearance"]`. It can take one of two options `bordered` (default) and `transparent`. The bordered options will add a border on every nested ``, while the transparent option will make the handle transparent, thus letting through the background color of the parent component. + +`SplitPanelProps["variant"]` - Change the background color of the SplitPanel component. The variant prop takes one of three options - `primary` (default), `secondary`, `tertiary`. Follows the same behavior as our Panel component. + +`SplitHandle["variant"]` - Change the background color of the SplitHandle component. It behaves similarly to the `SplitPanelProps["variant"]` prop and it is meant to be used in combination with it. The variant prop can take one of three options - `primary` (default), `secondary`, `tertiary`. Follows the same behavior as our Panel component. + +`SplitHandle["accent"]` - Override the border placement of the SplitHandle component. The accent prop takes one of seven options - `top`, `bottom`, `left`, `right`, `top-bottom`, `left-right`, `none`. The default value is computed based on the orientation and appearance of the parent Splitter component. If the parent splitter's orientation is `horizontal` and the appearance is `bordered`, the default value will be `left-right`. If the orientation is `vertical` and the appearance is `bordered`, the default value will be `top-bottom`. If the appearance is `transparent`, the default value will be `none`. diff --git a/packages/lab/package.json b/packages/lab/package.json index 36a96b0a856..c1e4659f944 100644 --- a/packages/lab/package.json +++ b/packages/lab/package.json @@ -31,6 +31,7 @@ "deepmerge": "^4.2.2", "no-scroll": "^2.1.1", "react-color": "^2.19.3", + "react-resizable-panels": "^2.1.7", "react-window": "^1.8.6", "rifm": "^0.12.0", "tabbable": "^6.0.0", diff --git a/packages/lab/src/__tests__/__e2e__/splitter/Splitter.cy.tsx b/packages/lab/src/__tests__/__e2e__/splitter/Splitter.cy.tsx new file mode 100644 index 00000000000..1cc8f7e3726 --- /dev/null +++ b/packages/lab/src/__tests__/__e2e__/splitter/Splitter.cy.tsx @@ -0,0 +1,63 @@ +// import { Splitter, SplitPanel, SplitHandle } from "@salt-ds/lab"; +import * as buttonStories from "@stories/splitter/splitter.stories"; +import { composeStories } from "@storybook/react"; + +const composedStories = composeStories(buttonStories); + +const { CollapseDoubleClick, ProgrammableResize } = composedStories; + +describe("", () => { + it("should collapse on double click", () => { + cy.mount(); + + cy.findByRole("separator") + .prev() + .should("have.attr", "data-panel-size", "50.0"); + + cy.findByRole("separator").dblclick(); + + cy.findByRole("separator") + .prev() + .should("have.attr", "data-panel-size", "0.0"); + + cy.findByRole("separator").dblclick(); + + cy.findByRole("separator") + .prev() + .should("have.attr", "data-panel-size", "50.0"); + }); + + it("should resize when triggered from outside", () => { + cy.mount(); + + cy.findByText("0 | 100").click(); + + cy.findByRole("separator") + .prev() + .should("have.attr", "data-panel-size", "0.0"); + + cy.findByRole("separator") + .next() + .should("have.attr", "data-panel-size", "100.0"); + + cy.findByText("50 | 50").click(); + + cy.findByRole("separator") + .prev() + .should("have.attr", "data-panel-size", "50.0"); + + cy.findByRole("separator") + .next() + .should("have.attr", "data-panel-size", "50.0"); + + cy.findByText("100 | 0").click(); + + cy.findByRole("separator") + .prev() + .should("have.attr", "data-panel-size", "100.0"); + + cy.findByRole("separator") + .next() + .should("have.attr", "data-panel-size", "0.0"); + }); +}); diff --git a/packages/lab/src/index.ts b/packages/lab/src/index.ts index f07cae73883..b3e23bf15d9 100644 --- a/packages/lab/src/index.ts +++ b/packages/lab/src/index.ts @@ -58,6 +58,7 @@ export * from "./query-input"; export * from "./responsive"; export * from "./search-input"; export * from "./slider"; +export * from "./splitter"; export * from "./static-list"; export * from "./stepped-tracker"; export * from "./stepper-input"; diff --git a/packages/lab/src/splitter/SplitHandle.css b/packages/lab/src/splitter/SplitHandle.css new file mode 100644 index 00000000000..d6fe8ffa0c3 --- /dev/null +++ b/packages/lab/src/splitter/SplitHandle.css @@ -0,0 +1,198 @@ +.saltSplitHandle { + --splitHandle-size: var(--salt-size-thickness-400, 4px); + --splitHandle-borderWidth: var(--salt-size-thickness-100, var(--salt-size-border, 1px)); + --splitHandle-borderColor: var(--salt-separable-secondary-borderColor, rgba(0, 0, 0, 0.3)); + --splitHandle-dot-background: var(--salt-separable-foreground, var(--salt-content-primary-foreground, rgba(0, 0, 0, 1))); + --splitHandle-dot-background-hover: var(--salt-separable-foreground-hover, var(--salt-content-primary-foreground, rgba(0, 0, 0, 1))); + --splitHandle-dot-background-active: var(--salt-separable-foreground-active, var(--salt-content-primary-background, rgba(255, 255, 255, 1))); + + position: relative; + + display: inline-flex; + justify-content: center; + align-items: center; + gap: 2px; + + box-sizing: content-box; + + background: var(--splitHandle-background); +} + +.saltSplitHandle-bordered, +.saltSplitHandle-variant-primary { + --splitHandle-background: var(--salt-container-primary-background, rgba(255, 255, 255, 1)); + --splitHandle-background-hover: var(--salt-separable-background-hover, rgba(0, 0, 0, 0.15)); + --splitHandle-background-active: var(--salt-separable-background-active, rgba(0, 120, 207, 1)); +} + +.saltSplitHandle-variant-secondary { + --splitHandle-background: var(--salt-container-secondary-background, rgba(234, 237, 239, 1)); + --splitHandle-background-hover: var(--salt-separable-background-hover, rgba(0, 0, 0, 0.15)); + --splitHandle-background-active: var(--salt-separable-background-active, rgba(0, 120, 207, 1)); +} + +.saltSplitHandle-variant-tertiary { + --splitHandle-background: var(--salt-container-tertiary-background, rgba(224, 228, 233, 1)); + --splitHandle-background-hover: var(--salt-separable-background-hover, rgba(0, 0, 0, 0.15)); + --splitHandle-background-active: var(--salt-separable-background-active, rgba(0, 120, 207, 1)); +} + +.saltSplitHandle-variant-transparent { + --splitHandle-background: var(--salt-actionable-subtle-background, rgba(0, 0, 0, 0)); + --splitHandle-background-hover: var(--salt-separable-background-hover, rgba(0, 0, 0, 0.15)); + --splitHandle-background-active: var(--salt-separable-background-active, rgba(0, 120, 207, 1)); +} + +.saltSplitHandle[data-resize-handle-state="hover"] { + background: var(--splitHandle-background-hover); +} + +.saltSplitHandle[data-resize-handle-state="drag"] { + background: var(--splitHandle-background-active); +} + +.saltSplitHandle[data-panel-group-direction="horizontal"] { + flex-direction: column; + width: 4px; +} + +.saltSplitHandle[data-panel-group-direction="vertical"] { + flex-direction: row; + height: 4px; +} + +.saltSplitHandle[data-panel-group-direction="horizontal"]::after { + content: ""; + position: absolute; + left: -6px; + right: -6px; + top: 0; + bottom: 0; +} + +.saltSplitHandle[data-panel-group-direction="vertical"]::after { + content: ""; + position: absolute; + left: 0; + right: 0; + top: -6px; + bottom: -6px; +} + +.saltSplitHandle:focus-visible { + outline-style: var(--salt-focused-outlineStyle); + outline-width: var(--salt-focused-outlineWidth); + outline-color: var(--salt-focused-outlineColor); + outline-offset: var(--salt-focused-outlineOffset); +} + +.saltSplitHandle-accent-top { + border-top-width: var(--splitHandle-borderWidth); + border-top-color: var(--splitHandle-borderColor); + border-top-style: solid; + + border-bottom-width: var(--splitHandle-borderWidth); + border-bottom-color: transparent; + border-bottom-style: solid; +} + +.saltSplitHandle-accent-bottom { + border-top-width: var(--splitHandle-borderWidth); + border-top-color: transparent; + border-top-style: solid; + + border-bottom-width: var(--splitHandle-borderWidth); + border-bottom-color: var(--splitHandle-borderColor); + border-bottom-style: solid; +} + +.saltSplitHandle-accent-top-bottom { + border-top-width: var(--splitHandle-borderWidth); + border-top-color: var(--splitHandle-borderColor); + border-top-style: solid; + + border-bottom-width: var(--splitHandle-borderWidth); + border-bottom-color: var(--splitHandle-borderColor); + border-bottom-style: solid; +} + +.saltSplitHandle-accent-left { + border-left-width: var(--splitHandle-borderWidth); + border-left-color: var(--splitHandle-borderColor); + border-left-style: solid; + + border-right-width: var(--splitHandle-borderWidth); + border-right-color: transparent; + border-right-style: solid; +} + +.saltSplitHandle-accent-right { + border-left-width: var(--splitHandle-borderWidth); + border-left-color: transparent; + border-left-style: solid; + + border-right-width: var(--splitHandle-borderWidth); + border-right-color: var(--splitHandle-borderColor); + border-right-style: solid; +} + +.saltSplitHandle-accent-left-right { + border-left-width: var(--splitHandle-borderWidth); + border-left-color: var(--splitHandle-borderColor); + border-left-style: solid; + + border-right-width: var(--splitHandle-borderWidth); + border-right-color: var(--splitHandle-borderColor); + border-right-style: solid; +} + +/* two handles touching horizontally */ +.saltSplitHandle-bordered[data-panel-group-direction="horizontal"] + div[data-panel-size="0.0"] + .saltSplitHandle-bordered[data-panel-group-direction="horizontal"] { + margin-left: -1px; +} + +/* two handles touching vertically */ +.saltSplitHandle-bordered[data-panel-group-direction="vertical"] + div[data-panel-size="0.0"] + .saltSplitHandle-bordered[data-panel-group-direction="vertical"] { + margin-top: -1px; +} + +/* handle touching the side of a container */ +@supports selector(:has(*)) { + .saltSplitPanel:first-of-type[data-panel-size="0.0"] + .saltSplitHandle-bordered[data-panel-group-direction="horizontal"] { + margin-left: -1px; + } + + .saltSplitPanel:first-of-type[data-panel-size="0.0"] + .saltSplitHandle-bordered[data-panel-group-direction="vertical"] { + margin-top: -1px; + } + + .saltSplitHandle-bordered[data-panel-group-direction="horizontal"]:has(+ .saltSplitPanel:last-of-type[data-panel-size="0.0"]) { + margin-right: -1px; + } + + .saltSplitHandle-bordered[data-panel-group-direction="vertical"]:has(+ .saltSplitPanel:last-of-type[data-panel-size="0.0"]) { + margin-bottom: -1px; + } +} + +.saltSplitHandle-dot { + background: var(--splitHandle-dot-background); +} + +.saltSplitHandle[data-resize-handle-state="hover"] > .saltSplitHandle-dot { + background: var(--splitHandle-dot-background-hover); +} + +.saltSplitHandle[data-resize-handle-state="drag"] > .saltSplitHandle-dot { + background: var(--splitHandle-dot-background-active); +} + +.saltSplitHandle[data-panel-group-direction="horizontal"] > .saltSplitHandle-dot { + width: 2px; + height: 1px; +} + +.saltSplitHandle[data-panel-group-direction="vertical"] > .saltSplitHandle-dot { + height: 2px; + width: 1px; +} diff --git a/packages/lab/src/splitter/SplitHandle.tsx b/packages/lab/src/splitter/SplitHandle.tsx new file mode 100644 index 00000000000..d7aeaccc1be --- /dev/null +++ b/packages/lab/src/splitter/SplitHandle.tsx @@ -0,0 +1,86 @@ +import { makePrefixer } from "@salt-ds/core"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import { useWindow } from "@salt-ds/window"; +import clsx from "clsx"; +import { useContext } from "react"; +import { + PanelResizeHandle, + type PanelResizeHandleProps, +} from "react-resizable-panels"; + +import splitHandleCSS from "./SplitHandle.css"; +import { AppearanceContext, OrientationContext } from "./Splitter"; +import { computeAccent, computeVariant } from "./utils"; + +const withBaseName = makePrefixer("saltSplitHandle"); + +export type SplitHandleVariant = + | "primary" + | "secondary" + | "tertiary" + | "transparent"; + +export type SplitHandleAccent = + | "top" + | "bottom" + | "right" + | "left" + | "top-bottom" + | "left-right" + | "none"; + +export interface SplitHandleProps extends PanelResizeHandleProps { + /** + * Styling variant + * @default "primary" + */ + variant?: SplitHandleVariant; + /** + * Change which sides get a border displayed + * + * Default is based on the orientation and appearance + * set on the parent Stepper components, ex. + * bordered + horizontal = left-right + * bordered + vertical = top-bottom + * transparent = none + */ + accent?: SplitHandleAccent; +} + +export function SplitHandle({ + variant: variantProp, + accent: accentProp, + className, + ...props +}: SplitHandleProps) { + const targetWindow = useWindow(); + const appearance = useContext(AppearanceContext); + const orientation = useContext(OrientationContext); + + const variant = variantProp ?? computeVariant(appearance); + const accent = accentProp ?? computeAccent(appearance, orientation); + + useComponentCssInjection({ + testId: "salt-split-handle", + css: splitHandleCSS, + window: targetWindow, + }); + + return ( + + + + + + + ); +} diff --git a/packages/lab/src/splitter/SplitPanel.css b/packages/lab/src/splitter/SplitPanel.css new file mode 100644 index 00000000000..fdbf5bb0d89 --- /dev/null +++ b/packages/lab/src/splitter/SplitPanel.css @@ -0,0 +1,27 @@ +.saltSplitPanel { + box-sizing: border-box; + + background: var(--splitPanel-background); +} + +.saltSplitPanel[data-panel-size="0.0"] { + visibility: hidden; +} + +.saltSplitPanel-primary { + --splitPanel-background: var(--salt-container-primary-background); +} + +.saltSplitPanel-secondary { + --splitPanel-background: var(--salt-container-secondary-background); +} + +.saltSplitPanel-tertiary { + --splitPanel-background: var(--salt-container-tertiary-background); +} + +@supports selector(:has(*)) { + .saltSplitPanel:has(.saltSplitPanel) { + background: unset; + } +} diff --git a/packages/lab/src/splitter/SplitPanel.tsx b/packages/lab/src/splitter/SplitPanel.tsx new file mode 100644 index 00000000000..979371c66cb --- /dev/null +++ b/packages/lab/src/splitter/SplitPanel.tsx @@ -0,0 +1,45 @@ +import { makePrefixer } 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 { + type ImperativePanelHandle, + Panel, + type PanelProps, +} from "react-resizable-panels"; + +import splitPanelCSS from "./SplitPanel.css"; + +export type SplitPanelVariant = "primary" | "secondary" | "tertiary"; + +export interface SplitPanelProps extends PanelProps { + /** + * Styling variant + * @default "primary" + */ + variant?: SplitPanelVariant; +} + +const withBaseName = makePrefixer("saltSplitPanel"); + +export const SplitPanel = forwardRef( + function SplitPanel({ variant = "primary", className, ...props }, ref) { + const targetWindow = useWindow(); + + useComponentCssInjection({ + testId: "salt-split-panel", + css: splitPanelCSS, + window: targetWindow, + }); + + return ( + + ); + }, +); diff --git a/packages/lab/src/splitter/Splitter.tsx b/packages/lab/src/splitter/Splitter.tsx new file mode 100644 index 00000000000..60eece002da --- /dev/null +++ b/packages/lab/src/splitter/Splitter.tsx @@ -0,0 +1,44 @@ +import { type ReactNode, createContext, useContext } from "react"; +import { PanelGroup, type PanelGroupProps } from "react-resizable-panels"; + +export const OrientationContext = + createContext("horizontal"); + +export const AppearanceContext = createContext("bordered"); +export type SplitterAppearance = "bordered" | "transparent"; + +export type SplitterOrientation = PanelGroupProps["direction"]; + +export interface SplitterProps extends Omit { + /** + * The orientation of the splitter. + * Replaces `PanelGroupProps["direction"]` + */ + orientation: SplitterOrientation; + /** + * The appearance of the splitter. + * If set to "transparent", the splitter handle will + * be transparent, hence the background will be visible. + * @default "bordered" + */ + appearance?: SplitterAppearance; + children: ReactNode; +} + +export function Splitter({ + orientation, + appearance: appearanceProp, + className, + ...props +}: SplitterProps) { + const appearanceContext = useContext(AppearanceContext); + const appearance = appearanceProp ?? appearanceContext; + + return ( + + + + + + ); +} diff --git a/packages/lab/src/splitter/index.ts b/packages/lab/src/splitter/index.ts new file mode 100644 index 00000000000..632ae263f4e --- /dev/null +++ b/packages/lab/src/splitter/index.ts @@ -0,0 +1,5 @@ +export * from "./Splitter"; +export * from "./SplitPanel"; +export * from "./SplitHandle"; + +export type { ImperativePanelHandle } from "react-resizable-panels"; diff --git a/packages/lab/src/splitter/utils.ts b/packages/lab/src/splitter/utils.ts new file mode 100644 index 00000000000..2b188ebcd7c --- /dev/null +++ b/packages/lab/src/splitter/utils.ts @@ -0,0 +1,23 @@ +import type { SplitHandleAccent } from "./SplitHandle"; +import type { SplitterAppearance, SplitterOrientation } from "./Splitter"; + +export function computeAccent( + appearance: SplitterAppearance, + orientation: SplitterOrientation, +): SplitHandleAccent { + if (appearance === "transparent") { + return "none"; + } + + if (orientation === "horizontal") { + return "left-right"; + } + + return "top-bottom"; +} + +export function computeVariant( + appearance: SplitterAppearance, +): "primary" | "transparent" { + return appearance === "bordered" ? "primary" : "transparent"; +} diff --git a/packages/lab/stories/splitter/splitter.qa.stories.tsx b/packages/lab/stories/splitter/splitter.qa.stories.tsx new file mode 100644 index 00000000000..e03679d9ca5 --- /dev/null +++ b/packages/lab/stories/splitter/splitter.qa.stories.tsx @@ -0,0 +1,66 @@ +import { FlexLayout, StackLayout, Text } from "@salt-ds/core"; +import { SplitHandle, SplitPanel, Splitter } from "@salt-ds/lab"; +import type { Meta } from "@storybook/react"; +import { QAContainer } from "docs/components"; + +export default { + title: "Lab/Splitter/Splitter QA", + component: Splitter, + subcomponents: { SplitPanel, SplitHandle }, +} as Meta; + +const box = { + width: 240, + height: 80, + border: "1px solid lightgrey", +}; + +const altBox = { + width: 80, + height: 240, + border: "1px solid lightgrey", +}; + +export function Horizontal() { + return ( + + + + + Panel 1 + + + + Panel 2 + + + + Panel 3 + + + + + ); +} + +export function Vertical() { + return ( + + + + + Panel 1 + + + + Panel 2 + + + + Panel 3 + + + + + ); +} diff --git a/packages/lab/stories/splitter/splitter.stories.tsx b/packages/lab/stories/splitter/splitter.stories.tsx new file mode 100644 index 00000000000..0f4aec2db50 --- /dev/null +++ b/packages/lab/stories/splitter/splitter.stories.tsx @@ -0,0 +1,456 @@ +import { Button, FlexLayout, StackLayout, Text } from "@salt-ds/core"; +import { + ArrowLeftIcon, + ArrowRightIcon, + DoubleChevronLeftIcon, + DoubleChevronRightIcon, +} from "@salt-ds/icons"; +import { + type ImperativePanelHandle, + SplitHandle, + SplitPanel, + Splitter, +} from "@salt-ds/lab"; +import type { Meta } from "@storybook/react"; +import { type CSSProperties, useRef, useState } from "react"; + +export default { + title: "Lab/Splitter", + components: Splitter, + subcomponents: { + SplitPanel, + SplitHandle, + }, +} as Meta; + +const center = { + display: "flex", + alignItems: "center", + justifyContent: "center", + height: "100%", + textAlign: "center", +} as CSSProperties; + +const box = { + width: 420, + height: 240, + border: "1px solid var(--salt-separable-secondary-borderColor)", +}; + +export function Horizontal() { + return ( + + + Left + + + + Center + + + + Right + + + ); +} + +export function HorizontalMany() { + return ( + + + Panel 1 + + + + Panel 2 + + + + Panel 3 + + + + Panel 4 + + + + Panel 5 + + + ); +} + +export function Vertical() { + return ( + + + Top + + + + Middle + + + + Bottom + + + ); +} + +export function VerticalMany() { + return ( + + + Panel 1 + + + + Panel 2 + + + + Panel 3 + + + + Panel 4 + + + + Panel 5 + + + ); +} + +export function MultiOrientational() { + return ( + + + + + Top Left + + + + Middle Left + + + + Bottom Left + + + + + + + + Top Right + + + + Bottom Right + + + + + ); +} + +export function Transparent() { + return ( + + + + + + Top Left + + + + Middle Left + + + + Bottom Left + + + + + + + + Top Right + + + + Bottom Right + + + + + + ); +} + +export function Accent() { + return ( + + + Left + + + + Center + + + + Right + + + ); +} + +export function Variant() { + return ( + + + Left + + + + Center + + + + Right + + + ); +} + +export function VariantAlt() { + return ( + + + Left + + + + Center + + + + Right + + + ); +} + +export function MinMaxSize() { + return ( + + + Left [20%, X] + + + + Middle [30%, 60%] + + + + Right [20%, X] + + + ); +} + +export function CollapsibleFixedSize() { + return ( + + + Left + + + + Right + + + ); +} + +export function Collapsible0() { + return ( + + + Left + + + + Right + + + ); +} + +export function CollapseButton() { + const ref = useRef(null); + const [expanded, setExpanded] = useState(true); + + function toggle() { + if (!ref.current) return; + + const { expand, collapse, isExpanded } = ref.current; + + if (isExpanded()) { + collapse(); + setExpanded(false); + } else { + expand(); + setExpanded(true); + } + } + + return ( + + setExpanded(true)} + onCollapse={() => setExpanded(false)} + ref={ref} + style={center} + > + + + + + Content + + + ); +} + +export function CollapseDoubleClick() { + const ref = useRef(null); + + function toggle() { + if (!ref.current) return; + + const { expand, collapse, isExpanded } = ref.current; + + if (isExpanded()) { + return collapse(); + } + + return expand(); + } + + return ( + + + + Double Click the handle + + + + + + Double Click the handle + + + + ); +} + +export function ProgrammableResize() { + const ref = useRef(null); + + function handleResizeLeft(size: number) { + return () => { + ref.current?.resize(size); + }; + } + + return ( + + + + + + + + + + + + Left + + + + Right + + + + + ); +} + +export function AccentCornerCase() { + return ( + + + + + Top Left + + + + Middle Left + + + + Bottom Left + + + + + + + + Top Right + + + + Bottom Right + + + + + ); +} diff --git a/site/docs/components/splitter/accessibility.mdx b/site/docs/components/splitter/accessibility.mdx new file mode 100644 index 00000000000..c029c793e58 --- /dev/null +++ b/site/docs/components/splitter/accessibility.mdx @@ -0,0 +1,50 @@ +--- +title: + $ref: ./#/title +layout: DetailComponent +sidebar: + exclude: true +data: + $ref: ./#/data +--- + +## Keyboard interactions + +Keyboard interactions apply only to the `SplitHandle`s, which will be a child of the `Splitter` component. + + + + + - Moves the handle up in a vertical splitter. + + + + - Moves the handle down in a vertical splitter. + + + + - Moves the handle left in a horizontal splitter. + + + + - Moves the handle right in a horizontal splitter. + + + + - If the associated split is not collapsed, collapses the split. If the split + is collapsed, restores the splitter to it's previous position. + + + + - Moves the handle of the vertical splitter as top as possible or as left as + possible in the horizontal splitter. - Rules for min and max sizing are + respected. + + + + - Moves the handle of the vertical splitter as bottom as possible or as right + as possible in the horizontal splitter. - Rules for min and max sizing are + respected. + + + diff --git a/site/docs/components/splitter/examples.mdx b/site/docs/components/splitter/examples.mdx new file mode 100644 index 00000000000..a60564853ed --- /dev/null +++ b/site/docs/components/splitter/examples.mdx @@ -0,0 +1,123 @@ +--- + title: + $ref: ./#/title + layout: DetailComponent + sidebar: + exclude: true + data: + $ref: ./#/data +--- + + + +The Splitter allows you to divide window content into separate regions called split panels. These panels can be dragged and resized, allowing users to customize the layout of their workspace. + +The Salt Splitter leverages the popular open-source library [react-resizable-panels](https://react-resizable-panels.vercel.app/), whilst making adjustments to component names, adding custom styling, and introducing extra functionality to better align with the rest of the components present in our design system. + + + +## Horizontal + +The `Splitter` component accepts an `orientation` prop, which can be set to either `horizontal` or `vertical`. To enhance code clarity and maintain consistency, we recommend explicitly defining this prop whenever you use the component. This prop determines the direction in which the panels can be resized—horizontally or vertically. + +While the underlying library uses the term `direction` for this functionality, we have remapped it to `orientation` to ensure better alignment with the naming conventions used across the rest of our design system. This change promotes a more intuitive and cohesive developer experience. + + + + + +## Vertical + + + + + +## Multi-orientational + +You can nest another `Splitter` component within a `SplitPanel` to achieve a multi-orientational layout. This allows you to create more complex and flexible designs by combining horizontal and vertical splits within the same interface. + + + + + +## Transparent + +All the examples we've discussed so far have featured the splitter in its default bordered appearance. In addition to this standard style, we also offer a transparent variant for the splitter. When the transparent style is applied, the handles become see-through, allowing the background color of the parent component to show through seamlessly. + + + + + +## Accent + +The `accent` prop determines which sides of the `SplitHandle` component will have a border. This prop accepts one of seven options: `top`, `bottom`, `left`, `right`, `top-bottom`, `left-right`, or `none`. + +The default value for the `accent` prop is dynamically computed based on the `orientation` and `appearance` of the parent `Splitter` component. Here’s how it works: + +- If the parent `Splitter` has a `horizontal` orientation and a `bordered` appearance, the default value for `accent` will be `left-right`. +- If the parent `Splitter` has a `vertical` orientation and a `bordered` appearance, the default value for `accent` will be `top-bottom`. +- If the parent `Splitter` has a `transparent` appearance, the default value for `accent` will be `none`. + +This intelligent default behavior ensures consistency while still providing flexibility to customize the borders as needed. + + + + + +## Variant + +The `variant` prop enables you to customize the background of the `SplitHandle` and `SplitPanel` components. It accepts one of three options: `primary`, `secondary`, or `tertiary`. This prop functions similarly to the `variant` prop in our `Panel` component, ensuring a cohesive and consistent visual style throughout your application. By leveraging this prop, you can seamlessly align the appearance of the splitter components with the rest of your design system. + + + + + +## MinMaxSize + +The `minSize` and `maxSize` props allow you to define minimum and maximum size constraints for the `SplitPanel` components. This functionality is especially useful when you want to limit the resizing of panels to a specific range, ensuring that the layout remains both visually balanced and functional as users adjust the splitter. By setting these boundaries, you can prevent panels from becoming too small or too large, maintaining an optimal user experience. + +Additionally, if you'd like the panels to have a default size when the layout is first rendered, you can set an initial size using the `defaultSize` prop. This combination of `minSize`, `maxSize`, and `defaultSize` provides fine-grained control over the behavior and appearance of your splitter layout, making it adaptable to various design requirements. + + + + + +## Collapsible Fixed Size + +`SplitPanel` components can be made collapsible by setting the `collapsible` and `collapsedSize` props. It’s important to note that collapsing a panel does not necessarily reduce its size to 0. Instead, you can define a fixed size for the collapsed state using the `collapsedSize` prop. This allows you to maintain a specific, consistent size for the panel when collapsed, ensuring it remains partially visible or accessible while still optimizing the layout for usability. This feature provides greater flexibility in designing intuitive and space-efficient interfaces. + + + + + +## Collapsible to 0 + +If you would like it to collapse to 0, you can set the `collapsedSize` prop to 0. + + + + + +## CollapseButton + +Collapsing or expanding a `SplitPanel` can be controlled externally using the imperative ref API. This allows you to programmatically trigger these actions based on user interactions or other logic. Below is an example demonstrating how to achieve this using the API: + + + + + +## CollapseDoubleClick + +To enable expanding or collapsing a `SplitPanel` when the user double-clicks on a handle, you can use the imperative API and manually associate the `onDoubleClick` event with the appropriate handle. Since there is no default assignment of which handle "belongs" to which panel, you need to explicitly define this relationship. Here's an example of how to implement this: + + + + + +## ProgrammableResize + +In addition to collapsing and expanding panels, you can also programmatically resize `SplitPanel` components using the imperative ref API. This feature enables you to dynamically adjust the size of panels in response to user interactions or other events, providing greater control over the layout. Here's an example of how to implement programmatic resizing: + + + + diff --git a/site/docs/components/splitter/index.mdx b/site/docs/components/splitter/index.mdx new file mode 100644 index 00000000000..c56c98caec3 --- /dev/null +++ b/site/docs/components/splitter/index.mdx @@ -0,0 +1,14 @@ +--- +title: Splitter +data: + description: "`Splitter` divides window content into separate regions called split panels that can be dragged and resized, allowing users to customize the layout of their workspace." + sourceCodeUrl: "https://github.com/jpmorganchase/salt-ds/tree/main/packages/lab/src/splitter" + package: + name: "@salt-ds/lab" + alsoKnownAs: ["Resizable", "Resizable panel"] + +# Leave this as is +layout: DetailComponent +--- + +{/* This area stays blank */} diff --git a/site/docs/components/splitter/usage.mdx b/site/docs/components/splitter/usage.mdx new file mode 100644 index 00000000000..6cffc1f7fbd --- /dev/null +++ b/site/docs/components/splitter/usage.mdx @@ -0,0 +1,45 @@ +--- +title: + $ref: ./#/title +layout: DetailComponent +sidebar: + exclude: true +data: + $ref: ./#/data +--- + +## Using the components + +### When to use + +TODO + +### When not to use + +TODO + +## Content + +TODO + +## Import + +To import `Splitter` and related components from the lab Salt package, use: + +``` +import { Splitter, SplitPanel, SplitHandle } from "@salt-ds/lab"; +``` + +## Props + +### `Splitter` + + + +### `SplitPanel` + + + +### `SplitHandle` + + diff --git a/site/src/examples/splitter/Accent.tsx b/site/src/examples/splitter/Accent.tsx new file mode 100644 index 00000000000..42213430128 --- /dev/null +++ b/site/src/examples/splitter/Accent.tsx @@ -0,0 +1,35 @@ +import { Text } from "@salt-ds/core"; +import { SplitHandle, SplitPanel, Splitter } from "@salt-ds/lab"; +import type { CSSProperties } from "react"; + +const center = { + display: "flex", + alignItems: "center", + justifyContent: "center", + height: "100%", + textAlign: "center", +} as CSSProperties; + +const box = { + width: 420, + height: 240, + border: "1px solid var(--salt-separable-secondary-borderColor)", +}; + +export function Accent() { + return ( + + + Left + + + + Center + + + + Right + + + ); +} diff --git a/site/src/examples/splitter/CollapseButton.tsx b/site/src/examples/splitter/CollapseButton.tsx new file mode 100644 index 00000000000..d8784d8bebd --- /dev/null +++ b/site/src/examples/splitter/CollapseButton.tsx @@ -0,0 +1,65 @@ +import { Button, Text } from "@salt-ds/core"; +import { DoubleChevronLeftIcon, DoubleChevronRightIcon } from "@salt-ds/icons"; +import { + type ImperativePanelHandle, + SplitHandle, + SplitPanel, + Splitter, +} from "@salt-ds/lab"; +import { type CSSProperties, useRef, useState } from "react"; + +const center = { + display: "flex", + alignItems: "center", + justifyContent: "center", + height: "100%", + textAlign: "center", +} as CSSProperties; + +const box = { + width: 420, + height: 240, + border: "1px solid var(--salt-separable-secondary-borderColor)", +}; + +export function CollapseButton() { + const ref = useRef(null); + const [expanded, setExpanded] = useState(true); + + function toggle() { + if (!ref.current) return; + + const { expand, collapse, isExpanded } = ref.current; + + if (isExpanded()) { + collapse(); + setExpanded(false); + } else { + expand(); + setExpanded(true); + } + } + + return ( + + setExpanded(true)} + onCollapse={() => setExpanded(false)} + ref={ref} + style={center} + > + + + + + Content + + + ); +} diff --git a/site/src/examples/splitter/CollapseDoubleClick.tsx b/site/src/examples/splitter/CollapseDoubleClick.tsx new file mode 100644 index 00000000000..8f57a776547 --- /dev/null +++ b/site/src/examples/splitter/CollapseDoubleClick.tsx @@ -0,0 +1,60 @@ +import { Text } from "@salt-ds/core"; +import { ArrowLeftIcon, ArrowRightIcon } from "@salt-ds/icons"; +import { + type ImperativePanelHandle, + SplitHandle, + SplitPanel, + Splitter, +} from "@salt-ds/lab"; +import { type CSSProperties, useRef } from "react"; + +const center = { + display: "flex", + alignItems: "center", + justifyContent: "center", + height: "100%", + textAlign: "center", +} as CSSProperties; + +const box = { + width: 420, + height: 240, + border: "1px solid var(--salt-separable-secondary-borderColor)", +}; +export function CollapseDoubleClick() { + const ref = useRef(null); + + function toggle() { + if (!ref.current) return; + + const { expand, collapse, isExpanded } = ref.current; + + if (isExpanded()) { + return collapse(); + } + + return expand(); + } + + return ( + + + + Double Click the handle + + + + + + Double Click the handle + + + + ); +} diff --git a/site/src/examples/splitter/Collapsible0.tsx b/site/src/examples/splitter/Collapsible0.tsx new file mode 100644 index 00000000000..f8ed631dd71 --- /dev/null +++ b/site/src/examples/splitter/Collapsible0.tsx @@ -0,0 +1,31 @@ +import { Text } from "@salt-ds/core"; +import { SplitHandle, SplitPanel, Splitter } from "@salt-ds/lab"; +import type { CSSProperties } from "react"; + +const center = { + display: "flex", + alignItems: "center", + justifyContent: "center", + height: "100%", + textAlign: "center", +} as CSSProperties; + +const box = { + width: 420, + height: 240, + border: "1px solid var(--salt-separable-secondary-borderColor)", +}; + +export function Collapsible0() { + return ( + + + Left + + + + Right + + + ); +} diff --git a/site/src/examples/splitter/CollapsibleFixedSize.tsx b/site/src/examples/splitter/CollapsibleFixedSize.tsx new file mode 100644 index 00000000000..8b899d66f5d --- /dev/null +++ b/site/src/examples/splitter/CollapsibleFixedSize.tsx @@ -0,0 +1,37 @@ +import { Text } from "@salt-ds/core"; +import { SplitHandle, SplitPanel, Splitter } from "@salt-ds/lab"; +import type { CSSProperties } from "react"; + +const center = { + display: "flex", + alignItems: "center", + justifyContent: "center", + height: "100%", + textAlign: "center", +} as CSSProperties; + +const box = { + width: 420, + height: 240, + border: "1px solid var(--salt-separable-secondary-borderColor)", +}; + +export function CollapsibleFixedSize() { + return ( + + + Left + + + + Right + + + ); +} diff --git a/site/src/examples/splitter/Horizontal.tsx b/site/src/examples/splitter/Horizontal.tsx new file mode 100644 index 00000000000..c8cc0353ca1 --- /dev/null +++ b/site/src/examples/splitter/Horizontal.tsx @@ -0,0 +1,35 @@ +import { Text } from "@salt-ds/core"; +import { SplitHandle, SplitPanel, Splitter } from "@salt-ds/lab"; +import type { CSSProperties } from "react"; + +const center = { + display: "flex", + alignItems: "center", + justifyContent: "center", + height: "100%", + textAlign: "center", +} as CSSProperties; + +const box = { + width: 420, + height: 240, + border: "1px solid var(--salt-separable-secondary-borderColor)", +}; + +export function Horizontal() { + return ( + + + Left + + + + Center + + + + Right + + + ); +} diff --git a/site/src/examples/splitter/MinMaxSize.tsx b/site/src/examples/splitter/MinMaxSize.tsx new file mode 100644 index 00000000000..7d74ccdac52 --- /dev/null +++ b/site/src/examples/splitter/MinMaxSize.tsx @@ -0,0 +1,37 @@ +import { FlexLayout, Text } from "@salt-ds/core"; +import { SplitHandle, SplitPanel, Splitter } from "@salt-ds/lab"; +import type { CSSProperties } from "react"; + +const center = { + display: "flex", + alignItems: "center", + justifyContent: "center", + height: "100%", + textAlign: "center", +} as CSSProperties; + +const box = { + width: 420, + height: 240, + border: "1px solid var(--salt-separable-secondary-borderColor)", +}; + +export function MinMaxSize() { + return ( + + + + Left [20%, X] + + + + Middle [30%, 60%] + + + + Right [20%, X] + + + + ); +} diff --git a/site/src/examples/splitter/MultiOrientational.tsx b/site/src/examples/splitter/MultiOrientational.tsx new file mode 100644 index 00000000000..9d6088f245a --- /dev/null +++ b/site/src/examples/splitter/MultiOrientational.tsx @@ -0,0 +1,51 @@ +import { Text } from "@salt-ds/core"; +import { SplitHandle, SplitPanel, Splitter } from "@salt-ds/lab"; +import type { CSSProperties } from "react"; + +const center = { + display: "flex", + alignItems: "center", + justifyContent: "center", + height: "100%", + textAlign: "center", +} as CSSProperties; + +const box = { + width: 420, + height: 240, + border: "1px solid var(--salt-separable-secondary-borderColor)", +}; + +export function MultiOrientational() { + return ( + + + + + Top Left + + + + Middle Left + + + + Bottom Left + + + + + + + + Top Right + + + + Bottom Right + + + + + ); +} diff --git a/site/src/examples/splitter/ProgrammableResize.tsx b/site/src/examples/splitter/ProgrammableResize.tsx new file mode 100644 index 00000000000..fb10ebd391c --- /dev/null +++ b/site/src/examples/splitter/ProgrammableResize.tsx @@ -0,0 +1,55 @@ +import { Button, FlexLayout, StackLayout, Text } from "@salt-ds/core"; +import { + type ImperativePanelHandle, + SplitHandle, + SplitPanel, + Splitter, +} from "@salt-ds/lab"; +import { type CSSProperties, useRef } from "react"; + +const center = { + display: "flex", + alignItems: "center", + justifyContent: "center", + height: "100%", + textAlign: "center", +} as CSSProperties; + +const box = { + width: 420, + height: 240, + border: "1px solid var(--salt-separable-secondary-borderColor)", +}; + +export function ProgrammableResize() { + const ref = useRef(null); + + function handleResizeLeft(size: number) { + return () => { + ref.current?.resize(size); + }; + } + + return ( + + + + + + + + + + + + Left + + + + Right + + + + + ); +} diff --git a/site/src/examples/splitter/Transparent.tsx b/site/src/examples/splitter/Transparent.tsx new file mode 100644 index 00000000000..720366aa8cb --- /dev/null +++ b/site/src/examples/splitter/Transparent.tsx @@ -0,0 +1,60 @@ +import { FlexLayout, Text } from "@salt-ds/core"; +import { SplitHandle, SplitPanel, Splitter } from "@salt-ds/lab"; +import type { CSSProperties } from "react"; + +const center = { + display: "flex", + alignItems: "center", + justifyContent: "center", + height: "100%", + textAlign: "center", +} as CSSProperties; + +const box = { + width: 420, + height: 240, + border: "1px solid var(--salt-separable-secondary-borderColor)", +}; + +export function Transparent() { + return ( + + + + + + Top Left + + + + Middle Left + + + + Bottom Left + + + + + + + + Top Right + + + + Bottom Right + + + + + + ); +} diff --git a/site/src/examples/splitter/Variant.tsx b/site/src/examples/splitter/Variant.tsx new file mode 100644 index 00000000000..91dc40b83a2 --- /dev/null +++ b/site/src/examples/splitter/Variant.tsx @@ -0,0 +1,35 @@ +import { Text } from "@salt-ds/core"; +import { SplitHandle, SplitPanel, Splitter } from "@salt-ds/lab"; +import type { CSSProperties } from "react"; + +const center = { + display: "flex", + alignItems: "center", + justifyContent: "center", + height: "100%", + textAlign: "center", +} as CSSProperties; + +const box = { + width: 420, + height: 240, + border: "1px solid var(--salt-separable-secondary-borderColor)", +}; + +export function Variant() { + return ( + + + Left + + + + Center + + + + Right + + + ); +} diff --git a/site/src/examples/splitter/Vertical.tsx b/site/src/examples/splitter/Vertical.tsx new file mode 100644 index 00000000000..f6857b184bc --- /dev/null +++ b/site/src/examples/splitter/Vertical.tsx @@ -0,0 +1,35 @@ +import { Text } from "@salt-ds/core"; +import { SplitHandle, SplitPanel, Splitter } from "@salt-ds/lab"; +import type { CSSProperties } from "react"; + +const center = { + display: "flex", + alignItems: "center", + justifyContent: "center", + height: "100%", + textAlign: "center", +} as CSSProperties; + +const box = { + width: 420, + height: 240, + border: "1px solid var(--salt-separable-secondary-borderColor)", +}; + +export function Vertical() { + return ( + + + Top + + + + Middle + + + + Bottom + + + ); +} diff --git a/site/src/examples/splitter/index.ts b/site/src/examples/splitter/index.ts new file mode 100644 index 00000000000..f41139cef62 --- /dev/null +++ b/site/src/examples/splitter/index.ts @@ -0,0 +1,12 @@ +export * from "./Horizontal"; +export * from "./Vertical"; +export * from "./MultiOrientational"; +export * from "./Transparent"; +export * from "./Accent"; +export * from "./Variant"; +export * from "./MinMaxSize"; +export * from "./CollapsibleFixedSize"; +export * from "./Collapsible0"; +export * from "./CollapseButton"; +export * from "./CollapseDoubleClick"; +export * from "./ProgrammableResize"; diff --git a/yarn.lock b/yarn.lock index fd77fd74c43..1a5f559c613 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3303,6 +3303,7 @@ __metadata: moment-timezone: "npm:^0.5.46" no-scroll: "npm:^2.1.1" react-color: "npm:^2.19.3" + react-resizable-panels: "npm:^2.1.7" react-window: "npm:^1.8.6" rifm: "npm:^0.12.0" tabbable: "npm:^6.0.0" @@ -14806,6 +14807,16 @@ __metadata: languageName: node linkType: hard +"react-resizable-panels@npm:^2.1.7": + version: 2.1.7 + resolution: "react-resizable-panels@npm:2.1.7" + peerDependencies: + react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + checksum: 10/7fb0e4122a31bd8157126e622c3ab593ee04d64861cf876b0ac9433bfb680e10ac4fb66c5672e1e0f3ab3c3098a176faab1b4fd271db812919c0930041678cba + languageName: node + linkType: hard + "react-responsive-carousel@npm:3.2.10": version: 3.2.10 resolution: "react-responsive-carousel@npm:3.2.10"