Skip to content
Closed

Toolbar #6132

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
1 change: 1 addition & 0 deletions packages/lab/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export * from "./toast-group";
export * from "./tokenized-input";
export * from "./tokenized-input-next";
export * from "./toolbar";
export * from "./toolbar-next";
export * from "./tree";
export * from "./utils";
export * from "./window";
34 changes: 34 additions & 0 deletions packages/lab/src/toolbar-next/ToolbarNext.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
.saltToolbarNext {

Check failure on line 1 in packages/lab/src/toolbar-next/ToolbarNext.css

View workflow job for this annotation

GitHub Actions / lint

format

File content differs from formatting output
box-sizing: border-box;
inline-size: 100%;
min-width: 0;
}

.saltToolbarNext-layout {
display: grid;
align-items: center;
column-gap: var(--salt-spacing-100);
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
}

.saltToolbarNext-fallback {
display: flex;
align-items: center;
gap: var(--salt-spacing-100);
}

.saltToolbarNext-layout > * {
min-width: 0;
}

.saltToolbarNext-bordered {
border: var(--salt-size-fixed-100) var(--salt-borderStyle-solid)
var(--salt-container-primary-borderColor);
border-radius: var(--salt-palette-corner, 0);
padding: var(--salt-spacing-100);
}

.saltToolbarNext-transparent {
border: none;
padding: 0;
}
151 changes: 151 additions & 0 deletions packages/lab/src/toolbar-next/ToolbarNext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { Divider, makePrefixer } from "@salt-ds/core";
import { useComponentCssInjection } from "@salt-ds/styles";
import { useWindow } from "@salt-ds/window";
import { clsx } from "clsx";
import {
Children,
type ComponentPropsWithoutRef,
Fragment,
forwardRef,
isValidElement,
type ReactElement,
type ReactNode,
} from "react";

import toolbarNextCss from "./ToolbarNext.css";
import { ToolbarRegion, type ToolbarRegionPosition } from "./ToolbarRegion";
import { TooltrayNext, type TooltrayNextProps } from "./Tooltray";

export interface ToolbarNextProps extends ComponentPropsWithoutRef<"div"> {
/**
* Defaults to `"bordered"`.
*/
variant?: "bordered" | "transparent";
}

const withBaseName = makePrefixer("saltToolbarNext");

type ToolbarNextChild = Exclude<ReactNode, boolean | null | undefined>;

function flattenToolbarChildren(children: ReactNode): ToolbarNextChild[] {
const flattened: ToolbarNextChild[] = [];

Children.forEach(children, (child) => {
if (child == null || typeof child === "boolean") {
return;
}

if (isValidElement(child) && child.type === Fragment) {
flattened.push(...flattenToolbarChildren(child.props.children));
return;
}

flattened.push(child);
});

return flattened;
}

function isToolbarRegionElement(
child: ToolbarNextChild,
): child is ReactElement<ComponentPropsWithoutRef<typeof ToolbarRegion>> {
return isValidElement(child) && child.type === ToolbarRegion;
}

function isTooltrayElement(
child: ToolbarNextChild,
): child is ReactElement<TooltrayNextProps> {
return isValidElement(child) && child.type === TooltrayNext;
}

function isDividerElement(child: ToolbarNextChild): child is ReactElement {
return isValidElement(child) && child.type === Divider;
}

function getTooltrayAlign(child: ReactElement<TooltrayNextProps>) {
return child.props.align ?? "start";
}

function buildImplicitRegions(children: ToolbarNextChild[]) {
const buckets: Record<ToolbarRegionPosition, ToolbarNextChild[]> = {
start: [],
center: [],
end: [],
};
let currentPosition: ToolbarRegionPosition = "start";

for (const child of children) {
if (isTooltrayElement(child)) {
currentPosition = getTooltrayAlign(child);
buckets[currentPosition].push(child);
continue;
}

if (isDividerElement(child)) {
buckets[currentPosition].push(child);
}
}

return (Object.keys(buckets) as ToolbarRegionPosition[]).map((position) => {
const regionChildren = buckets[position];

if (regionChildren.length === 0) {
return null;
}

return (
<ToolbarRegion data-implicit position={position} key={position}>
{regionChildren}
</ToolbarRegion>
);
});
}

