diff --git a/packages/core/src/Adapters/icon.ts b/packages/core/src/Adapters/icon.ts index c8d9e248..f60f993c 100644 --- a/packages/core/src/Adapters/icon.ts +++ b/packages/core/src/Adapters/icon.ts @@ -36,6 +36,7 @@ export const SEMANTIC_ICON_NAMES = [ "calendar", "download", "chevronUp", + "panelLeft", "chevronDown", "chevronLeft", "chevronRight", diff --git a/packages/core/src/Config/index.ts b/packages/core/src/Config/index.ts index a4777535..56624f71 100644 --- a/packages/core/src/Config/index.ts +++ b/packages/core/src/Config/index.ts @@ -97,6 +97,8 @@ export type { RadioConfigOverrides, SelectConfigBase, SelectConfigOverrides, + SidebarConfigBase, + SidebarConfigOverrides, SkeletonConfigBase, SkeletonConfigOverrides, SliderConfigBase, diff --git a/packages/core/src/Config/types.ts b/packages/core/src/Config/types.ts index cf283fef..58505c0c 100644 --- a/packages/core/src/Config/types.ts +++ b/packages/core/src/Config/types.ts @@ -152,6 +152,14 @@ import type { RadioRounded, RadioSize, } from "@/Tokens/Radio"; +import type { + SidebarCollapsible, + SidebarCollapsibleItem, + SidebarSide, + SidebarVariant, + SidebarVariantItem, + SidebarWidth, +} from "@/Tokens/Sidebar"; import type { SkeletonRounded } from "@/Tokens/Skeleton"; import type { SliderColor, @@ -384,6 +392,7 @@ export interface PasswordFieldConfigOverrides {} export interface ProgressConfigOverrides {} export interface RadioConfigOverrides {} export interface SelectConfigOverrides {} +export interface SidebarConfigOverrides {} export interface SkeletonConfigOverrides {} export interface SliderConfigOverrides {} export interface SnackbarConfigOverrides {} @@ -1228,6 +1237,22 @@ export interface ProgressConfigBase { }>; } +export interface SidebarConfigBase { + classes: object; + defaultProps: Partial<{ + collapsible: keyof SidebarCollapsible; + defaultOpen: boolean; + side: keyof SidebarSide; + variant: keyof SidebarVariant; + }>; + tokens: Partial<{ + collapsible: Record; + side: Record; + variant: Record; + width: Partial; + }>; +} + export interface SkeletonConfigBase { classes: object; defaultProps: Partial<{ @@ -1543,6 +1568,7 @@ export type BridgeUIComponentsConfig = Partial<{ Progress: Partial>; Radio: Partial>; Select: Partial>; + Sidebar: Partial>; Skeleton: Partial>; Slider: Partial>; Snackbar: Partial>; diff --git a/packages/core/src/Domain/__tests__/sidebar.test.ts b/packages/core/src/Domain/__tests__/sidebar.test.ts new file mode 100644 index 00000000..dcb850e6 --- /dev/null +++ b/packages/core/src/Domain/__tests__/sidebar.test.ts @@ -0,0 +1,116 @@ +// ** External Imports +import { describe, expect, test } from "vitest"; + +// ** Local Imports +import { + getSidebarPanelId, + isSidebarIconOnly, + resolveSidebarCollapsibleData, + resolveSidebarListTooltipPlacement, + resolveSidebarState, + shouldRenderSidebarAsDrawer, + shouldToggleDesktopSidebar, + SIDEBAR_DESKTOP_BREAKPOINT, + toggleSidebarOpen, +} from "@/Domain/sidebar"; + +describe("resolveSidebarState", () => { + test("it should return expanded when collapsible is none", () => { + expect(resolveSidebarState(false, "none")).toBe("expanded"); + expect(resolveSidebarState(true, "none")).toBe("expanded"); + }); + + test("it should follow open for icon and offcanvas", () => { + expect(resolveSidebarState(true, "icon")).toBe("expanded"); + expect(resolveSidebarState(false, "icon")).toBe("collapsed"); + expect(resolveSidebarState(false, "offcanvas")).toBe("collapsed"); + }); +}); + +describe("shouldRenderSidebarAsDrawer", () => { + test("it should be true only on mobile", () => { + expect(shouldRenderSidebarAsDrawer(true)).toBe(true); + expect(shouldRenderSidebarAsDrawer(false)).toBe(false); + }); +}); + +describe("toggleSidebarOpen", () => { + test("it should invert the flag", () => { + expect(toggleSidebarOpen(true)).toBe(false); + expect(toggleSidebarOpen(false)).toBe(true); + }); +}); + +describe("resolveSidebarCollapsibleData", () => { + test("it should be empty when expanded or none", () => { + expect(resolveSidebarCollapsibleData("expanded", "icon")).toBe(""); + expect(resolveSidebarCollapsibleData("collapsed", "none")).toBe(""); + }); + + test("it should echo the mode when collapsed", () => { + expect(resolveSidebarCollapsibleData("collapsed", "icon")).toBe("icon"); + expect(resolveSidebarCollapsibleData("collapsed", "offcanvas")).toBe( + "offcanvas", + ); + }); +}); + +describe("getSidebarPanelId", () => { + test("it should build a stable panel id", () => { + expect(getSidebarPanelId("sidebar-1")).toBe("sidebar-1-panel"); + }); +}); + +describe("shouldToggleDesktopSidebar", () => { + test("it should be false when collapsible is none", () => { + expect(shouldToggleDesktopSidebar("none")).toBe(false); + expect(shouldToggleDesktopSidebar("icon")).toBe(true); + expect(shouldToggleDesktopSidebar("offcanvas")).toBe(true); + }); +}); + +describe("SIDEBAR_DESKTOP_BREAKPOINT", () => { + test("it should match the md shell breakpoint", () => { + expect(SIDEBAR_DESKTOP_BREAKPOINT).toBe("md"); + }); +}); + +describe("isSidebarIconOnly", () => { + test("it should be true only on a collapsed desktop icon rail", () => { + expect( + isSidebarIconOnly({ + isMobile: false, + state: "collapsed", + collapsible: "icon", + }), + ).toBe(true); + expect( + isSidebarIconOnly({ + isMobile: true, + state: "collapsed", + collapsible: "icon", + }), + ).toBe(false); + expect( + isSidebarIconOnly({ + isMobile: false, + state: "expanded", + collapsible: "icon", + }), + ).toBe(false); + expect( + isSidebarIconOnly({ + isMobile: false, + state: "collapsed", + collapsible: "offcanvas", + }), + ).toBe(false); + }); +}); + +describe("resolveSidebarListTooltipPlacement", () => { + test("it should sit opposite the dock edge", () => { + expect(resolveSidebarListTooltipPlacement("left")).toBe("right"); + expect(resolveSidebarListTooltipPlacement("right")).toBe("left"); + }); +}); diff --git a/packages/core/src/Domain/index.ts b/packages/core/src/Domain/index.ts index c46e9d1b..dee40df7 100644 --- a/packages/core/src/Domain/index.ts +++ b/packages/core/src/Domain/index.ts @@ -228,6 +228,21 @@ export type { SelectOptionLike, SelectValue, } from "@/Domain/select"; +export { + SIDEBAR_DESKTOP_BREAKPOINT, + SIDEBAR_WIDTH_ICON_VAR, + SIDEBAR_WIDTH_MOBILE_VAR, + SIDEBAR_WIDTH_VAR, + getSidebarPanelId, + isSidebarIconOnly, + resolveSidebarCollapsibleData, + resolveSidebarListTooltipPlacement, + resolveSidebarState, + shouldRenderSidebarAsDrawer, + shouldToggleDesktopSidebar, + toggleSidebarOpen, +} from "@/Domain/sidebar"; +export type { SidebarCollapsibleMode, SidebarState } from "@/Domain/sidebar"; export { DEFAULT_SLIDER_MAX, DEFAULT_SLIDER_MIN, diff --git a/packages/core/src/Domain/sidebar.ts b/packages/core/src/Domain/sidebar.ts new file mode 100644 index 00000000..2649a443 --- /dev/null +++ b/packages/core/src/Domain/sidebar.ts @@ -0,0 +1,116 @@ +/** + * Desktop visual state derived from `open` and `collapsible`. + */ +export type SidebarState = "expanded" | "collapsed"; + +/** + * CSS custom property for the expanded desktop rail width. + */ +export const SIDEBAR_WIDTH_VAR = "--bridge-sidebar-width"; + +/** + * CSS custom property for the collapsed icon-rail width. + */ +export const SIDEBAR_WIDTH_ICON_VAR = "--bridge-sidebar-width-icon"; + +/** + * CSS custom property for the mobile drawer panel width. + */ +export const SIDEBAR_WIDTH_MOBILE_VAR = "--bridge-sidebar-width-mobile"; + +/** + * Tailwind breakpoint at which the desktop rail is shown (`md:` on the shell). + * The overlay `Drawer` is used below this width, not the global `sm` mobile flag, + * so the rail is not blank between `sm` and `md`. + */ +export const SIDEBAR_DESKTOP_BREAKPOINT = "md"; + +/** + * Collapsible modes accepted by {@link resolveSidebarState}. + */ +export type SidebarCollapsibleMode = "icon" | "none" | "offcanvas"; + +/** + * Resolves `expanded` vs `collapsed` for desktop chrome. + * `none` is always expanded. Mobile overlay is independent (`openMobile`). + */ +export function resolveSidebarState( + open: boolean, + collapsible: SidebarCollapsibleMode, +): SidebarState { + if (collapsible === "none") { + return "expanded"; + } + + return open ? "expanded" : "collapsed"; +} + +/** + * Whether the sidebar should mount its mobile `Drawer` overlay. + * Desktop chrome stays mounted and is hidden with CSS (`md:`). + */ +export function shouldRenderSidebarAsDrawer(isMobile: boolean): boolean { + return isMobile; +} + +/** + * Next boolean for a sidebar open flag (desktop `open` or mobile `openMobile`). + */ +export function toggleSidebarOpen(open: boolean): boolean { + return !open; +} + +/** + * `data-collapsible` value: the mode when collapsed, empty when expanded. + */ +export function resolveSidebarCollapsibleData( + state: SidebarState, + collapsible: SidebarCollapsibleMode, +): "" | SidebarCollapsibleMode { + if (state === "expanded" || collapsible === "none") { + return ""; + } + + return collapsible; +} + +/** + * Stable DOM id for the sidebar panel (`aria-controls` on the trigger). + */ +export function getSidebarPanelId(sidebarId: string): string { + return `${sidebarId}-panel`; +} + +/** + * Whether desktop toggle should change `open`. `none` is a no-op on desktop. + */ +export function shouldToggleDesktopSidebar( + collapsible: SidebarCollapsibleMode, +): boolean { + return collapsible !== "none"; +} + +/** + * Whether rail lists should collapse to icons. + * False below the desktop breakpoint so the overlay drawer keeps labels. + */ +export function isSidebarIconOnly({ + state, + isMobile, + collapsible, +}: { + collapsible: SidebarCollapsibleMode; + isMobile: boolean; + state: SidebarState; +}): boolean { + return !isMobile && collapsible === "icon" && state === "collapsed"; +} + +/** + * Tooltip placement for collapsed icon-rail items. Opposite the dock edge. + */ +export function resolveSidebarListTooltipPlacement( + side: "left" | "right", +): "left" | "right" { + return side === "right" ? "left" : "right"; +} diff --git a/packages/core/src/Tokens/Accordion/Variant.ts b/packages/core/src/Tokens/Accordion/Variant.ts index 244714cd..ed902b9b 100644 --- a/packages/core/src/Tokens/Accordion/Variant.ts +++ b/packages/core/src/Tokens/Accordion/Variant.ts @@ -7,6 +7,11 @@ export interface AccordionVariantItem { */ "item": string; + /** + * Classes for the expandable panel region. + */ + "panel": string; + /** * Classes for the accordion root. */ @@ -50,20 +55,16 @@ export interface AccordionVariant { * Default accordion variant class maps. */ export const variantProps: AccordionVariant = { - "plain": { - "root": "flex flex-col gap-1", - "item": "overflow-hidden rounded-lg", - "trigger": - "text-dark-700 hover:bg-dark-500/5 dark:text-dark-200 dark:hover:bg-dark-500/10", - }, "default": { "item": "", + "panel": "", "trigger": "text-dark-700 hover:bg-dark-500/5 dark:text-dark-200 dark:hover:bg-dark-500/10", "root": "divide-y divide-dark-200 border-y border-dark-200 dark:divide-dark-700 dark:border-dark-700", }, "separated": { + "panel": "", "root": "flex flex-col gap-2", "item": "overflow-hidden rounded-lg border border-dark-200 dark:border-dark-700", @@ -72,9 +73,18 @@ export const variantProps: AccordionVariant = { }, "outlined": { "item": "", + "panel": "", "trigger": "text-dark-700 hover:bg-dark-500/5 dark:text-dark-200 dark:hover:bg-dark-500/10", "root": "overflow-hidden rounded-lg border border-dark-200 divide-y divide-dark-200 dark:border-dark-700 dark:divide-dark-700", }, + "plain": { + "item": "", + "root": "flex flex-col gap-1 px-2 py-2", + "trigger": + "rounded-lg min-h-8 px-2 py-1.5 text-dark-700 hover:bg-black/5 dark:text-dark-200 dark:hover:bg-white/10", + "panel": + "ml-3.5 translate-x-px border-l border-dark-200 p-0 py-0.5 pl-2.5 group-data-[collapsible=icon]:hidden dark:border-dark-700", + }, }; diff --git a/packages/core/src/Tokens/Sidebar/Collapsible.ts b/packages/core/src/Tokens/Sidebar/Collapsible.ts new file mode 100644 index 00000000..2ca63c4b --- /dev/null +++ b/packages/core/src/Tokens/Sidebar/Collapsible.ts @@ -0,0 +1,58 @@ +/** + * Per-mode gap and panel classes for desktop collapse. + */ +export interface SidebarCollapsibleItem { + /** + * Classes merged onto the in-flow gap spacer. + */ + "gap": string; + + /** + * Classes merged onto the fixed desktop panel. + */ + "panel": string; +} + +/** + * How the desktop sidebar hides. + * + * `offcanvas` slides out of the layout. `icon` shrinks to the icon rail. + * `none` stays expanded. + */ +export interface SidebarCollapsible { + /** + * Collapse to icons. Labels hide via `SidebarList`. + */ + "icon": SidebarCollapsibleItem; + + /** + * Always expanded. Trigger is a no-op on desktop. + */ + "none": SidebarCollapsibleItem; + + /** + * Slide the rail off-canvas (gap width goes to 0). + */ + "offcanvas": SidebarCollapsibleItem; +} + +/** + * Default sidebar collapsible class maps. + */ +export const collapsibleProps: SidebarCollapsible = { + "none": { + "gap": "", + "panel": "", + }, + "icon": { + "gap": "group-data-[collapsible=icon]:w-[var(--bridge-sidebar-width-icon)]", + "panel": + "group-data-[collapsible=icon]:w-[var(--bridge-sidebar-width-icon)]", + }, + "offcanvas": { + "gap": + "group-data-[collapsible=offcanvas]:w-0 group-data-[collapsible=offcanvas]:overflow-hidden", + "panel": + "group-data-[collapsible=offcanvas]:data-[side=left]:left-[calc(var(--bridge-sidebar-width)*-1)] group-data-[collapsible=offcanvas]:data-[side=right]:right-[calc(var(--bridge-sidebar-width)*-1)]", + }, +}; diff --git a/packages/core/src/Tokens/Sidebar/Side.ts b/packages/core/src/Tokens/Sidebar/Side.ts new file mode 100644 index 00000000..27038a28 --- /dev/null +++ b/packages/core/src/Tokens/Sidebar/Side.ts @@ -0,0 +1,22 @@ +/** + * Per-side positioning classes for the fixed desktop panel. + */ +export interface SidebarSide { + /** + * Dock the rail to the inline start (physical left in `ltr`). + */ + "left": string; + + /** + * Dock the rail to the inline end (physical right in `ltr`). + */ + "right": string; +} + +/** + * Physical left/right docking for the fixed desktop panel (`side` is left/right). + */ +export const sideProps: SidebarSide = { + "left": "left-0", + "right": "right-0", +}; diff --git a/packages/core/src/Tokens/Sidebar/Variant.ts b/packages/core/src/Tokens/Sidebar/Variant.ts new file mode 100644 index 00000000..72947cfa --- /dev/null +++ b/packages/core/src/Tokens/Sidebar/Variant.ts @@ -0,0 +1,55 @@ +/** + * Per-variant chrome for the gap, fixed panel, and main inset. + */ +export interface SidebarVariantItem { + /** + * Classes for the in-flow gap spacer. + */ + "gap": string; + + /** + * Classes for `SidebarInset` (main content). + */ + "inset": string; + + /** + * Classes for the inner panel surface. + */ + "panel": string; +} + +/** + * Sidebar visual layout: flush rail (`sidebar`) or padded main (`inset`). + */ +export interface SidebarVariant { + /** + * Main content is inset with margin and rounding. + */ + "inset": SidebarVariantItem; + + /** + * Flush rail that shares an edge with the main column. Default. + */ + "sidebar": SidebarVariantItem; +} + +/** + * Default sidebar variant class maps. + */ +export const variantProps: SidebarVariant = { + "sidebar": { + "inset": + "relative flex min-h-svh min-w-0 flex-1 flex-col bg-white dark:bg-dark-900", + "gap": + "relative hidden w-[var(--bridge-sidebar-width)] bg-transparent transition-[width] duration-200 ease-linear md:block", + "panel": + "border-dark-200 bg-white data-[side=left]:border-e data-[side=right]:border-s dark:border-dark-700 dark:bg-dark-800", + }, + "inset": { + "panel": "bg-white p-2 dark:bg-dark-800", + "inset": + "relative m-2 flex min-h-svh min-w-0 flex-1 flex-col rounded-xl bg-white shadow-sm dark:bg-dark-900", + "gap": + "relative hidden w-[var(--bridge-sidebar-width)] bg-transparent transition-[width] duration-200 ease-linear md:block group-data-[collapsible=icon]:w-[calc(var(--bridge-sidebar-width-icon)+1rem)]", + }, +}; diff --git a/packages/core/src/Tokens/Sidebar/Width.ts b/packages/core/src/Tokens/Sidebar/Width.ts new file mode 100644 index 00000000..dc3125d9 --- /dev/null +++ b/packages/core/src/Tokens/Sidebar/Width.ts @@ -0,0 +1,28 @@ +/** + * CSS length values for sidebar rails (set as `--bridge-sidebar-*` on the provider). + */ +export interface SidebarWidth { + /** + * Expanded desktop rail width. + */ + "default": string; + + /** + * Collapsed icon-rail width. + */ + "icon": string; + + /** + * Mobile drawer panel width. + */ + "mobile": string; +} + +/** + * Default sidebar width CSS lengths. + */ +export const widthProps: SidebarWidth = { + "icon": "3rem", + "mobile": "18rem", + "default": "16rem", +}; diff --git a/packages/core/src/Tokens/Sidebar/index.ts b/packages/core/src/Tokens/Sidebar/index.ts new file mode 100644 index 00000000..ddebea90 --- /dev/null +++ b/packages/core/src/Tokens/Sidebar/index.ts @@ -0,0 +1,15 @@ +// ** Exports +export { collapsibleProps } from "@/Tokens/Sidebar/Collapsible"; +export type { + SidebarCollapsible, + SidebarCollapsibleItem, +} from "@/Tokens/Sidebar/Collapsible"; +export { sideProps } from "@/Tokens/Sidebar/Side"; +export type { SidebarSide } from "@/Tokens/Sidebar/Side"; +export { variantProps } from "@/Tokens/Sidebar/Variant"; +export type { + SidebarVariant, + SidebarVariantItem, +} from "@/Tokens/Sidebar/Variant"; +export { widthProps } from "@/Tokens/Sidebar/Width"; +export type { SidebarWidth } from "@/Tokens/Sidebar/Width"; diff --git a/packages/core/src/Tokens/index.ts b/packages/core/src/Tokens/index.ts index 7ae81cd4..cd235dce 100644 --- a/packages/core/src/Tokens/index.ts +++ b/packages/core/src/Tokens/index.ts @@ -288,6 +288,20 @@ export type { RadioRounded, RadioSize, } from "@/Tokens/Radio"; +export { + collapsibleProps as sidebarCollapsibleProps, + sideProps as sidebarSideProps, + variantProps as sidebarVariantProps, + widthProps as sidebarWidthProps, +} from "@/Tokens/Sidebar"; +export type { + SidebarCollapsible, + SidebarCollapsibleItem, + SidebarSide, + SidebarVariant, + SidebarVariantItem, + SidebarWidth, +} from "@/Tokens/Sidebar"; export { roundedProps as skeletonRoundedProps } from "@/Tokens/Skeleton"; export type { SkeletonRounded } from "@/Tokens/Skeleton"; export { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c9956c3a..8bef47c7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -75,6 +75,7 @@ export type { ProgressConfigOverrides, RadioConfigOverrides, SelectConfigOverrides, + SidebarConfigOverrides, SkeletonConfigOverrides, SliderConfigOverrides, SpinnerConfigOverrides, @@ -116,6 +117,10 @@ export { DEFAULT_SLIDER_STEP, DEFAULT_SPINNER_THICKNESS, DEFAULT_START_OF_WEEK, + SIDEBAR_DESKTOP_BREAKPOINT, + SIDEBAR_WIDTH_ICON_VAR, + SIDEBAR_WIDTH_MOBILE_VAR, + SIDEBAR_WIDTH_VAR, SPINNER_VIEWBOX_SIZE, applyDateSelection, applyOtpInput, @@ -166,6 +171,7 @@ export { getFieldOverlayControlSize, getNumberFieldStepper, getPaginationItems, + getSidebarPanelId, getSliderBarGeometry, getSliderPointerClientX, getSliderPrecision, @@ -201,6 +207,7 @@ export { isMonthDisabled, isOtpCharAllowed, isOtpComplete, + isSidebarIconOnly, isSliderStopCovered, isTimeDisabled, isTimeRangeValue, @@ -238,6 +245,9 @@ export { resolveSelectAsyncDebounce, resolveSelectAsyncLimit, resolveSelectAsyncOptions, + resolveSidebarCollapsibleData, + resolveSidebarListTooltipPlacement, + resolveSidebarState, resolveSliderBounds, resolveSliderDefaultValue, resolveStartOfWeek, @@ -251,6 +261,8 @@ export { setDataTableColumnFilter, setDataTableColumnSearch, setDataTableRowSelection, + shouldRenderSidebarAsDrawer, + shouldToggleDesktopSidebar, sliceDataTablePage, snapMinutes, snapSliderValue, @@ -272,6 +284,7 @@ export { toggleDataTableRowExpansion, toggleDataTableRowSelection, toggleDataTableSorting, + toggleSidebarOpen, unitFromPointer, valueToPercent, writeSliderRangeThumb, @@ -330,6 +343,8 @@ export type { SelectOptionKeys, SelectOptionLike, SelectValue, + SidebarCollapsibleMode, + SidebarState, SliderBarGeometry, SliderBounds, SliderRangeValue, @@ -712,6 +727,20 @@ export type { RadioRounded, RadioSize, } from "@/Tokens/Radio"; +export { + collapsibleProps as sidebarCollapsibleProps, + sideProps as sidebarSideProps, + variantProps as sidebarVariantProps, + widthProps as sidebarWidthProps, +} from "@/Tokens/Sidebar"; +export type { + SidebarCollapsible, + SidebarCollapsibleItem, + SidebarSide, + SidebarVariant, + SidebarVariantItem, + SidebarWidth, +} from "@/Tokens/Sidebar"; export { roundedProps as skeletonRoundedProps } from "@/Tokens/Skeleton"; export type { SkeletonRounded } from "@/Tokens/Skeleton"; export { diff --git a/packages/react/ai/skills/bridge-ui-components/SKILL.md b/packages/react/ai/skills/bridge-ui-components/SKILL.md index 16fc0c42..57908f48 100644 --- a/packages/react/ai/skills/bridge-ui-components/SKILL.md +++ b/packages/react/ai/skills/bridge-ui-components/SKILL.md @@ -2,7 +2,7 @@ name: bridge-ui-components description: >- Use Bridge UI React components — Button, Avatar, Card, Alert, Accordion, Badge, Icon, - Link, List, Table, DataTable, Tabs, Spinner, Skeleton, EmptyState, Progress, Stepper, Pagination, TextField, Select, + Link, List, Table, DataTable, Tabs, Spinner, Skeleton, EmptyState, Sidebar, Progress, Stepper, Pagination, TextField, Select, Autocomplete, DateField, DatePicker, DateRangeField, DateRangePicker, TimeField, TimePicker, DateTimeField, DateTimePicker, ColorField, ColorPicker, classes, customProps, slots. Use when building UI with Bridge components. @@ -30,6 +30,7 @@ Do **not** invent APIs. Copy examples from `.ai/docs/components/{Component}.md` | Tabs | `.ai/docs/components/Tabs.md` | | Text input | `.ai/docs/components/TextField.md` | | Select / autocomplete | `.ai/docs/components/Select.md`, `Autocomplete.md` | +| Sidebar / app shell | `.ai/docs/components/Sidebar.md` | | Date | `.ai/docs/components/DateField.md`, `DatePicker.md` | | Date range | `.ai/docs/components/DateRangeField.md`, `DateRangePicker.md` | | Time | `.ai/docs/components/TimeField.md`, `TimePicker.md` | diff --git a/packages/react/docs/README.md b/packages/react/docs/README.md index 511d1ff7..639b2cf4 100644 --- a/packages/react/docs/README.md +++ b/packages/react/docs/README.md @@ -50,6 +50,7 @@ Component reference and adapter samples for **React**. This folder ships with th - [Progress](./components/Progress.md) - [Radio](./components/Radio.md) - [Select](./components/Select.md) +- [Sidebar](./components/Sidebar.md) - [Skeleton](./components/Skeleton.md) - [Slider](./components/Slider.md) - [Snackbar](./components/Snackbar.md) diff --git a/packages/react/docs/components/Drawer.md b/packages/react/docs/components/Drawer.md index 9a49d6a8..4d8b4f79 100644 --- a/packages/react/docs/components/Drawer.md +++ b/packages/react/docs/components/Drawer.md @@ -171,4 +171,4 @@ import { Drawer } from "@bridge-ui/react/Components/Drawer"; ## Related components -Card, [useDrawerAction](./useDrawerAction.md), useDialogAction, useModalAction +Card, Sidebar, [useDrawerAction](./useDrawerAction.md), useDialogAction, useModalAction diff --git a/packages/react/docs/components/List.md b/packages/react/docs/components/List.md index eacde6ba..4a6208f0 100644 --- a/packages/react/docs/components/List.md +++ b/packages/react/docs/components/List.md @@ -172,4 +172,4 @@ With the default `as="li"`, sticky styles apply on the section root. The list (o ## Related components -Menu, Select +Menu, Select, Sidebar diff --git a/packages/react/docs/components/Sidebar.md b/packages/react/docs/components/Sidebar.md new file mode 100644 index 00000000..bb2a65bc --- /dev/null +++ b/packages/react/docs/components/Sidebar.md @@ -0,0 +1,269 @@ +# Sidebar + +Persistent app-shell rail. Mount `SidebarProvider` around `Sidebar` and `SidebarInset` as siblings. Put `SidebarList` / `Accordion` in the rail. On small viewports the panel opens as a `Drawer`. + +## Import + +```ts +import { + Sidebar, + SidebarInset, + SidebarList, + SidebarListItem, + SidebarProvider, + SidebarTrigger, + useSidebar, +} from "@bridge-ui/react/Components/Sidebar"; +``` + +## Prerequisites + +Mount `SidebarProvider` around the app shell (`Sidebar` and `SidebarInset` as siblings) before using `SidebarTrigger` or `useSidebar`. + +`BridgeUIProvider` still holds theme, tokens, and `defaultProps`. It does not own instance `open` state. + +## Examples + +### Usage + +```tsx + + + + + + + + + + {children} + + +``` + +### Header and footer + +Put brand and account rows in `slots.header` / `slots.footer`. `SidebarList` collapses those rows to the start avatar. The end chevron hides while collapsed. Header and footer lists use `classes.root` `p-0` because those slots are already padded. + +```tsx +function Brand() { + return ( + + , + start: , + }} + /> + + ); +} + +function Account() { + return ( + + , + start: , + }} + /> + + ); +} + + + , + footer: , + }} + > + + + + + + + {children} + +; +``` + +### Icon collapse + +Use `SidebarList` / `SidebarListItem` when `collapsible="icon"`. Collapsed items keep `primary` as an `aria-label` and show it in a `Tooltip` on the whole item. Nested `SidebarList` is hidden. The mobile drawer keeps labels. + +```tsx + + , + footer: , + }} + > + + + }} + /> + + + + + {children} + + +``` + +### Controlled + +```tsx + + + + + + + + + {children} + + +``` + +### Right side + +```tsx + + + + + + + + + {children} + + +``` + +### Inset variant + +```tsx + + + + + + + + + {children} + + +``` + +### Collapsible groups + +```tsx + + + + + + + + + + + + + + {children} + + +``` + +## Props (`SidebarProvider`) + +| Prop | Type | Default | Description | +| -------------- | ---------------------------- | ------- | -------------------------------------------- | +| `children` | `ReactNode` | — | `Sidebar`, `SidebarInset`, and other shell. | +| `classes` | `SidebarProviderClasses` | — | Part classes (`root`). | +| `customProps` | `SidebarProviderCustomProps` | — | Extra props for the layout wrapper. | +| `defaultOpen` | `boolean` | `true` | Uncontrolled initial desktop expanded state. | +| `onOpenChange` | `(open: boolean) => void` | — | Called when desktop `open` should change. | +| `open` | `boolean` | — | Controlled desktop expanded state. | + +## Props (`Sidebar`) + +| Prop | Type | Default | Description | +| ------------- | --------------------------------- | ------------- | ------------------------------------------------------ | +| `ariaLabel` | `string` | `"Sidebar"` | Accessible name for the `aside` and the mobile drawer. | +| `children` | `ReactNode` | — | Rail content (`SidebarList` / `Accordion`). | +| `classes` | `SidebarClasses` | — | Part classes (`root`, `header`, `content`, …). | +| `collapsible` | `"icon" \| "none" \| "offcanvas"` | `"offcanvas"` | How the desktop rail hides. | +| `customProps` | `SidebarCustomProps` | — | Extra props for internal parts. | +| `side` | `"left" \| "right"` | `"left"` | Edge the rail docks to. | +| `slots` | `SidebarSlots` | — | `header`, `footer`. | +| `variant` | `"inset" \| "sidebar"` | `"sidebar"` | Flush rail or padded main column. | + +## Props (`SidebarInset`) + +| Prop | Type | Default | Description | +| ------------- | ------------------------- | ------- | ------------------------------- | +| `children` | `ReactNode` | — | Main content. | +| `classes` | `SidebarInsetClasses` | — | Part classes (`root`). | +| `customProps` | `SidebarInsetCustomProps` | — | Extra props for the inset root. | + +## Props (`SidebarTrigger`) + +Renders a `Button`. Forwards native button attributes. Default accessible name is `Toggle sidebar`. + +## Props (`SidebarList`) + +Same as `List`, plus `iconOnly`. Sets `iconOnly` when the icon rail is collapsed on desktop. Applies stacked nav chrome (gap, compact rows, nested guide). Override with `iconOnly`. Nested `SidebarList` is hidden while collapsed. `ListSection` labels are hidden while collapsed. + +## Props (`SidebarListItem`) + +Same as `ListItem`, plus `tooltip` / `tooltipPlacement`. Applies compact nav chrome. Tooltips only show while the icon rail is collapsed (`primary`, or `tooltip` to override). + +## `useSidebar` + +Must be called under `SidebarProvider`. + +| Field | Type | Description | +| --------------- | --------------------------------- | ---------------------------------------------- | +| `collapsible` | `"icon" \| "none" \| "offcanvas"` | Mode from the nearest `Sidebar`. | +| `isMobile` | `boolean` | Viewport is below `md` (desktop rail CSS). | +| `open` | `boolean` | Desktop expanded state. | +| `openMobile` | `boolean` | Mobile drawer visibility. | +| `setOpen` | `(open: boolean) => void` | Sets desktop `open`. | +| `setOpenMobile` | `(open: boolean) => void` | Sets mobile drawer visibility. | +| `side` | `"left" \| "right"` | Dock edge. | +| `state` | `"collapsed" \| "expanded"` | Desktop visual state. | +| `toggleSidebar` | `() => void` | Toggles desktop `open` or mobile `openMobile`. | +| `variant` | `"inset" \| "sidebar"` | Visual variant. | + +## Accessibility + +- Desktop panel is an `aside` with `aria-label` +- Offcanvas collapsed panel is `inert` (out of the tab order) +- Mobile uses `Drawer` (dialog, overlay, Escape) +- Trigger sets `aria-expanded` and `aria-controls` +- `SidebarListItem` copies string `primary` to `aria-label` when collapsed + +## Related components + +Accordion, Avatar, Button, Drawer, List, ListItem, ListSection, Tooltip diff --git a/packages/react/docs/examples/icon-fontawesome.ts b/packages/react/docs/examples/icon-fontawesome.ts index e582a164..c6086ac9 100644 --- a/packages/react/docs/examples/icon-fontawesome.ts +++ b/packages/react/docs/examples/icon-fontawesome.ts @@ -122,6 +122,7 @@ const icons = { columns: faTableColumns, chevronUpDown: faUpDown, calendar: faCalendarDays, + panelLeft: faTableColumns, search: faMagnifyingGlass, alert: faCircleExclamation, chevronDown: faChevronDown, diff --git a/packages/react/docs/examples/icon-heroicons.ts b/packages/react/docs/examples/icon-heroicons.ts index 3ef89374..db5d9e72 100644 --- a/packages/react/docs/examples/icon-heroicons.ts +++ b/packages/react/docs/examples/icon-heroicons.ts @@ -8,6 +8,7 @@ import { ArrowDownTrayIcon, ArrowPathIcon, + Bars3Icon, BellIcon, CalendarDaysIcon, CheckCircleIcon, @@ -51,6 +52,7 @@ const icons = { filter: FunnelIcon, palette: SwatchIcon, eyeOff: EyeSlashIcon, + panelLeft: Bars3Icon, loader: ArrowPathIcon, success: CheckCircleIcon, chevronUp: ChevronUpIcon, diff --git a/packages/react/docs/examples/icon-lucide.ts b/packages/react/docs/examples/icon-lucide.ts index 9638fc46..56de8631 100644 --- a/packages/react/docs/examples/icon-lucide.ts +++ b/packages/react/docs/examples/icon-lucide.ts @@ -28,6 +28,7 @@ import { Loader2, Minus, Palette, + PanelLeft, Plus, Search, TriangleAlert, @@ -60,6 +61,7 @@ const icons = { download: Download, success: CircleCheck, chevronUp: ChevronUp, + panelLeft: PanelLeft, warning: TriangleAlert, chevronDown: ChevronDown, chevronLeft: ChevronLeft, diff --git a/packages/react/docs/examples/icon-phosphor.ts b/packages/react/docs/examples/icon-phosphor.ts index b4faec7e..7dbf3e8c 100644 --- a/packages/react/docs/examples/icon-phosphor.ts +++ b/packages/react/docs/examples/icon-phosphor.ts @@ -26,6 +26,7 @@ import { MinusIcon, PaletteIcon, PlusIcon, + SidebarIcon, SpinnerGapIcon, UserIcon, WarningCircleIcon, @@ -56,6 +57,7 @@ const icons = { warning: WarningIcon, loader: SpinnerGapIcon, chevronUp: CaretUpIcon, + panelLeft: SidebarIcon, alert: WarningCircleIcon, success: CheckCircleIcon, chevronLeft: CaretLeftIcon, diff --git a/packages/react/docs/examples/icon-tabler.ts b/packages/react/docs/examples/icon-tabler.ts index 871dadd5..6ef2d80d 100644 --- a/packages/react/docs/examples/icon-tabler.ts +++ b/packages/react/docs/examples/icon-tabler.ts @@ -24,6 +24,7 @@ import { IconEyeOff, IconFilter, IconInfoCircle, + IconLayoutSidebar, IconLoader2, IconMinus, IconPalette, @@ -62,6 +63,7 @@ const icons = { warning: IconAlertTriangle, chevronUpDown: IconSelector, calendar: IconCalendarMonth, + panelLeft: IconLayoutSidebar, chevronDown: IconChevronDown, chevronLeft: IconChevronLeft, chevronRight: IconChevronRight, diff --git a/packages/react/src/Components/Accordion/AccordionContext.tsx b/packages/react/src/Components/Accordion/AccordionContext.tsx index fa5157d0..ca6517a1 100644 --- a/packages/react/src/Components/Accordion/AccordionContext.tsx +++ b/packages/react/src/Components/Accordion/AccordionContext.tsx @@ -63,6 +63,7 @@ export type AccordionContextValue = { itemSize?: string; itemVariant?: string; panelSize?: string; + panelVariant?: string; rootSize?: string; rootVariant?: string; triggerSize?: string; diff --git a/packages/react/src/Components/Accordion/__tests__/Accordion.test.tsx b/packages/react/src/Components/Accordion/__tests__/Accordion.test.tsx index 337001fc..009800c9 100644 --- a/packages/react/src/Components/Accordion/__tests__/Accordion.test.tsx +++ b/packages/react/src/Components/Accordion/__tests__/Accordion.test.tsx @@ -152,6 +152,20 @@ test("it should apply plain variant classes on the root", () => { expect(className).toContain("gap-1"); expect(className).not.toContain("border"); expect(className).not.toContain("divide-y"); + + const trigger = screen.getByRole("button", { name: "One" }); + + expect(trigger.className).toContain("px-2"); + expect(trigger.className).toContain("min-h-8"); + expect(trigger.className).toContain("py-1.5"); + expect(trigger.className).not.toContain("text-primary-700"); + + const panel = screen.getByRole("region", { hidden: true }); + + expect(panel.className).toContain("p-0"); + expect(panel.className).toContain("border-l"); + expect(panel.className).not.toContain("pb-4"); + expect(panel.className).toContain("group-data-[collapsible=icon]:hidden"); }); test("it should apply separated variant classes on the root", () => { diff --git a/packages/react/src/Components/Accordion/hooks/useAccordion.ts b/packages/react/src/Components/Accordion/hooks/useAccordion.ts index cf4b5eaf..3886ac30 100644 --- a/packages/react/src/Components/Accordion/hooks/useAccordion.ts +++ b/packages/react/src/Components/Accordion/hooks/useAccordion.ts @@ -224,9 +224,14 @@ export function useAccordion( triggerSize: get(sizeItem, "trigger"), rootVariant: get(variantItem, "root"), itemVariant: get(variantItem, "item"), + panelVariant: get(variantItem, "panel"), triggerVariant: get(variantItem, "trigger"), - colorIndicator: get(colorItem, "indicator"), - colorTriggerExpanded: get(colorItem, "triggerExpanded"), + colorIndicator: + merged.variant === "plain" ? undefined : get(colorItem, "indicator"), + colorTriggerExpanded: + merged.variant === "plain" + ? undefined + : get(colorItem, "triggerExpanded"), }, }; }, [ @@ -237,6 +242,7 @@ export function useAccordion( focusTrigger, itemValues, merged.disabled, + merged.variant, multiple, registerItem, sizeItem, diff --git a/packages/react/src/Components/AccordionItem/hooks/useAccordionItem.ts b/packages/react/src/Components/AccordionItem/hooks/useAccordionItem.ts index 16ecf77f..682a8701 100644 --- a/packages/react/src/Components/AccordionItem/hooks/useAccordionItem.ts +++ b/packages/react/src/Components/AccordionItem/hooks/useAccordionItem.ts @@ -252,6 +252,7 @@ export function useAccordionItem(props: AccordionItemProps) { "aria-labelledby": getAccordionTriggerId(accordion.id, value), className: cn({ [accordion.tokenClasses.panelSize ?? ""]: true, + [accordion.tokenClasses.panelVariant ?? ""]: true, [get(mergedClasses, "panel") ?? ""]: true, }), }, diff --git a/packages/react/src/Components/List/hooks/useList.ts b/packages/react/src/Components/List/hooks/useList.ts index ecf48268..058b2280 100644 --- a/packages/react/src/Components/List/hooks/useList.ts +++ b/packages/react/src/Components/List/hooks/useList.ts @@ -62,15 +62,13 @@ export function useList(props: ListProps) { }); const rootBind = derived(() => { - return mergePartBind( - customProps?.root, - rootInheritedAttrs, - cn({ + return mergePartBind(customProps?.root, rootInheritedAttrs, { + className: cn({ "m-0 list-none py-2 text-dark-900 dark:text-dark-100": true, "pl-4": merged.nested, [get(mergedClasses, "root") ?? ""]: true, }), - ); + }); }); return { diff --git a/packages/react/src/Components/ListItem/ListItem.tsx b/packages/react/src/Components/ListItem/ListItem.tsx index 4611492c..d72cab28 100644 --- a/packages/react/src/Components/ListItem/ListItem.tsx +++ b/packages/react/src/Components/ListItem/ListItem.tsx @@ -29,13 +29,15 @@ function ListItemRow({
{slots?.start}
) : null} -
- {hasPrimary ? {primaryContent} : null} + {hasPrimary || hasSecondary ? ( +
+ {hasPrimary ? {primaryContent} : null} - {hasSecondary ? ( - {secondaryContent} - ) : null} -
+ {hasSecondary ? ( + {secondaryContent} + ) : null} +
+ ) : null} {hasEnd ? (
diff --git a/packages/react/src/Components/ListItem/__tests__/ListItem.test.tsx b/packages/react/src/Components/ListItem/__tests__/ListItem.test.tsx index 3adf0e5a..a4d95c19 100644 --- a/packages/react/src/Components/ListItem/__tests__/ListItem.test.tsx +++ b/packages/react/src/Components/ListItem/__tests__/ListItem.test.tsx @@ -1,11 +1,15 @@ // ** External Imports -import { render, screen } from "@testing-library/react"; -import { expect, test } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, test } from "vitest"; // ** Local Imports import { List } from "@/Components/List"; import { ListItem } from "@/Components/ListItem"; +afterEach(() => { + cleanup(); +}); + test("it should render primary text from the primary prop", () => { render(); diff --git a/packages/react/src/Components/ListSection/ListSection.tsx b/packages/react/src/Components/ListSection/ListSection.tsx index 7b5fabab..a9ff51ef 100644 --- a/packages/react/src/Components/ListSection/ListSection.tsx +++ b/packages/react/src/Components/ListSection/ListSection.tsx @@ -6,7 +6,12 @@ import { useListSection } from "@/Components/ListSection/hooks/useListSection"; import type { ListSectionProps } from "@/Components/ListSection/listSection.types"; function ListSection(props: ListSectionProps) { - const { label, merged, rootBind, titleBind } = useListSection(props); + const { label, merged, rootBind, isHidden, titleBind } = + useListSection(props); + + if (isHidden) { + return null; + } if (merged.as === "div") { return
{label}
; diff --git a/packages/react/src/Components/ListSection/ListSectionContext.tsx b/packages/react/src/Components/ListSection/ListSectionContext.tsx new file mode 100644 index 00000000..81e1a8db --- /dev/null +++ b/packages/react/src/Components/ListSection/ListSectionContext.tsx @@ -0,0 +1,17 @@ +// ** External Imports +import { createContext, useContext } from "react"; + +export type ListSectionContextValue = { + hidden: boolean; +}; + +export const ListSectionContext = createContext( + null, +); + +/** + * Visibility from the nearest list that owns sections. + */ +export function useListSectionContext() { + return useContext(ListSectionContext); +} diff --git a/packages/react/src/Components/ListSection/__tests__/ListSection.test.tsx b/packages/react/src/Components/ListSection/__tests__/ListSection.test.tsx index 3703b30b..635f729e 100644 --- a/packages/react/src/Components/ListSection/__tests__/ListSection.test.tsx +++ b/packages/react/src/Components/ListSection/__tests__/ListSection.test.tsx @@ -1,11 +1,15 @@ // ** External Imports -import { render, screen } from "@testing-library/react"; -import { expect, test } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, test } from "vitest"; // ** Local Imports import { List } from "@/Components/List"; import { ListSection } from "@/Components/ListSection"; +afterEach(() => { + cleanup(); +}); + test("it should render the title from the title prop", () => { render(); diff --git a/packages/react/src/Components/ListSection/__tests__/useListSection.test.ts b/packages/react/src/Components/ListSection/__tests__/useListSection.test.ts index 5f6d4001..3d0805ac 100644 --- a/packages/react/src/Components/ListSection/__tests__/useListSection.test.ts +++ b/packages/react/src/Components/ListSection/__tests__/useListSection.test.ts @@ -24,6 +24,7 @@ test("it should apply section title classes", () => { const { result } = renderUseListSection({ title: "Settings" }); expect(result.current.titleBind.role).toBe("presentation"); + expect(result.current.titleBind.className).toContain("px-2"); expect(result.current.titleBind.className).toContain("text-xs"); expect(result.current.titleBind.className).toContain("uppercase"); }); diff --git a/packages/react/src/Components/ListSection/hooks/useListSection.ts b/packages/react/src/Components/ListSection/hooks/useListSection.ts index ce8ed71b..d440c6f2 100644 --- a/packages/react/src/Components/ListSection/hooks/useListSection.ts +++ b/packages/react/src/Components/ListSection/hooks/useListSection.ts @@ -10,6 +10,7 @@ import type { ListSectionOwnProps, ListSectionProps, } from "@/Components/ListSection/listSection.types"; +import { useListSectionContext } from "@/Components/ListSection/ListSectionContext"; import { derived, mergePartBind, @@ -28,6 +29,7 @@ const listSectionBridgeKeys = [ export function useListSection(props: ListSectionProps) { const listContext = useListContext(); + const listSection = useListSectionContext(); const { componentProps, inheritedAttrs } = splitComponentProps< ListSectionProps, @@ -66,6 +68,10 @@ export function useListSection(props: ListSectionProps) { return listContext?.dense ?? false; }); + const isHidden = derived(() => { + return listSection?.hidden ?? false; + }); + const label = derived(() => { return merged.title ?? children; }); @@ -94,7 +100,7 @@ export function useListSection(props: ListSectionProps) { { role: "presentation", className: cn({ - "bg-white px-4 text-xs font-semibold tracking-wide text-dark-500 uppercase dark:bg-dark-800 dark:text-dark-300": true, + "bg-white px-2 text-xs font-semibold tracking-wide text-dark-500 uppercase dark:bg-dark-800 dark:text-dark-300": true, "sticky top-0 z-10": merged.sticky && isDivRoot, "py-2": !isDense, "py-1.5": isDense, @@ -109,6 +115,7 @@ export function useListSection(props: ListSectionProps) { label, merged, rootBind, + isHidden, titleBind, }; } diff --git a/packages/react/src/Components/Sidebar/Sidebar.tsx b/packages/react/src/Components/Sidebar/Sidebar.tsx new file mode 100644 index 00000000..6e27fde1 --- /dev/null +++ b/packages/react/src/Components/Sidebar/Sidebar.tsx @@ -0,0 +1,103 @@ +// ** Local Imports +import { Drawer } from "@/Components/Drawer"; +import { useSidebarShell } from "@/Components/Sidebar/hooks/useSidebarShell"; +import type { SidebarProps } from "@/Components/Sidebar/sidebar.types"; +import { hasNamedSlot } from "@/Utils"; + +const sidebarLibDefaults = { + side: "left", + variant: "sidebar", + ariaLabel: "Sidebar", + collapsible: "offcanvas", +} as const; + +function SidebarPanelBody({ + slots, + children, + headerBind, + footerBind, + contentBind, +}: Pick< + ReturnType, + "slots" | "children" | "footerBind" | "headerBind" | "contentBind" +>) { + return ( + <> + {hasNamedSlot(slots, "header") ? ( +
{slots?.header}
+ ) : null} + +
{children}
+ + {hasNamedSlot(slots, "footer") ? ( +
{slots?.footer}
+ ) : null} + + ); +} + +function Sidebar(props: SidebarProps) { + const { + slots, + merged, + panelId, + gapBind, + children, + rootBind, + isMobile, + asideBind, + panelBind, + headerBind, + footerBind, + openMobile, + contentBind, + mobileWidth, + showAsDrawer, + setOpenMobile, + } = useSidebarShell(props, sidebarLibDefaults); + + const body = ( + + ); + + return ( + <> +
+
+ + +
+ + {isMobile ? ( + +
{showAsDrawer ? body : null}
+
+ ) : null} + + ); +} + +export default Sidebar; diff --git a/packages/react/src/Components/Sidebar/SidebarContext.tsx b/packages/react/src/Components/Sidebar/SidebarContext.tsx new file mode 100644 index 00000000..a50a68cc --- /dev/null +++ b/packages/react/src/Components/Sidebar/SidebarContext.tsx @@ -0,0 +1,122 @@ +// ** External Imports +import { createContext, useContext } from "react"; + +// ** Core Imports +import type { + SidebarCollapsibleMode, + SidebarState, +} from "@bridge-ui/core/Domain"; +import type { + SidebarCollapsible, + SidebarSide, + SidebarVariant, +} from "@bridge-ui/core/Tokens"; + +/** + * Layout fields registered by `Sidebar` for inset / trigger consumers. + */ +export type SidebarLayout = { + /** + * Desktop collapse mode. + */ + collapsible: keyof SidebarCollapsible; + + /** + * Id of the visible panel (`aria-controls`). + */ + panelId: string; + + /** + * Dock edge. + */ + side: keyof SidebarSide; + + /** + * Visual variant. + */ + variant: keyof SidebarVariant; +}; + +/** + * Shared sidebar state for `Sidebar`, `SidebarInset`, and `SidebarTrigger`. + */ +export type SidebarContextValue = { + /** + * Desktop collapse mode from the nearest `Sidebar`. + */ + collapsible: keyof SidebarCollapsible; + + /** + * Whether the viewport is below the mobile breakpoint. + */ + isMobile: boolean; + + /** + * Desktop expanded state. + */ + open: boolean; + + /** + * Mobile drawer visibility. + */ + openMobile: boolean; + + /** + * Id of the visible panel for `aria-controls`. + */ + panelId: string; + + /** + * Sets layout fields from the `Sidebar` panel. + * + * @internal + */ + setLayout: (layout: Partial) => void; + + /** + * Sets the desktop expanded state. + */ + setOpen: (open: boolean) => void; + + /** + * Sets the mobile drawer visibility. + */ + setOpenMobile: (open: boolean) => void; + + /** + * Dock edge from the nearest `Sidebar`. + */ + side: keyof SidebarSide; + + /** + * Desktop visual state (`expanded` / `collapsed`). + */ + state: SidebarState; + + /** + * Toggles desktop `open` or mobile `openMobile` based on viewport. + */ + toggleSidebar: () => void; + + /** + * Visual variant from the nearest `Sidebar`. + */ + variant: keyof SidebarVariant; +}; + +export const SidebarContext = createContext(null); + +/** + * Reads the nearest `SidebarProvider` context. Throws when used outside it. + */ +export function useSidebar(): SidebarContextValue { + const context = useContext(SidebarContext); + + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider"); + } + + return context; +} + +export type { SidebarCollapsibleMode }; diff --git a/packages/react/src/Components/Sidebar/SidebarInset.tsx b/packages/react/src/Components/Sidebar/SidebarInset.tsx new file mode 100644 index 00000000..fc937194 --- /dev/null +++ b/packages/react/src/Components/Sidebar/SidebarInset.tsx @@ -0,0 +1,11 @@ +// ** Local Imports +import { useSidebarInset } from "@/Components/Sidebar/hooks/useSidebarInset"; +import type { SidebarInsetProps } from "@/Components/Sidebar/sidebar.types"; + +function SidebarInset(props: SidebarInsetProps) { + const { children, rootBind } = useSidebarInset(props); + + return
{children}
; +} + +export default SidebarInset; diff --git a/packages/react/src/Components/Sidebar/SidebarList.tsx b/packages/react/src/Components/Sidebar/SidebarList.tsx new file mode 100644 index 00000000..1b399db0 --- /dev/null +++ b/packages/react/src/Components/Sidebar/SidebarList.tsx @@ -0,0 +1,39 @@ +// ** Core Imports +import { cn } from "@bridge-ui/core/Utils"; + +// ** Local Imports +import { List } from "@/Components/List"; +import { ListSectionContext } from "@/Components/ListSection/ListSectionContext"; +import { useSidebarList } from "@/Components/Sidebar/hooks/useSidebarList"; +import type { SidebarListProps } from "@/Components/Sidebar/sidebar.types"; +import { SidebarListContext } from "@/Components/Sidebar/SidebarListContext"; + +function SidebarList({ + nested, + classes, + iconOnly: iconOnlyProp, + ...props +}: SidebarListProps) { + const { iconOnly, rootClassName } = useSidebarList({ + nested, + iconOnly: iconOnlyProp, + }); + + return ( + + + + + ); +} + +export default SidebarList; diff --git a/packages/react/src/Components/Sidebar/SidebarListContext.tsx b/packages/react/src/Components/Sidebar/SidebarListContext.tsx new file mode 100644 index 00000000..6de52b58 --- /dev/null +++ b/packages/react/src/Components/Sidebar/SidebarListContext.tsx @@ -0,0 +1,17 @@ +// ** External Imports +import { createContext, useContext } from "react"; + +export type SidebarListContextValue = { + iconOnly: boolean; +}; + +export const SidebarListContext = createContext( + null, +); + +/** + * Icon-rail mode from the nearest `SidebarList`. + */ +export function useSidebarListContext() { + return useContext(SidebarListContext); +} diff --git a/packages/react/src/Components/Sidebar/SidebarListItem.tsx b/packages/react/src/Components/Sidebar/SidebarListItem.tsx new file mode 100644 index 00000000..f55059bf --- /dev/null +++ b/packages/react/src/Components/Sidebar/SidebarListItem.tsx @@ -0,0 +1,65 @@ +// ** Core Imports +import { cn } from "@bridge-ui/core/Utils"; + +// ** Local Imports +import { ListItem } from "@/Components/ListItem"; +import { useSidebarListItem } from "@/Components/Sidebar/hooks/useSidebarListItem"; +import type { SidebarListItemProps } from "@/Components/Sidebar/sidebar.types"; +import { Tooltip } from "@/Components/Tooltip"; + +function SidebarListItem({ + classes, + customProps, + tooltip: tooltipProp, + tooltipPlacement: tooltipPlacementProp, + ...props +}: SidebarListItemProps) { + const { tooltip, itemClasses, accessibleName, tooltipPlacement } = + useSidebarListItem({ + tooltip: tooltipProp, + primary: props.primary, + secondary: props.secondary, + tooltipPlacement: tooltipPlacementProp, + }); + + const item = ( + + ); + + if (!tooltip) { + return item; + } + + return ( +
  • + +
  • + ); +} + +export default SidebarListItem; diff --git a/packages/react/src/Components/Sidebar/SidebarProvider.tsx b/packages/react/src/Components/Sidebar/SidebarProvider.tsx new file mode 100644 index 00000000..bde61c61 --- /dev/null +++ b/packages/react/src/Components/Sidebar/SidebarProvider.tsx @@ -0,0 +1,23 @@ +// ** Local Imports +import { useSidebarProvider } from "@/Components/Sidebar/hooks/useSidebarProvider"; +import type { SidebarProviderProps } from "@/Components/Sidebar/sidebar.types"; +import { SidebarContext } from "@/Components/Sidebar/SidebarContext"; + +const sidebarProviderLibDefaults = { + defaultOpen: true, +} as const; + +function SidebarProvider(props: SidebarProviderProps) { + const { children, rootBind, contextValue } = useSidebarProvider( + props, + sidebarProviderLibDefaults, + ); + + return ( + +
    {children}
    +
    + ); +} + +export default SidebarProvider; diff --git a/packages/react/src/Components/Sidebar/SidebarTrigger.tsx b/packages/react/src/Components/Sidebar/SidebarTrigger.tsx new file mode 100644 index 00000000..d10f48ea --- /dev/null +++ b/packages/react/src/Components/Sidebar/SidebarTrigger.tsx @@ -0,0 +1,31 @@ +// ** Local Imports +import { Button } from "@/Components/Button"; +import { useSidebarTrigger } from "@/Components/Sidebar/hooks/useSidebarTrigger"; +import type { SidebarTriggerProps } from "@/Components/Sidebar/sidebar.types"; + +function SidebarTrigger(props: SidebarTriggerProps) { + const { side, panelId, children, expanded, handleClick, rootInheritedAttrs } = + useSidebarTrigger(props); + + return ( + + ); +} + +export default SidebarTrigger; diff --git a/packages/react/src/Components/Sidebar/__tests__/Sidebar.cy.tsx b/packages/react/src/Components/Sidebar/__tests__/Sidebar.cy.tsx new file mode 100644 index 00000000..31ebb4e5 --- /dev/null +++ b/packages/react/src/Components/Sidebar/__tests__/Sidebar.cy.tsx @@ -0,0 +1,75 @@ +// ** Local Imports +import { + Sidebar, + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/Components/Sidebar"; + +test("it should render the sidebar and inset", () => { + cy.mount( + + Home + + + Main + + , + ); + + cy.contains("Home").should("exist"); + cy.contains("Main").should("be.visible"); + cy.get("button[aria-label='Toggle sidebar']").should("be.visible"); +}); + +test("it should collapse when the trigger is clicked", () => { + cy.mount( + + Home + + + Main + + , + ); + + cy.get("[data-state='expanded']").should("exist"); + cy.get("button[aria-label='Toggle sidebar']").click(); + cy.get("[data-state='collapsed']").should("exist"); +}); + +test("it should render header and footer slot content", () => { + cy.mount( + + Brand
    , + footer:
    Account
    , + }} + > + Nav + + + + + , + ); + + cy.contains("Brand").should("exist"); + cy.contains("Account").should("exist"); +}); + +test("it should inert the aside when offcanvas is collapsed", () => { + cy.mount( + + Home + + + Main + + , + ); + + cy.get("button[aria-label='Toggle sidebar']").click(); + cy.get("aside").should("have.attr", "inert"); +}); diff --git a/packages/react/src/Components/Sidebar/__tests__/Sidebar.test.tsx b/packages/react/src/Components/Sidebar/__tests__/Sidebar.test.tsx new file mode 100644 index 00000000..8171e2d2 --- /dev/null +++ b/packages/react/src/Components/Sidebar/__tests__/Sidebar.test.tsx @@ -0,0 +1,275 @@ +// ** External Imports +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, test } from "vitest"; + +// ** Local Imports +import { ListSection } from "@/Components/ListSection"; +import { + Sidebar, + SidebarInset, + SidebarList, + SidebarListItem, + SidebarProvider, + SidebarTrigger, +} from "@/Components/Sidebar"; + +afterEach(() => { + cleanup(); +}); + +function AppShell({ + open, + defaultOpen, + collapsible, + onOpenChange, +}: { + collapsible?: "icon" | "none" | "offcanvas"; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + open?: boolean; +}) { + return ( + + + + + + +

    Main

    +
    +
    + ); +} + +test("it should render the sidebar aside and main inset", () => { + render(); + + expect(screen.getByText("Home")).toBeTruthy(); + expect(screen.getByText("Main")).toBeTruthy(); + expect(screen.getByRole("complementary", { name: "Sidebar" })).toBeTruthy(); +}); + +test("it should default to expanded desktop state", () => { + const { container } = render(); + + expect(container.querySelector('[data-state="expanded"]')).not.toBeNull(); +}); + +test("it should toggle desktop open when the trigger is clicked", () => { + const { container } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" })); + + expect(container.querySelector('[data-state="collapsed"]')).not.toBeNull(); +}); + +test("it should call onOpenChange when the trigger is clicked", () => { + const onOpenChange = (open: boolean) => { + calls.push(open); + }; + const calls: boolean[] = []; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" })); + + expect(calls).toEqual([false]); +}); + +test("it should keep expanded state when collapsible is none", () => { + const { container } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" })); + + expect(container.querySelector('[data-state="expanded"]')).not.toBeNull(); + expect(container.querySelector('[data-state="collapsed"]')).toBeNull(); +}); + +test("it should render header and footer slots", () => { + render( + + User
    , + header:
    Brand
    , + }} + > + Nav + + + + + , + ); + + expect(screen.getByText("Brand")).toBeTruthy(); + expect(screen.getByText("User")).toBeTruthy(); +}); + +test("it should mark the trigger as expanded by default", () => { + render(); + + expect( + screen + .getByRole("button", { name: "Toggle sidebar" }) + .getAttribute("aria-expanded"), + ).toBe("true"); +}); + +test("it should dock the left aside and slide it off-canvas by offsetting left", () => { + const { container } = render(); + const aside = container.querySelector("aside"); + + expect(aside?.className).toContain("left-0"); + expect(aside?.className).toContain("overflow-hidden"); + expect(aside?.className).toContain( + "left-[calc(var(--bridge-sidebar-width)*-1)]", + ); +}); + +test("it should apply data-side from the side prop", () => { + const { container } = render( + + Nav + + + + , + ); + + expect(container.querySelector('[data-side="right"]')).not.toBeNull(); +}); + +test("it should inert the aside when offcanvas is collapsed", () => { + const { container } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" })); + + expect(container.querySelector("aside")?.hasAttribute("inert")).toBe(true); +}); + +test("it should not inert the aside when icon mode is collapsed", () => { + const { container } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" })); + + expect(container.querySelector("aside")?.hasAttribute("inert")).toBe(false); +}); + +test("it should collapse SidebarList items when the icon rail is collapsed", () => { + render( + + + + + + + + + + , + ); + + expect( + screen.getByRole("button", { name: "Home" }).getAttribute("aria-label"), + ).toBe("Home"); +}); + +test("it should collapse header rows with secondary to a square hit", () => { + render( + + + + + ), + }} + > + + + + + + + + , + ); + + const item = screen.getByRole("button", { name: "Acme Inc" }); + + expect(item.className).toContain("size-8"); + expect(item.className).toContain("p-0"); + expect(item.className).not.toMatch(/\bpx-4\b/); +}); + +test("it should apply nav chrome on SidebarList and SidebarListItem", () => { + const { container } = render( + + + + + + + + + + , + ); + + const item = screen.getByRole("button", { name: "Home" }); + + expect(container.querySelector("ul")?.className).toContain("gap-1"); + expect(item.className).toContain("min-h-8"); + expect(item.className).toContain("rounded-lg"); +}); + +test("it should hide nested SidebarList when the icon rail is collapsed", () => { + const { container } = render( + + + + + + + + + + + + + , + ); + + expect(container.querySelectorAll("ul")[1]?.hasAttribute("hidden")).toBe( + true, + ); +}); + +test("it should hide ListSection when the icon rail is collapsed", () => { + render( + + + + + + + + + + + , + ); + + expect(screen.queryByText("Application")).toBeNull(); +}); diff --git a/packages/react/src/Components/Sidebar/__tests__/useSidebar.test.tsx b/packages/react/src/Components/Sidebar/__tests__/useSidebar.test.tsx new file mode 100644 index 00000000..e9b21577 --- /dev/null +++ b/packages/react/src/Components/Sidebar/__tests__/useSidebar.test.tsx @@ -0,0 +1,62 @@ +// ** External Imports +import { cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { afterEach, expect, test } from "vitest"; + +// ** Local Imports +import { Sidebar, SidebarProvider, useSidebar } from "@/Components/Sidebar"; + +afterEach(() => { + cleanup(); +}); + +function wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +test("it should throw when used outside SidebarProvider", () => { + expect(() => { + renderHook(() => useSidebar()); + }).toThrow("useSidebar must be used within a SidebarProvider"); +}); + +test("it should default to open expanded state", () => { + const { result } = renderHook(() => useSidebar(), { wrapper }); + + expect(result.current.open).toBe(true); + expect(result.current.state).toBe("expanded"); + expect(result.current.openMobile).toBe(false); +}); + +test("it should toggle desktop open", () => { + const { result } = renderHook(() => useSidebar(), { wrapper }); + + act(() => { + result.current.toggleSidebar(); + }); + + expect(result.current.open).toBe(false); + expect(result.current.state).toBe("collapsed"); +}); + +test("it should expose side and collapsible from Sidebar", () => { + function iconWrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); + } + + const { result } = renderHook(() => useSidebar(), { wrapper: iconWrapper }); + + expect(result.current.side).toBe("right"); + expect(result.current.collapsible).toBe("icon"); +}); diff --git a/packages/react/src/Components/Sidebar/__tests__/useSidebarList.test.tsx b/packages/react/src/Components/Sidebar/__tests__/useSidebarList.test.tsx new file mode 100644 index 00000000..100c44d3 --- /dev/null +++ b/packages/react/src/Components/Sidebar/__tests__/useSidebarList.test.tsx @@ -0,0 +1,73 @@ +// ** External Imports +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { expect, test } from "vitest"; + +// ** Local Imports +import { Sidebar, SidebarProvider, useSidebarList } from "@/Components/Sidebar"; + +function collapsedIconWrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function expandedIconWrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +test("it should set iconOnly when the icon rail is collapsed", () => { + const { result } = renderHook(() => useSidebarList({}), { + wrapper: collapsedIconWrapper, + }); + + expect(result.current.iconOnly).toBe(true); +}); + +test("it should not set iconOnly when the icon rail is expanded", () => { + const { result } = renderHook(() => useSidebarList({}), { + wrapper: expandedIconWrapper, + }); + + expect(result.current.iconOnly).toBe(false); +}); + +test("it should allow iconOnly to be overridden", () => { + const { result } = renderHook(() => useSidebarList({ iconOnly: false }), { + wrapper: collapsedIconWrapper, + }); + + expect(result.current.iconOnly).toBe(false); +}); + +test("it should apply stacked nav chrome on the list root", () => { + const { result } = renderHook(() => useSidebarList({}), { + wrapper: expandedIconWrapper, + }); + + expect(result.current.rootClassName).toContain("px-2"); + expect(result.current.rootClassName).toContain("gap-1"); +}); + +test("it should apply a nested start-edge guide line", () => { + const { result } = renderHook(() => useSidebarList({ nested: true }), { + wrapper: expandedIconWrapper, + }); + + expect(result.current.rootClassName).toContain("ml-3.5"); + expect(result.current.rootClassName).toContain("border-l"); +}); + +test("it should hide nested lists when the icon rail is collapsed", () => { + const { result } = renderHook(() => useSidebarList({ nested: true }), { + wrapper: collapsedIconWrapper, + }); + + expect(result.current.rootClassName).toContain("hidden"); +}); diff --git a/packages/react/src/Components/Sidebar/__tests__/useSidebarListItem.test.tsx b/packages/react/src/Components/Sidebar/__tests__/useSidebarListItem.test.tsx new file mode 100644 index 00000000..8da2dd0f --- /dev/null +++ b/packages/react/src/Components/Sidebar/__tests__/useSidebarListItem.test.tsx @@ -0,0 +1,152 @@ +// ** External Imports +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { expect, test } from "vitest"; + +// ** Local Imports +import { + Sidebar, + SidebarProvider, + useSidebarListItem, +} from "@/Components/Sidebar"; + +function collapsedIconWrapper({ + side, + children, +}: { + children: ReactNode; + side?: "left" | "right"; +}) { + return ( + + + {children} + + + ); +} + +test("it should use primary as tooltip when the icon rail is collapsed", () => { + const { result } = renderHook(() => useSidebarListItem({ primary: "Home" }), { + wrapper: ({ children }: { children: ReactNode }) => { + return collapsedIconWrapper({ children }); + }, + }); + + expect(result.current.tooltip).toBe("Home"); + expect(result.current.tooltipPlacement).toBe("right"); +}); + +test("it should place the tooltip opposite a right rail", () => { + const { result } = renderHook(() => useSidebarListItem({ primary: "Home" }), { + wrapper: ({ children }: { children: ReactNode }) => { + return collapsedIconWrapper({ children, side: "right" }); + }, + }); + + expect(result.current.tooltipPlacement).toBe("left"); +}); + +test("it should omit the tooltip when the rail is expanded", () => { + const { result } = renderHook(() => useSidebarListItem({ primary: "Home" }), { + wrapper: ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); + }, + }); + + expect(result.current.tooltip).toBeUndefined(); +}); + +test("it should omit an explicit tooltip when the rail is expanded", () => { + const { result } = renderHook( + () => useSidebarListItem({ primary: "Home", tooltip: "Go home" }), + { + wrapper: ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); + }, + }, + ); + + expect(result.current.tooltip).toBeUndefined(); +}); + +test("it should use an explicit tooltip when the icon rail is collapsed", () => { + const { result } = renderHook( + () => useSidebarListItem({ primary: "Home", tooltip: "Go home" }), + { + wrapper: ({ children }: { children: ReactNode }) => { + return collapsedIconWrapper({ children }); + }, + }, + ); + + expect(result.current.tooltip).toBe("Go home"); +}); + +test("it should apply compact nav chrome when the rail is expanded", () => { + const { result } = renderHook(() => useSidebarListItem({ primary: "Home" }), { + wrapper: ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); + }, + }); + + expect(result.current.itemClasses.interactive).toContain("min-h-8"); + expect(result.current.itemClasses.interactive).toContain("rounded-lg"); +}); + +test("it should apply a taller hit when secondary is set", () => { + const { result } = renderHook( + () => useSidebarListItem({ primary: "Acme Inc", secondary: "Enterprise" }), + { + wrapper: ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); + }, + }, + ); + + expect(result.current.itemClasses.interactive).toContain("py-2"); + expect(result.current.itemClasses.interactive).toContain("min-h-12"); +}); + +test("it should apply a compact hit when the icon rail is collapsed", () => { + const { result } = renderHook(() => useSidebarListItem({ primary: "Home" }), { + wrapper: ({ children }: { children: ReactNode }) => { + return collapsedIconWrapper({ children }); + }, + }); + + expect(result.current.itemClasses.interactive).toContain("h-8"); + expect(result.current.itemClasses.content).toContain("hidden"); +}); + +test("it should square the hit when secondary is set on the icon rail", () => { + const { result } = renderHook( + () => useSidebarListItem({ primary: "Acme Inc", secondary: "Enterprise" }), + { + wrapper: ({ children }: { children: ReactNode }) => { + return collapsedIconWrapper({ children }); + }, + }, + ); + + expect(result.current.itemClasses.interactive).toContain("size-8"); + expect(result.current.itemClasses.interactive).toContain("p-0"); + expect(result.current.itemClasses.content).toContain("hidden"); + expect(result.current.itemClasses.end).toContain("hidden"); +}); diff --git a/packages/react/src/Components/Sidebar/hooks/useSidebar.ts b/packages/react/src/Components/Sidebar/hooks/useSidebar.ts new file mode 100644 index 00000000..e3d81433 --- /dev/null +++ b/packages/react/src/Components/Sidebar/hooks/useSidebar.ts @@ -0,0 +1,3 @@ +// ** Local Imports +export { useSidebar } from "@/Components/Sidebar/SidebarContext"; +export type { SidebarContextValue } from "@/Components/Sidebar/SidebarContext"; diff --git a/packages/react/src/Components/Sidebar/hooks/useSidebarInset.ts b/packages/react/src/Components/Sidebar/hooks/useSidebarInset.ts new file mode 100644 index 00000000..6768b9d1 --- /dev/null +++ b/packages/react/src/Components/Sidebar/hooks/useSidebarInset.ts @@ -0,0 +1,80 @@ +// ** External Imports +import { get, omit } from "es-toolkit/compat"; +import { useMemo } from "react"; + +// ** Core Imports +import { sidebarVariantProps as variantProps } from "@bridge-ui/core/Tokens"; +import { + cn, + mergeBridgeUILayeredClasses, + splitComponentProps, +} from "@bridge-ui/core/Utils"; + +// ** Local Imports +import type { + SidebarInsetOwnProps, + SidebarInsetProps, +} from "@/Components/Sidebar/sidebar.types"; +import { useSidebar } from "@/Components/Sidebar/SidebarContext"; +import { derived, mergePartBind, useBridgeUIComponent } from "@/Utils"; + +const sidebarInsetBridgeKeys = [ + "classes", + "customProps", +] as const satisfies readonly (keyof SidebarInsetOwnProps)[]; + +export function useSidebarInset(props: SidebarInsetProps) { + const sidebar = useSidebar(); + + const { componentProps, inheritedAttrs } = splitComponentProps< + SidebarInsetProps, + typeof sidebarInsetBridgeKeys + >({ + props, + bridgeKeys: sidebarInsetBridgeKeys, + }); + + const { merged, entry: bridgeSidebar } = useBridgeUIComponent< + SidebarInsetOwnProps, + "Sidebar" + >({ + props: componentProps, + componentName: "Sidebar", + }); + + const children = derived(() => { + return props.children; + }); + + const rootInheritedAttrs = derived(() => { + return omit(inheritedAttrs, ["children"]); + }); + + const customProps = derived(() => { + return merged.customProps as SidebarInsetOwnProps["customProps"]; + }); + + const variantItem = useMemo(() => { + const classes = mergeBridgeUILayeredClasses( + variantProps, + bridgeSidebar?.tokens?.variant, + ); + + return get(classes, sidebar.variant); + }, [sidebar.variant, bridgeSidebar?.tokens?.variant]); + + const rootBind = derived(() => { + return mergePartBind(customProps?.root, rootInheritedAttrs, { + className: cn({ + [get(variantItem, "inset") ?? ""]: true, + [get(merged.classes, "root") ?? ""]: true, + }), + }); + }); + + return { + merged, + children, + rootBind, + }; +} diff --git a/packages/react/src/Components/Sidebar/hooks/useSidebarList.ts b/packages/react/src/Components/Sidebar/hooks/useSidebarList.ts new file mode 100644 index 00000000..04a75eea --- /dev/null +++ b/packages/react/src/Components/Sidebar/hooks/useSidebarList.ts @@ -0,0 +1,47 @@ +// ** Core Imports +import { isSidebarIconOnly } from "@bridge-ui/core/Domain"; +import { cn } from "@bridge-ui/core/Utils"; + +// ** Local Imports +import { useSidebar } from "@/Components/Sidebar/SidebarContext"; +import { useSidebarListContext } from "@/Components/Sidebar/SidebarListContext"; +import type { SidebarListProps } from "@/Components/Sidebar/sidebar.types"; +import { derived } from "@/Utils"; + +/** + * Binds icon-rail mode and applies nav chrome. + */ +export function useSidebarList( + props: Pick, +) { + const sidebar = useSidebar(); + const parent = useSidebarListContext(); + + const iconOnly = derived(() => { + if (props.iconOnly !== undefined) { + return props.iconOnly === true; + } + + if (parent) { + return parent.iconOnly; + } + + return isSidebarIconOnly({ + state: sidebar.state, + isMobile: sidebar.isMobile, + collapsible: sidebar.collapsible, + }); + }); + + const rootClassName = derived(() => { + return cn({ + "flex flex-col gap-1": true, + "px-2": props.nested !== true, + "ml-3.5 translate-x-px border-l border-dark-200 py-0.5 pl-2.5 dark:border-dark-700": + props.nested === true, + hidden: props.nested === true && iconOnly, + }); + }); + + return { iconOnly, rootClassName }; +} diff --git a/packages/react/src/Components/Sidebar/hooks/useSidebarListItem.ts b/packages/react/src/Components/Sidebar/hooks/useSidebarListItem.ts new file mode 100644 index 00000000..0858b76f --- /dev/null +++ b/packages/react/src/Components/Sidebar/hooks/useSidebarListItem.ts @@ -0,0 +1,96 @@ +// ** Core Imports +import { + isSidebarIconOnly, + resolveSidebarListTooltipPlacement, +} from "@bridge-ui/core/Domain"; +import { cn } from "@bridge-ui/core/Utils"; + +// ** Local Imports +import { useSidebar } from "@/Components/Sidebar/SidebarContext"; +import { useSidebarListContext } from "@/Components/Sidebar/SidebarListContext"; +import type { SidebarListItemProps } from "@/Components/Sidebar/sidebar.types"; +import { derived, isPropPresent } from "@/Utils"; + +/** + * Tooltip and nav chrome for a rail item. + */ +export function useSidebarListItem( + props: Pick< + SidebarListItemProps, + "primary" | "tooltip" | "secondary" | "tooltipPlacement" + >, +) { + const sidebar = useSidebar(); + const list = useSidebarListContext(); + + const iconOnly = derived(() => { + if (list) { + return list.iconOnly; + } + + return isSidebarIconOnly({ + state: sidebar.state, + isMobile: sidebar.isMobile, + collapsible: sidebar.collapsible, + }); + }); + + const tooltip = derived(() => { + if (!iconOnly) { + return undefined; + } + + if (props.tooltip !== undefined) { + return props.tooltip || undefined; + } + + if (typeof props.primary !== "string") { + return undefined; + } + + return props.primary; + }); + + const tooltipPlacement = derived(() => { + if (props.tooltipPlacement !== undefined) { + return props.tooltipPlacement; + } + + return resolveSidebarListTooltipPlacement(sidebar.side); + }); + + const accessibleName = derived(() => { + if (!iconOnly || typeof props.primary !== "string") { + return undefined; + } + + return props.primary; + }); + + const itemClasses = derived(() => { + const hasSecondary = isPropPresent(props.secondary); + + return { + end: cn({ + hidden: iconOnly, + }), + content: cn({ + hidden: iconOnly, + }), + start: cn({ + "items-center justify-center": true, + }), + interactive: cn({ + "gap-x-2 overflow-hidden rounded-lg px-2 transition-[width,height,padding] duration-200 ease-linear": + !iconOnly, + "min-h-12 py-2": !iconOnly && hasSecondary, + "min-h-8 py-0": !iconOnly && !hasSecondary, + "gap-0 overflow-hidden rounded-lg p-0": iconOnly, + "size-8 justify-center": iconOnly && hasSecondary, + "h-8 w-full px-2": iconOnly && !hasSecondary, + }), + }; + }); + + return { tooltip, itemClasses, accessibleName, tooltipPlacement }; +} diff --git a/packages/react/src/Components/Sidebar/hooks/useSidebarProvider.ts b/packages/react/src/Components/Sidebar/hooks/useSidebarProvider.ts new file mode 100644 index 00000000..1336766c --- /dev/null +++ b/packages/react/src/Components/Sidebar/hooks/useSidebarProvider.ts @@ -0,0 +1,219 @@ +// ** External Imports +import { get, omit } from "es-toolkit/compat"; +import { toMerged } from "es-toolkit/object"; +import { useCallback, useMemo, useState, type CSSProperties } from "react"; + +// ** Core Imports +import { + resolveSidebarState, + shouldToggleDesktopSidebar, + SIDEBAR_DESKTOP_BREAKPOINT, + SIDEBAR_WIDTH_ICON_VAR, + SIDEBAR_WIDTH_MOBILE_VAR, + SIDEBAR_WIDTH_VAR, + toggleSidebarOpen, +} from "@bridge-ui/core/Domain"; +import { + sidebarWidthProps as widthProps, + type SidebarWidth, +} from "@bridge-ui/core/Tokens"; +import { + cn, + splitComponentProps, + type LibDefaultsShape, + type MergeLibDefaults, +} from "@bridge-ui/core/Utils"; + +// ** Local Imports +import type { + SidebarContextValue, + SidebarLayout, +} from "@/Components/Sidebar/SidebarContext"; +import type { + SidebarProviderOwnProps, + SidebarProviderProps, +} from "@/Components/Sidebar/sidebar.types"; +import { + derived, + mergePartBind, + useBreakpoint, + useBridgeUIComponent, + useBridgeUIMergedRegistryClasses, +} from "@/Utils"; + +const sidebarProviderBridgeKeys = [ + "open", + "classes", + "customProps", + "defaultOpen", + "onOpenChange", +] as const satisfies readonly (keyof (SidebarProviderOwnProps & { + onOpenChange?: (open: boolean) => void; +}))[]; + +type SidebarProviderLibDefaults = LibDefaultsShape< + SidebarProviderOwnProps, + "defaultOpen" +>; + +type SidebarProviderMerged = MergeLibDefaults< + SidebarProviderOwnProps, + SidebarProviderLibDefaults +>; + +const defaultLayout: SidebarLayout = { + panelId: "", + side: "left", + variant: "sidebar", + collapsible: "offcanvas", +}; + +export function useSidebarProvider( + props: SidebarProviderProps, + libDefaults: SidebarProviderLibDefaults, +) { + const { componentProps, inheritedAttrs } = splitComponentProps< + SidebarProviderProps, + typeof sidebarProviderBridgeKeys + >({ + props, + bridgeKeys: sidebarProviderBridgeKeys, + }); + + const { merged, entry: bridgeSidebar } = useBridgeUIComponent< + SidebarProviderMerged, + "Sidebar" + >({ + libDefaults, + props: componentProps, + componentName: "Sidebar", + }); + + const children = derived(() => { + return props.children; + }); + + const rootInheritedAttrs = derived(() => { + return omit(inheritedAttrs, ["children"]); + }); + + const customProps = derived(() => { + return merged.customProps; + }); + + const mergedClasses = useBridgeUIMergedRegistryClasses({ + entry: bridgeSidebar, + props: componentProps, + }); + + const breakpoint = useBreakpoint(); + const isMobile = derived(() => { + return breakpoint.lessThan(SIDEBAR_DESKTOP_BREAKPOINT); + }); + + const isOpenControlled = derived(() => { + return props.open !== undefined; + }); + + const [uncontrolledOpen, setUncontrolledOpen] = useState( + () => merged.defaultOpen, + ); + const open = derived(() => { + return isOpenControlled ? Boolean(props.open) : uncontrolledOpen; + }); + + const [openMobile, setOpenMobile] = useState(false); + const [layout, setLayoutState] = useState(defaultLayout); + + const setOpen = useCallback( + (next: boolean) => { + if (!isOpenControlled) { + setUncontrolledOpen(next); + } + + props.onOpenChange?.(next); + }, + [isOpenControlled, props.onOpenChange], + ); + + const setLayout = useCallback((next: Partial) => { + setLayoutState((current) => { + const mergedLayout = { ...current, ...next }; + + if ( + current.side === mergedLayout.side && + current.panelId === mergedLayout.panelId && + current.variant === mergedLayout.variant && + current.collapsible === mergedLayout.collapsible + ) { + return current; + } + + return mergedLayout; + }); + }, []); + + const toggleSidebar = useCallback(() => { + if (isMobile) { + setOpenMobile((current) => toggleSidebarOpen(current)); + return; + } + + if (!shouldToggleDesktopSidebar(layout.collapsible)) { + return; + } + + setOpen(toggleSidebarOpen(open)); + }, [isMobile, layout.collapsible, open, setOpen]); + + const state = derived(() => { + return resolveSidebarState(open, layout.collapsible); + }); + + const widthItem = useMemo((): SidebarWidth => { + return toMerged(widthProps, bridgeSidebar?.tokens?.width ?? {}); + }, [bridgeSidebar?.tokens?.width]); + + const contextValue = derived((): SidebarContextValue => { + return { + open, + state, + setOpen, + isMobile, + setLayout, + openMobile, + toggleSidebar, + setOpenMobile, + side: layout.side, + panelId: layout.panelId, + variant: layout.variant, + collapsible: layout.collapsible, + }; + }); + + const rootBind = derived(() => { + const inheritedStyle = (rootInheritedAttrs as { style?: CSSProperties }) + .style; + + return mergePartBind(customProps?.root, rootInheritedAttrs, { + "data-side": layout.side, + className: cn({ + "flex min-h-svh w-full data-[side=right]:flex-row-reverse": true, + [get(mergedClasses, "root") ?? ""]: true, + }), + style: { + ...inheritedStyle, + [SIDEBAR_WIDTH_VAR]: widthItem.default, + [SIDEBAR_WIDTH_ICON_VAR]: widthItem.icon, + [SIDEBAR_WIDTH_MOBILE_VAR]: widthItem.mobile, + } as CSSProperties, + }); + }); + + return { + merged, + children, + rootBind, + contextValue, + }; +} diff --git a/packages/react/src/Components/Sidebar/hooks/useSidebarShell.ts b/packages/react/src/Components/Sidebar/hooks/useSidebarShell.ts new file mode 100644 index 00000000..8a9c573e --- /dev/null +++ b/packages/react/src/Components/Sidebar/hooks/useSidebarShell.ts @@ -0,0 +1,271 @@ +// ** External Imports +import { get, omit } from "es-toolkit/compat"; +import { toMerged } from "es-toolkit/object"; +import { useId, useLayoutEffect, useMemo } from "react"; + +// ** Core Imports +import { + getSidebarPanelId, + resolveSidebarCollapsibleData, + shouldRenderSidebarAsDrawer, +} from "@bridge-ui/core/Domain"; +import { + sidebarCollapsibleProps as collapsibleProps, + sidebarSideProps as sideProps, + sidebarVariantProps as variantProps, + sidebarWidthProps as widthProps, + type SidebarWidth, +} from "@bridge-ui/core/Tokens"; +import { + cn, + mergeBridgeUILayeredClasses, + splitComponentProps, + type LibDefaultsShape, + type MergeLibDefaults, +} from "@bridge-ui/core/Utils"; + +// ** Local Imports +import type { + SidebarOwnProps, + SidebarProps, +} from "@/Components/Sidebar/sidebar.types"; +import { useSidebar } from "@/Components/Sidebar/SidebarContext"; +import { + derived, + mergePartBind, + useBridgeUIComponent, + useBridgeUIMergedRegistryClasses, +} from "@/Utils"; + +const sidebarBridgeKeys = [ + "side", + "slots", + "classes", + "variant", + "ariaLabel", + "collapsible", + "customProps", +] as const satisfies readonly (keyof SidebarOwnProps)[]; + +type SidebarLibDefaults = LibDefaultsShape< + SidebarOwnProps, + "side" | "variant" | "ariaLabel" | "collapsible" +>; + +type SidebarMerged = MergeLibDefaults; + +export function useSidebarShell( + props: SidebarProps, + libDefaults: SidebarLibDefaults, +) { + const reactId = useId(); + const panelId = getSidebarPanelId(`bridge-sidebar${reactId}`); + const sidebar = useSidebar(); + + const { componentProps, inheritedAttrs } = splitComponentProps< + SidebarProps, + typeof sidebarBridgeKeys + >({ + props, + bridgeKeys: sidebarBridgeKeys, + }); + + const { merged, entry: bridgeSidebar } = useBridgeUIComponent< + SidebarMerged, + "Sidebar" + >({ + libDefaults, + props: componentProps, + componentName: "Sidebar", + }); + + useLayoutEffect(() => { + sidebar.setLayout({ + panelId, + side: merged.side, + variant: merged.variant, + collapsible: merged.collapsible, + }); + }, [ + panelId, + merged.side, + merged.variant, + sidebar.setLayout, + merged.collapsible, + ]); + + const slots = derived(() => { + return props.slots; + }); + + const children = derived(() => { + return props.children; + }); + + const rootInheritedAttrs = derived(() => { + return omit(inheritedAttrs, ["slots", "children"]); + }); + + const customProps = derived(() => { + return merged.customProps; + }); + + const mergedClasses = useBridgeUIMergedRegistryClasses({ + entry: bridgeSidebar, + props: componentProps, + }); + + const variantItem = useMemo(() => { + const classes = mergeBridgeUILayeredClasses( + variantProps, + bridgeSidebar?.tokens?.variant, + ); + + return get(classes, merged.variant); + }, [merged.variant, bridgeSidebar?.tokens?.variant]); + + const collapsibleItem = useMemo(() => { + const classes = mergeBridgeUILayeredClasses( + collapsibleProps, + bridgeSidebar?.tokens?.collapsible, + ); + + return get(classes, merged.collapsible); + }, [merged.collapsible, bridgeSidebar?.tokens?.collapsible]); + + const sideClass = useMemo(() => { + const classes = mergeBridgeUILayeredClasses( + sideProps, + bridgeSidebar?.tokens?.side, + ); + + return get(classes, merged.side); + }, [merged.side, bridgeSidebar?.tokens?.side]); + + const collapsibleData = derived(() => { + return resolveSidebarCollapsibleData(sidebar.state, merged.collapsible); + }); + + const showAsDrawer = derived(() => { + return shouldRenderSidebarAsDrawer(sidebar.isMobile) && sidebar.openMobile; + }); + + const rootBind = derived(() => { + return mergePartBind(customProps?.root, rootInheritedAttrs, { + "data-side": merged.side, + "data-state": sidebar.state, + "data-variant": merged.variant, + "data-collapsible": collapsibleData, + "data-mobile": sidebar.isMobile ? "true" : "false", + className: cn({ + "group peer hidden text-dark-900 md:block dark:text-dark-100": true, + [get(mergedClasses, "root") ?? ""]: true, + }), + }); + }); + + const gapBind = derived(() => { + return mergePartBind( + customProps?.gap, + {}, + cn({ + [get(variantItem, "gap") ?? ""]: true, + [get(collapsibleItem, "gap") ?? ""]: true, + [get(mergedClasses, "gap") ?? ""]: true, + }), + ); + }); + + const asideBind = derived(() => { + const offcanvasCollapsed = collapsibleData === "offcanvas"; + + return mergePartBind( + {}, + {}, + { + "data-side": merged.side, + "aria-label": merged.ariaLabel, + id: showAsDrawer ? undefined : panelId, + inert: offcanvasCollapsed ? true : undefined, + className: cn({ + "fixed inset-y-0 z-10 hidden h-full w-[var(--bridge-sidebar-width)] overflow-hidden transition-[left,right,width] duration-200 ease-linear md:flex": true, + [sideClass ?? ""]: true, + [get(collapsibleItem, "panel") ?? ""]: true, + }), + }, + ); + }); + + const panelBind = derived(() => { + return mergePartBind( + customProps?.panel, + {}, + cn({ + "flex h-full w-full flex-col overflow-hidden": true, + [get(variantItem, "panel") ?? ""]: true, + [get(mergedClasses, "panel") ?? ""]: true, + }), + ); + }); + + const headerBind = derived(() => { + return mergePartBind( + customProps?.header, + {}, + cn({ + "flex shrink-0 flex-col gap-2 px-2 py-2.5": true, + [get(mergedClasses, "header") ?? ""]: true, + }), + ); + }); + + const contentBind = derived(() => { + return mergePartBind( + customProps?.content, + {}, + cn({ + "bridge-scroll-fade-y flex min-h-0 flex-1 flex-col overflow-y-auto": true, + [get(mergedClasses, "content") ?? ""]: true, + }), + ); + }); + + const footerBind = derived(() => { + return mergePartBind( + customProps?.footer, + {}, + cn({ + "flex shrink-0 flex-col gap-2 px-2 py-2.5": true, + [get(mergedClasses, "footer") ?? ""]: true, + }), + ); + }); + + const mobileWidth = derived(() => { + const widthItem = toMerged( + widthProps, + bridgeSidebar?.tokens?.width ?? {}, + ) as SidebarWidth; + + return widthItem.mobile; + }); + + return { + slots, + merged, + panelId, + gapBind, + children, + rootBind, + asideBind, + panelBind, + headerBind, + footerBind, + contentBind, + mobileWidth, + showAsDrawer, + isMobile: sidebar.isMobile, + openMobile: sidebar.openMobile, + setOpenMobile: sidebar.setOpenMobile, + }; +} diff --git a/packages/react/src/Components/Sidebar/hooks/useSidebarTrigger.ts b/packages/react/src/Components/Sidebar/hooks/useSidebarTrigger.ts new file mode 100644 index 00000000..59d85515 --- /dev/null +++ b/packages/react/src/Components/Sidebar/hooks/useSidebarTrigger.ts @@ -0,0 +1,63 @@ +// ** External Imports +import { omit } from "es-toolkit/compat"; +import type { MouseEvent } from "react"; + +// ** Core Imports +import { splitComponentProps } from "@bridge-ui/core/Utils"; + +// ** Local Imports +import type { + SidebarTriggerOwnProps, + SidebarTriggerProps, +} from "@/Components/Sidebar/sidebar.types"; +import { useSidebar } from "@/Components/Sidebar/SidebarContext"; +import { derived } from "@/Utils"; + +const sidebarTriggerBridgeKeys = [ + "children", +] as const satisfies readonly (keyof SidebarTriggerOwnProps)[]; + +export function useSidebarTrigger(props: SidebarTriggerProps) { + const sidebar = useSidebar(); + + const { inheritedAttrs } = splitComponentProps< + SidebarTriggerProps, + typeof sidebarTriggerBridgeKeys + >({ + props, + bridgeKeys: sidebarTriggerBridgeKeys, + }); + + const children = derived(() => { + return props.children; + }); + + const rootInheritedAttrs = derived(() => { + return omit(inheritedAttrs, ["children", "onClick"]); + }); + + const expanded = derived(() => { + return sidebar.isMobile ? sidebar.openMobile : sidebar.open; + }); + + const handleClick = derived(() => { + return (event: MouseEvent) => { + props.onClick?.(event); + + if (event.defaultPrevented) { + return; + } + + sidebar.toggleSidebar(); + }; + }); + + return { + children, + expanded, + handleClick, + side: sidebar.side, + rootInheritedAttrs, + panelId: sidebar.panelId, + }; +} diff --git a/packages/react/src/Components/Sidebar/index.ts b/packages/react/src/Components/Sidebar/index.ts new file mode 100644 index 00000000..b6ce64eb --- /dev/null +++ b/packages/react/src/Components/Sidebar/index.ts @@ -0,0 +1,44 @@ +// ** Exports +export { useSidebar } from "@/Components/Sidebar/hooks/useSidebar"; +export { useSidebarInset } from "@/Components/Sidebar/hooks/useSidebarInset"; +export { useSidebarList } from "@/Components/Sidebar/hooks/useSidebarList"; +export { useSidebarListItem } from "@/Components/Sidebar/hooks/useSidebarListItem"; +export { useSidebarProvider } from "@/Components/Sidebar/hooks/useSidebarProvider"; +export { useSidebarShell } from "@/Components/Sidebar/hooks/useSidebarShell"; +export { useSidebarTrigger } from "@/Components/Sidebar/hooks/useSidebarTrigger"; +export { default as Sidebar } from "@/Components/Sidebar/Sidebar"; +export type { + SidebarClasses, + SidebarCollapsibleOverrides, + SidebarCustomProps, + SidebarInsetClasses, + SidebarInsetCustomProps, + SidebarInsetOwnProps, + SidebarInsetProps, + SidebarListItemOwnProps, + SidebarListItemProps, + SidebarListOwnProps, + SidebarListProps, + SidebarOwnProps, + SidebarProps, + SidebarProviderCallbacks, + SidebarProviderClasses, + SidebarProviderCustomProps, + SidebarProviderOwnProps, + SidebarProviderProps, + SidebarSideOverrides, + SidebarSlots, + SidebarTriggerOwnProps, + SidebarTriggerProps, + SidebarVariantOverrides, +} from "@/Components/Sidebar/sidebar.types"; +export { + SidebarContext, + type SidebarContextValue, + type SidebarLayout, +} from "@/Components/Sidebar/SidebarContext"; +export { default as SidebarInset } from "@/Components/Sidebar/SidebarInset"; +export { default as SidebarList } from "@/Components/Sidebar/SidebarList"; +export { default as SidebarListItem } from "@/Components/Sidebar/SidebarListItem"; +export { default as SidebarProvider } from "@/Components/Sidebar/SidebarProvider"; +export { default as SidebarTrigger } from "@/Components/Sidebar/SidebarTrigger"; diff --git a/packages/react/src/Components/Sidebar/sidebar.types.ts b/packages/react/src/Components/Sidebar/sidebar.types.ts new file mode 100644 index 00000000..b613abf0 --- /dev/null +++ b/packages/react/src/Components/Sidebar/sidebar.types.ts @@ -0,0 +1,324 @@ +// ** External Imports +import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react"; + +// ** Core Imports +import type { PositionPlacement } from "@bridge-ui/core/Runtime"; +import type { + SidebarCollapsible, + SidebarSide, + SidebarVariant, +} from "@bridge-ui/core/Tokens"; +import type { MergeHtmlProps, MergeProps } from "@bridge-ui/core/Utils"; + +// ** Local Imports +import type { ListProps } from "@/Components/List/list.types"; +import type { ListItemProps } from "@/Components/ListItem/listItem.types"; + +export interface SidebarSideOverrides {} +export interface SidebarVariantOverrides {} +export interface SidebarCollapsibleOverrides {} + +export interface SidebarClasses { + /** + * The classes to apply to the scrollable content region. + */ + content?: string; + + /** + * The classes to apply to the footer. + */ + footer?: string; + + /** + * The classes to apply to the in-flow gap spacer. + */ + gap?: string; + + /** + * The classes to apply to the header. + */ + header?: string; + + /** + * The classes to apply to the inner panel surface. + */ + panel?: string; + + /** + * The classes to apply to the desktop rail chrome (gap + fixed panel). + */ + root?: string; +} + +export interface SidebarCustomProps { + /** + * Props forwarded to the scrollable content region. + */ + content?: HTMLAttributes; + + /** + * Props forwarded to the footer. + */ + footer?: HTMLAttributes; + + /** + * Props forwarded to the in-flow gap spacer. + */ + gap?: HTMLAttributes; + + /** + * Props forwarded to the header. + */ + header?: HTMLAttributes; + + /** + * Props forwarded to the inner panel surface. + */ + panel?: HTMLAttributes; + + /** + * Props forwarded to the desktop rail chrome. + */ + root?: HTMLAttributes; +} + +export interface SidebarInsetClasses { + /** + * The classes to apply to the inset root. + */ + root?: string; +} + +export interface SidebarInsetCustomProps { + /** + * Props forwarded to the inset root. + */ + root?: HTMLAttributes; +} + +export interface SidebarInsetOwnProps { + /** + * The children to render. + * + * @default undefined + */ + children?: ReactNode; + + /** + * The classes to apply to the inset. + * + * @default undefined + */ + classes?: SidebarInsetClasses; + + /** + * Extra props for internal parts. + * + * @default undefined + */ + customProps?: SidebarInsetCustomProps; +} + +export interface SidebarListItemOwnProps { + /** + * Tooltip label while the icon rail is collapsed. When omitted, string + * `primary` is used. Has no effect on the mobile drawer or an expanded rail. + * + * @default undefined + */ + tooltip?: string; + + /** + * Placement of {@link SidebarListItemOwnProps.tooltip}. Defaults to the + * side opposite the rail. + * + * @default undefined + */ + tooltipPlacement?: PositionPlacement; +} + +export interface SidebarListOwnProps { + /** + * Collapse items to leading icons. Defaults to the collapsed icon rail. + * Nested `SidebarList` is hidden. `ListSection` labels are hidden. + * + * @default undefined + */ + iconOnly?: boolean; +} + +/** + * Persistent app-shell sidebar panel. Mount under `SidebarProvider` with + * `SidebarInset`. Put `SidebarList` / `Accordion` in `children`. + */ +export interface SidebarOwnProps { + /** + * Accessible name for the desktop `aside` and the mobile drawer. + * + * @default "Sidebar" + */ + ariaLabel?: string; + + /** + * The children to render in the scrollable region. + * + * @default undefined + */ + children?: ReactNode; + + /** + * The classes to apply to the sidebar. + * + * @default undefined + */ + classes?: SidebarClasses; + + /** + * How the desktop rail hides. + * + * @default "offcanvas" + */ + collapsible?: MergeProps; + + /** + * Extra props for internal parts (`header`, `content`, `footer`, etc.). + * Root HTML attributes stay on the component top level. + * + * @default undefined + */ + customProps?: SidebarCustomProps; + + /** + * Which edge the rail docks to. + * + * @default "left" + */ + side?: MergeProps; + + /** + * Header and footer slots. `children` is the scroller. + * + * @default undefined + */ + slots?: SidebarSlots; + + /** + * Visual layout of the rail and inset. + * + * @default "sidebar" + */ + variant?: MergeProps; +} + +export interface SidebarProviderCallbacks { + /** + * Called when the desktop `open` state should change (controlled). + * + * @default undefined + */ + onOpenChange?: (open: boolean) => void; +} + +export interface SidebarProviderClasses { + /** + * The classes to apply to the layout wrapper. + */ + root?: string; +} + +export interface SidebarProviderCustomProps { + /** + * Props forwarded to the layout wrapper. + */ + root?: HTMLAttributes; +} + +export interface SidebarProviderOwnProps { + /** + * The children to render (`Sidebar`, `SidebarInset`, etc.). + * + * @default undefined + */ + children?: ReactNode; + + /** + * The classes to apply to the provider wrapper. + * + * @default undefined + */ + classes?: SidebarProviderClasses; + + /** + * Extra props for the layout wrapper. + * + * @default undefined + */ + customProps?: SidebarProviderCustomProps; + + /** + * Initial desktop expanded state when `open` is omitted. + * + * @default true + */ + defaultOpen?: boolean; + + /** + * Controlled desktop expanded state. + * + * @default undefined + */ + open?: boolean; +} + +export interface SidebarSlots { + /** + * Sticky footer (user menu, settings). + */ + footer?: ReactNode; + + /** + * Sticky header (branding, workspace switcher). + */ + header?: ReactNode; +} + +export interface SidebarTriggerOwnProps { + /** + * The children to render inside the trigger. Replaces the default icon. + * + * @default undefined + */ + children?: ReactNode; +} + +export type SidebarInsetProps = MergeHtmlProps< + SidebarInsetOwnProps, + HTMLAttributes +>; + +export type SidebarProps = MergeHtmlProps< + SidebarOwnProps, + HTMLAttributes +>; + +export type SidebarProviderProps = MergeHtmlProps< + SidebarProviderOwnProps & SidebarProviderCallbacks, + HTMLAttributes +>; + +export type SidebarTriggerProps = MergeHtmlProps< + SidebarTriggerOwnProps, + ButtonHTMLAttributes +>; + +/** + * `List` bound to the nearest `Sidebar`. Sets `iconOnly` when the icon rail + * is collapsed on desktop. + */ +export type SidebarListProps = ListProps & SidebarListOwnProps; + +/** + * `ListItem` bound to the nearest `Sidebar`. Shows `primary` in a tooltip + * when the icon rail is collapsed. + */ +export type SidebarListItemProps = ListItemProps & SidebarListItemOwnProps; diff --git a/packages/react/src/augments.ts b/packages/react/src/augments.ts index d6a743c5..dcfb14bf 100644 --- a/packages/react/src/augments.ts +++ b/packages/react/src/augments.ts @@ -35,6 +35,13 @@ import type { import type { ProgressClasses, ProgressProps } from "@/Components/Progress"; import type { RadioClasses, RadioProps } from "@/Components/Radio"; import type { SelectClasses, SelectProps } from "@/Components/Select"; +import type { + SidebarClasses, + SidebarInsetClasses, + SidebarProps, + SidebarProviderClasses, + SidebarProviderProps, +} from "@/Components/Sidebar"; import type { SkeletonClasses, SkeletonProps } from "@/Components/Skeleton"; import type { SliderClasses, SliderProps } from "@/Components/Slider"; import type { SnackbarClasses, SnackbarProps } from "@/Components/Snackbar"; @@ -251,6 +258,14 @@ declare module "@bridge-ui/core/Config" { >; } + interface SidebarConfigOverrides { + classes: SidebarClasses & SidebarInsetClasses & SidebarProviderClasses; + defaultProps: Partial< + Pick & + Pick + >; + } + interface SkeletonConfigOverrides { classes: SkeletonClasses; defaultProps: Partial>; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 8e56f8d2..fc37c84d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -501,6 +501,47 @@ export type { SelectSlots, SelectValue, } from "@/Components/Select"; +export { + Sidebar, + SidebarInset, + SidebarList, + SidebarListItem, + SidebarProvider, + SidebarTrigger, + useSidebar, + useSidebarInset, + useSidebarList, + useSidebarListItem, + useSidebarProvider, + useSidebarShell, + useSidebarTrigger, +} from "@/Components/Sidebar"; +export type { + SidebarClasses, + SidebarCollapsibleOverrides, + SidebarContextValue, + SidebarCustomProps, + SidebarInsetClasses, + SidebarInsetCustomProps, + SidebarInsetOwnProps, + SidebarInsetProps, + SidebarListItemOwnProps, + SidebarListItemProps, + SidebarListOwnProps, + SidebarListProps, + SidebarOwnProps, + SidebarProps, + SidebarProviderCallbacks, + SidebarProviderClasses, + SidebarProviderCustomProps, + SidebarProviderOwnProps, + SidebarProviderProps, + SidebarSideOverrides, + SidebarSlots, + SidebarTriggerOwnProps, + SidebarTriggerProps, + SidebarVariantOverrides, +} from "@/Components/Sidebar"; export { Skeleton, useSkeleton } from "@/Components/Skeleton"; export type { SkeletonClasses, diff --git a/packages/vue/ai/skills/bridge-ui-components/SKILL.md b/packages/vue/ai/skills/bridge-ui-components/SKILL.md index ae3ed02d..667e036c 100644 --- a/packages/vue/ai/skills/bridge-ui-components/SKILL.md +++ b/packages/vue/ai/skills/bridge-ui-components/SKILL.md @@ -2,7 +2,7 @@ name: bridge-ui-components description: >- Use Bridge UI Vue components — Button, Avatar, Card, Alert, Accordion, Badge, Icon, - Link, List, Table, DataTable, Tabs, Spinner, Skeleton, EmptyState, Progress, Stepper, Pagination, TextField, Select, + Link, List, Table, DataTable, Tabs, Spinner, Skeleton, EmptyState, Sidebar, Progress, Stepper, Pagination, TextField, Select, Autocomplete, DateField, DatePicker, DateRangeField, DateRangePicker, TimeField, TimePicker, DateTimeField, DateTimePicker, ColorField, ColorPicker, classes, customProps, slots. Use when building UI with Bridge components. @@ -32,6 +32,7 @@ In templates, use kebab-case attrs (`start-icon`, `custom-props`, `error-message | Tabs | `.ai/docs/components/Tabs.md` | | Text input | `.ai/docs/components/TextField.md` | | Select / autocomplete | `.ai/docs/components/Select.md`, `Autocomplete.md` | +| Sidebar / app shell | `.ai/docs/components/Sidebar.md` | | Date | `.ai/docs/components/DateField.md`, `DatePicker.md` | | Date range | `.ai/docs/components/DateRangeField.md`, `DateRangePicker.md` | | Time | `.ai/docs/components/TimeField.md`, `TimePicker.md` | diff --git a/packages/vue/docs/README.md b/packages/vue/docs/README.md index eab88561..417d30dd 100644 --- a/packages/vue/docs/README.md +++ b/packages/vue/docs/README.md @@ -50,6 +50,7 @@ Component reference and adapter samples for **Vue**. This folder ships with the - [Progress](./components/Progress.md) - [Radio](./components/Radio.md) - [Select](./components/Select.md) +- [Sidebar](./components/Sidebar.md) - [Skeleton](./components/Skeleton.md) - [Slider](./components/Slider.md) - [Snackbar](./components/Snackbar.md) diff --git a/packages/vue/docs/components/Drawer.md b/packages/vue/docs/components/Drawer.md index d849e6ed..5c63c306 100644 --- a/packages/vue/docs/components/Drawer.md +++ b/packages/vue/docs/components/Drawer.md @@ -134,4 +134,4 @@ import { Drawer } from "@bridge-ui/vue/Components/Drawer"; ## Related components -Card, useDialogAction, useDrawerAction, useModalAction +Card, Sidebar, useDialogAction, useDrawerAction, useModalAction diff --git a/packages/vue/docs/components/List.md b/packages/vue/docs/components/List.md index dc21ef03..b853d7ad 100644 --- a/packages/vue/docs/components/List.md +++ b/packages/vue/docs/components/List.md @@ -164,4 +164,4 @@ With the default `as="li"`, sticky styles apply on the section root. The list (o ## Related components -Menu, Select +Menu, Select, Sidebar diff --git a/packages/vue/docs/components/Sidebar.md b/packages/vue/docs/components/Sidebar.md new file mode 100644 index 00000000..58408cc9 --- /dev/null +++ b/packages/vue/docs/components/Sidebar.md @@ -0,0 +1,281 @@ +# Sidebar + +Persistent app-shell rail. Mount `SidebarProvider` around `Sidebar` and `SidebarInset` as siblings. Put `SidebarList` / `Accordion` in the default slot. On small viewports the panel opens as a `Drawer`. + +## Import + +```ts +import { + Sidebar, + SidebarInset, + SidebarList, + SidebarListItem, + SidebarProvider, + SidebarTrigger, + useSidebar, +} from "@bridge-ui/vue/Components/Sidebar"; +``` + +## Prerequisites + +Mount `SidebarProvider` around the app shell (`Sidebar` and `SidebarInset` as siblings) before using `SidebarTrigger` or `useSidebar`. + +`BridgeUIProvider` still holds theme, tokens, and `defaultProps`. It does not own instance `open` state. + +## Examples + +### Usage + +```vue + + + + + + + + + + + + +``` + +### Header and footer + +Put brand and account rows in `#header` / `#footer`. `SidebarList` collapses those rows to the start avatar. The end chevron hides while collapsed. Header and footer lists use `classes.root` `p-0` because those slots are already padded. + +```vue + +``` + +```vue + +``` + +```vue + + + + + + + + + + + + + +``` + +### Icon collapse + +Use `SidebarList` / `SidebarListItem` when `collapsible="icon"`. Collapsed items keep `primary` as an `aria-label` and show it in a `Tooltip` on the whole item. Nested `SidebarList` is hidden. The mobile drawer keeps labels. + +```vue + +``` + +```vue + + + +