Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Splitter #4650

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/seven-penguins-fly.md
Original file line number Diff line number Diff line change
@@ -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 |
| ------------------------------ | ------------------------------ |
| `<Splitter />` | `<PanelGroup />` |
| `<SplitPanel />` | `<Panel />` |
| `<SplitHandle />` | `<PanelResizeHandle />` |
| `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 `<SplitHandle />`, 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`.
1 change: 1 addition & 0 deletions packages/lab/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
63 changes: 63 additions & 0 deletions packages/lab/src/__tests__/__e2e__/splitter/Splitter.cy.tsx
Original file line number Diff line number Diff line change
@@ -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("<Splitter />", () => {
it("should collapse on double click", () => {
cy.mount(<CollapseDoubleClick />);

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(<ProgrammableResize />);

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");
});
});
1 change: 1 addition & 0 deletions packages/lab/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
198 changes: 198 additions & 0 deletions packages/lab/src/splitter/SplitHandle.css
Original file line number Diff line number Diff line change
@@ -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;
}
86 changes: 86 additions & 0 deletions packages/lab/src/splitter/SplitHandle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PanelResizeHandle
className={clsx(
withBaseName(),
withBaseName(appearance),
withBaseName("accent", accent),
withBaseName("variant", variant),
className,
)}
{...props}
>
<span className={withBaseName("dot")} />
<span className={withBaseName("dot")} />
<span className={withBaseName("dot")} />
<span className={withBaseName("dot")} />
</PanelResizeHandle>
);
}
Loading
Loading