export const ToolbarNext = forwardRef<HTMLDivElement, ToolbarNextProps>(
function ToolbarNext(
{ children, className, variant = "bordered", ...rest },
ref,
) {
const targetWindow = useWindow();
useComponentCssInjection({
testId: "salt-toolbar-next",
css: toolbarNextCss,
window: targetWindow,
});

const flattenedChildren = flattenToolbarChildren(children);
const hasRegionChildren = flattenedChildren.some(isToolbarRegionElement);
const hasOnlyRegions =
hasRegionChildren && flattenedChildren.every(isToolbarRegionElement);
const hasOnlyFlatChildren = flattenedChildren.every(
(child) => isTooltrayElement(child) || isDividerElement(child),
);

const mode = hasOnlyRegions
? "explicit"
: hasOnlyFlatChildren
? "flat"
: "invalid";

return (
<div
className={clsx(
withBaseName(),
{
[withBaseName("fallback")]: mode === "invalid",
[withBaseName("layout")]: mode !== "invalid",
},
withBaseName(variant),
className,
)}
data-mode={mode}
ref={ref}
{...rest}
role="toolbar"
aria-orientation="horizontal"
>
{mode === "flat" ? buildImplicitRegions(flattenedChildren) : children}
</div>
);
},
);
28 changes: 28 additions & 0 deletions packages/lab/src/toolbar-next/ToolbarRegion.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.saltToolbarRegion {
display: flex;
align-items: center;
gap: var(--salt-spacing-100);
min-width: 0;
}

.saltToolbarRegion[data-position="start"] {
grid-column: 1;
justify-self: stretch;
}

.saltToolbarRegion[data-position="center"] {
grid-column: 2;
justify-self: center;
max-width: 100%;
}

.saltToolbarRegion[data-position="end"] {
grid-column: 3;
justify-content: flex-end;
justify-self: stretch;
}

.saltToolbarRegion[data-implicit] > .saltTooltrayNext[data-align="center"],
.saltToolbarRegion[data-implicit] > .saltTooltrayNext[data-align="end"] {
margin-inline: 0;
}
40 changes: 40 additions & 0 deletions packages/lab/src/toolbar-next/ToolbarRegion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { makePrefixer } from "@salt-ds/core";
import { useComponentCssInjection } from "@salt-ds/styles";
import { useWindow } from "@salt-ds/window";
import { clsx } from "clsx";
import { type ComponentPropsWithoutRef, forwardRef } from "react";

import toolbarRegionCss from "./ToolbarRegion.css";

export type ToolbarRegionPosition = "start" | "center" | "end";

export interface ToolbarRegionProps extends ComponentPropsWithoutRef<"div"> {
/**
* Controls where the region is placed across the full toolbar.
*/
position: ToolbarRegionPosition;
}

const withBaseName = makePrefixer("saltToolbarRegion");

export const ToolbarRegion = forwardRef<HTMLDivElement, ToolbarRegionProps>(
function ToolbarRegion({ children, className, position, ...rest }, ref) {
const targetWindow = useWindow();
useComponentCssInjection({
testId: "salt-toolbar-region",
css: toolbarRegionCss,
window: targetWindow,
});

return (
<div
className={clsx(withBaseName(), className)}
data-position={position}
ref={ref}
{...rest}
>
{children}
</div>
);
},
);
14 changes: 14 additions & 0 deletions packages/lab/src/toolbar-next/Tooltray.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.saltTooltrayNext {
display: flex;
align-items: center;
gap: var(--salt-spacing-100);
min-width: 0;
}

.saltTooltrayNext[data-align="end"] {
margin-inline-start: auto;
}

.saltTooltrayNext[data-align="center"] {
margin-inline: auto;
}
67 changes: 67 additions & 0 deletions packages/lab/src/toolbar-next/Tooltray.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { makePrefixer } from "@salt-ds/core";
import { useComponentCssInjection } from "@salt-ds/styles";
import { useWindow } from "@salt-ds/window";
import { clsx } from "clsx";
import { type ComponentPropsWithoutRef, forwardRef } from "react";

import tooltrayCss from "./Tooltray.css";

export interface TooltrayNextProps
extends Omit<ComponentPropsWithoutRef<"div">, "align"> {
/**
* `TooltrayNext` is layout-only by default.
* Pass `role="group"` with `aria-label` or `aria-labelledby` when the tray
* represents a meaningful subgroup inside the toolbar.
*
* Alignment of the tooltray.
* - When a `TooltrayNext` is used directly inside `ToolbarNext`, this acts as
* shorthand for which toolbar band the tray belongs to.
* - When a `TooltrayNext` is used inside `ToolbarRegion`, this alignment is
* local to that region.
*
* Defaults to `"start"`.
*/
align?: "start" | "end" | "center";
}

const withBaseName = makePrefixer("saltTooltrayNext");

export const TooltrayNext = forwardRef<HTMLDivElement, TooltrayNextProps>(
function TooltrayNext(
{
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
align = "start",
children,
className,
role,
...rest
},
ref,
) {
const targetWindow = useWindow();
useComponentCssInjection({
testId: "salt-tooltray-next",
css: tooltrayCss,
window: targetWindow,
});

return (
<div
className={clsx(withBaseName(), className)}
data-align={align}
ref={ref}
role={role}
{...(role != null
? {
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
}
: {})}
{...rest}
>
{children}
</div>
);
},
);
3 changes: 3 additions & 0 deletions packages/lab/src/toolbar-next/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./ToolbarNext";
export * from "./ToolbarRegion";
export * from "./Tooltray";
Loading
Loading