diff --git a/packages/lab/src/index.ts b/packages/lab/src/index.ts index 3662ed000ad..7694611b482 100644 --- a/packages/lab/src/index.ts +++ b/packages/lab/src/index.ts @@ -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"; diff --git a/packages/lab/src/toolbar-next/ToolbarNext.css b/packages/lab/src/toolbar-next/ToolbarNext.css new file mode 100644 index 00000000000..ad5d31438af --- /dev/null +++ b/packages/lab/src/toolbar-next/ToolbarNext.css @@ -0,0 +1,34 @@ +.saltToolbarNext { + 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; +} diff --git a/packages/lab/src/toolbar-next/ToolbarNext.tsx b/packages/lab/src/toolbar-next/ToolbarNext.tsx new file mode 100644 index 00000000000..42f6e2f7b24 --- /dev/null +++ b/packages/lab/src/toolbar-next/ToolbarNext.tsx @@ -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; + +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> { + return isValidElement(child) && child.type === ToolbarRegion; +} + +function isTooltrayElement( + child: ToolbarNextChild, +): child is ReactElement { + return isValidElement(child) && child.type === TooltrayNext; +} + +function isDividerElement(child: ToolbarNextChild): child is ReactElement { + return isValidElement(child) && child.type === Divider; +} + +function getTooltrayAlign(child: ReactElement) { + return child.props.align ?? "start"; +} + +function buildImplicitRegions(children: ToolbarNextChild[]) { + const buckets: Record = { + 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 ( + + {regionChildren} + + ); + }); +} + +export const ToolbarNext = forwardRef( + 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 ( +
+ {mode === "flat" ? buildImplicitRegions(flattenedChildren) : children} +
+ ); + }, +); diff --git a/packages/lab/src/toolbar-next/ToolbarRegion.css b/packages/lab/src/toolbar-next/ToolbarRegion.css new file mode 100644 index 00000000000..0648b101d74 --- /dev/null +++ b/packages/lab/src/toolbar-next/ToolbarRegion.css @@ -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; +} diff --git a/packages/lab/src/toolbar-next/ToolbarRegion.tsx b/packages/lab/src/toolbar-next/ToolbarRegion.tsx new file mode 100644 index 00000000000..d357dbad7e0 --- /dev/null +++ b/packages/lab/src/toolbar-next/ToolbarRegion.tsx @@ -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( + function ToolbarRegion({ children, className, position, ...rest }, ref) { + const targetWindow = useWindow(); + useComponentCssInjection({ + testId: "salt-toolbar-region", + css: toolbarRegionCss, + window: targetWindow, + }); + + return ( +
+ {children} +
+ ); + }, +); diff --git a/packages/lab/src/toolbar-next/Tooltray.css b/packages/lab/src/toolbar-next/Tooltray.css new file mode 100644 index 00000000000..265703b5903 --- /dev/null +++ b/packages/lab/src/toolbar-next/Tooltray.css @@ -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; +} diff --git a/packages/lab/src/toolbar-next/Tooltray.tsx b/packages/lab/src/toolbar-next/Tooltray.tsx new file mode 100644 index 00000000000..22be14e364c --- /dev/null +++ b/packages/lab/src/toolbar-next/Tooltray.tsx @@ -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, "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( + 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 ( +
+ {children} +
+ ); + }, +); diff --git a/packages/lab/src/toolbar-next/index.ts b/packages/lab/src/toolbar-next/index.ts new file mode 100644 index 00000000000..7c74846eb88 --- /dev/null +++ b/packages/lab/src/toolbar-next/index.ts @@ -0,0 +1,3 @@ +export * from "./ToolbarNext"; +export * from "./ToolbarRegion"; +export * from "./Tooltray"; diff --git a/packages/lab/stories/toolbar-next/toolbar-next.stories.tsx b/packages/lab/stories/toolbar-next/toolbar-next.stories.tsx new file mode 100644 index 00000000000..ad72f837d22 --- /dev/null +++ b/packages/lab/stories/toolbar-next/toolbar-next.stories.tsx @@ -0,0 +1,250 @@ +import { + Button, + Divider, + Dropdown, + Input, + Option, + Switch, + Text, + ToggleButton, + ToggleButtonGroup, +} from "@salt-ds/core"; +import { GridIcon, ListIcon, SearchIcon } from "@salt-ds/icons"; +import { + DateInputSingle, + ToolbarNext, + ToolbarRegion, + TooltrayNext, +} from "@salt-ds/lab"; +import type { Meta, StoryFn } from "@storybook/react-vite"; + +export default { + title: "Lab/Toolbar Next", + component: ToolbarNext, + subcomponents: { + ToolbarRegion, + TooltrayNext, + }, +} as Meta; + +const options = ["Option A", "Option B", "Option C"]; + +export const FlatAlignSugar: StoryFn = () => ( + + + } placeholder="Search" /> + + {options.map((option) => ( + + + + + + + + +); + +export const FlatWithDividers: StoryFn = () => ( + + + } placeholder="Search" /> + + {options.map((option) => ( + + + + + Description + + + + {options.map((option) => ( + + + + + + + + + +); + +export const Spacing300Groups: StoryFn = () => ( + + + + } placeholder="Search" /> + + {options.map((option) => ( + + + + + + + + + + + +); + +export const RegionFirst: StoryFn = () => ( + + + + } placeholder="Search" /> + + + + + + Toggle + Toggle + Toggle + + + + + + Description + + + +); + +export const Transparent: StoryFn = () => ( + + + + } placeholder="Search" /> + + {options.map((option) => ( + + + + + + + + + + + +); + +export const DataViewActions: StoryFn = () => ( + + + + } placeholder="Search" /> + + {options.map((option) => ( + + + + + Description + + + + + + {options.map((option) => ( + + + + + + + + + + +); + +export const MixedFormControls: StoryFn = () => ( + + + + } placeholder="Search" /> + + {options.map((option) => ( + + + + + + + + + + + +); + +export const RightToLeft: StoryFn = () => ( +
+ + + } placeholder="Search" /> + + {options.map((option) => ( + + + + + + + + +
+);