From 7b7cdeb2545c6f5d6812f540f0475cc88de12536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Lopes?= Date: Sun, 30 Aug 2026 15:01:08 -0300 Subject: [PATCH 01/22] feat(sidebar): add tokens, domain helpers, and config Share rail layout tokens and collapse rules across React and Vue. --- packages/core/src/Config/index.ts | 2 + packages/core/src/Config/types.ts | 26 ++++++ .../core/src/Domain/__tests__/sidebar.test.ts | 67 +++++++++++++++ packages/core/src/Domain/index.ts | 12 +++ packages/core/src/Domain/sidebar.ts | 84 +++++++++++++++++++ .../core/src/Tokens/Sidebar/Collapsible.ts | 57 +++++++++++++ packages/core/src/Tokens/Sidebar/Side.ts | 22 +++++ packages/core/src/Tokens/Sidebar/Variant.ts | 55 ++++++++++++ packages/core/src/Tokens/Sidebar/Width.ts | 28 +++++++ packages/core/src/Tokens/Sidebar/index.ts | 15 ++++ packages/core/src/Tokens/index.ts | 14 ++++ packages/core/src/index.ts | 26 ++++++ 12 files changed, 408 insertions(+) create mode 100644 packages/core/src/Domain/__tests__/sidebar.test.ts create mode 100644 packages/core/src/Domain/sidebar.ts create mode 100644 packages/core/src/Tokens/Sidebar/Collapsible.ts create mode 100644 packages/core/src/Tokens/Sidebar/Side.ts create mode 100644 packages/core/src/Tokens/Sidebar/Variant.ts create mode 100644 packages/core/src/Tokens/Sidebar/Width.ts create mode 100644 packages/core/src/Tokens/Sidebar/index.ts 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..1e9ce964 --- /dev/null +++ b/packages/core/src/Domain/__tests__/sidebar.test.ts @@ -0,0 +1,67 @@ +// ** External Imports +import { describe, expect, test } from "vitest"; + +// ** Local Imports +import { + getSidebarPanelId, + resolveSidebarCollapsibleData, + resolveSidebarState, + shouldRenderSidebarAsDrawer, + shouldToggleDesktopSidebar, + 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); + }); +}); diff --git a/packages/core/src/Domain/index.ts b/packages/core/src/Domain/index.ts index c46e9d1b..f1571c04 100644 --- a/packages/core/src/Domain/index.ts +++ b/packages/core/src/Domain/index.ts @@ -228,6 +228,18 @@ export type { SelectOptionLike, SelectValue, } from "@/Domain/select"; +export { + SIDEBAR_WIDTH_ICON_VAR, + SIDEBAR_WIDTH_MOBILE_VAR, + SIDEBAR_WIDTH_VAR, + getSidebarPanelId, + resolveSidebarCollapsibleData, + 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..9d45adaf --- /dev/null +++ b/packages/core/src/Domain/sidebar.ts @@ -0,0 +1,84 @@ +/** + * 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"; + +/** + * 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"; +} diff --git a/packages/core/src/Tokens/Sidebar/Collapsible.ts b/packages/core/src/Tokens/Sidebar/Collapsible.ts new file mode 100644 index 00000000..0c664728 --- /dev/null +++ b/packages/core/src/Tokens/Sidebar/Collapsible.ts @@ -0,0 +1,57 @@ +/** + * 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 `List` `iconOnly`. + */ + "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", + "panel": + "group-data-[collapsible=offcanvas]:data-[side=left]:inset-inline-start-[calc(var(--bridge-sidebar-width)*-1)] group-data-[collapsible=offcanvas]:data-[side=right]:inset-inline-end-[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..ca9bae45 --- /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; +} + +/** + * Logical-property placement for the fixed desktop panel. + */ +export const sideProps: SidebarSide = { + "left": "data-[side=left]:inset-inline-start-0", + "right": "data-[side=right]:inset-inline-end-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..19472158 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,9 @@ export { DEFAULT_SLIDER_STEP, DEFAULT_SPINNER_THICKNESS, DEFAULT_START_OF_WEEK, + SIDEBAR_WIDTH_ICON_VAR, + SIDEBAR_WIDTH_MOBILE_VAR, + SIDEBAR_WIDTH_VAR, SPINNER_VIEWBOX_SIZE, applyDateSelection, applyOtpInput, @@ -166,6 +170,7 @@ export { getFieldOverlayControlSize, getNumberFieldStepper, getPaginationItems, + getSidebarPanelId, getSliderBarGeometry, getSliderPointerClientX, getSliderPrecision, @@ -238,6 +243,8 @@ export { resolveSelectAsyncDebounce, resolveSelectAsyncLimit, resolveSelectAsyncOptions, + resolveSidebarCollapsibleData, + resolveSidebarState, resolveSliderBounds, resolveSliderDefaultValue, resolveStartOfWeek, @@ -251,6 +258,8 @@ export { setDataTableColumnFilter, setDataTableColumnSearch, setDataTableRowSelection, + shouldRenderSidebarAsDrawer, + shouldToggleDesktopSidebar, sliceDataTablePage, snapMinutes, snapSliderValue, @@ -272,6 +281,7 @@ export { toggleDataTableRowExpansion, toggleDataTableRowSelection, toggleDataTableSorting, + toggleSidebarOpen, unitFromPointer, valueToPercent, writeSliderRangeThumb, @@ -330,6 +340,8 @@ export type { SelectOptionKeys, SelectOptionLike, SelectValue, + SidebarCollapsibleMode, + SidebarState, SliderBarGeometry, SliderBounds, SliderRangeValue, @@ -712,6 +724,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 { From 2f3e7b2d7c486f74ad6b2b210527839853754102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Lopes?= Date: Sun, 30 Aug 2026 15:01:08 -0300 Subject: [PATCH 02/22] feat(list): add iconOnly for compact navigation Let collapsed icon rails hide labels without coupling List to Sidebar. --- packages/react/docs/components/List.md | 18 +++++++++- .../react/src/Components/List/ListContext.tsx | 1 + .../Components/List/__tests__/useList.test.ts | 6 ++++ .../src/Components/List/hooks/useList.ts | 2 ++ .../react/src/Components/List/list.types.ts | 8 +++++ .../src/Components/ListItem/ListItem.tsx | 15 ++++---- .../ListItem/__tests__/ListItem.test.tsx | 21 +++++++++-- .../Components/ListItem/hooks/useListItem.ts | 36 +++++++++++++++++-- .../Components/ListSection/ListSection.tsx | 7 +++- .../__tests__/ListSection.test.tsx | 18 ++++++++-- .../ListSection/hooks/useListSection.ts | 5 +++ packages/vue/docs/components/List.md | 18 +++++++++- packages/vue/src/Components/List/List.vue | 1 + .../Components/List/__tests__/useList.test.ts | 28 +++++++++++++++ .../Components/List/composables/useList.ts | 2 ++ .../vue/src/Components/List/list.types.ts | 8 +++++ .../src/Components/List/listInjectionKey.ts | 1 + .../vue/src/Components/ListItem/ListItem.vue | 5 +-- .../ListItem/__tests__/ListItem.test.ts | 16 +++++++++ .../ListItem/composables/useListItem.ts | 27 ++++++++++++-- .../Components/ListSection/ListSection.vue | 26 +++++++------- .../ListSection/__tests__/ListSection.test.ts | 11 ++++++ .../ListSection/composables/useListSection.ts | 5 +++ 23 files changed, 254 insertions(+), 31 deletions(-) diff --git a/packages/react/docs/components/List.md b/packages/react/docs/components/List.md index eacde6ba..31bfb77a 100644 --- a/packages/react/docs/components/List.md +++ b/packages/react/docs/components/List.md @@ -127,6 +127,21 @@ With the default `as="li"`, sticky styles apply on the section root. The list (o ``` +### Icon only + +Hide section labels and item text so only `slots.start` remains. Use with `Sidebar` `collapsible="icon"`. + +```tsx + + + }} + /> + +``` + ## Props (`List`) | Prop | Type | Default | Description | @@ -136,6 +151,7 @@ With the default `as="li"`, sticky styles apply on the section root. The list (o | `classes` | `ListClasses` | — | The classes to apply to the list. | | `customProps` | `ListCustomProps` | — | Props forwarded to each list part. | | `dense` | `boolean` | `false` | Compact vertical spacing on items (`ListItem` / `ListSection`), not the list root. | +| `iconOnly` | `boolean` | `false` | Hide section labels and item text so only leading icons remain. | | `nested` | `boolean` | `false` | When true, indents the list for nested navigation/submenus. | ## Props (`ListItem`) @@ -172,4 +188,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/src/Components/List/ListContext.tsx b/packages/react/src/Components/List/ListContext.tsx index c2077794..5c1066cc 100644 --- a/packages/react/src/Components/List/ListContext.tsx +++ b/packages/react/src/Components/List/ListContext.tsx @@ -3,6 +3,7 @@ import { createContext, useContext } from "react"; export type ListContextValue = { dense: boolean; + iconOnly: boolean; }; export const ListContext = createContext(null); diff --git a/packages/react/src/Components/List/__tests__/useList.test.ts b/packages/react/src/Components/List/__tests__/useList.test.ts index eacaee8e..1aca563f 100644 --- a/packages/react/src/Components/List/__tests__/useList.test.ts +++ b/packages/react/src/Components/List/__tests__/useList.test.ts @@ -29,6 +29,12 @@ test("it should expose dense context value", () => { expect(result.current.contextValue.dense).toBe(true); }); +test("it should expose iconOnly context value", () => { + const { result } = renderUseList({ iconOnly: true }); + + expect(result.current.contextValue.iconOnly).toBe(true); +}); + test("it should merge className into root bind", () => { const { result } = renderUseList({ className: "custom-list" }); diff --git a/packages/react/src/Components/List/hooks/useList.ts b/packages/react/src/Components/List/hooks/useList.ts index ecf48268..31d23dde 100644 --- a/packages/react/src/Components/List/hooks/useList.ts +++ b/packages/react/src/Components/List/hooks/useList.ts @@ -18,6 +18,7 @@ const listBridgeKeys = [ "dense", "nested", "classes", + "iconOnly", "customProps", ] as const satisfies readonly (keyof ListOwnProps)[]; @@ -54,6 +55,7 @@ export function useList(props: ListProps) { const contextValue = derived(() => { return { dense: merged.dense === true, + iconOnly: merged.iconOnly === true, }; }); diff --git a/packages/react/src/Components/List/list.types.ts b/packages/react/src/Components/List/list.types.ts index 8f631312..471720e7 100644 --- a/packages/react/src/Components/List/list.types.ts +++ b/packages/react/src/Components/List/list.types.ts @@ -58,6 +58,14 @@ export interface ListOwnProps { */ dense?: boolean; + /** + * Hide section labels and item text so only leading icons remain. + * Bind from `useSidebar().state === "collapsed"` when the sidebar uses `collapsible="icon"`. + * + * @default false + */ + iconOnly?: boolean; + /** * When true, indents the list for nested navigation/submenus. * diff --git a/packages/react/src/Components/ListItem/ListItem.tsx b/packages/react/src/Components/ListItem/ListItem.tsx index 4611492c..ed0058f5 100644 --- a/packages/react/src/Components/ListItem/ListItem.tsx +++ b/packages/react/src/Components/ListItem/ListItem.tsx @@ -13,6 +13,7 @@ function ListItemRow({ endBind, startBind, hasPrimary, + isIconOnly, contentBind, primaryBind, hasSecondary, @@ -29,13 +30,15 @@ function ListItemRow({
{slots?.start}
) : null} -
- {hasPrimary ? {primaryContent} : null} + {!isIconOnly ? ( +
+ {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..f88fa09b 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(); @@ -79,3 +83,16 @@ test("it should apply divider border on the root item", () => { expect(root?.className.includes("border-b")).toBe(true); }); + +test("it should hide primary text and set aria-label when List is iconOnly", () => { + render( + + + , + ); + + expect(screen.queryByText("Home")).toBeNull(); + expect( + screen.getByRole("button", { name: "Home" }).getAttribute("aria-label"), + ).toBe("Home"); +}); diff --git a/packages/react/src/Components/ListItem/hooks/useListItem.ts b/packages/react/src/Components/ListItem/hooks/useListItem.ts index a43efa28..f79af153 100644 --- a/packages/react/src/Components/ListItem/hooks/useListItem.ts +++ b/packages/react/src/Components/ListItem/hooks/useListItem.ts @@ -166,13 +166,25 @@ export function useListItem( return merged.dense ?? listContext?.dense ?? false; }); + const isIconOnly = derived(() => { + return listContext?.iconOnly ?? false; + }); + const hasPrimary = derived(() => { + if (isIconOnly) { + return false; + } + return ( hasSlotOrProp(slots, "primary", merged.primary) || isPropPresent(children) ); }); const hasSecondary = derived(() => { + if (isIconOnly) { + return false; + } + return hasSlotOrProp(slots, "secondary", merged.secondary); }); @@ -205,6 +217,10 @@ export function useListItem( ]); const hasEnd = derived(() => { + if (isIconOnly) { + return false; + } + return hasNamedSlot(slots, "end") || resolvedSelectedIcon != null; }); @@ -223,6 +239,18 @@ export function useListItem( }); }); + const accessibleName = derived(() => { + if (!isIconOnly) { + return undefined; + } + + if (typeof merged.primary === "string") { + return merged.primary; + } + + return undefined; + }); + const interactiveBind = derived(() => { const interactive = merged.interactive || isListboxOption; @@ -234,6 +262,7 @@ export function useListItem( customProps?.interactive, {}, { + "aria-label": accessibleName, role: isListboxOption ? "option" : merged.role, "aria-disabled": merged.disabled ? true : undefined, tabIndex: merged.disabled || isListboxOption ? -1 : 0, @@ -254,7 +283,8 @@ export function useListItem( className: cn({ "flex w-full min-w-0 items-center gap-x-3 text-left text-dark-900 outline-hidden transition-colors dark:text-dark-100": true, "cursor-pointer select-none": !merged.disabled, - "px-4": true, + "px-4": !isIconOnly, + "justify-center px-2": isIconOnly, "py-2": !isDense, "py-1.5": isDense, "hover:bg-black/5 focus-visible:bg-black/5 dark:hover:bg-white/10 dark:focus-visible:bg-white/10": @@ -285,7 +315,8 @@ export function useListItem( return cn({ "flex w-full min-w-0 gap-x-3": true, "items-center text-dark-900 dark:text-dark-100": !merged.interactive, - "px-4": !merged.interactive, + "px-4": !merged.interactive && !isIconOnly, + "justify-center px-2": !merged.interactive && isIconOnly, "py-2": !merged.interactive && !isDense, "py-1.5": !merged.interactive && isDense, }); @@ -397,6 +428,7 @@ export function useListItem( rootBind, startBind, hasPrimary, + isIconOnly, contentBind, primaryBind, rowClassName, diff --git a/packages/react/src/Components/ListSection/ListSection.tsx b/packages/react/src/Components/ListSection/ListSection.tsx index 7b5fabab..5e6d2c51 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, titleBind, isIconOnly } = + useListSection(props); + + if (isIconOnly) { + return null; + } if (merged.as === "div") { return
{label}
; diff --git a/packages/react/src/Components/ListSection/__tests__/ListSection.test.tsx b/packages/react/src/Components/ListSection/__tests__/ListSection.test.tsx index 3703b30b..6931b248 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(); @@ -73,3 +77,13 @@ test("it should render a div root when as prop is div", () => { expect(container.querySelector("li")).toBeNull(); expect(container.querySelector("div[role='presentation']")).not.toBeNull(); }); + +test("it should hide the section when parent List is iconOnly", () => { + render( + + + , + ); + + expect(screen.queryByText("Application")).toBeNull(); +}); diff --git a/packages/react/src/Components/ListSection/hooks/useListSection.ts b/packages/react/src/Components/ListSection/hooks/useListSection.ts index ce8ed71b..6144e3bc 100644 --- a/packages/react/src/Components/ListSection/hooks/useListSection.ts +++ b/packages/react/src/Components/ListSection/hooks/useListSection.ts @@ -66,6 +66,10 @@ export function useListSection(props: ListSectionProps) { return listContext?.dense ?? false; }); + const isIconOnly = derived(() => { + return listContext?.iconOnly ?? false; + }); + const label = derived(() => { return merged.title ?? children; }); @@ -110,5 +114,6 @@ export function useListSection(props: ListSectionProps) { merged, rootBind, titleBind, + isIconOnly, }; } diff --git a/packages/vue/docs/components/List.md b/packages/vue/docs/components/List.md index dc21ef03..0801aa87 100644 --- a/packages/vue/docs/components/List.md +++ b/packages/vue/docs/components/List.md @@ -123,6 +123,21 @@ With the default `as="li"`, sticky styles apply on the section root. The list (o ``` +### Icon only + +Hide section labels and item text so only the `start` slot remains. Use with `Sidebar` `collapsible="icon"`. + +```vue + + + + + + +``` + ## Props (`List`) | Prop | Type | Default | Description | @@ -131,6 +146,7 @@ With the default `as="li"`, sticky styles apply on the section root. The list (o | `classes` | `ListClasses` | — | The classes to apply to the list. | | `customProps` | `ListCustomProps` | — | Props forwarded to each list part. | | `dense` | `boolean` | `false` | Compact vertical spacing on items (`ListItem` / `ListSection`), not the list root. | +| `iconOnly` | `boolean` | `false` | Hide section labels and item text so only leading icons remain. | | `nested` | `boolean` | `false` | When true, indents the list for nested navigation/submenus. | ## Props (`ListItem`) @@ -164,4 +180,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/src/Components/List/List.vue b/packages/vue/src/Components/List/List.vue index 399536f7..8c32c863 100644 --- a/packages/vue/src/Components/List/List.vue +++ b/packages/vue/src/Components/List/List.vue @@ -12,6 +12,7 @@ const props = withDefaults(defineProps(), { as: "ul", dense: false, nested: false, + iconOnly: false, }); const { merged, rootBind } = useList(props); diff --git a/packages/vue/src/Components/List/__tests__/useList.test.ts b/packages/vue/src/Components/List/__tests__/useList.test.ts index ae69ba80..ad2ac948 100644 --- a/packages/vue/src/Components/List/__tests__/useList.test.ts +++ b/packages/vue/src/Components/List/__tests__/useList.test.ts @@ -66,6 +66,34 @@ test("it should provide dense context to descendants", () => { expect(injectedDense).toBe("true"); }); +test("it should provide iconOnly context to descendants", () => { + let injectedIconOnly = "missing"; + + const Probe = defineComponent({ + setup() { + const context = inject(LIST_INJECTION_KEY, null); + + injectedIconOnly = String( + context ? toValue(context).iconOnly : "missing", + ); + + return () => h("div"); + }, + }); + + const Wrapper = defineComponent({ + setup() { + useList({ iconOnly: true }); + + return () => h(Probe); + }, + }); + + mount(Wrapper); + + expect(injectedIconOnly).toBe("true"); +}); + test("it should merge class into root bind", () => { const { rootBind } = mountUseList({ class: "custom-list" }); diff --git a/packages/vue/src/Components/List/composables/useList.ts b/packages/vue/src/Components/List/composables/useList.ts index 607a97ae..0e8bea06 100644 --- a/packages/vue/src/Components/List/composables/useList.ts +++ b/packages/vue/src/Components/List/composables/useList.ts @@ -19,6 +19,7 @@ const listBridgeKeys = [ "dense", "nested", "classes", + "iconOnly", "customProps", ] as const satisfies readonly (keyof ListOwnProps)[]; @@ -52,6 +53,7 @@ export function useList(props: ListOwnProps) { const contextValue = computed(() => { return { dense: merged.value.dense === true, + iconOnly: merged.value.iconOnly === true, }; }); diff --git a/packages/vue/src/Components/List/list.types.ts b/packages/vue/src/Components/List/list.types.ts index 86ab3396..7a58777e 100644 --- a/packages/vue/src/Components/List/list.types.ts +++ b/packages/vue/src/Components/List/list.types.ts @@ -50,6 +50,14 @@ export interface ListOwnProps { */ dense?: boolean; + /** + * Hide section labels and item text so only leading icons remain. + * Bind from `useSidebar().state === "collapsed"` when the sidebar uses `collapsible="icon"`. + * + * @default false + */ + iconOnly?: boolean; + /** * When true, indents the list for nested navigation/submenus. * diff --git a/packages/vue/src/Components/List/listInjectionKey.ts b/packages/vue/src/Components/List/listInjectionKey.ts index 2b26e1ad..e243b7a7 100644 --- a/packages/vue/src/Components/List/listInjectionKey.ts +++ b/packages/vue/src/Components/List/listInjectionKey.ts @@ -3,6 +3,7 @@ import type { ComputedRef, InjectionKey } from "vue"; export type ListContextValue = { dense: boolean; + iconOnly: boolean; }; export const LIST_INJECTION_KEY = Symbol("bridge-list") as InjectionKey< diff --git a/packages/vue/src/Components/ListItem/ListItem.vue b/packages/vue/src/Components/ListItem/ListItem.vue index 2cfe2bfc..90a5a441 100644 --- a/packages/vue/src/Components/ListItem/ListItem.vue +++ b/packages/vue/src/Components/ListItem/ListItem.vue @@ -34,6 +34,7 @@ const { rowClass, startBind, hasPrimary, + isIconOnly, contentBind, primaryBind, hasSecondary, @@ -56,7 +57,7 @@ const rootTag = computed(() => {
-
+
@@ -93,7 +94,7 @@ const rootTag = computed(() => {
-
+
diff --git a/packages/vue/src/Components/ListItem/__tests__/ListItem.test.ts b/packages/vue/src/Components/ListItem/__tests__/ListItem.test.ts index c445439e..5fef33ec 100644 --- a/packages/vue/src/Components/ListItem/__tests__/ListItem.test.ts +++ b/packages/vue/src/Components/ListItem/__tests__/ListItem.test.ts @@ -123,3 +123,19 @@ test("it should apply divider border on the root item", () => { expect(wrapper.classes().join(" ")).toContain("border-b"); }); + +test("it should hide primary text and set aria-label when List is iconOnly", () => { + const wrapper = mount( + defineComponent({ + components: { List, ListItem }, + template: ` + + + + `, + }), + ); + + expect(wrapper.text()).not.toContain("Home"); + expect(wrapper.find('[role="button"]').attributes("aria-label")).toBe("Home"); +}); diff --git a/packages/vue/src/Components/ListItem/composables/useListItem.ts b/packages/vue/src/Components/ListItem/composables/useListItem.ts index 1ff10210..e3471c57 100644 --- a/packages/vue/src/Components/ListItem/composables/useListItem.ts +++ b/packages/vue/src/Components/ListItem/composables/useListItem.ts @@ -182,7 +182,15 @@ export function useListItem( return listContext ? toValue(listContext).dense : false; }); + const isIconOnly = computed(() => { + return listContext ? toValue(listContext).iconOnly : false; + }); + const hasPrimary = computed(() => { + if (isIconOnly.value) { + return false; + } + return ( hasNamedSlot(slots, "primary") || hasNamedSlot(slots, "default") || @@ -191,6 +199,10 @@ export function useListItem( }); const hasSecondary = computed(() => { + if (isIconOnly.value) { + return false; + } + return hasNamedSlot(slots, "secondary") || Boolean(merged.value.secondary); }); @@ -215,6 +227,10 @@ export function useListItem( }); const hasEnd = computed(() => { + if (isIconOnly.value) { + return false; + } + return hasNamedSlot(slots, "end") || resolvedSelectedIcon.value != null; }); @@ -261,6 +277,10 @@ export function useListItem( "aria-selected": isListboxOption.value ? listboxSelected.value : undefined, + "aria-label": + isIconOnly.value && typeof merged.value.primary === "string" + ? merged.value.primary + : undefined, onMousedown: isListboxOption.value ? (event: MouseEvent) => { event.preventDefault(); @@ -276,7 +296,8 @@ export function useListItem( class: cn({ "flex w-full min-w-0 items-center gap-x-3 text-left text-dark-900 outline-hidden transition-colors dark:text-dark-100": true, "cursor-pointer select-none": !merged.value.disabled, - "px-4": true, + "px-4": !isIconOnly.value, + "justify-center px-2": isIconOnly.value, "py-2": !isDense.value, "py-1.5": isDense.value, "hover:bg-black/5 focus-visible:bg-black/5 dark:hover:bg-white/10 dark:focus-visible:bg-white/10": @@ -316,7 +337,8 @@ export function useListItem( "flex w-full min-w-0 gap-x-3": true, "items-center text-dark-900 dark:text-dark-100": !merged.value.interactive, - "px-4": !merged.value.interactive, + "px-4": !merged.value.interactive && !isIconOnly.value, + "justify-center px-2": !merged.value.interactive && isIconOnly.value, "py-2": !merged.value.interactive && !isDense.value, "py-1.5": !merged.value.interactive && isDense.value, }); @@ -402,6 +424,7 @@ export function useListItem( rowClass, startBind, hasPrimary, + isIconOnly, contentBind, primaryBind, hasSecondary, diff --git a/packages/vue/src/Components/ListSection/ListSection.vue b/packages/vue/src/Components/ListSection/ListSection.vue index a39ef824..45adae98 100644 --- a/packages/vue/src/Components/ListSection/ListSection.vue +++ b/packages/vue/src/Components/ListSection/ListSection.vue @@ -22,7 +22,7 @@ const props = withDefaults(defineProps(), { sticky: false, }); -const { merged, rootBind, titleBind } = useListSection(props); +const { merged, rootBind, titleBind, isIconOnly } = useListSection(props); const rootTag = computed(() => { return merged.value.as ?? "li"; @@ -30,21 +30,23 @@ const rootTag = computed(() => { diff --git a/packages/vue/src/Components/ListSection/__tests__/ListSection.test.ts b/packages/vue/src/Components/ListSection/__tests__/ListSection.test.ts index 6d1cf507..53a9dfd6 100644 --- a/packages/vue/src/Components/ListSection/__tests__/ListSection.test.ts +++ b/packages/vue/src/Components/ListSection/__tests__/ListSection.test.ts @@ -68,3 +68,14 @@ test("it should render a div root when as prop is div", () => { expect(wrapper.element.tagName).toBe("DIV"); }); + +test("it should hide the section when parent List is iconOnly", () => { + const Host = defineComponent({ + components: { List, ListSection }, + template: '', + }); + + const wrapper = mount(Host); + + expect(wrapper.text()).not.toContain("Application"); +}); diff --git a/packages/vue/src/Components/ListSection/composables/useListSection.ts b/packages/vue/src/Components/ListSection/composables/useListSection.ts index 2ab1fb18..73bd357f 100644 --- a/packages/vue/src/Components/ListSection/composables/useListSection.ts +++ b/packages/vue/src/Components/ListSection/composables/useListSection.ts @@ -59,6 +59,10 @@ export function useListSection(props: ListSectionOwnProps) { return listContext ? toValue(listContext).dense : false; }); + const isIconOnly = computed(() => { + return listContext ? toValue(listContext).iconOnly : false; + }); + const rootInheritedAttrs = computed(() => { return omit(split.value.inheritedAttrs, []); }); @@ -102,5 +106,6 @@ export function useListSection(props: ListSectionOwnProps) { merged, rootBind, titleBind, + isIconOnly, }; } From 006aa430fc65b79c51dc6e53c1e15d61ed2478d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Lopes?= Date: Sun, 30 Aug 2026 15:01:08 -0300 Subject: [PATCH 03/22] feat(react): add Sidebar component Provide a persistent app-shell rail with provider, inset, and trigger. --- .../react/src/Components/Sidebar/Sidebar.tsx | 101 +++++++ .../src/Components/Sidebar/SidebarContext.tsx | 122 ++++++++ .../src/Components/Sidebar/SidebarInset.tsx | 11 + .../Components/Sidebar/SidebarProvider.tsx | 23 ++ .../src/Components/Sidebar/SidebarTrigger.tsx | 31 ++ .../Sidebar/__tests__/Sidebar.cy.tsx | 58 ++++ .../Sidebar/__tests__/Sidebar.test.tsx | 131 ++++++++ .../Sidebar/__tests__/useSidebar.test.tsx | 62 ++++ .../Components/Sidebar/hooks/useSidebar.ts | 3 + .../Sidebar/hooks/useSidebarInset.ts | 80 +++++ .../Sidebar/hooks/useSidebarProvider.ts | 209 +++++++++++++ .../Sidebar/hooks/useSidebarShell.ts | 256 ++++++++++++++++ .../Sidebar/hooks/useSidebarTrigger.ts | 63 ++++ .../react/src/Components/Sidebar/index.ts | 36 +++ .../src/Components/Sidebar/sidebar.types.ts | 279 ++++++++++++++++++ packages/react/src/augments.ts | 15 + packages/react/src/index.ts | 33 +++ 17 files changed, 1513 insertions(+) create mode 100644 packages/react/src/Components/Sidebar/Sidebar.tsx create mode 100644 packages/react/src/Components/Sidebar/SidebarContext.tsx create mode 100644 packages/react/src/Components/Sidebar/SidebarInset.tsx create mode 100644 packages/react/src/Components/Sidebar/SidebarProvider.tsx create mode 100644 packages/react/src/Components/Sidebar/SidebarTrigger.tsx create mode 100644 packages/react/src/Components/Sidebar/__tests__/Sidebar.cy.tsx create mode 100644 packages/react/src/Components/Sidebar/__tests__/Sidebar.test.tsx create mode 100644 packages/react/src/Components/Sidebar/__tests__/useSidebar.test.tsx create mode 100644 packages/react/src/Components/Sidebar/hooks/useSidebar.ts create mode 100644 packages/react/src/Components/Sidebar/hooks/useSidebarInset.ts create mode 100644 packages/react/src/Components/Sidebar/hooks/useSidebarProvider.ts create mode 100644 packages/react/src/Components/Sidebar/hooks/useSidebarShell.ts create mode 100644 packages/react/src/Components/Sidebar/hooks/useSidebarTrigger.ts create mode 100644 packages/react/src/Components/Sidebar/index.ts create mode 100644 packages/react/src/Components/Sidebar/sidebar.types.ts diff --git a/packages/react/src/Components/Sidebar/Sidebar.tsx b/packages/react/src/Components/Sidebar/Sidebar.tsx new file mode 100644 index 00000000..c7b2b800 --- /dev/null +++ b/packages/react/src/Components/Sidebar/Sidebar.tsx @@ -0,0 +1,101 @@ +// ** 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, + 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/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..bbc4f92f --- /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..784937ec --- /dev/null +++ b/packages/react/src/Components/Sidebar/__tests__/Sidebar.cy.tsx @@ -0,0 +1,58 @@ +// ** 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 slot content", () => { + cy.mount( + + Brand
, + }} + > + Nav + + + + + , + ); + + cy.contains("Brand").should("exist"); +}); 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..07f4c30c --- /dev/null +++ b/packages/react/src/Components/Sidebar/__tests__/Sidebar.test.tsx @@ -0,0 +1,131 @@ +// ** External Imports +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, test } from "vitest"; + +afterEach(() => { + cleanup(); +}); + +// ** Local Imports +import { + Sidebar, + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/Components/Sidebar"; + +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.getByRole("complementary", { name: "Sidebar" })).toBeTruthy(); + expect(screen.getByText("Home")).toBeTruthy(); + expect(screen.getByText("Main")).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 apply data-side from the side prop", () => { + const { container } = render( + + Nav + + + + , + ); + + expect(container.querySelector('[data-side="right"]')).not.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/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/useSidebarProvider.ts b/packages/react/src/Components/Sidebar/hooks/useSidebarProvider.ts new file mode 100644 index 00000000..bd67ad0c --- /dev/null +++ b/packages/react/src/Components/Sidebar/hooks/useSidebarProvider.ts @@ -0,0 +1,209 @@ +// ** 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_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 = breakpoint.mobile; + + const isOpenControlled = props.open !== undefined; + const [uncontrolledOpen, setUncontrolledOpen] = useState( + () => merged.defaultOpen, + ); + const open = 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 = 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..54791af3 --- /dev/null +++ b/packages/react/src/Components/Sidebar/hooks/useSidebarShell.ts @@ -0,0 +1,256 @@ +// ** External Imports +import { get, omit } from "es-toolkit/compat"; +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, +} 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, + merged.collapsible, + sidebar.setLayout, + ]); + + 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 = 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(() => { + return mergePartBind( + {}, + {}, + { + "data-side": merged.side, + "aria-label": merged.ariaLabel, + id: showAsDrawer ? undefined : panelId, + className: cn({ + "fixed inset-y-0 z-10 hidden h-svh w-[var(--bridge-sidebar-width)] transition-[inset-inline-start,inset-inline-end,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": true, + [get(variantItem, "panel") ?? ""]: true, + [get(mergedClasses, "panel") ?? ""]: true, + }), + ); + }); + + const headerBind = derived(() => { + return mergePartBind( + customProps?.header, + {}, + cn({ + "flex shrink-0 flex-col gap-2 p-2": 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 p-2": true, + [get(mergedClasses, "footer") ?? ""]: true, + }), + ); + }); + + return { + slots, + merged, + panelId, + gapBind, + children, + rootBind, + asideBind, + panelBind, + headerBind, + footerBind, + contentBind, + 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..17639f7d --- /dev/null +++ b/packages/react/src/Components/Sidebar/index.ts @@ -0,0 +1,36 @@ +// ** Exports +export { useSidebarInset } from "@/Components/Sidebar/hooks/useSidebarInset"; +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, + SidebarOwnProps, + SidebarProps, + SidebarProviderCallbacks, + SidebarProviderClasses, + SidebarProviderCustomProps, + SidebarProviderOwnProps, + SidebarProviderProps, + SidebarSideOverrides, + SidebarSlots, + SidebarTriggerOwnProps, + SidebarTriggerProps, + SidebarVariantOverrides, +} from "@/Components/Sidebar/sidebar.types"; +export { + SidebarContext, + useSidebar, + type SidebarContextValue, + type SidebarLayout, +} from "@/Components/Sidebar/SidebarContext"; +export { default as SidebarInset } from "@/Components/Sidebar/SidebarInset"; +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..57dbcea6 --- /dev/null +++ b/packages/react/src/Components/Sidebar/sidebar.types.ts @@ -0,0 +1,279 @@ +// ** External Imports +import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react"; + +// ** Core Imports +import type { + SidebarCollapsible, + SidebarSide, + SidebarVariant, +} from "@bridge-ui/core/Tokens"; +import type { MergeHtmlProps, MergeProps } from "@bridge-ui/core/Utils"; + +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; +} + +/** + * Persistent app-shell sidebar panel. Mount under `SidebarProvider` with + * `SidebarInset`. Put `List` / `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 +>; 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..6e8caf8f 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -501,6 +501,39 @@ export type { SelectSlots, SelectValue, } from "@/Components/Select"; +export { + Sidebar, + SidebarInset, + SidebarProvider, + SidebarTrigger, + useSidebar, + useSidebarInset, + useSidebarProvider, + useSidebarShell, + useSidebarTrigger, +} from "@/Components/Sidebar"; +export type { + SidebarClasses, + SidebarCollapsibleOverrides, + SidebarContextValue, + SidebarCustomProps, + SidebarInsetClasses, + SidebarInsetCustomProps, + SidebarInsetOwnProps, + SidebarInsetProps, + SidebarOwnProps, + SidebarProps, + SidebarProviderCallbacks, + SidebarProviderClasses, + SidebarProviderCustomProps, + SidebarProviderOwnProps, + SidebarProviderProps, + SidebarSideOverrides, + SidebarSlots, + SidebarTriggerOwnProps, + SidebarTriggerProps, + SidebarVariantOverrides, +} from "@/Components/Sidebar"; export { Skeleton, useSkeleton } from "@/Components/Skeleton"; export type { SkeletonClasses, From ee7645f8ec75c4d695f57961baca177da541cb5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Lopes?= Date: Sun, 30 Aug 2026 15:01:09 -0300 Subject: [PATCH 04/22] feat(vue): add Sidebar component Match the React Sidebar shell with provider, inset, and trigger. --- .../vue/src/Components/Sidebar/Sidebar.vue | 97 +++++++ .../src/Components/Sidebar/SidebarInset.vue | 22 ++ .../Components/Sidebar/SidebarProvider.vue | 36 +++ .../src/Components/Sidebar/SidebarTrigger.vue | 48 ++++ .../Sidebar/__tests__/Sidebar.cy.ts | 64 +++++ .../Sidebar/__tests__/Sidebar.test.ts | 103 +++++++ .../Sidebar/__tests__/useSidebar.test.ts | 95 +++++++ .../Sidebar/composables/useSidebar.ts | 23 ++ .../Sidebar/composables/useSidebarInset.ts | 78 ++++++ .../Sidebar/composables/useSidebarProvider.ts | 222 +++++++++++++++ .../Sidebar/composables/useSidebarShell.ts | 251 +++++++++++++++++ .../Sidebar/composables/useSidebarTrigger.ts | 64 +++++ packages/vue/src/Components/Sidebar/index.ts | 39 +++ .../src/Components/Sidebar/sidebar.types.ts | 263 ++++++++++++++++++ .../Components/Sidebar/sidebarInjectionKey.ts | 106 +++++++ packages/vue/src/augments.ts | 15 + packages/vue/src/index.ts | 36 +++ 17 files changed, 1562 insertions(+) create mode 100644 packages/vue/src/Components/Sidebar/Sidebar.vue create mode 100644 packages/vue/src/Components/Sidebar/SidebarInset.vue create mode 100644 packages/vue/src/Components/Sidebar/SidebarProvider.vue create mode 100644 packages/vue/src/Components/Sidebar/SidebarTrigger.vue create mode 100644 packages/vue/src/Components/Sidebar/__tests__/Sidebar.cy.ts create mode 100644 packages/vue/src/Components/Sidebar/__tests__/Sidebar.test.ts create mode 100644 packages/vue/src/Components/Sidebar/__tests__/useSidebar.test.ts create mode 100644 packages/vue/src/Components/Sidebar/composables/useSidebar.ts create mode 100644 packages/vue/src/Components/Sidebar/composables/useSidebarInset.ts create mode 100644 packages/vue/src/Components/Sidebar/composables/useSidebarProvider.ts create mode 100644 packages/vue/src/Components/Sidebar/composables/useSidebarShell.ts create mode 100644 packages/vue/src/Components/Sidebar/composables/useSidebarTrigger.ts create mode 100644 packages/vue/src/Components/Sidebar/index.ts create mode 100644 packages/vue/src/Components/Sidebar/sidebar.types.ts create mode 100644 packages/vue/src/Components/Sidebar/sidebarInjectionKey.ts diff --git a/packages/vue/src/Components/Sidebar/Sidebar.vue b/packages/vue/src/Components/Sidebar/Sidebar.vue new file mode 100644 index 00000000..4260aa9e --- /dev/null +++ b/packages/vue/src/Components/Sidebar/Sidebar.vue @@ -0,0 +1,97 @@ + + + diff --git a/packages/vue/src/Components/Sidebar/SidebarInset.vue b/packages/vue/src/Components/Sidebar/SidebarInset.vue new file mode 100644 index 00000000..cf955bee --- /dev/null +++ b/packages/vue/src/Components/Sidebar/SidebarInset.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/vue/src/Components/Sidebar/SidebarProvider.vue b/packages/vue/src/Components/Sidebar/SidebarProvider.vue new file mode 100644 index 00000000..6f25f05e --- /dev/null +++ b/packages/vue/src/Components/Sidebar/SidebarProvider.vue @@ -0,0 +1,36 @@ + + + diff --git a/packages/vue/src/Components/Sidebar/SidebarTrigger.vue b/packages/vue/src/Components/Sidebar/SidebarTrigger.vue new file mode 100644 index 00000000..0b17c7f9 --- /dev/null +++ b/packages/vue/src/Components/Sidebar/SidebarTrigger.vue @@ -0,0 +1,48 @@ + + + diff --git a/packages/vue/src/Components/Sidebar/__tests__/Sidebar.cy.ts b/packages/vue/src/Components/Sidebar/__tests__/Sidebar.cy.ts new file mode 100644 index 00000000..0751ff58 --- /dev/null +++ b/packages/vue/src/Components/Sidebar/__tests__/Sidebar.cy.ts @@ -0,0 +1,64 @@ +// ** External Imports +import { h } from "vue"; + +// ** Local Imports +import { + Sidebar, + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/Components/Sidebar"; + +test("it should render the sidebar and inset", () => { + cy.mount(SidebarProvider, { + slots: { + default: () => [ + h(Sidebar, null, { default: () => "Home" }), + h(SidebarInset, null, { + default: () => [h(SidebarTrigger), "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(SidebarProvider, { + slots: { + default: () => [ + h(Sidebar, null, { default: () => "Home" }), + h(SidebarInset, null, { + default: () => [h(SidebarTrigger), "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 slot content", () => { + cy.mount(SidebarProvider, { + slots: { + default: () => [ + h( + Sidebar, + {}, + { + header: () => h("div", "Brand"), + default: () => "Nav", + }, + ), + h(SidebarInset, null, { default: () => h(SidebarTrigger) }), + ], + }, + }); + + cy.contains("Brand").should("exist"); +}); diff --git a/packages/vue/src/Components/Sidebar/__tests__/Sidebar.test.ts b/packages/vue/src/Components/Sidebar/__tests__/Sidebar.test.ts new file mode 100644 index 00000000..4b018576 --- /dev/null +++ b/packages/vue/src/Components/Sidebar/__tests__/Sidebar.test.ts @@ -0,0 +1,103 @@ +// ** External Imports +import { mount } from "@vue/test-utils"; +import { expect, test } from "vitest"; +import { defineComponent, h, nextTick } from "vue"; + +// ** Local Imports +import { + Sidebar, + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/Components/Sidebar"; + +const AppShell = defineComponent({ + props: { + collapsible: { + type: String, + default: "offcanvas", + }, + }, + setup(props) { + return () => + h(SidebarProvider, null, { + default: () => [ + h( + Sidebar, + { collapsible: props.collapsible as "offcanvas" }, + { default: () => h("nav", "Home") }, + ), + h(SidebarInset, null, { + default: () => [h(SidebarTrigger), h("p", "Main")], + }), + ], + }); + }, +}); + +test("it should render the sidebar aside and main inset", () => { + const wrapper = mount(AppShell); + + expect(wrapper.find("aside").exists()).toBe(true); + expect(wrapper.text()).toContain("Home"); + expect(wrapper.text()).toContain("Main"); +}); + +test("it should default to expanded desktop state", () => { + const wrapper = mount(AppShell); + + expect(wrapper.find("[data-state='expanded']").exists()).toBe(true); +}); + +test("it should toggle desktop open when the trigger is clicked", async () => { + const wrapper = mount(AppShell); + + await wrapper.get("button[aria-label='Toggle sidebar']").trigger("click"); + + expect(wrapper.find("[data-state='collapsed']").exists()).toBe(true); +}); + +test("it should keep expanded state when collapsible is none", async () => { + const wrapper = mount(AppShell, { props: { collapsible: "none" } }); + + await wrapper.get("button[aria-label='Toggle sidebar']").trigger("click"); + + expect(wrapper.find("[data-state='expanded']").exists()).toBe(true); + expect(wrapper.find("[data-state='collapsed']").exists()).toBe(false); +}); + +test("it should render header and footer slots", () => { + const wrapper = mount(SidebarProvider, { + slots: { + default: () => [ + h( + Sidebar, + {}, + { + footer: () => h("div", "User"), + header: () => h("div", "Brand"), + default: () => "Nav", + }, + ), + h(SidebarInset, null, { + default: () => h(SidebarTrigger), + }), + ], + }, + }); + + expect(wrapper.text()).toContain("Brand"); + expect(wrapper.text()).toContain("User"); +}); + +test("it should mark the trigger as expanded by default", async () => { + const wrapper = mount(AppShell); + + await nextTick(); + + expect( + wrapper + .get("button[aria-label='Toggle sidebar']") + .attributes("aria-expanded"), + ).toBe("true"); +}); diff --git a/packages/vue/src/Components/Sidebar/__tests__/useSidebar.test.ts b/packages/vue/src/Components/Sidebar/__tests__/useSidebar.test.ts new file mode 100644 index 00000000..9b92dd06 --- /dev/null +++ b/packages/vue/src/Components/Sidebar/__tests__/useSidebar.test.ts @@ -0,0 +1,95 @@ +// ** External Imports +import { mount } from "@vue/test-utils"; +import { expect, test } from "vitest"; +import { defineComponent, h, nextTick } from "vue"; + +// ** Local Imports +import { Sidebar, SidebarProvider, useSidebar } from "@/Components/Sidebar"; + +test("it should throw when used outside SidebarProvider", () => { + const Orphan = defineComponent({ + setup() { + useSidebar(); + + return () => h("div"); + }, + }); + + expect(() => { + mount(Orphan); + }).toThrow("useSidebar must be used within a SidebarProvider"); +}); + +test("it should default to open expanded state", () => { + let sidebar!: ReturnType; + + const Probe = defineComponent({ + setup() { + sidebar = useSidebar(); + + return () => h("div"); + }, + }); + + mount(SidebarProvider, { + slots: { + default: () => h(Sidebar, null, { default: () => h(Probe) }), + }, + }); + + expect(sidebar.value.open).toBe(true); + expect(sidebar.value.state).toBe("expanded"); + expect(sidebar.value.openMobile).toBe(false); +}); + +test("it should toggle desktop open", async () => { + let sidebar!: ReturnType; + + const Probe = defineComponent({ + setup() { + sidebar = useSidebar(); + + return () => h("div"); + }, + }); + + mount(SidebarProvider, { + slots: { + default: () => h(Sidebar, null, { default: () => h(Probe) }), + }, + }); + + sidebar.value.toggleSidebar(); + await nextTick(); + + expect(sidebar.value.open).toBe(false); + expect(sidebar.value.state).toBe("collapsed"); +}); + +test("it should expose side and collapsible from Sidebar", async () => { + let sidebar!: ReturnType; + + const Probe = defineComponent({ + setup() { + sidebar = useSidebar(); + + return () => h("div"); + }, + }); + + mount(SidebarProvider, { + slots: { + default: () => + h( + Sidebar, + { side: "right", collapsible: "icon" }, + { default: () => h(Probe) }, + ), + }, + }); + + await nextTick(); + + expect(sidebar.value.side).toBe("right"); + expect(sidebar.value.collapsible).toBe("icon"); +}); diff --git a/packages/vue/src/Components/Sidebar/composables/useSidebar.ts b/packages/vue/src/Components/Sidebar/composables/useSidebar.ts new file mode 100644 index 00000000..29bbae08 --- /dev/null +++ b/packages/vue/src/Components/Sidebar/composables/useSidebar.ts @@ -0,0 +1,23 @@ +// ** External Imports +import { inject } from "vue"; + +// ** Local Imports +import { + SIDEBAR_INJECTION_KEY, + type SidebarContextValue, +} from "@/Components/Sidebar/sidebarInjectionKey"; + +/** + * Reads the nearest `SidebarProvider` context. Throws when used outside it. + */ +export function useSidebar() { + const context = inject(SIDEBAR_INJECTION_KEY); + + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider"); + } + + return context; +} + +export type { SidebarContextValue }; diff --git a/packages/vue/src/Components/Sidebar/composables/useSidebarInset.ts b/packages/vue/src/Components/Sidebar/composables/useSidebarInset.ts new file mode 100644 index 00000000..25397ce6 --- /dev/null +++ b/packages/vue/src/Components/Sidebar/composables/useSidebarInset.ts @@ -0,0 +1,78 @@ +// ** External Imports +import { get, omit } from "es-toolkit/compat"; +import { computed, useAttrs } from "vue"; + +// ** Core Imports +import { sidebarVariantProps as variantProps } from "@bridge-ui/core/Tokens"; +import { + cn, + mergeBridgeUILayeredClasses, + splitComponentProps, +} from "@bridge-ui/core/Utils"; + +// ** Local Imports +import { useSidebar } from "@/Components/Sidebar/composables/useSidebar"; +import type { + SidebarInsetOwnProps, + SidebarInsetProps, +} from "@/Components/Sidebar/sidebar.types"; +import { mergePartBind, useBridgeUIComponent } from "@/Utils"; + +const sidebarInsetBridgeKeys = [ + "classes", + "customProps", +] as const satisfies readonly (keyof SidebarInsetOwnProps)[]; + +export function useSidebarInset(props: SidebarInsetOwnProps) { + const sidebar = useSidebar(); + const attrs = useAttrs(); + + const split = computed(() => { + return splitComponentProps< + SidebarInsetProps, + typeof sidebarInsetBridgeKeys + >({ + props: { ...attrs, ...props }, + bridgeKeys: sidebarInsetBridgeKeys, + }); + }); + + const { merged, entry: bridgeSidebar } = useBridgeUIComponent< + SidebarInsetOwnProps, + "Sidebar" + >({ + componentName: "Sidebar", + props: () => split.value.componentProps, + }); + + const customProps = computed(() => { + return merged.value.customProps as SidebarInsetOwnProps["customProps"]; + }); + + const variantItem = computed(() => { + const classes = mergeBridgeUILayeredClasses( + variantProps, + bridgeSidebar.value?.tokens?.variant, + ); + + return get(classes, sidebar.value.variant); + }); + + const rootInheritedAttrs = computed(() => { + return omit(split.value.inheritedAttrs, []); + }); + + const rootBind = computed(() => { + return mergePartBind(customProps.value?.root, rootInheritedAttrs.value, { + class: cn({ + [get(variantItem.value, "inset") ?? ""]: true, + [get(merged.value.classes, "root") ?? ""]: true, + }), + }); + }); + + return { + merged, + rootBind, + }; +} diff --git a/packages/vue/src/Components/Sidebar/composables/useSidebarProvider.ts b/packages/vue/src/Components/Sidebar/composables/useSidebarProvider.ts new file mode 100644 index 00000000..aea153e7 --- /dev/null +++ b/packages/vue/src/Components/Sidebar/composables/useSidebarProvider.ts @@ -0,0 +1,222 @@ +// ** External Imports +import { get, omit } from "es-toolkit/compat"; +import { toMerged } from "es-toolkit/object"; +import { + computed, + provide, + ref, + useAttrs, + type Ref, + type SetupContext, +} from "vue"; + +// ** Core Imports +import { + resolveSidebarState, + shouldToggleDesktopSidebar, + 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 { + SidebarProviderEmits, + SidebarProviderOwnProps, + SidebarProviderProps, +} from "@/Components/Sidebar/sidebar.types"; +import { + SIDEBAR_INJECTION_KEY, + type SidebarContextValue, + type SidebarLayout, +} from "@/Components/Sidebar/sidebarInjectionKey"; +import { + mergePartBind, + useBreakpoint, + useBridgeUIComponent, + useBridgeUIMergedRegistryClasses, +} from "@/Utils"; + +const sidebarProviderBridgeKeys = [ + "classes", + "customProps", + "defaultOpen", +] as const satisfies readonly (keyof SidebarProviderOwnProps)[]; + +type SidebarProviderLibDefaults = LibDefaultsShape< + SidebarProviderOwnProps, + "defaultOpen" +>; + +type SidebarProviderMerged = MergeLibDefaults< + SidebarProviderOwnProps, + SidebarProviderLibDefaults +>; + +const defaultLayout: SidebarLayout = { + panelId: "", + side: "left", + variant: "sidebar", + collapsible: "offcanvas", +}; + +export function useSidebarProvider( + props: SidebarProviderOwnProps, + libDefaults: SidebarProviderLibDefaults, + openModel: Ref, + emit: SetupContext["emit"], +) { + const attrs = useAttrs(); + const breakpoint = useBreakpoint(); + + const split = computed(() => { + return splitComponentProps< + SidebarProviderProps, + typeof sidebarProviderBridgeKeys + >({ + props: { ...attrs, ...props }, + bridgeKeys: sidebarProviderBridgeKeys, + }); + }); + + const { merged, entry: bridgeSidebar } = useBridgeUIComponent< + SidebarProviderMerged, + "Sidebar" + >({ + libDefaults, + componentName: "Sidebar", + props: () => split.value.componentProps, + }); + + const customProps = computed(() => { + return merged.value.customProps; + }); + + const mergedClasses = useBridgeUIMergedRegistryClasses({ + entry: bridgeSidebar, + props: () => split.value.componentProps, + }); + + const isMobile = computed(() => { + return breakpoint.mobile; + }); + + const internalOpen = ref(merged.value.defaultOpen); + const openMobile = ref(false); + const layout = ref({ ...defaultLayout }); + + const open = computed(() => { + return openModel.value ?? internalOpen.value; + }); + + const setOpen = (next: boolean) => { + if (openModel.value === undefined) { + internalOpen.value = next; + } + + openModel.value = next; + emit("openChange", next); + }; + + const setOpenMobile = (next: boolean) => { + openMobile.value = next; + }; + + const setLayout = (next: Partial) => { + const mergedLayout = { ...layout.value, ...next }; + + if ( + layout.value.side === mergedLayout.side && + layout.value.panelId === mergedLayout.panelId && + layout.value.variant === mergedLayout.variant && + layout.value.collapsible === mergedLayout.collapsible + ) { + return; + } + + layout.value = mergedLayout; + }; + + const toggleSidebar = () => { + if (isMobile.value) { + setOpenMobile(toggleSidebarOpen(openMobile.value)); + return; + } + + if (!shouldToggleDesktopSidebar(layout.value.collapsible)) { + return; + } + + setOpen(toggleSidebarOpen(open.value)); + }; + + const state = computed(() => { + return resolveSidebarState(open.value, layout.value.collapsible); + }); + + const widthItem = computed((): SidebarWidth => { + return toMerged(widthProps, bridgeSidebar.value?.tokens?.width ?? {}); + }); + + const contextValue = computed((): SidebarContextValue => { + return { + setOpen, + setLayout, + toggleSidebar, + setOpenMobile, + open: open.value, + state: state.value, + isMobile: isMobile.value, + openMobile: openMobile.value, + side: layout.value.side, + panelId: layout.value.panelId, + variant: layout.value.variant, + collapsible: layout.value.collapsible, + }; + }); + + provide(SIDEBAR_INJECTION_KEY, contextValue); + + const rootInheritedAttrs = computed(() => { + return omit(split.value.inheritedAttrs, [ + "modelValue", + "onUpdate:modelValue", + ]); + }); + + const rootBind = computed(() => { + const inheritedStyle = ( + rootInheritedAttrs.value as { style?: Record } + ).style; + + return mergePartBind(customProps.value?.root, rootInheritedAttrs.value, { + "data-side": layout.value.side, + class: cn({ + "flex min-h-svh w-full data-[side=right]:flex-row-reverse": true, + [get(mergedClasses.value, "root") ?? ""]: true, + }), + style: { + ...inheritedStyle, + [SIDEBAR_WIDTH_VAR]: widthItem.value.default, + [SIDEBAR_WIDTH_ICON_VAR]: widthItem.value.icon, + [SIDEBAR_WIDTH_MOBILE_VAR]: widthItem.value.mobile, + }, + }); + }); + + return { + merged, + rootBind, + }; +} diff --git a/packages/vue/src/Components/Sidebar/composables/useSidebarShell.ts b/packages/vue/src/Components/Sidebar/composables/useSidebarShell.ts new file mode 100644 index 00000000..faec743e --- /dev/null +++ b/packages/vue/src/Components/Sidebar/composables/useSidebarShell.ts @@ -0,0 +1,251 @@ +// ** External Imports +import { get, omit } from "es-toolkit/compat"; +import { computed, useAttrs, useId, useSlots, watch } from "vue"; + +// ** Core Imports +import { + getSidebarPanelId, + resolveSidebarCollapsibleData, + shouldRenderSidebarAsDrawer, +} from "@bridge-ui/core/Domain"; +import { + sidebarCollapsibleProps as collapsibleProps, + sidebarSideProps as sideProps, + sidebarVariantProps as variantProps, +} from "@bridge-ui/core/Tokens"; +import { + cn, + mergeBridgeUILayeredClasses, + splitComponentProps, + type LibDefaultsShape, + type MergeLibDefaults, +} from "@bridge-ui/core/Utils"; + +// ** Local Imports +import { useSidebar } from "@/Components/Sidebar/composables/useSidebar"; +import type { + SidebarOwnProps, + SidebarProps, +} from "@/Components/Sidebar/sidebar.types"; +import { + mergePartBind, + useBridgeUIComponent, + useBridgeUIMergedRegistryClasses, +} from "@/Utils"; + +const sidebarBridgeKeys = [ + "side", + "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: SidebarOwnProps, + libDefaults: SidebarLibDefaults, +) { + const vueId = useId(); + const panelId = getSidebarPanelId(`bridge-sidebar${vueId}`); + const attrs = useAttrs(); + const slots = useSlots(); + const sidebar = useSidebar(); + + const split = computed(() => { + return splitComponentProps({ + props: { ...attrs, ...props }, + bridgeKeys: sidebarBridgeKeys, + }); + }); + + const { merged, entry: bridgeSidebar } = useBridgeUIComponent< + SidebarMerged, + "Sidebar" + >({ + libDefaults, + componentName: "Sidebar", + props: () => split.value.componentProps, + }); + + const customProps = computed(() => { + return merged.value.customProps; + }); + + const mergedClasses = useBridgeUIMergedRegistryClasses({ + entry: bridgeSidebar, + props: () => split.value.componentProps, + }); + + watch( + () => ({ + panelId, + side: merged.value.side, + variant: merged.value.variant, + collapsible: merged.value.collapsible, + }), + (layout) => { + sidebar.value.setLayout(layout); + }, + { immediate: true }, + ); + + const variantItem = computed(() => { + const classes = mergeBridgeUILayeredClasses( + variantProps, + bridgeSidebar.value?.tokens?.variant, + ); + + return get(classes, merged.value.variant); + }); + + const collapsibleItem = computed(() => { + const classes = mergeBridgeUILayeredClasses( + collapsibleProps, + bridgeSidebar.value?.tokens?.collapsible, + ); + + return get(classes, merged.value.collapsible); + }); + + const sideClass = computed(() => { + const classes = mergeBridgeUILayeredClasses( + sideProps, + bridgeSidebar.value?.tokens?.side, + ); + + return get(classes, merged.value.side); + }); + + const collapsibleData = computed(() => { + return resolveSidebarCollapsibleData( + sidebar.value.state, + merged.value.collapsible, + ); + }); + + const showAsDrawer = computed(() => { + return ( + shouldRenderSidebarAsDrawer(sidebar.value.isMobile) && + sidebar.value.openMobile + ); + }); + + const rootInheritedAttrs = computed(() => { + return omit(split.value.inheritedAttrs, []); + }); + + const rootBind = computed(() => { + return mergePartBind(customProps.value?.root, rootInheritedAttrs.value, { + "data-state": sidebar.value.state, + "data-side": merged.value.side, + "data-variant": merged.value.variant, + "data-collapsible": collapsibleData.value, + "data-mobile": sidebar.value.isMobile ? "true" : "false", + class: cn({ + "group peer hidden text-dark-900 md:block dark:text-dark-100": true, + [get(mergedClasses.value, "root") ?? ""]: true, + }), + }); + }); + + const gapBind = computed(() => { + return mergePartBind( + customProps.value?.gap, + {}, + cn({ + [get(variantItem.value, "gap") ?? ""]: true, + [get(collapsibleItem.value, "gap") ?? ""]: true, + [get(mergedClasses.value, "gap") ?? ""]: true, + }), + ); + }); + + const asideBind = computed(() => { + return mergePartBind( + {}, + {}, + { + "data-side": merged.value.side, + "aria-label": merged.value.ariaLabel, + id: showAsDrawer.value ? undefined : panelId, + class: cn({ + "fixed inset-y-0 z-10 hidden h-svh w-[var(--bridge-sidebar-width)] transition-[inset-inline-start,inset-inline-end,width] duration-200 ease-linear md:flex": true, + [sideClass.value ?? ""]: true, + [get(collapsibleItem.value, "panel") ?? ""]: true, + }), + }, + ); + }); + + const panelBind = computed(() => { + return mergePartBind( + customProps.value?.panel, + {}, + cn({ + "flex h-full w-full flex-col": true, + [get(variantItem.value, "panel") ?? ""]: true, + [get(mergedClasses.value, "panel") ?? ""]: true, + }), + ); + }); + + const headerBind = computed(() => { + return mergePartBind( + customProps.value?.header, + {}, + cn({ + "flex shrink-0 flex-col gap-2 p-2": true, + [get(mergedClasses.value, "header") ?? ""]: true, + }), + ); + }); + + const contentBind = computed(() => { + return mergePartBind( + customProps.value?.content, + {}, + cn({ + "bridge-scroll-fade-y flex min-h-0 flex-1 flex-col overflow-y-auto": true, + [get(mergedClasses.value, "content") ?? ""]: true, + }), + ); + }); + + const footerBind = computed(() => { + return mergePartBind( + customProps.value?.footer, + {}, + cn({ + "flex shrink-0 flex-col gap-2 p-2": true, + [get(mergedClasses.value, "footer") ?? ""]: true, + }), + ); + }); + + return { + slots, + merged, + panelId, + gapBind, + rootBind, + asideBind, + panelBind, + headerBind, + footerBind, + contentBind, + showAsDrawer, + isMobile: computed(() => sidebar.value.isMobile), + openMobile: computed(() => sidebar.value.openMobile), + setOpenMobile: (next: boolean) => { + sidebar.value.setOpenMobile(next); + }, + }; +} diff --git a/packages/vue/src/Components/Sidebar/composables/useSidebarTrigger.ts b/packages/vue/src/Components/Sidebar/composables/useSidebarTrigger.ts new file mode 100644 index 00000000..a4732a84 --- /dev/null +++ b/packages/vue/src/Components/Sidebar/composables/useSidebarTrigger.ts @@ -0,0 +1,64 @@ +// ** External Imports +import { omit } from "es-toolkit/compat"; +import { computed, useAttrs, type useSlots } from "vue"; + +// ** Local Imports +import { useSidebar } from "@/Components/Sidebar/composables/useSidebar"; +import { hasNamedSlot } from "@/Utils"; + +export function useSidebarTrigger(slots: ReturnType) { + const sidebar = useSidebar(); + const attrs = useAttrs(); + + const hasDefaultSlot = computed(() => { + return hasNamedSlot(slots, "default"); + }); + + const expanded = computed(() => { + return sidebar.value.isMobile + ? sidebar.value.openMobile + : sidebar.value.open; + }); + + const iconClass = computed(() => { + return sidebar.value.side === "right" + ? "rotate-180 rtl:rotate-0" + : "rtl:rotate-180"; + }); + + const ariaLabel = computed(() => { + const label = attrs["aria-label"]; + + return typeof label === "string" ? label : "Toggle sidebar"; + }); + + const rootAttrs = computed(() => { + return omit(attrs, ["onClick", "aria-label"]); + }); + + const handleClick = (event: MouseEvent) => { + const onClick = attrs.onClick; + + if (typeof onClick === "function") { + onClick(event); + } + + if (event.defaultPrevented) { + return; + } + + sidebar.value.toggleSidebar(); + }; + + return { + expanded, + iconClass, + ariaLabel, + handleClick, + hasDefaultSlot, + attrs: rootAttrs, + panelId: computed(() => { + return sidebar.value.panelId; + }), + }; +} diff --git a/packages/vue/src/Components/Sidebar/index.ts b/packages/vue/src/Components/Sidebar/index.ts new file mode 100644 index 00000000..03d31e37 --- /dev/null +++ b/packages/vue/src/Components/Sidebar/index.ts @@ -0,0 +1,39 @@ +// ** Exports +export { useSidebar } from "@/Components/Sidebar/composables/useSidebar"; +export { useSidebarInset } from "@/Components/Sidebar/composables/useSidebarInset"; +export { useSidebarProvider } from "@/Components/Sidebar/composables/useSidebarProvider"; +export { useSidebarShell } from "@/Components/Sidebar/composables/useSidebarShell"; +export { useSidebarTrigger } from "@/Components/Sidebar/composables/useSidebarTrigger"; +export type { + SidebarClasses, + SidebarCollapsibleOverrides, + SidebarCustomProps, + SidebarInsetClasses, + SidebarInsetCustomProps, + SidebarInsetOwnProps, + SidebarInsetProps, + SidebarInsetSlots, + SidebarOwnProps, + SidebarProps, + SidebarProviderClasses, + SidebarProviderCustomProps, + SidebarProviderEmits, + SidebarProviderOwnProps, + SidebarProviderProps, + SidebarProviderSlots, + SidebarSideOverrides, + SidebarSlots, + SidebarTriggerOwnProps, + SidebarTriggerProps, + SidebarTriggerSlots, + SidebarVariantOverrides, +} from "@/Components/Sidebar/sidebar.types"; +export { default as Sidebar } from "@/Components/Sidebar/Sidebar.vue"; +export { + SIDEBAR_INJECTION_KEY, + type SidebarContextValue, + type SidebarLayout, +} from "@/Components/Sidebar/sidebarInjectionKey"; +export { default as SidebarInset } from "@/Components/Sidebar/SidebarInset.vue"; +export { default as SidebarProvider } from "@/Components/Sidebar/SidebarProvider.vue"; +export { default as SidebarTrigger } from "@/Components/Sidebar/SidebarTrigger.vue"; diff --git a/packages/vue/src/Components/Sidebar/sidebar.types.ts b/packages/vue/src/Components/Sidebar/sidebar.types.ts new file mode 100644 index 00000000..e28a5a04 --- /dev/null +++ b/packages/vue/src/Components/Sidebar/sidebar.types.ts @@ -0,0 +1,263 @@ +// ** External Imports +import type { ButtonHTMLAttributes, HTMLAttributes, Slot } from "vue"; + +// ** Core Imports +import type { + SidebarCollapsible, + SidebarSide, + SidebarVariant, +} from "@bridge-ui/core/Tokens"; +import type { MergeHtmlProps, MergeProps } from "@bridge-ui/core/Utils"; + +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 classes to apply to the inset. + * + * @default undefined + */ + classes?: SidebarInsetClasses; + + /** + * Extra props for internal parts. + * + * @default undefined + */ + customProps?: SidebarInsetCustomProps; +} + +export interface SidebarInsetSlots { + /** + * Main content. + */ + default?: Slot; +} + +/** + * Persistent app-shell sidebar panel. Mount under `SidebarProvider` with + * `SidebarInset`. Put `List` / `Accordion` in the default slot. + */ +export interface SidebarOwnProps { + /** + * Accessible name for the desktop `aside` and the mobile drawer. + * + * @default "Sidebar" + */ + ariaLabel?: string; + + /** + * 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; + + /** + * Visual layout of the rail and inset. + * + * @default "sidebar" + */ + variant?: MergeProps; +} + +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 SidebarProviderEmits { + /** + * Emitted when the desktop `open` state should change. + */ + openChange: [open: boolean]; + + /** + * Emitted when `v-model` should update. + */ + "update:modelValue": [open: boolean]; +} + +export interface SidebarProviderOwnProps { + /** + * 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 `v-model` is omitted. + * + * @default true + */ + defaultOpen?: boolean; +} + +export interface SidebarProviderSlots { + /** + * App shell (`Sidebar`, `SidebarInset`, …). + */ + default?: Slot; +} + +export interface SidebarSlots { + /** + * Scrollable rail (`List`, `Accordion`, …). + */ + default?: Slot; + + /** + * Sticky footer (user menu, settings). + */ + footer?: Slot; + + /** + * Sticky header (branding, workspace switcher). + */ + header?: Slot; +} + +export interface SidebarTriggerOwnProps {} + +export interface SidebarTriggerSlots { + /** + * Replaces the default toggle icon. + */ + default?: Slot; +} + +export type SidebarInsetProps = MergeHtmlProps< + SidebarInsetOwnProps, + HTMLAttributes +>; + +export type SidebarProps = MergeHtmlProps; + +export type SidebarProviderProps = MergeHtmlProps< + SidebarProviderOwnProps, + HTMLAttributes +>; + +export type SidebarTriggerProps = MergeHtmlProps< + SidebarTriggerOwnProps, + ButtonHTMLAttributes +>; diff --git a/packages/vue/src/Components/Sidebar/sidebarInjectionKey.ts b/packages/vue/src/Components/Sidebar/sidebarInjectionKey.ts new file mode 100644 index 00000000..9e1d3fc2 --- /dev/null +++ b/packages/vue/src/Components/Sidebar/sidebarInjectionKey.ts @@ -0,0 +1,106 @@ +// ** External Imports +import type { ComputedRef, InjectionKey } from "vue"; + +// ** Core Imports +import type { 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 SIDEBAR_INJECTION_KEY = Symbol("bridge-sidebar") as InjectionKey< + ComputedRef +>; diff --git a/packages/vue/src/augments.ts b/packages/vue/src/augments.ts index d6a743c5..f03743c9 100644 --- a/packages/vue/src/augments.ts +++ b/packages/vue/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/vue/src/index.ts b/packages/vue/src/index.ts index 45ef2797..04c8c79a 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -552,6 +552,42 @@ export type { SelectSlots, SelectValue, } from "@/Components/Select"; +export { + Sidebar, + SidebarInset, + SidebarProvider, + SidebarTrigger, + useSidebar, + useSidebarInset, + useSidebarProvider, + useSidebarShell, + useSidebarTrigger, +} from "@/Components/Sidebar"; +export type { + SidebarClasses, + SidebarCollapsibleOverrides, + SidebarContextValue, + SidebarCustomProps, + SidebarInsetClasses, + SidebarInsetCustomProps, + SidebarInsetOwnProps, + SidebarInsetProps, + SidebarInsetSlots, + SidebarOwnProps, + SidebarProps, + SidebarProviderClasses, + SidebarProviderCustomProps, + SidebarProviderEmits, + SidebarProviderOwnProps, + SidebarProviderProps, + SidebarProviderSlots, + SidebarSideOverrides, + SidebarSlots, + SidebarTriggerOwnProps, + SidebarTriggerProps, + SidebarTriggerSlots, + SidebarVariantOverrides, +} from "@/Components/Sidebar"; export { Skeleton, useSkeleton } from "@/Components/Skeleton"; export type { SkeletonClasses, From a2405553475b7d1d0c14b03c4e2f09754c6188ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Lopes?= Date: Sun, 30 Aug 2026 15:01:09 -0300 Subject: [PATCH 05/22] docs(sidebar): document Sidebar usage Show SidebarProvider composition and how the shell relates to Drawer and List. --- .../ai/skills/bridge-ui-components/SKILL.md | 3 +- packages/react/docs/README.md | 1 + packages/react/docs/components/Drawer.md | 2 +- packages/react/docs/components/Sidebar.md | 225 +++++++++++++++++ .../ai/skills/bridge-ui-components/SKILL.md | 3 +- packages/vue/docs/README.md | 1 + packages/vue/docs/components/Drawer.md | 2 +- packages/vue/docs/components/Sidebar.md | 231 ++++++++++++++++++ 8 files changed, 464 insertions(+), 4 deletions(-) create mode 100644 packages/react/docs/components/Sidebar.md create mode 100644 packages/vue/docs/components/Sidebar.md 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/Sidebar.md b/packages/react/docs/components/Sidebar.md new file mode 100644 index 00000000..2dd4f830 --- /dev/null +++ b/packages/react/docs/components/Sidebar.md @@ -0,0 +1,225 @@ +# Sidebar + +Persistent app-shell rail. Mount `SidebarProvider` around `Sidebar` and `SidebarInset` as siblings. Put `List` / `Accordion` in the rail. On small viewports the panel opens as a `Drawer`. + +## Import + +```ts +import { + Sidebar, + SidebarInset, + 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 + +```tsx + + Acme
, + footer: , + }} + > + + + + + + + {children} + + +``` + +### Icon collapse + +Bind `List` `iconOnly` from `useSidebar` when `collapsible="icon"`. Wrap items in `Tooltip` if you want a label while collapsed. `useSidebar` must run under `SidebarProvider`. + +```tsx +function Nav() { + const { state } = useSidebar(); + + return ( + + + }} + /> + + ); +} + + + +
, + footer:
Account
, }} > Nav @@ -55,6 +56,7 @@ test("it should render header slot content", () => { ); cy.contains("Brand").should("exist"); + cy.contains("Account").should("exist"); }); test("it should inert the aside when offcanvas is collapsed", () => { diff --git a/packages/react/src/Components/Sidebar/hooks/useSidebarShell.ts b/packages/react/src/Components/Sidebar/hooks/useSidebarShell.ts index caabdeb2..558bd80f 100644 --- a/packages/react/src/Components/Sidebar/hooks/useSidebarShell.ts +++ b/packages/react/src/Components/Sidebar/hooks/useSidebarShell.ts @@ -186,7 +186,7 @@ export function useSidebarShell( id: showAsDrawer ? undefined : panelId, inert: offcanvasCollapsed ? true : undefined, className: cn({ - "fixed inset-y-0 z-10 hidden h-svh w-[var(--bridge-sidebar-width)] overflow-hidden transition-[left,right,width] duration-200 ease-linear md:flex": true, + "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, }), diff --git a/packages/vue/src/Components/Sidebar/__tests__/Sidebar.cy.ts b/packages/vue/src/Components/Sidebar/__tests__/Sidebar.cy.ts index 88e3bb21..b0f3dc94 100644 --- a/packages/vue/src/Components/Sidebar/__tests__/Sidebar.cy.ts +++ b/packages/vue/src/Components/Sidebar/__tests__/Sidebar.cy.ts @@ -43,7 +43,7 @@ test("it should collapse when the trigger is clicked", () => { cy.get("[data-state='collapsed']").should("exist"); }); -test("it should render header slot content", () => { +test("it should render header and footer slot content", () => { cy.mount(SidebarProvider, { slots: { default: () => [ @@ -53,6 +53,7 @@ test("it should render header slot content", () => { { default: () => "Nav", header: () => h("div", "Brand"), + footer: () => h("div", "Account"), }, ), h(SidebarInset, null, { default: () => h(SidebarTrigger) }), @@ -61,6 +62,7 @@ test("it should render header slot content", () => { }); cy.contains("Brand").should("exist"); + cy.contains("Account").should("exist"); }); test("it should inert the aside when offcanvas is collapsed", () => { diff --git a/packages/vue/src/Components/Sidebar/composables/useSidebarShell.ts b/packages/vue/src/Components/Sidebar/composables/useSidebarShell.ts index 729ae6d2..05778913 100644 --- a/packages/vue/src/Components/Sidebar/composables/useSidebarShell.ts +++ b/packages/vue/src/Components/Sidebar/composables/useSidebarShell.ts @@ -180,7 +180,7 @@ export function useSidebarShell( inert: offcanvasCollapsed ? true : undefined, id: showAsDrawer.value ? undefined : panelId, class: cn({ - "fixed inset-y-0 z-10 hidden h-svh w-[var(--bridge-sidebar-width)] overflow-hidden transition-[left,right,width] duration-200 ease-linear md:flex": true, + "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.value ?? ""]: true, [get(collapsibleItem.value, "panel") ?? ""]: true, }), From 66960819a4e47acd055c6af3213a02e4fe4402ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Lopes?= Date: Mon, 31 Aug 2026 11:01:54 -0300 Subject: [PATCH 14/22] style(accordion): keep variant maps in a stable key order --- packages/core/src/Tokens/Accordion/Variant.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/core/src/Tokens/Accordion/Variant.ts b/packages/core/src/Tokens/Accordion/Variant.ts index 68dabdc4..3f692ffe 100644 --- a/packages/core/src/Tokens/Accordion/Variant.ts +++ b/packages/core/src/Tokens/Accordion/Variant.ts @@ -55,14 +55,6 @@ export interface AccordionVariant { * Default accordion variant class maps. */ export const variantProps: AccordionVariant = { - "plain": { - "item": "", - "root": "flex flex-col gap-1 px-2 py-2", - "panel": - "ml-3.5 translate-x-px border-l border-dark-200 p-0 py-0.5 pl-2.5 dark:border-dark-700", - "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", - }, "default": { "item": "", "panel": "", @@ -72,10 +64,10 @@ export const variantProps: AccordionVariant = { "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", - "panel": "", "trigger": "text-dark-700 hover:bg-dark-500/5 dark:text-dark-200 dark:hover:bg-dark-500/10", }, @@ -87,4 +79,12 @@ export const variantProps: AccordionVariant = { "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", + "panel": + "ml-3.5 translate-x-px border-l border-dark-200 p-0 py-0.5 pl-2.5 dark:border-dark-700", + "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", + }, }; From 12d201b68fd0dbdd613d14ec1c4bca4bb89b3045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Lopes?= Date: Mon, 31 Aug 2026 11:47:08 -0300 Subject: [PATCH 15/22] fix(sidebar): keep nav chrome on SidebarList Generic List was styling Listbox and Menu like a rail. Compact rows, nested guides, and tooltip belong on SidebarList/SidebarListItem. --- .../core/src/Domain/__tests__/sidebar.test.ts | 32 +++- packages/core/src/Domain/sidebar.ts | 14 +- packages/react/docs/components/List.md | 4 +- packages/react/docs/components/Sidebar.md | 4 +- .../Accordion/__tests__/Accordion.test.tsx | 2 +- .../src/Components/List/__tests__/List.cy.tsx | 5 +- .../Components/List/__tests__/List.test.tsx | 9 +- .../Components/List/__tests__/useList.test.ts | 8 +- .../src/Components/List/hooks/useList.ts | 6 +- .../react/src/Components/List/list.types.ts | 3 +- .../src/Components/ListItem/ListItem.tsx | 13 +- .../ListItem/__tests__/ListItem.cy.tsx | 2 +- .../ListItem/__tests__/ListItem.test.tsx | 4 +- .../ListItem/__tests__/useListItem.test.ts | 35 +--- .../Components/ListItem/hooks/useListItem.ts | 48 ++--- .../src/Components/ListItem/listItem.types.ts | 15 -- .../src/Components/Sidebar/SidebarList.tsx | 27 ++- .../Components/Sidebar/SidebarListItem.tsx | 46 ++++- .../Sidebar/__tests__/Sidebar.test.tsx | 31 +++- .../Sidebar/__tests__/useSidebarList.test.tsx | 18 ++ .../__tests__/useSidebarListItem.test.tsx | 33 ++++ .../Sidebar/hooks/useSidebarList.ts | 33 +++- .../Sidebar/hooks/useSidebarListItem.ts | 70 ++++++-- .../Sidebar/hooks/useSidebarProvider.ts | 17 +- .../Sidebar/hooks/useSidebarShell.ts | 7 +- .../react/src/Components/Sidebar/index.ts | 3 +- .../src/Components/Sidebar/sidebar.types.ts | 21 ++- packages/react/src/index.ts | 1 + packages/vue/docs/components/List.md | 4 +- packages/vue/docs/components/Sidebar.md | 4 +- .../Accordion/__tests__/Accordion.test.ts | 2 +- .../src/Components/List/__tests__/List.cy.ts | 5 +- .../Components/List/__tests__/List.test.ts | 5 +- .../Components/List/__tests__/useList.test.ts | 8 +- .../Components/List/composables/useList.ts | 6 +- .../vue/src/Components/List/list.types.ts | 3 +- .../vue/src/Components/ListItem/ListItem.vue | 169 ++++-------------- .../ListItem/__tests__/ListItem.cy.ts | 2 +- .../ListItem/__tests__/ListItem.test.ts | 8 +- .../ListItem/__tests__/useListItem.test.ts | 35 +--- .../ListItem/composables/useListItem.ts | 61 ++----- .../src/Components/ListItem/listItem.types.ts | 15 -- .../src/Components/Sidebar/SidebarList.vue | 11 +- .../Components/Sidebar/SidebarListItem.vue | 57 +++++- .../Sidebar/__tests__/Sidebar.test.ts | 30 ++++ .../Sidebar/__tests__/useSidebarList.test.ts | 33 ++++ .../__tests__/useSidebarListItem.test.ts | 26 ++- .../Sidebar/composables/useSidebarList.ts | 28 ++- .../Sidebar/composables/useSidebarListItem.ts | 44 ++++- packages/vue/src/Components/Sidebar/index.ts | 1 + .../src/Components/Sidebar/sidebar.types.ts | 21 ++- packages/vue/src/index.ts | 1 + 52 files changed, 639 insertions(+), 451 deletions(-) diff --git a/packages/core/src/Domain/__tests__/sidebar.test.ts b/packages/core/src/Domain/__tests__/sidebar.test.ts index cf50647b..dcb850e6 100644 --- a/packages/core/src/Domain/__tests__/sidebar.test.ts +++ b/packages/core/src/Domain/__tests__/sidebar.test.ts @@ -77,10 +77,34 @@ describe("SIDEBAR_DESKTOP_BREAKPOINT", () => { describe("isSidebarIconOnly", () => { test("it should be true only on a collapsed desktop icon rail", () => { - expect(isSidebarIconOnly(false, "icon", "collapsed")).toBe(true); - expect(isSidebarIconOnly(true, "icon", "collapsed")).toBe(false); - expect(isSidebarIconOnly(false, "icon", "expanded")).toBe(false); - expect(isSidebarIconOnly(false, "offcanvas", "collapsed")).toBe(false); + 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); }); }); diff --git a/packages/core/src/Domain/sidebar.ts b/packages/core/src/Domain/sidebar.ts index ec9e7aac..2649a443 100644 --- a/packages/core/src/Domain/sidebar.ts +++ b/packages/core/src/Domain/sidebar.ts @@ -94,11 +94,15 @@ export function shouldToggleDesktopSidebar( * Whether rail lists should collapse to icons. * False below the desktop breakpoint so the overlay drawer keeps labels. */ -export function isSidebarIconOnly( - isMobile: boolean, - collapsible: SidebarCollapsibleMode, - state: SidebarState, -): boolean { +export function isSidebarIconOnly({ + state, + isMobile, + collapsible, +}: { + collapsible: SidebarCollapsibleMode; + isMobile: boolean; + state: SidebarState; +}): boolean { return !isMobile && collapsible === "icon" && state === "collapsed"; } diff --git a/packages/react/docs/components/List.md b/packages/react/docs/components/List.md index 39129d8c..2d0bddfa 100644 --- a/packages/react/docs/components/List.md +++ b/packages/react/docs/components/List.md @@ -95,7 +95,7 @@ Selected rows show a check icon by default. Customize it with `selectedIcon` on ### Nested -`nested` indents the list and draws a start-edge guide line under the parent row. +`nested` indents the list for nested navigation. ```tsx @@ -154,7 +154,7 @@ Hide section labels and clip item text so only leading icons remain. Nested `Lis | `customProps` | `ListCustomProps` | — | Props forwarded to each list part. | | `dense` | `boolean` | `false` | Compact vertical spacing on items (`ListItem` / `ListSection`), not the list root. | | `iconOnly` | `boolean` | `false` | Hide section labels and clip item text so only leading icons remain. Nested `List` is hidden. | -| `nested` | `boolean` | `false` | Indents the list and draws a start-edge guide line for nested navigation. | +| `nested` | `boolean` | `false` | Indents the list for nested navigation. | ## Props (`ListItem`) diff --git a/packages/react/docs/components/Sidebar.md b/packages/react/docs/components/Sidebar.md index e2354456..e97bc525 100644 --- a/packages/react/docs/components/Sidebar.md +++ b/packages/react/docs/components/Sidebar.md @@ -233,11 +233,11 @@ Renders a `Button`. Forwards native button attributes. Default accessible name i ## Props (`SidebarList`) -Same as `List`. Sets `iconOnly` when the icon rail is collapsed on desktop. Override with `iconOnly`. Nested `SidebarList` is hidden while collapsed. +Same as `List`. 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. ## Props (`SidebarListItem`) -Same as `ListItem`. When the icon rail is collapsed, string `primary` is shown in a tooltip on the whole row. +Same as `ListItem`, plus `tooltip` / `tooltipPlacement`. Applies compact nav chrome. When the icon rail is collapsed, string `primary` is shown in a tooltip on the whole row. ## `useSidebar` diff --git a/packages/react/src/Components/Accordion/__tests__/Accordion.test.tsx b/packages/react/src/Components/Accordion/__tests__/Accordion.test.tsx index 71a28f90..ebd74b2a 100644 --- a/packages/react/src/Components/Accordion/__tests__/Accordion.test.tsx +++ b/packages/react/src/Components/Accordion/__tests__/Accordion.test.tsx @@ -155,9 +155,9 @@ test("it should apply plain variant classes on the root", () => { const trigger = screen.getByRole("button", { name: "One" }); - expect(trigger.className).toContain("py-1.5"); 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 }); diff --git a/packages/react/src/Components/List/__tests__/List.cy.tsx b/packages/react/src/Components/List/__tests__/List.cy.tsx index e071368a..6d72de21 100644 --- a/packages/react/src/Components/List/__tests__/List.cy.tsx +++ b/packages/react/src/Components/List/__tests__/List.cy.tsx @@ -9,11 +9,10 @@ test("it should render the root element", () => { cy.get("ul").should("have.class", "py-2"); }); -test("it should apply nested indent and a start-edge guide line", () => { +test("it should apply nested indent when nested is true", () => { cy.mount(); - cy.get("ul").should("have.class", "border-l"); - cy.get("ul").should("have.class", "ml-3.5"); + cy.get("ul").should("have.class", "pl-4"); }); test("it should render children", () => { diff --git a/packages/react/src/Components/List/__tests__/List.test.tsx b/packages/react/src/Components/List/__tests__/List.test.tsx index 6673e011..0c359281 100644 --- a/packages/react/src/Components/List/__tests__/List.test.tsx +++ b/packages/react/src/Components/List/__tests__/List.test.tsx @@ -16,15 +16,10 @@ test("it should render the root element", () => { expect(root?.classList.contains("list-none")).toBe(true); }); -test("it should apply nested indent and a start-edge guide line", () => { +test("it should apply nested indent when nested is true", () => { const { container } = render(); - expect(container.querySelector("ul")?.classList.contains("border-l")).toBe( - true, - ); - expect(container.querySelector("ul")?.classList.contains("ml-3.5")).toBe( - true, - ); + expect(container.querySelector("ul")?.classList.contains("pl-4")).toBe(true); }); test("it should render children", () => { diff --git a/packages/react/src/Components/List/__tests__/useList.test.ts b/packages/react/src/Components/List/__tests__/useList.test.ts index cd67e6ad..c0d46904 100644 --- a/packages/react/src/Components/List/__tests__/useList.test.ts +++ b/packages/react/src/Components/List/__tests__/useList.test.ts @@ -15,18 +15,14 @@ test("it should apply list root classes", () => { const { result } = renderUseList(); expect(result.current.rootBind.className).toContain("m-0"); - expect(result.current.rootBind.className).toContain("px-2"); expect(result.current.rootBind.className).toContain("py-2"); expect(result.current.rootBind.className).toContain("list-none"); - expect(result.current.rootBind.className).toContain("flex"); - expect(result.current.rootBind.className).toContain("gap-1"); }); -test("it should apply nested indent and a start-edge guide line", () => { +test("it should apply nested indent when nested is true", () => { const { result } = renderUseList({ nested: true }); - expect(result.current.rootBind.className).toContain("border-l"); - expect(result.current.rootBind.className).toContain("ml-3.5"); + expect(result.current.rootBind.className).toContain("pl-4"); }); test("it should expose dense context value", () => { diff --git a/packages/react/src/Components/List/hooks/useList.ts b/packages/react/src/Components/List/hooks/useList.ts index ec449311..235b7f0d 100644 --- a/packages/react/src/Components/List/hooks/useList.ts +++ b/packages/react/src/Components/List/hooks/useList.ts @@ -74,10 +74,8 @@ export function useList(props: ListProps) { return mergePartBind(customProps?.root, rootInheritedAttrs, { hidden: hideNestedSubmenu ? true : undefined, className: cn({ - "m-0 flex list-none flex-col gap-1 text-dark-900 dark:text-dark-100": true, - "px-2 py-2": !merged.nested, - "ml-3.5 translate-x-px border-l border-dark-200 py-0.5 pl-2.5 dark:border-dark-700": - merged.nested, + "m-0 list-none py-2 text-dark-900 dark:text-dark-100": true, + "pl-4": merged.nested, hidden: hideNestedSubmenu, [get(mergedClasses, "root") ?? ""]: true, }), diff --git a/packages/react/src/Components/List/list.types.ts b/packages/react/src/Components/List/list.types.ts index 4339997e..e6077150 100644 --- a/packages/react/src/Components/List/list.types.ts +++ b/packages/react/src/Components/List/list.types.ts @@ -67,8 +67,7 @@ export interface ListOwnProps { iconOnly?: boolean; /** - * When true, indents the list and draws a start-edge guide line - * for nested navigation. + * When true, indents the list for nested navigation/submenus. * * @default false */ diff --git a/packages/react/src/Components/ListItem/ListItem.tsx b/packages/react/src/Components/ListItem/ListItem.tsx index 832cd23d..d05868b6 100644 --- a/packages/react/src/Components/ListItem/ListItem.tsx +++ b/packages/react/src/Components/ListItem/ListItem.tsx @@ -5,7 +5,6 @@ import { createElement } from "react"; import { Icon } from "@/Components/Icon"; import { useListItem } from "@/Components/ListItem/hooks/useListItem"; import type { ListItemProps } from "@/Components/ListItem/listItem.types"; -import { Tooltip } from "@/Components/Tooltip"; import { hasNamedSlot } from "@/Utils"; function ListItemRow({ @@ -64,21 +63,11 @@ function ListItem(props: ListItemProps) { ) : ( row ); - const body = listItemState.tooltipContent ? ( - - ) : ( - hit - ); return createElement( listItemState.merged.as ?? "li", listItemState.rootBind, - body, + hit, ); } diff --git a/packages/react/src/Components/ListItem/__tests__/ListItem.cy.tsx b/packages/react/src/Components/ListItem/__tests__/ListItem.cy.tsx index 7941dffe..993ac87d 100644 --- a/packages/react/src/Components/ListItem/__tests__/ListItem.cy.tsx +++ b/packages/react/src/Components/ListItem/__tests__/ListItem.cy.tsx @@ -22,7 +22,7 @@ test("it should inherit dense padding from parent List", () => { , ); - cy.get('[role="menuitem"]').should("have.class", "min-h-7"); + cy.get('[role="menuitem"]').should("have.class", "py-1.5"); }); test("it should apply selected styles when selected is true", () => { diff --git a/packages/react/src/Components/ListItem/__tests__/ListItem.test.tsx b/packages/react/src/Components/ListItem/__tests__/ListItem.test.tsx index ad76426f..f43730ed 100644 --- a/packages/react/src/Components/ListItem/__tests__/ListItem.test.tsx +++ b/packages/react/src/Components/ListItem/__tests__/ListItem.test.tsx @@ -33,8 +33,8 @@ test("it should inherit dense padding from parent List", () => { const interactive = container.querySelector('[role="menuitem"]'); - expect(interactive?.classList.contains("min-h-8")).toBe(false); - expect(interactive?.classList.contains("min-h-7")).toBe(true); + expect(interactive?.classList.contains("py-2")).toBe(false); + expect(interactive?.classList.contains("py-1.5")).toBe(true); }); test("it should apply selected styles when selected is true", () => { diff --git a/packages/react/src/Components/ListItem/__tests__/useListItem.test.ts b/packages/react/src/Components/ListItem/__tests__/useListItem.test.ts index 0b5e589a..7a161e37 100644 --- a/packages/react/src/Components/ListItem/__tests__/useListItem.test.ts +++ b/packages/react/src/Components/ListItem/__tests__/useListItem.test.ts @@ -65,9 +65,7 @@ test("it should expose interactive bind when interactive is true", () => { }); expect(result.current.interactiveBind?.role).toBe("button"); - expect(result.current.interactiveBind?.className).toContain("px-2"); - expect(result.current.interactiveBind?.className).toContain("min-h-8"); - expect(result.current.interactiveBind?.className).toContain("rounded-lg"); + expect(result.current.interactiveBind?.className).toContain("px-4"); expect(result.current.interactiveBind?.className).toContain("cursor-pointer"); }); @@ -85,7 +83,6 @@ test("it should use a compact rounded hit target when List is iconOnly", () => { "justify-center", ); expect(result.current.interactiveBind?.className).toContain("rounded-lg"); - expect(result.current.tooltipContent).toBeUndefined(); }); test("it should collapse secondary rows to a square hit when List is iconOnly", () => { @@ -103,30 +100,6 @@ test("it should collapse secondary rows to a square hit when List is iconOnly", expect(result.current.interactiveBind?.className).not.toContain("w-full"); }); -test("it should use a taller hit target when secondary text is set", () => { - const { result } = renderUseListItem({ - interactive: true, - primary: "Acme Inc", - secondary: "Enterprise", - }); - - expect(result.current.interactiveBind?.className).toContain("min-h-12"); - expect(result.current.interactiveBind?.className).toContain("py-2"); - expect(result.current.interactiveBind?.className).not.toContain("min-h-8"); -}); - -test("it should expose tooltip content when tooltip is set", () => { - const { result } = renderUseListItem({ - primary: "Home", - tooltip: "Home", - interactive: true, - tooltipPlacement: "right", - }); - - expect(result.current.tooltipContent).toBe("Home"); - expect(result.current.tooltipPlacement).toBe("right"); -}); - test("it should apply dense padding on interactive bind", () => { const { result } = renderUseListItem({ dense: true, @@ -134,8 +107,8 @@ test("it should apply dense padding on interactive bind", () => { primary: "Dense item", }); - expect(result.current.interactiveBind?.className).toContain("min-h-7"); - expect(result.current.interactiveBind?.className).not.toContain("min-h-8"); + expect(result.current.interactiveBind?.className).toContain("py-1.5"); + expect(result.current.interactiveBind?.className).not.toContain("py-2"); }); test("it should inherit dense padding from parent List context", () => { @@ -144,7 +117,7 @@ test("it should inherit dense padding from parent List context", () => { { dense: true }, ); - expect(result.current.interactiveBind?.className).toContain("min-h-7"); + expect(result.current.interactiveBind?.className).toContain("py-1.5"); }); test("it should apply selected styles on interactive bind", () => { diff --git a/packages/react/src/Components/ListItem/hooks/useListItem.ts b/packages/react/src/Components/ListItem/hooks/useListItem.ts index b62a8927..85968e21 100644 --- a/packages/react/src/Components/ListItem/hooks/useListItem.ts +++ b/packages/react/src/Components/ListItem/hooks/useListItem.ts @@ -39,14 +39,12 @@ const listItemBridgeKeys = [ "classes", "divider", "primary", - "tooltip", "disabled", "selected", "secondary", "customProps", "interactive", "selectedIcon", - "tooltipPlacement", ] as const satisfies readonly (keyof ListItemOwnProps)[]; type ListItemLibDefaults = LibDefaultsShape; @@ -283,16 +281,15 @@ export function useListItem( } : undefined, className: cn({ - "flex min-w-0 items-center text-left text-dark-900 outline-hidden transition-[width,height,padding] duration-200 ease-linear dark:text-dark-100": true, - "overflow-hidden": true, - "w-full gap-x-2 px-2": !isIconOnly, + "flex w-full min-w-0 items-center gap-x-3 text-left text-dark-900 outline-hidden transition-colors dark:text-dark-100": true, + "cursor-pointer select-none": !merged.disabled, + "px-4": !isIconOnly, + "py-2": !isDense && !isIconOnly, + "py-1.5": isDense && !isIconOnly, + "overflow-hidden": isIconOnly, "size-8": isIconOnly && hasSecondaryLabel, "h-8 w-full px-2": isIconOnly && !hasSecondaryLabel, - "rounded-lg": true, - "cursor-pointer select-none": !merged.disabled, - "min-h-12 py-2": hasSecondaryLabel && !isDense && !isIconOnly, - "min-h-8": !hasSecondaryLabel && !isDense && !isIconOnly, - "min-h-7": isDense && !isIconOnly, + "rounded-lg": isIconOnly, "hover:bg-black/5 focus-visible:bg-black/5 dark:hover:bg-white/10 dark:focus-visible:bg-white/10": !merged.disabled && !isListboxOption, "bg-dark-100 font-medium text-dark-900 dark:bg-white/15 dark:text-white": @@ -319,19 +316,15 @@ export function useListItem( const rowClassName = derived(() => { return cn({ - "flex min-w-0 items-center": true, - "w-full gap-x-2": !isIconOnly, - "text-dark-900 dark:text-dark-100": !merged.interactive, - "px-2": !merged.interactive && !isIconOnly, + "flex w-full min-w-0 gap-x-3": true, + "items-center text-dark-900 dark:text-dark-100": !merged.interactive, + "px-4": !merged.interactive && !isIconOnly, + "py-2": !merged.interactive && !isDense && !isIconOnly, + "py-1.5": !merged.interactive && isDense && !isIconOnly, "size-8 overflow-hidden": !merged.interactive && isIconOnly && hasSecondaryLabel, "h-8 overflow-hidden": !merged.interactive && isIconOnly && !hasSecondaryLabel, - "min-h-12 py-2": - !merged.interactive && hasSecondaryLabel && !isDense && !isIconOnly, - "min-h-8": - !merged.interactive && !hasSecondaryLabel && !isDense && !isIconOnly, - "min-h-7": !merged.interactive && isDense && !isIconOnly, }); }); @@ -340,7 +333,8 @@ export function useListItem( customProps?.start, {}, cn({ - "flex shrink-0 items-center justify-center text-dark-600 dark:text-dark-300": true, + "flex shrink-0 text-dark-600 dark:text-dark-300": true, + "items-center justify-center": isIconOnly, [get(mergedClasses, "start") ?? ""]: true, }), ); @@ -434,18 +428,6 @@ export function useListItem( return null; }); - const tooltipContent = derived(() => { - if (typeof merged.tooltip !== "string" || merged.tooltip.length === 0) { - return undefined; - } - - return merged.tooltip; - }); - - const tooltipPlacement = derived(() => { - return merged.tooltipPlacement ?? ("top" as const); - }); - return { slots, merged, @@ -461,11 +443,9 @@ export function useListItem( hasSecondary, secondaryBind, primaryContent, - tooltipContent, interactiveBind, secondaryContent, selectedIconBind, - tooltipPlacement, resolvedSelectedIcon, }; } diff --git a/packages/react/src/Components/ListItem/listItem.types.ts b/packages/react/src/Components/ListItem/listItem.types.ts index c587407b..d7dcb035 100644 --- a/packages/react/src/Components/ListItem/listItem.types.ts +++ b/packages/react/src/Components/ListItem/listItem.types.ts @@ -3,7 +3,6 @@ import type { HTMLAttributes, ReactNode } from "react"; // ** Core Imports import type { ListboxValue } from "@bridge-ui/core/Domain"; -import type { PositionPlacement } from "@bridge-ui/core/Runtime"; import type { MergeHtmlProps } from "@bridge-ui/core/Utils"; // ** Local Imports @@ -198,20 +197,6 @@ export interface ListItemOwnProps { */ slots?: ListItemSlots; - /** - * Tooltip label for the whole hit target. - * - * @internal - */ - tooltip?: string; - - /** - * Placement of {@link ListItemOwnProps.tooltip}. - * - * @internal - */ - tooltipPlacement?: PositionPlacement; - /** * When set inside a `Listbox`, registers this row as a selectable option. * diff --git a/packages/react/src/Components/Sidebar/SidebarList.tsx b/packages/react/src/Components/Sidebar/SidebarList.tsx index fa622e70..2257beaa 100644 --- a/packages/react/src/Components/Sidebar/SidebarList.tsx +++ b/packages/react/src/Components/Sidebar/SidebarList.tsx @@ -1,12 +1,33 @@ +// ** Core Imports +import { cn } from "@bridge-ui/core/Utils"; + // ** Local Imports import { List } from "@/Components/List"; import { useSidebarList } from "@/Components/Sidebar/hooks/useSidebarList"; import type { SidebarListProps } from "@/Components/Sidebar/sidebar.types"; -function SidebarList({ iconOnly: iconOnlyProp, ...props }: SidebarListProps) { - const { iconOnly } = useSidebarList({ iconOnly: iconOnlyProp }); +function SidebarList({ + nested, + classes, + iconOnly: iconOnlyProp, + ...props +}: SidebarListProps) { + const { iconOnly, rootClassName } = useSidebarList({ + nested, + iconOnly: iconOnlyProp, + }); - return ; + return ( + + ); } export default SidebarList; diff --git a/packages/react/src/Components/Sidebar/SidebarListItem.tsx b/packages/react/src/Components/Sidebar/SidebarListItem.tsx index b7fb1c5d..ace28930 100644 --- a/packages/react/src/Components/Sidebar/SidebarListItem.tsx +++ b/packages/react/src/Components/Sidebar/SidebarListItem.tsx @@ -1,18 +1,54 @@ +// ** 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(props: SidebarListItemProps) { - const { tooltip, tooltipPlacement } = useSidebarListItem(props); +function SidebarListItem({ + classes, + tooltip: tooltipProp, + tooltipPlacement: tooltipPlacementProp, + ...props +}: SidebarListItemProps) { + const { tooltip, itemClasses, tooltipPlacement } = useSidebarListItem({ + tooltip: tooltipProp, + primary: props.primary, + secondary: props.secondary, + tooltipPlacement: tooltipPlacementProp, + }); - return ( + const item = ( ); + + if (!tooltip) { + return item; + } + + return ( +
  • + +
  • + ); } export default SidebarListItem; diff --git a/packages/react/src/Components/Sidebar/__tests__/Sidebar.test.tsx b/packages/react/src/Components/Sidebar/__tests__/Sidebar.test.tsx index a63d2f6a..2aa1f8c7 100644 --- a/packages/react/src/Components/Sidebar/__tests__/Sidebar.test.tsx +++ b/packages/react/src/Components/Sidebar/__tests__/Sidebar.test.tsx @@ -2,10 +2,6 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, expect, test } from "vitest"; -afterEach(() => { - cleanup(); -}); - // ** Local Imports import { Sidebar, @@ -16,6 +12,10 @@ import { SidebarTrigger, } from "@/Components/Sidebar"; +afterEach(() => { + cleanup(); +}); + function AppShell({ open, defaultOpen, @@ -47,9 +47,9 @@ function AppShell({ test("it should render the sidebar aside and main inset", () => { render(); - expect(screen.getByRole("complementary", { name: "Sidebar" })).toBeTruthy(); 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", () => { @@ -177,3 +177,24 @@ test("it should collapse SidebarList items when the icon rail is collapsed", () screen.getByRole("button", { name: "Home" }).getAttribute("aria-label"), ).toBe("Home"); }); + +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"); +}); diff --git a/packages/react/src/Components/Sidebar/__tests__/useSidebarList.test.tsx b/packages/react/src/Components/Sidebar/__tests__/useSidebarList.test.tsx index d61f19ed..6b7d7576 100644 --- a/packages/react/src/Components/Sidebar/__tests__/useSidebarList.test.tsx +++ b/packages/react/src/Components/Sidebar/__tests__/useSidebarList.test.tsx @@ -45,3 +45,21 @@ test("it should allow iconOnly to be overridden", () => { 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"); +}); diff --git a/packages/react/src/Components/Sidebar/__tests__/useSidebarListItem.test.tsx b/packages/react/src/Components/Sidebar/__tests__/useSidebarListItem.test.tsx index 9897e276..b7f6df69 100644 --- a/packages/react/src/Components/Sidebar/__tests__/useSidebarListItem.test.tsx +++ b/packages/react/src/Components/Sidebar/__tests__/useSidebarListItem.test.tsx @@ -60,3 +60,36 @@ test("it should omit the tooltip when the rail is expanded", () => { expect(result.current.tooltip).toBeUndefined(); }); + +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"); +}); diff --git a/packages/react/src/Components/Sidebar/hooks/useSidebarList.ts b/packages/react/src/Components/Sidebar/hooks/useSidebarList.ts index 1a6edac4..b240da5f 100644 --- a/packages/react/src/Components/Sidebar/hooks/useSidebarList.ts +++ b/packages/react/src/Components/Sidebar/hooks/useSidebarList.ts @@ -1,18 +1,39 @@ // ** Core Imports import { isSidebarIconOnly } from "@bridge-ui/core/Domain"; +import { cn } from "@bridge-ui/core/Utils"; // ** Local Imports import type { SidebarListProps } from "@/Components/Sidebar/sidebar.types"; import { useSidebar } from "@/Components/Sidebar/SidebarContext"; +import { derived } from "@/Utils"; /** - * Binds `List` `iconOnly` to the nearest icon rail. + * Binds `List` `iconOnly` to the nearest icon rail and applies nav chrome. */ -export function useSidebarList(props: Pick) { +export function useSidebarList( + props: Pick, +) { const sidebar = useSidebar(); - const iconOnly = - props.iconOnly ?? - isSidebarIconOnly(sidebar.isMobile, sidebar.collapsible, sidebar.state); - return { iconOnly }; + const iconOnly = derived(() => { + return ( + props.iconOnly ?? + 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, + }); + }); + + return { iconOnly, rootClassName }; } diff --git a/packages/react/src/Components/Sidebar/hooks/useSidebarListItem.ts b/packages/react/src/Components/Sidebar/hooks/useSidebarListItem.ts index 1e67195e..5d6036a5 100644 --- a/packages/react/src/Components/Sidebar/hooks/useSidebarListItem.ts +++ b/packages/react/src/Components/Sidebar/hooks/useSidebarListItem.ts @@ -3,28 +3,70 @@ import { isSidebarIconOnly, resolveSidebarListTooltipPlacement, } from "@bridge-ui/core/Domain"; +import { cn } from "@bridge-ui/core/Utils"; // ** Local Imports import type { SidebarListItemProps } from "@/Components/Sidebar/sidebar.types"; import { useSidebar } from "@/Components/Sidebar/SidebarContext"; +import { derived, isPropPresent } from "@/Utils"; /** - * Tooltip for a rail item when the icon rail is collapsed. + * Tooltip and nav chrome for a rail item. */ export function useSidebarListItem( - props: Pick, + props: Pick< + SidebarListItemProps, + "primary" | "tooltip" | "secondary" | "tooltipPlacement" + >, ) { const sidebar = useSidebar(); - const iconOnly = isSidebarIconOnly( - sidebar.isMobile, - sidebar.collapsible, - sidebar.state, - ); - const tooltip = - props.tooltip ?? - (iconOnly && typeof props.primary === "string" ? props.primary : undefined); - const tooltipPlacement = - props.tooltipPlacement ?? resolveSidebarListTooltipPlacement(sidebar.side); - - return { tooltip, tooltipPlacement }; + + const tooltip = derived(() => { + if (props.tooltip !== undefined) { + return props.tooltip; + } + + const iconOnly = isSidebarIconOnly({ + state: sidebar.state, + isMobile: sidebar.isMobile, + collapsible: sidebar.collapsible, + }); + + if (!iconOnly || typeof props.primary !== "string") { + return undefined; + } + + return props.primary; + }); + + const tooltipPlacement = derived(() => { + if (props.tooltipPlacement !== undefined) { + return props.tooltipPlacement; + } + + return resolveSidebarListTooltipPlacement(sidebar.side); + }); + + const itemClasses = derived(() => { + const iconOnly = isSidebarIconOnly({ + state: sidebar.state, + isMobile: sidebar.isMobile, + collapsible: sidebar.collapsible, + }); + const hasSecondary = isPropPresent(props.secondary); + + return { + 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, + }), + }; + }); + + return { tooltip, itemClasses, tooltipPlacement }; } diff --git a/packages/react/src/Components/Sidebar/hooks/useSidebarProvider.ts b/packages/react/src/Components/Sidebar/hooks/useSidebarProvider.ts index d5ffda38..1336766c 100644 --- a/packages/react/src/Components/Sidebar/hooks/useSidebarProvider.ts +++ b/packages/react/src/Components/Sidebar/hooks/useSidebarProvider.ts @@ -107,13 +107,20 @@ export function useSidebarProvider( }); const breakpoint = useBreakpoint(); - const isMobile = breakpoint.lessThan(SIDEBAR_DESKTOP_BREAKPOINT); + const isMobile = derived(() => { + return breakpoint.lessThan(SIDEBAR_DESKTOP_BREAKPOINT); + }); + + const isOpenControlled = derived(() => { + return props.open !== undefined; + }); - const isOpenControlled = props.open !== undefined; const [uncontrolledOpen, setUncontrolledOpen] = useState( () => merged.defaultOpen, ); - const open = isOpenControlled ? Boolean(props.open) : uncontrolledOpen; + const open = derived(() => { + return isOpenControlled ? Boolean(props.open) : uncontrolledOpen; + }); const [openMobile, setOpenMobile] = useState(false); const [layout, setLayoutState] = useState(defaultLayout); @@ -159,7 +166,9 @@ export function useSidebarProvider( setOpen(toggleSidebarOpen(open)); }, [isMobile, layout.collapsible, open, setOpen]); - const state = resolveSidebarState(open, layout.collapsible); + const state = derived(() => { + return resolveSidebarState(open, layout.collapsible); + }); const widthItem = useMemo((): SidebarWidth => { return toMerged(widthProps, bridgeSidebar?.tokens?.width ?? {}); diff --git a/packages/react/src/Components/Sidebar/hooks/useSidebarShell.ts b/packages/react/src/Components/Sidebar/hooks/useSidebarShell.ts index 558bd80f..c5f2b0a0 100644 --- a/packages/react/src/Components/Sidebar/hooks/useSidebarShell.ts +++ b/packages/react/src/Components/Sidebar/hooks/useSidebarShell.ts @@ -139,10 +139,9 @@ export function useSidebarShell( return get(classes, merged.side); }, [merged.side, bridgeSidebar?.tokens?.side]); - const collapsibleData = resolveSidebarCollapsibleData( - sidebar.state, - merged.collapsible, - ); + const collapsibleData = derived(() => { + return resolveSidebarCollapsibleData(sidebar.state, merged.collapsible); + }); const showAsDrawer = derived(() => { return shouldRenderSidebarAsDrawer(sidebar.isMobile) && sidebar.openMobile; diff --git a/packages/react/src/Components/Sidebar/index.ts b/packages/react/src/Components/Sidebar/index.ts index 74cc6363..be4acc2a 100644 --- a/packages/react/src/Components/Sidebar/index.ts +++ b/packages/react/src/Components/Sidebar/index.ts @@ -1,4 +1,5 @@ // ** 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"; @@ -14,6 +15,7 @@ export type { SidebarInsetCustomProps, SidebarInsetOwnProps, SidebarInsetProps, + SidebarListItemOwnProps, SidebarListItemProps, SidebarListProps, SidebarOwnProps, @@ -31,7 +33,6 @@ export type { } from "@/Components/Sidebar/sidebar.types"; export { SidebarContext, - useSidebar, type SidebarContextValue, type SidebarLayout, } from "@/Components/Sidebar/SidebarContext"; diff --git a/packages/react/src/Components/Sidebar/sidebar.types.ts b/packages/react/src/Components/Sidebar/sidebar.types.ts index 3c372d6d..3750baa3 100644 --- a/packages/react/src/Components/Sidebar/sidebar.types.ts +++ b/packages/react/src/Components/Sidebar/sidebar.types.ts @@ -2,6 +2,7 @@ import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react"; // ** Core Imports +import type { PositionPlacement } from "@bridge-ui/core/Runtime"; import type { SidebarCollapsible, SidebarSide, @@ -118,6 +119,24 @@ export interface SidebarInsetOwnProps { customProps?: SidebarInsetCustomProps; } +export interface SidebarListItemOwnProps { + /** + * Tooltip label for the whole hit target. When omitted, string `primary` + * is used while the icon rail is collapsed. + * + * @default undefined + */ + tooltip?: string; + + /** + * Placement of {@link SidebarListItemOwnProps.tooltip}. Defaults to the + * side opposite the rail. + * + * @default undefined + */ + tooltipPlacement?: PositionPlacement; +} + /** * Persistent app-shell sidebar panel. Mount under `SidebarProvider` with * `SidebarInset`. Put `SidebarList` / `Accordion` in `children`. @@ -292,4 +311,4 @@ export type SidebarListProps = ListProps; * `ListItem` bound to the nearest `Sidebar`. Shows `primary` in a tooltip * when the icon rail is collapsed. */ -export type SidebarListItemProps = ListItemProps; +export type SidebarListItemProps = ListItemProps & SidebarListItemOwnProps; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index c0bf1ddf..97dbb738 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -525,6 +525,7 @@ export type { SidebarInsetCustomProps, SidebarInsetOwnProps, SidebarInsetProps, + SidebarListItemOwnProps, SidebarListItemProps, SidebarListProps, SidebarOwnProps, diff --git a/packages/vue/docs/components/List.md b/packages/vue/docs/components/List.md index c8f21775..65cc22f1 100644 --- a/packages/vue/docs/components/List.md +++ b/packages/vue/docs/components/List.md @@ -91,7 +91,7 @@ Selected rows show a check icon by default. Customize it with `selectedIcon` on ### Nested -`nested` indents the list and draws a start-edge guide line under the parent row. +`nested` indents the list for nested navigation. ```vue @@ -149,7 +149,7 @@ Hide section labels and clip item text so only the `start` slot remains. Nested | `customProps` | `ListCustomProps` | — | Props forwarded to each list part. | | `dense` | `boolean` | `false` | Compact vertical spacing on items (`ListItem` / `ListSection`), not the list root. | | `iconOnly` | `boolean` | `false` | Hide section labels and clip item text so only leading icons remain. Nested `List` is hidden. | -| `nested` | `boolean` | `false` | Indents the list and draws a start-edge guide line for nested navigation. | +| `nested` | `boolean` | `false` | Indents the list for nested navigation. | ## Props (`ListItem`) diff --git a/packages/vue/docs/components/Sidebar.md b/packages/vue/docs/components/Sidebar.md index d0bce02e..4018585a 100644 --- a/packages/vue/docs/components/Sidebar.md +++ b/packages/vue/docs/components/Sidebar.md @@ -245,11 +245,11 @@ Renders a `Button`. Forwards native button attributes. Default accessible name i ## Props (`SidebarList`) -Same as `List`. Sets `icon-only` when the icon rail is collapsed on desktop. Override with `icon-only`. Nested `SidebarList` is hidden while collapsed. +Same as `List`. Sets `icon-only` when the icon rail is collapsed on desktop. Applies stacked nav chrome (gap, compact rows, nested guide). Override with `icon-only`. Nested `SidebarList` is hidden while collapsed. ## Props (`SidebarListItem`) -Same as `ListItem`. When the icon rail is collapsed, string `primary` is shown in a tooltip on the whole row. +Same as `ListItem`, plus `tooltip` / `tooltip-placement`. Applies compact nav chrome. When the icon rail is collapsed, string `primary` is shown in a tooltip on the whole row. ## `useSidebar` diff --git a/packages/vue/src/Components/Accordion/__tests__/Accordion.test.ts b/packages/vue/src/Components/Accordion/__tests__/Accordion.test.ts index b1f416ca..13a92957 100644 --- a/packages/vue/src/Components/Accordion/__tests__/Accordion.test.ts +++ b/packages/vue/src/Components/Accordion/__tests__/Accordion.test.ts @@ -192,9 +192,9 @@ test("it should apply plain variant classes on the root", () => { const trigger = wrapper.get("button"); - expect(trigger.classes()).toContain("py-1.5"); expect(trigger.classes()).toContain("px-2"); expect(trigger.classes()).toContain("min-h-8"); + expect(trigger.classes()).toContain("py-1.5"); expect(trigger.classes().join(" ")).not.toContain("text-primary-700"); const panel = wrapper.get('[role="region"]'); diff --git a/packages/vue/src/Components/List/__tests__/List.cy.ts b/packages/vue/src/Components/List/__tests__/List.cy.ts index b727e27d..a8b485bc 100644 --- a/packages/vue/src/Components/List/__tests__/List.cy.ts +++ b/packages/vue/src/Components/List/__tests__/List.cy.ts @@ -9,11 +9,10 @@ test("it should render the root element", () => { cy.get("ul").should("have.class", "py-2"); }); -test("it should apply nested indent and a start-edge guide line", () => { +test("it should apply nested indent when nested is true", () => { cy.mount(List, { props: { nested: true } }); - cy.get("ul").should("have.class", "border-l"); - cy.get("ul").should("have.class", "ml-3.5"); + cy.get("ul").should("have.class", "pl-4"); }); test("it should render default slot content", () => { diff --git a/packages/vue/src/Components/List/__tests__/List.test.ts b/packages/vue/src/Components/List/__tests__/List.test.ts index 2bb01887..0bb19daa 100644 --- a/packages/vue/src/Components/List/__tests__/List.test.ts +++ b/packages/vue/src/Components/List/__tests__/List.test.ts @@ -14,11 +14,10 @@ test("it should render the root element", () => { expect(wrapper.classes()).toContain("list-none"); }); -test("it should apply nested indent and a start-edge guide line", () => { +test("it should apply nested indent when nested is true", () => { const wrapper = mount(List, { props: { nested: true } }); - expect(wrapper.classes()).toContain("border-l"); - expect(wrapper.classes()).toContain("ml-3.5"); + expect(wrapper.classes()).toContain("pl-4"); }); test("it should render default slot content", () => { diff --git a/packages/vue/src/Components/List/__tests__/useList.test.ts b/packages/vue/src/Components/List/__tests__/useList.test.ts index aa0ee40c..d10d4f79 100644 --- a/packages/vue/src/Components/List/__tests__/useList.test.ts +++ b/packages/vue/src/Components/List/__tests__/useList.test.ts @@ -30,18 +30,14 @@ test("it should apply list root classes", () => { const { rootBind } = mountUseList(); expect(rootBind.value.class).toContain("m-0"); - expect(rootBind.value.class).toContain("px-2"); expect(rootBind.value.class).toContain("py-2"); expect(rootBind.value.class).toContain("list-none"); - expect(rootBind.value.class).toContain("flex"); - expect(rootBind.value.class).toContain("gap-1"); }); -test("it should apply nested indent and a start-edge guide line", () => { +test("it should apply nested indent when nested is true", () => { const { rootBind } = mountUseList({ nested: true }); - expect(rootBind.value.class).toContain("border-l"); - expect(rootBind.value.class).toContain("ml-3.5"); + expect(rootBind.value.class).toContain("pl-4"); }); test("it should provide dense context to descendants", () => { diff --git a/packages/vue/src/Components/List/composables/useList.ts b/packages/vue/src/Components/List/composables/useList.ts index 4fee3403..93bff596 100644 --- a/packages/vue/src/Components/List/composables/useList.ts +++ b/packages/vue/src/Components/List/composables/useList.ts @@ -73,10 +73,8 @@ export function useList(props: ListOwnProps) { return mergePartBind(customProps.value?.root, rootInheritedAttrs.value, { hidden: hideNestedSubmenu.value ? true : undefined, class: cn({ - "m-0 flex list-none flex-col gap-1 text-dark-900 dark:text-dark-100": true, - "px-2 py-2": !merged.value.nested, - "ml-3.5 translate-x-px border-l border-dark-200 py-0.5 pl-2.5 dark:border-dark-700": - merged.value.nested, + "m-0 list-none py-2 text-dark-900 dark:text-dark-100": true, + "pl-4": merged.value.nested, hidden: hideNestedSubmenu.value, [get(mergedClasses.value, "root") ?? ""]: true, }), diff --git a/packages/vue/src/Components/List/list.types.ts b/packages/vue/src/Components/List/list.types.ts index dc76bd47..a68cce77 100644 --- a/packages/vue/src/Components/List/list.types.ts +++ b/packages/vue/src/Components/List/list.types.ts @@ -59,8 +59,7 @@ export interface ListOwnProps { iconOnly?: boolean; /** - * When true, indents the list and draws a start-edge guide line - * for nested navigation. + * When true, indents the list for nested navigation/submenus. * * @default false */ diff --git a/packages/vue/src/Components/ListItem/ListItem.vue b/packages/vue/src/Components/ListItem/ListItem.vue index 99b77da0..24b09d0f 100644 --- a/packages/vue/src/Components/ListItem/ListItem.vue +++ b/packages/vue/src/Components/ListItem/ListItem.vue @@ -9,7 +9,6 @@ import type { ListItemOwnProps, ListItemSlots, } from "@/Components/ListItem/listItem.types"; -import Tooltip from "@/Components/Tooltip/Tooltip.vue"; import { hasNamedSlot, isPropPresent } from "@/Utils"; defineSlots(); @@ -39,10 +38,8 @@ const { primaryBind, hasSecondary, secondaryBind, - tooltipContent, interactiveBind, selectedIconBind, - tooltipPlacement, resolvedSelectedIcon, } = useListItem(props, { role: "button" }, slots); @@ -53,132 +50,8 @@ const rootTag = computed(() => { diff --git a/packages/vue/src/Components/ListItem/__tests__/ListItem.cy.ts b/packages/vue/src/Components/ListItem/__tests__/ListItem.cy.ts index d67a5e2f..3f0f642f 100644 --- a/packages/vue/src/Components/ListItem/__tests__/ListItem.cy.ts +++ b/packages/vue/src/Components/ListItem/__tests__/ListItem.cy.ts @@ -37,7 +37,7 @@ test("it should inherit dense padding from parent List", () => { }, }); - cy.get('[role="menuitem"]').should("have.class", "min-h-7"); + cy.get('[role="menuitem"]').should("have.class", "py-1.5"); }); test("it should apply selected styles when selected is true", () => { diff --git a/packages/vue/src/Components/ListItem/__tests__/ListItem.test.ts b/packages/vue/src/Components/ListItem/__tests__/ListItem.test.ts index 91894506..b6af660a 100644 --- a/packages/vue/src/Components/ListItem/__tests__/ListItem.test.ts +++ b/packages/vue/src/Components/ListItem/__tests__/ListItem.test.ts @@ -42,8 +42,8 @@ test("it should apply dense padding when dense prop is set", () => { const interactive = wrapper.find('[role="menuitem"]'); - expect(interactive.classes()).toContain("min-h-7"); - expect(interactive.classes()).not.toContain("min-h-8"); + expect(interactive.classes()).toContain("py-1.5"); + expect(interactive.classes()).not.toContain("py-2"); }); test("it should inherit dense padding from parent List", () => { @@ -56,8 +56,8 @@ test("it should inherit dense padding from parent List", () => { const wrapper = mount(Host); const interactive = wrapper.find('[role="menuitem"]'); - expect(interactive.classes()).toContain("min-h-7"); - expect(interactive.classes()).not.toContain("min-h-8"); + expect(interactive.classes()).toContain("py-1.5"); + expect(interactive.classes()).not.toContain("py-2"); }); test("it should apply selected styles when selected is true", () => { diff --git a/packages/vue/src/Components/ListItem/__tests__/useListItem.test.ts b/packages/vue/src/Components/ListItem/__tests__/useListItem.test.ts index 8ef42b6e..207a2097 100644 --- a/packages/vue/src/Components/ListItem/__tests__/useListItem.test.ts +++ b/packages/vue/src/Components/ListItem/__tests__/useListItem.test.ts @@ -73,9 +73,7 @@ test("it should expose interactive bind when interactive is true", () => { }); expect(interactiveBind.value?.role).toBe("button"); - expect(interactiveBind.value?.class).toContain("px-2"); - expect(interactiveBind.value?.class).toContain("min-h-8"); - expect(interactiveBind.value?.class).toContain("rounded-lg"); + expect(interactiveBind.value?.class).toContain("px-4"); expect(interactiveBind.value?.class).toContain("cursor-pointer"); }); @@ -115,7 +113,6 @@ test("it should use a compact rounded hit target when List is iconOnly", () => { expect(result.interactiveBind.value?.class).not.toContain("size-8"); expect(result.interactiveBind.value?.class).not.toContain("justify-center"); expect(result.interactiveBind.value?.class).toContain("rounded-lg"); - expect(result.tooltipContent.value).toBeUndefined(); }); test("it should collapse secondary rows to a square hit when List is iconOnly", () => { @@ -157,30 +154,6 @@ test("it should collapse secondary rows to a square hit when List is iconOnly", expect(result.interactiveBind.value?.class).not.toContain("w-full"); }); -test("it should use a taller hit target when secondary text is set", () => { - const { interactiveBind } = mountUseListItem({ - interactive: true, - primary: "Acme Inc", - secondary: "Enterprise", - }); - - expect(interactiveBind.value?.class).toContain("min-h-12"); - expect(interactiveBind.value?.class).toContain("py-2"); - expect(interactiveBind.value?.class).not.toContain("min-h-8"); -}); - -test("it should expose tooltip content when tooltip is set", () => { - const { tooltipContent, tooltipPlacement } = mountUseListItem({ - primary: "Home", - tooltip: "Home", - interactive: true, - tooltipPlacement: "right", - }); - - expect(tooltipContent.value).toBe("Home"); - expect(tooltipPlacement.value).toBe("right"); -}); - test("it should apply dense padding on interactive bind", () => { const { interactiveBind } = mountUseListItem({ dense: true, @@ -188,8 +161,8 @@ test("it should apply dense padding on interactive bind", () => { primary: "Dense item", }); - expect(interactiveBind.value?.class).toContain("min-h-7"); - expect(interactiveBind.value?.class).not.toContain("min-h-8"); + expect(interactiveBind.value?.class).toContain("py-1.5"); + expect(interactiveBind.value?.class).not.toContain("py-2"); }); test("it should inherit dense padding from parent List context", () => { @@ -222,7 +195,7 @@ test("it should inherit dense padding from parent List context", () => { mount(Wrapper); - expect(result.interactiveBind.value?.class).toContain("min-h-7"); + expect(result.interactiveBind.value?.class).toContain("py-1.5"); }); test("it should apply selected styles on interactive bind", () => { diff --git a/packages/vue/src/Components/ListItem/composables/useListItem.ts b/packages/vue/src/Components/ListItem/composables/useListItem.ts index 35ad1150..e604679e 100644 --- a/packages/vue/src/Components/ListItem/composables/useListItem.ts +++ b/packages/vue/src/Components/ListItem/composables/useListItem.ts @@ -44,14 +44,12 @@ const listItemBridgeKeys = [ "classes", "divider", "primary", - "tooltip", "disabled", "selected", "secondary", "customProps", "interactive", "selectedIcon", - "tooltipPlacement", ] as const satisfies readonly (keyof ListItemOwnProps)[]; type ListItemLibDefaults = LibDefaultsShape; @@ -296,18 +294,15 @@ export function useListItem( } : undefined, class: cn({ - "flex min-w-0 items-center text-left text-dark-900 outline-hidden transition-[width,height,padding] duration-200 ease-linear dark:text-dark-100": true, - "overflow-hidden": true, - "w-full gap-x-2 px-2": !isIconOnly.value, + "flex w-full min-w-0 items-center gap-x-3 text-left text-dark-900 outline-hidden transition-colors dark:text-dark-100": true, + "cursor-pointer select-none": !merged.value.disabled, + "px-4": !isIconOnly.value, + "py-2": !isDense.value && !isIconOnly.value, + "py-1.5": isDense.value && !isIconOnly.value, + "overflow-hidden": isIconOnly.value, "size-8": isIconOnly.value && hasSecondaryLabel.value, "h-8 w-full px-2": isIconOnly.value && !hasSecondaryLabel.value, - "rounded-lg": true, - "cursor-pointer select-none": !merged.value.disabled, - "min-h-12 py-2": - hasSecondaryLabel.value && !isDense.value && !isIconOnly.value, - "min-h-8": - !hasSecondaryLabel.value && !isDense.value && !isIconOnly.value, - "min-h-7": isDense.value && !isIconOnly.value, + "rounded-lg": isIconOnly.value, "hover:bg-black/5 focus-visible:bg-black/5 dark:hover:bg-white/10 dark:focus-visible:bg-white/10": !merged.value.disabled && !isListboxOption.value, "bg-dark-100 font-medium text-dark-900 dark:bg-white/15 dark:text-white": @@ -342,10 +337,12 @@ export function useListItem( const rowClass = computed(() => { return cn({ - "flex min-w-0 items-center": true, - "w-full gap-x-2": !isIconOnly.value, - "text-dark-900 dark:text-dark-100": !merged.value.interactive, - "px-2": !merged.value.interactive && !isIconOnly.value, + "flex w-full min-w-0 gap-x-3": true, + "items-center text-dark-900 dark:text-dark-100": + !merged.value.interactive, + "px-4": !merged.value.interactive && !isIconOnly.value, + "py-2": !merged.value.interactive && !isDense.value && !isIconOnly.value, + "py-1.5": !merged.value.interactive && isDense.value && !isIconOnly.value, "size-8 overflow-hidden": !merged.value.interactive && isIconOnly.value && @@ -354,18 +351,6 @@ export function useListItem( !merged.value.interactive && isIconOnly.value && !hasSecondaryLabel.value, - "min-h-12 py-2": - !merged.value.interactive && - hasSecondaryLabel.value && - !isDense.value && - !isIconOnly.value, - "min-h-8": - !merged.value.interactive && - !hasSecondaryLabel.value && - !isDense.value && - !isIconOnly.value, - "min-h-7": - !merged.value.interactive && isDense.value && !isIconOnly.value, }); }); @@ -374,7 +359,8 @@ export function useListItem( customProps.value?.start, {}, cn({ - "flex shrink-0 items-center justify-center text-dark-600 dark:text-dark-300": true, + "flex shrink-0 text-dark-600 dark:text-dark-300": true, + "items-center justify-center": isIconOnly.value, [get(mergedClasses.value, "start") ?? ""]: true, }), ); @@ -442,21 +428,6 @@ export function useListItem( ); }); - const tooltipContent = computed(() => { - if ( - typeof merged.value.tooltip !== "string" || - merged.value.tooltip.length === 0 - ) { - return undefined; - } - - return merged.value.tooltip; - }); - - const tooltipPlacement = computed(() => { - return merged.value.tooltipPlacement ?? "top"; - }); - return { merged, hasEnd, @@ -470,10 +441,8 @@ export function useListItem( primaryBind, hasSecondary, secondaryBind, - tooltipContent, interactiveBind, selectedIconBind, - tooltipPlacement, resolvedSelectedIcon, }; } diff --git a/packages/vue/src/Components/ListItem/listItem.types.ts b/packages/vue/src/Components/ListItem/listItem.types.ts index 9102e2ab..8865e995 100644 --- a/packages/vue/src/Components/ListItem/listItem.types.ts +++ b/packages/vue/src/Components/ListItem/listItem.types.ts @@ -3,7 +3,6 @@ import type { HTMLAttributes, Slot, VNode } from "vue"; // ** Core Imports import type { ListboxValue } from "@bridge-ui/core/Domain"; -import type { PositionPlacement } from "@bridge-ui/core/Runtime"; import type { MergeHtmlProps } from "@bridge-ui/core/Utils"; // ** Local Imports @@ -183,20 +182,6 @@ export interface ListItemOwnProps { */ selectedIcon?: null | IconSource; - /** - * Tooltip label for the whole hit target. - * - * @internal - */ - tooltip?: string; - - /** - * Placement of {@link ListItemOwnProps.tooltip}. - * - * @internal - */ - tooltipPlacement?: PositionPlacement; - /** * When set inside a `Listbox`, registers this row as a selectable option. * diff --git a/packages/vue/src/Components/Sidebar/SidebarList.vue b/packages/vue/src/Components/Sidebar/SidebarList.vue index 0894f084..c341470d 100644 --- a/packages/vue/src/Components/Sidebar/SidebarList.vue +++ b/packages/vue/src/Components/Sidebar/SidebarList.vue @@ -2,6 +2,9 @@ // ** External Imports import { computed, useAttrs } from "vue"; +// ** Core Imports +import { cn } from "@bridge-ui/core/Utils"; + // ** Local Imports import List from "@/Components/List/List.vue"; import type { ListOwnProps } from "@/Components/List/list.types"; @@ -13,15 +16,19 @@ const props = defineProps(); const attrs = useAttrs(); -const { iconOnly: resolvedIconOnly } = useSidebarList(props); +const { rootClassName, iconOnly: resolvedIconOnly } = useSidebarList(props); const listBind = computed(() => { - const { iconOnly: _iconOnly, ...listProps } = props; + const { classes, iconOnly: _iconOnly, ...listProps } = props; return { ...attrs, ...listProps, iconOnly: resolvedIconOnly.value, + classes: { + ...classes, + root: cn(rootClassName.value, classes?.root), + }, }; }); diff --git a/packages/vue/src/Components/Sidebar/SidebarListItem.vue b/packages/vue/src/Components/Sidebar/SidebarListItem.vue index abfda505..d80c7c43 100644 --- a/packages/vue/src/Components/Sidebar/SidebarListItem.vue +++ b/packages/vue/src/Components/Sidebar/SidebarListItem.vue @@ -2,6 +2,9 @@ // ** External Imports import { computed, useAttrs, useSlots } from "vue"; +// ** Core Imports +import { cn } from "@bridge-ui/core/Utils"; + // ** Local Imports import ListItem from "@/Components/ListItem/ListItem.vue"; import type { @@ -9,22 +12,28 @@ import type { ListItemSlots, } from "@/Components/ListItem/listItem.types"; import { useSidebarListItem } from "@/Components/Sidebar/composables/useSidebarListItem"; +import type { SidebarListItemOwnProps } from "@/Components/Sidebar/sidebar.types"; +import Tooltip from "@/Components/Tooltip/Tooltip.vue"; import { hasNamedSlot } from "@/Utils"; defineSlots(); defineOptions({ inheritAttrs: false }); -const props = defineProps(); +const props = defineProps(); const attrs = useAttrs(); const slots = useSlots(); -const { tooltip: resolvedTooltip, tooltipPlacement: resolvedTooltipPlacement } = - useSidebarListItem(props); +const { + itemClasses, + tooltip: resolvedTooltip, + tooltipPlacement: resolvedTooltipPlacement, +} = useSidebarListItem(props); const listItemBind = computed(() => { const { + classes, tooltip: _tooltip, tooltipPlacement: _placement, ...itemProps @@ -33,14 +42,50 @@ const listItemBind = computed(() => { return { ...attrs, ...itemProps, - tooltip: resolvedTooltip.value, - tooltipPlacement: resolvedTooltipPlacement.value, + ...(resolvedTooltip.value ? { as: "div" as const } : {}), + classes: { + ...classes, + start: cn(itemClasses.value.start, classes?.start), + interactive: cn(itemClasses.value.interactive, classes?.interactive), + }, }; });