From 6a542f3ae2937be9087faeede600cf6de263d8aa Mon Sep 17 00:00:00 2001 From: Lukas Ballier Date: Tue, 23 Jun 2026 11:33:48 +0200 Subject: [PATCH] feat(swirl-console-layout): collapsible sidebar + new swirl-sidebar-navigation - Add swirl-sidebar-navigation: reusable 296px sidebar shell (logo slot with Flip fallback, appName, collapse button, scrollable nav with scroll-state header/footer dividers, user footer slot, elevated variant) - Redesign swirl-console-layout: hideable/collapsible desktop sidebar with a floating reopen button, localStorage persistence, ambient-glow shell, 296px nav, internal scroll (no min-height overflow) - Backward-compatible API; adds sidebarVisibilityChange event, logo slot, and sidebarVisibilityStateStorageKey prop ACT-3612 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sidebar-navigation-console-layout.md | 15 + .../custom-elements.manifest.json | 198 ++++++- packages/swirl-components/src/components.d.ts | 79 ++- .../swirl-console-layout.css | 203 ++++--- .../swirl-console-layout.mdx | 33 +- .../swirl-console-layout.spec.tsx | 525 +++++++++++++++++- .../swirl-console-layout.stories.ts | 41 +- .../swirl-console-layout.tsx | 212 +++++-- .../swirl-sidebar-navigation.css | 131 +++++ .../swirl-sidebar-navigation.mdx | 53 ++ .../swirl-sidebar-navigation.spec.tsx | 93 ++++ .../swirl-sidebar-navigation.stories.ts | 67 +++ .../swirl-sidebar-navigation.tsx | 168 ++++++ .../swirl-components/src/styles/global.css | 8 + packages/swirl-components/vscode-data.json | 33 ++ 15 files changed, 1704 insertions(+), 155 deletions(-) create mode 100644 .changeset/sidebar-navigation-console-layout.md create mode 100644 packages/swirl-components/src/components/swirl-sidebar-navigation/swirl-sidebar-navigation.css create mode 100644 packages/swirl-components/src/components/swirl-sidebar-navigation/swirl-sidebar-navigation.mdx create mode 100644 packages/swirl-components/src/components/swirl-sidebar-navigation/swirl-sidebar-navigation.spec.tsx create mode 100644 packages/swirl-components/src/components/swirl-sidebar-navigation/swirl-sidebar-navigation.stories.ts create mode 100644 packages/swirl-components/src/components/swirl-sidebar-navigation/swirl-sidebar-navigation.tsx diff --git a/.changeset/sidebar-navigation-console-layout.md b/.changeset/sidebar-navigation-console-layout.md new file mode 100644 index 000000000..5773c2108 --- /dev/null +++ b/.changeset/sidebar-navigation-console-layout.md @@ -0,0 +1,15 @@ +--- +"@getflip/swirl-components": minor +--- + +Add `swirl-sidebar-navigation` component and redesign `swirl-console-layout` with a hideable, collapsible sidebar. + +**New component `swirl-sidebar-navigation`** — reusable 296px sidebar shell (logo slot with Flip fallback, `appName`, collapse button emitting `collapseButtonClick`, scrollable default slot with scroll-state header/footer dividers, `user` footer slot, `elevated` shadow variant, `focusCollapseButton()` method). + +**`swirl-console-layout` changes** (API is backward compatible, behavior notes below): + +- New shell styling: an ambient radial-glow background on a sunken surface (themeable via `--swirl-console-layout-background`), a rounded content card, and a translucent 296px sidebar rendered through `swirl-sidebar-navigation`. +- The desktop sidebar can now be hidden via its collapse button and reopened via a floating menu button (or the app bar toggle when an app bar is visible). New event `sidebarVisibilityChange(boolean)`; new slot `logo`. +- The visibility state persists to localStorage. The default key is shared per origin — set `sidebarVisibilityStateStorageKey` per app if multiple Swirl console apps run on the same origin. +- Behavior change: `toggleSidebar()`/`showSidebar()`/`hideSidebar()` now also hide/show the sidebar on desktop viewports (previously mobile-only). If you call `hideSidebar()` on route changes to close the mobile drawer, guard it with a viewport check to keep the desktop sidebar open. +- The mobile drawer is now full-height and overlays the app bar while open. diff --git a/packages/swirl-components/custom-elements.manifest.json b/packages/swirl-components/custom-elements.manifest.json index c92f1a503..febc7abe9 100644 --- a/packages/swirl-components/custom-elements.manifest.json +++ b/packages/swirl-components/custom-elements.manifest.json @@ -6703,6 +6703,14 @@ "default": "\"Show main navigation\"", "fieldName": "showNavigationButtonLabel" }, + { + "name": "sidebar-visibility-state-storage-key", + "type": { + "text": "string" + }, + "default": "\"SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE\"", + "fieldName": "sidebarVisibilityStateStorageKey" + }, { "name": "subheading", "type": { @@ -6826,6 +6834,16 @@ "readonly": true, "attribute": "show-navigation-button-label" }, + { + "kind": "field", + "name": "sidebarVisibilityStateStorageKey", + "type": { + "text": "string" + }, + "default": "\"SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE\"", + "readonly": true, + "attribute": "sidebar-visibility-state-storage-key" + }, { "kind": "field", "name": "subheading", @@ -6838,7 +6856,7 @@ { "kind": "method", "name": "hideSidebar", - "description": "Hide the mobile navigation.", + "description": "Hide the sidebar.", "return": { "type": { "text": "Promise", @@ -6854,7 +6872,7 @@ { "kind": "method", "name": "showSidebar", - "description": "Show the mobile navigation.", + "description": "Show the sidebar.", "return": { "type": { "text": "Promise", @@ -6870,7 +6888,7 @@ { "kind": "method", "name": "toggleSidebar", - "description": "Toggle the mobile navigation visibility.", + "description": "Toggle the sidebar visibility.", "return": { "type": { "text": "Promise", @@ -6908,6 +6926,12 @@ } ] } + }, + { + "name": "sidebarVisibilityChange", + "type": { + "text": "CustomEvent" + } } ], "slots": [ @@ -6931,6 +6955,10 @@ "name": "heading", "description": "The main content's heading (only rendered if \"heading\" prop is not set)." }, + { + "name": "logo", + "description": "Custom logo (forwarded to the sidebar navigation)" + }, { "name": "navigation", "description": "The main navigation" @@ -49288,6 +49316,170 @@ } ] }, + { + "kind": "javascript-module", + "path": "src/components/swirl-sidebar-navigation/swirl-sidebar-navigation.tsx", + "declarations": [ + { + "kind": "class", + "customElement": true, + "tagName": "swirl-sidebar-navigation", + "name": "SwirlSidebarNavigation", + "attributes": [ + { + "name": "app-name", + "type": { + "text": "string" + }, + "fieldName": "appName" + }, + { + "name": "collapse-button-label", + "type": { + "text": "string" + }, + "default": "\"Hide navigation\"", + "fieldName": "collapseButtonLabel" + }, + { + "name": "elevated", + "type": { + "text": "boolean" + }, + "fieldName": "elevated" + }, + { + "name": "hide-collapse-button", + "type": { + "text": "boolean" + }, + "fieldName": "hideCollapseButton" + }, + { + "name": "navigation-label", + "type": { + "text": "string" + }, + "default": "\"Main\"", + "fieldName": "navigationLabel" + } + ], + "members": [ + { + "kind": "field", + "name": "appName", + "type": { + "text": "string" + }, + "readonly": true, + "attribute": "app-name" + }, + { + "kind": "field", + "name": "collapseButtonLabel", + "type": { + "text": "string" + }, + "default": "\"Hide navigation\"", + "readonly": true, + "attribute": "collapse-button-label" + }, + { + "kind": "field", + "name": "elevated", + "type": { + "text": "boolean" + }, + "readonly": true, + "attribute": "elevated" + }, + { + "kind": "field", + "name": "hideCollapseButton", + "type": { + "text": "boolean" + }, + "readonly": true, + "attribute": "hide-collapse-button" + }, + { + "kind": "field", + "name": "navigationLabel", + "type": { + "text": "string" + }, + "default": "\"Main\"", + "readonly": true, + "attribute": "navigation-label" + }, + { + "kind": "method", + "name": "focusCollapseButton", + "description": "Focus the collapse button.", + "return": { + "type": { + "text": "Promise", + "references": [ + { + "name": "Promise", + "package": "global:" + }, + { + "name": "HTMLButtonElement", + "package": "global:" + } + ] + } + } + } + ], + "events": [ + { + "name": "collapseButtonClick", + "type": { + "text": "CustomEvent", + "references": [ + { + "name": "MouseEvent", + "package": "global:" + } + ] + } + } + ], + "slots": [ + { + "name": "(default)", + "description": "Navigation content (e.g. swirl-tree-navigation)" + }, + { + "name": "logo", + "description": "Custom logo/brand mark shown in the header (32×32). Falls back to the Flip logo mark." + }, + { + "name": "user", + "description": "User profile content pinned to the bottom" + } + ] + } + ], + "exports": [ + { + "kind": "js", + "name": "SwirlSidebarNavigation", + "declaration": { + "name": "SwirlSidebarNavigation" + } + }, + { + "kind": "custom-element-definition", + "name": "swirl-sidebar-navigation", + "declaration": { + "name": "SwirlSidebarNavigation" + } + } + ] + }, { "kind": "javascript-module", "path": "src/components/swirl-skeleton-box/swirl-skeleton-box.tsx", diff --git a/packages/swirl-components/src/components.d.ts b/packages/swirl-components/src/components.d.ts index 361902dbd..c1b203003 100644 --- a/packages/swirl-components/src/components.d.ts +++ b/packages/swirl-components/src/components.d.ts @@ -822,7 +822,7 @@ export namespace Components { */ "hideNavigationButtonLabel"?: string; /** - * Hide the mobile navigation. + * Hide the sidebar. */ "hideSidebar": () => Promise; /** @@ -841,12 +841,16 @@ export namespace Components { */ "showNavigationButtonLabel"?: string; /** - * Show the mobile navigation. + * Show the sidebar. */ "showSidebar": () => Promise; + /** + * @default "SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE" + */ + "sidebarVisibilityStateStorageKey"?: string; "subheading"?: string; /** - * Toggle the mobile navigation visibility. + * Toggle the sidebar visibility. */ "toggleSidebar": () => Promise; } @@ -4393,6 +4397,23 @@ export namespace Components { "variant": SwirlShellNavigationItemVariant; "withGradient"?: boolean; } + interface SwirlSidebarNavigation { + "appName"?: string; + /** + * @default "Hide navigation" + */ + "collapseButtonLabel"?: string; + "elevated"?: boolean; + /** + * Focus the collapse button. + */ + "focusCollapseButton": () => Promise; + "hideCollapseButton"?: boolean; + /** + * @default "Main" + */ + "navigationLabel"?: string; + } interface SwirlSkeletonBox { /** * @default true @@ -5901,6 +5922,10 @@ export interface SwirlShellLayoutCustomEvent extends CustomEvent { detail: T; target: HTMLSwirlShellLayoutElement; } +export interface SwirlSidebarNavigationCustomEvent extends CustomEvent { + detail: T; + target: HTMLSwirlSidebarNavigationElement; +} export interface SwirlSwitchCustomEvent extends CustomEvent { detail: T; target: HTMLSwirlSwitchElement; @@ -6260,6 +6285,7 @@ declare global { interface HTMLSwirlConsoleLayoutElementEventMap { "backButtonClick": MouseEvent; "helpButtonClick": MouseEvent; + "sidebarVisibilityChange": boolean; } interface HTMLSwirlConsoleLayoutElement extends Components.SwirlConsoleLayout, HTMLStencilElement { addEventListener(type: K, listener: (this: HTMLSwirlConsoleLayoutElement, ev: SwirlConsoleLayoutCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; @@ -8626,6 +8652,23 @@ declare global { prototype: HTMLSwirlShellNavigationItemElement; new (): HTMLSwirlShellNavigationItemElement; }; + interface HTMLSwirlSidebarNavigationElementEventMap { + "collapseButtonClick": MouseEvent; + } + interface HTMLSwirlSidebarNavigationElement extends Components.SwirlSidebarNavigation, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLSwirlSidebarNavigationElement, ev: SwirlSidebarNavigationCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLSwirlSidebarNavigationElement, ev: SwirlSidebarNavigationCustomEvent) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; + } + var HTMLSwirlSidebarNavigationElement: { + prototype: HTMLSwirlSidebarNavigationElement; + new (): HTMLSwirlSidebarNavigationElement; + }; interface HTMLSwirlSkeletonBoxElement extends Components.SwirlSkeletonBox, HTMLStencilElement { } var HTMLSwirlSkeletonBoxElement: { @@ -10056,6 +10099,7 @@ declare global { "swirl-separator": HTMLSwirlSeparatorElement; "swirl-shell-layout": HTMLSwirlShellLayoutElement; "swirl-shell-navigation-item": HTMLSwirlShellNavigationItemElement; + "swirl-sidebar-navigation": HTMLSwirlSidebarNavigationElement; "swirl-skeleton-box": HTMLSwirlSkeletonBoxElement; "swirl-skeleton-text": HTMLSwirlSkeletonTextElement; "swirl-spinner": HTMLSwirlSpinnerElement; @@ -10840,12 +10884,17 @@ declare namespace LocalJSX { "navigationLabel"?: string; "onBackButtonClick"?: (event: SwirlConsoleLayoutCustomEvent) => void; "onHelpButtonClick"?: (event: SwirlConsoleLayoutCustomEvent) => void; + "onSidebarVisibilityChange"?: (event: SwirlConsoleLayoutCustomEvent) => void; "showBackButton"?: boolean; "showHelpButton"?: boolean; /** * @default "Show main navigation" */ "showNavigationButtonLabel"?: string; + /** + * @default "SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE" + */ + "sidebarVisibilityStateStorageKey"?: string; "subheading"?: string; } interface SwirlDataCell { @@ -14294,6 +14343,20 @@ declare namespace LocalJSX { "variant"?: SwirlShellNavigationItemVariant; "withGradient"?: boolean; } + interface SwirlSidebarNavigation { + "appName"?: string; + /** + * @default "Hide navigation" + */ + "collapseButtonLabel"?: string; + "elevated"?: boolean; + "hideCollapseButton"?: boolean; + /** + * @default "Main" + */ + "navigationLabel"?: string; + "onCollapseButtonClick"?: (event: SwirlSidebarNavigationCustomEvent) => void; + } interface SwirlSkeletonBox { /** * @default true @@ -15904,6 +15967,7 @@ declare namespace LocalJSX { "showBackButton": boolean; "showHelpButton": boolean; "showNavigationButtonLabel": string; + "sidebarVisibilityStateStorageKey": string; "subheading": string; "hideContentHeader": boolean; } @@ -17813,6 +17877,13 @@ declare namespace LocalJSX { "variant": SwirlShellNavigationItemVariant; "withGradient": boolean; } + interface SwirlSidebarNavigationAttributes { + "appName": string; + "collapseButtonLabel": string; + "elevated": boolean; + "hideCollapseButton": boolean; + "navigationLabel": string; + } interface SwirlSkeletonBoxAttributes { "animated": boolean; "aspectRatio": string; @@ -18799,6 +18870,7 @@ declare namespace LocalJSX { "swirl-separator": Omit & { [K in keyof SwirlSeparator & keyof SwirlSeparatorAttributes]?: SwirlSeparator[K] } & { [K in keyof SwirlSeparator & keyof SwirlSeparatorAttributes as `attr:${K}`]?: SwirlSeparatorAttributes[K] } & { [K in keyof SwirlSeparator & keyof SwirlSeparatorAttributes as `prop:${K}`]?: SwirlSeparator[K] }; "swirl-shell-layout": Omit & { [K in keyof SwirlShellLayout & keyof SwirlShellLayoutAttributes]?: SwirlShellLayout[K] } & { [K in keyof SwirlShellLayout & keyof SwirlShellLayoutAttributes as `attr:${K}`]?: SwirlShellLayoutAttributes[K] } & { [K in keyof SwirlShellLayout & keyof SwirlShellLayoutAttributes as `prop:${K}`]?: SwirlShellLayout[K] }; "swirl-shell-navigation-item": Omit & { [K in keyof SwirlShellNavigationItem & keyof SwirlShellNavigationItemAttributes]?: SwirlShellNavigationItem[K] } & { [K in keyof SwirlShellNavigationItem & keyof SwirlShellNavigationItemAttributes as `attr:${K}`]?: SwirlShellNavigationItemAttributes[K] } & { [K in keyof SwirlShellNavigationItem & keyof SwirlShellNavigationItemAttributes as `prop:${K}`]?: SwirlShellNavigationItem[K] } & OneOf<"label", SwirlShellNavigationItem["label"], SwirlShellNavigationItemAttributes["label"]>; + "swirl-sidebar-navigation": Omit & { [K in keyof SwirlSidebarNavigation & keyof SwirlSidebarNavigationAttributes]?: SwirlSidebarNavigation[K] } & { [K in keyof SwirlSidebarNavigation & keyof SwirlSidebarNavigationAttributes as `attr:${K}`]?: SwirlSidebarNavigationAttributes[K] } & { [K in keyof SwirlSidebarNavigation & keyof SwirlSidebarNavigationAttributes as `prop:${K}`]?: SwirlSidebarNavigation[K] }; "swirl-skeleton-box": Omit & { [K in keyof SwirlSkeletonBox & keyof SwirlSkeletonBoxAttributes]?: SwirlSkeletonBox[K] } & { [K in keyof SwirlSkeletonBox & keyof SwirlSkeletonBoxAttributes as `attr:${K}`]?: SwirlSkeletonBoxAttributes[K] } & { [K in keyof SwirlSkeletonBox & keyof SwirlSkeletonBoxAttributes as `prop:${K}`]?: SwirlSkeletonBox[K] }; "swirl-skeleton-text": Omit & { [K in keyof SwirlSkeletonText & keyof SwirlSkeletonTextAttributes]?: SwirlSkeletonText[K] } & { [K in keyof SwirlSkeletonText & keyof SwirlSkeletonTextAttributes as `attr:${K}`]?: SwirlSkeletonTextAttributes[K] } & { [K in keyof SwirlSkeletonText & keyof SwirlSkeletonTextAttributes as `prop:${K}`]?: SwirlSkeletonText[K] }; "swirl-spinner": Omit & { [K in keyof SwirlSpinner & keyof SwirlSpinnerAttributes]?: SwirlSpinner[K] } & { [K in keyof SwirlSpinner & keyof SwirlSpinnerAttributes as `attr:${K}`]?: SwirlSpinnerAttributes[K] } & { [K in keyof SwirlSpinner & keyof SwirlSpinnerAttributes as `prop:${K}`]?: SwirlSpinner[K] }; @@ -19317,6 +19389,7 @@ declare module "@stencil/core" { * @deprecated This component is deprecated and will be removed in the next major release. */ "swirl-shell-navigation-item": LocalJSX.IntrinsicElements["swirl-shell-navigation-item"] & JSXBase.HTMLAttributes; + "swirl-sidebar-navigation": LocalJSX.IntrinsicElements["swirl-sidebar-navigation"] & JSXBase.HTMLAttributes; "swirl-skeleton-box": LocalJSX.IntrinsicElements["swirl-skeleton-box"] & JSXBase.HTMLAttributes; "swirl-skeleton-text": LocalJSX.IntrinsicElements["swirl-skeleton-text"] & JSXBase.HTMLAttributes; "swirl-spinner": LocalJSX.IntrinsicElements["swirl-spinner"] & JSXBase.HTMLAttributes; diff --git a/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.css b/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.css index efe1b2e5c..1adbb422b 100644 --- a/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.css +++ b/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.css @@ -6,7 +6,7 @@ height: 100vh; --console-footer-height: 4.25rem; --console-app-bar-height: 3.5rem; - --console-sidebar-width: 18.5rem; + --console-sidebar-width: 18.5rem; /* 296px */ & * { box-sizing: border-box; @@ -15,15 +15,35 @@ .console-layout { display: grid; + overflow: hidden; width: 100%; height: 100%; + padding: var(--s-space-8); + background: var( + --swirl-console-layout-background, + radial-gradient( + 140% 78% at 50% 116%, + rgba(20, 90, 245, 0.46) 0%, + rgba(20, 90, 245, 0) 68% + ) + var(--s-surface-sunken-default) + ); + gap: var(--s-space-8); grid-template-columns: 1fr; + /* Constrain the single row to the layout height so the inner areas stay + within the viewport and scroll internally instead of growing the layout */ + grid-template-rows: minmax(0, 1fr); grid-template-areas: "main"; @media (--from-tablet) { + transition: grid-template-columns 0.15s, gap 0.15s; grid-template-columns: var(--console-sidebar-width) 1fr; grid-template-areas: "sidebar main"; } + + @media (prefers-reduced-motion) { + transition: none; + } } .console-layout--sidebar-active { @@ -35,11 +55,28 @@ } } +.console-layout--sidebar-hidden { + @media (--from-tablet) { + gap: 0; + grid-template-columns: 0 1fr; + + & .console-layout__sidebar { + visibility: hidden; + opacity: 0; + transform: translate3d(-100%, 0, 0); + /* Delay hiding until the slide-out finished; when showing, the base + transition applies and visibility flips back instantly so the + sidebar can receive focus right away */ + transition: transform 0.15s, opacity 0.15s, visibility 0s linear 0.15s; + } + } +} + .console-layout--empty-app-bar { & .console-layout__main { @media (--from-tablet) { grid-template-areas: "content"; - grid-template-rows: 1fr; + grid-template-rows: minmax(0, 1fr); } } @@ -56,95 +93,142 @@ grid-template-areas: "content" "footer"; - grid-template-rows: 1fr min-content; + grid-template-rows: minmax(0, 1fr) min-content; } } } .console-layout__sidebar { position: fixed; - z-index: var(--s-z-30); - top: 4rem; + /* Above the sticky app bar (z-30) so the full-height drawer is not + covered by it on mobile */ + z-index: var(--s-z-40); + top: 0; bottom: 0; left: 0; - display: grid; - overflow-x: hidden; - overflow-y: auto; + display: block; width: 100%; - max-width: 20rem; - border-right: var(--s-border-width-default) solid var(--s-border-default); + max-width: var(--console-sidebar-width); transition: transform 0.15s, box-shadow 0.15s; transform: translate3d(-100%, 0, 0); - grid-template-rows: auto 1fr min-content; - grid-template-areas: - "header" - "navigation" - "user"; - @media (prefers-reduced-motion) { - transition: none; + @media (--to-tablet) { + --swirl-sidebar-navigation-background: var(--s-background-default); + --swirl-sidebar-navigation-border-radius: 0 var(--s-border-radius-l) + var(--s-border-radius-l) 0; } @media (--from-tablet) { position: static; top: auto; left: auto; + /* Fixed width keeps the content from reflowing while the grid column + animates; the transform slides the sidebar out in lockstep with the + shrinking column, clipped by the shell's overflow */ + min-width: var(--console-sidebar-width); + /* Allow the sidebar grid cell to shrink so its nav scrolls internally + rather than stretching the layout to the navigation's full height */ + min-height: 0; max-width: none; height: 100%; + transition: transform 0.15s, opacity 0.15s; transform: none; box-shadow: none; grid-area: sidebar; } -} -.console-layout__header { - overflow: hidden; - min-width: 0; - padding: var(--s-space-20) var(--s-space-24); - background-color: var(--s-background-default); - grid-area: header; -} - -.console-layout__navigation { - overflow-x: hidden; - overflow-y: auto; - padding-top: var(--s-space-8); - padding-bottom: var(--s-space-8); - background-color: var(--s-background-default); - grid-area: navigation; -} - -.console-layout__user { - overflow: hidden; - padding: var(--s-space-16) var(--s-space-24); - border-top: var(--s-border-width-default) solid var(--s-border-default); - background-color: var(--s-background-default); - grid-area: user; - min-height: var(--console-footer-height); - box-sizing: border-box; + @media (prefers-reduced-motion) { + transition: none; + } } .console-layout__main { + position: relative; display: grid; overflow: hidden; width: 100%; height: 100%; + /* Allow the grid cell to shrink below its content's intrinsic height so the + content area scrolls internally instead of stretching the layout */ + min-height: 0; + border-radius: var(--s-border-radius-l); + background-color: var(--s-background-default); grid-area: main; gap: var(--s-border-width-default); - grid-template-rows: var(--console-app-bar-height) 1fr; + grid-template-rows: var(--console-app-bar-height) minmax(0, 1fr); grid-template-areas: "app-bar" "content"; } .console-layout--has-footer .console-layout__main { - grid-template-rows: var(--console-app-bar-height) 1fr min-content; + grid-template-rows: var(--console-app-bar-height) minmax(0, 1fr) min-content; grid-template-areas: "app-bar" "content" "footer"; } +.console-layout__show-sidebar-button { + position: absolute; + z-index: var(--s-z-20); + top: var(--s-space-16); + left: var(--s-space-16); + display: none; + width: 2.25rem; + height: 2.25rem; + padding: var(--s-space-8); + align-items: center; + justify-content: center; + border: var(--s-border-width-default) solid + var(--s-border-translucent-outline); + border-radius: var(--s-border-radius-sm); + color: var(--s-icon-strong); + background-color: var(--s-translucent-medium-default); + backdrop-filter: blur(var(--s-blur-s)); + -webkit-backdrop-filter: blur(var(--s-blur-s)); + box-shadow: var(--s-shadow-level-2); + cursor: pointer; + + &:hover { + background-color: var(--s-translucent-medium-hovered); + } + + &:active { + background-color: var(--s-translucent-medium-pressed); + } + + &:focus { + outline: none; + } + + &:focus-visible { + box-shadow: var(--s-shadow-level-2), 0 0 0 0.125rem var(--s-focus-default); + } + + @media (--from-tablet) { + display: flex; + } +} + +/* When the default app bar is visible, the sidebar toggle moves into it + instead of floating over the content */ +.console-layout:not(.console-layout--empty-app-bar):not( + .console-layout--has-custom-app-bar + ) + .console-layout__show-sidebar-button { + @media (--from-tablet) { + display: none; + } +} + +/* A custom app bar occupies the top edge; offset the floating button below it */ +.console-layout--has-custom-app-bar .console-layout__show-sidebar-button { + @media (--from-tablet) { + top: calc(var(--console-app-bar-height) + var(--s-space-16)); + } +} + .console-layout__app-bar { position: sticky; z-index: var(--s-z-30); @@ -171,6 +255,14 @@ } } +/* Desktop: show the app bar toggle when the sidebar is hidden (the default + app bar is only rendered visible when appName/help button are set) */ +.console-layout--sidebar-hidden .console-layout__mobile-navigation-button { + @media (--from-tablet) { + display: inline-block; + } +} + .console-layout__app-name { min-width: 0; flex-grow: 1; @@ -222,27 +314,6 @@ width: 100%; } -.console-layout__logo { - display: flex; - min-width: 0; - align-items: center; - gap: var(--s-space-12); -} - -.console-layout__logo-mark { - flex-shrink: 0; -} - -.console-layout__logo-text { - min-width: 0; - - &::part(text) { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } -} - .console-layout__content-header { display: flex; margin-bottom: var(--s-space-24); diff --git a/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.mdx b/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.mdx index 98f4f312f..393aea2a0 100644 --- a/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.mdx +++ b/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.mdx @@ -6,7 +6,10 @@ import * as Stories from "./swirl-console-layout.stories"; # SwirlConsoleLayout The SwirlConsoleLayout component provides the basic shell layout for the Flip -admin tools. +admin tools. The sidebar is rendered via +[SwirlSidebarNavigation](?path=/docs/components-swirlsidebarnavigation--docs); +the `navigation`, `user` and `logo` slots are forwarded to it, and the +`logoText` prop sets the app name shown next to the logo. - **[Figma Design Specs](https://www.figma.com/file/Cks5hd1wSusGi1PYbpVgvX/Admindashboard?node-id=128%3A2201&t=RoIUBgbUhxFQOq8w-0)** - **[Source Code](https://github.com/flip-corp/swirl/tree/main/packages/swirl-components/src/components/swirl-console-layout)** @@ -19,16 +22,36 @@ preview this component. +## Sidebar visibility + +On desktop viewports the sidebar can be expanded (default) or hidden. The +sidebar's own collapse button hides it; a floating menu button at the top left +of the content area (or the toggle in the default app bar, when visible) shows +it again. The chosen state is persisted to localStorage under the key set via +the `sidebarVisibilityStateStorageKey` prop and restored on load. When several +admin apps run on the same origin, set a distinct key per app (e.g. +`MY_APP_SIDEBAR_STATE`) so the apps don't overwrite each other's state. On +mobile viewports the sidebar behaves as an off-canvas drawer toggled from the +app bar; the drawer state is not persisted. + +Use the `toggleSidebar`, `showSidebar` and `hideSidebar` methods to control the +sidebar programmatically. The methods affect both modes: on desktop they +expand/hide the sidebar and persist the state, on mobile they open/close the +drawer. Whenever the visibility changes — through user interaction or one of the +methods — the `sidebarVisibilityChange` event is emitted with the new visibility +as its payload. No event is emitted for the initial state on load. + ## Related components -No related components. +[SwirlSidebarNavigation](?path=/docs/components-swirlsidebarnavigation--docs) ## Accessibility -SwirlConsoleLayout is a layout shell with no ARIA attributes or -keyboard-specific behavior. Ensure slotted navigation and content provide +SwirlConsoleLayout is a layout shell with no keyboard-specific behavior of its +own. A hidden sidebar is removed from the accessibility tree and focus order via +`aria-hidden` and `inert`. Ensure slotted navigation and content provide appropriate semantics and focus management. ### Keyboard -No component-specific keyboard behavior. +On mobile viewports, pressing Escape closes the open sidebar drawer. diff --git a/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.spec.tsx b/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.spec.tsx index 4d8a04c09..858eb47f0 100644 --- a/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.spec.tsx +++ b/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.spec.tsx @@ -2,7 +2,46 @@ import { newSpecPage } from "@stencil/core/testing"; import { SwirlConsoleLayout } from "./swirl-console-layout"; +function mockMatchMedia(desktopViewport: boolean) { + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: jest.fn().mockImplementation((query: string) => ({ + matches: query === "(min-width: 768px)" ? desktopViewport : false, + media: query, + onchange: null, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); +} + +// "newSpecPage" resets the mock window, wiping its built-in storage. The mock +// is installed as a non-enumerable own property, so it survives the reset and +// allows seeding values before the page is created. +function mockLocalStorage() { + const store = new Map(); + + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => (store.has(key) ? store.get(key) : null), + setItem: (key: string, value: string) => store.set(key, String(value)), + removeItem: (key: string) => store.delete(key), + clear: () => store.clear(), + }, + }); +} + describe("swirl-console-layout", () => { + beforeEach(() => { + mockMatchMedia(true); + mockLocalStorage(); + }); + it("renders its contents", async () => { const page = await newSpecPage({ components: [SwirlConsoleLayout], @@ -18,32 +57,18 @@ describe("swirl-console-layout", () => { expect(page.root).toEqualHtml(` -
- +
+ + + +
- +
@@ -132,6 +157,129 @@ describe("swirl-console-layout", () => { expect(spy).toHaveBeenCalled(); }); + it("hides the sidebar when its collapse button is clicked", async () => { + const page = await newSpecPage({ + components: [SwirlConsoleLayout], + html: ` + +
Navigation
+
Content
+
+ `, + }); + + const spy = jest.fn(); + + page.root.addEventListener("sidebarVisibilityChange", spy); + + const sidebar = page.root.shadowRoot.querySelector( + "swirl-sidebar-navigation" + ); + + expect(page.root.shadowRoot.querySelector(".console-layout")).toHaveClass( + "console-layout--sidebar-active" + ); + + expect( + page.root.shadowRoot.querySelector(".console-layout__show-sidebar-button") + ).toBeNull(); + + sidebar.dispatchEvent(new CustomEvent("collapseButtonClick")); + await page.waitForChanges(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0].detail).toBe(false); + + expect(page.root.shadowRoot.querySelector(".console-layout")).toHaveClass( + "console-layout--sidebar-hidden" + ); + + expect(sidebar.getAttribute("aria-hidden")).toBe("true"); + expect(sidebar.hasAttribute("inert")).toBe(true); + + expect( + page.root.shadowRoot.querySelector(".console-layout__show-sidebar-button") + ).not.toBeNull(); + + expect(localStorage.getItem("SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE")).toBe( + "false" + ); + }); + + it("shows the sidebar when the floating show button is clicked", async () => { + localStorage.setItem("SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE", "false"); + + const page = await newSpecPage({ + components: [SwirlConsoleLayout], + html: ` + +
Navigation
+
Content
+
+ `, + }); + + const spy = jest.fn(); + + page.root.addEventListener("sidebarVisibilityChange", spy); + + page.root.shadowRoot + .querySelector(".console-layout__show-sidebar-button") + .click(); + await page.waitForChanges(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0].detail).toBe(true); + + expect(page.root.shadowRoot.querySelector(".console-layout")).toHaveClass( + "console-layout--sidebar-active" + ); + + const sidebar = page.root.shadowRoot.querySelector( + "swirl-sidebar-navigation" + ); + + expect(sidebar.getAttribute("aria-hidden")).toBe("false"); + expect(sidebar.hasAttribute("inert")).toBe(false); + + expect( + page.root.shadowRoot.querySelector(".console-layout__show-sidebar-button") + ).toBeNull(); + + expect(localStorage.getItem("SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE")).toBe( + "true" + ); + }); + + it("restores the hidden sidebar state from localStorage", async () => { + localStorage.setItem("SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE", "false"); + + const page = await newSpecPage({ + components: [SwirlConsoleLayout], + html: ` + +
Navigation
+
Content
+
+ `, + }); + + expect(page.root.shadowRoot.querySelector(".console-layout")).toHaveClass( + "console-layout--sidebar-hidden" + ); + + const sidebar = page.root.shadowRoot.querySelector( + "swirl-sidebar-navigation" + ); + + expect(sidebar.getAttribute("aria-hidden")).toBe("true"); + expect(sidebar.hasAttribute("inert")).toBe(true); + + expect( + page.root.shadowRoot.querySelector(".console-layout__show-sidebar-button") + ).not.toBeNull(); + }); + it("renders custom app bar slot and hides default app bar", async () => { const page = await newSpecPage({ components: [SwirlConsoleLayout], @@ -226,6 +374,343 @@ describe("swirl-console-layout", () => { expect(layoutContainer).not.toHaveClass("console-layout--has-footer"); }); + it("toggles the off-canvas sidebar on mobile viewports without persisting the state", async () => { + mockMatchMedia(false); + + const page = await newSpecPage({ + components: [SwirlConsoleLayout], + html: ` + +
Navigation
+
Content
+
+ `, + }); + + const layout = page.root as HTMLSwirlConsoleLayoutElement; + const layoutContainer = + layout.shadowRoot.querySelector(".console-layout"); + const sidebar = layout.shadowRoot.querySelector("swirl-sidebar-navigation"); + + const spy = jest.fn(); + + layout.addEventListener("sidebarVisibilityChange", spy); + + // The drawer starts closed + expect(layoutContainer).toHaveClass("console-layout--sidebar-hidden"); + expect(sidebar.getAttribute("aria-hidden")).toBe("true"); + expect(sidebar.hasAttribute("inert")).toBe(true); + + // toggleSidebar() opens the drawer + await layout.toggleSidebar(); + await page.waitForChanges(); + + expect(layoutContainer).toHaveClass("console-layout--sidebar-active"); + expect(sidebar.getAttribute("aria-hidden")).toBe("false"); + expect(sidebar.hasAttribute("inert")).toBe(false); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0].detail).toBe(true); + + // Escape closes the drawer + layoutContainer.dispatchEvent( + new KeyboardEvent("keydown", { code: "Escape" }) + ); + await page.waitForChanges(); + + expect(layoutContainer).toHaveClass("console-layout--sidebar-hidden"); + expect(sidebar.hasAttribute("inert")).toBe(true); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[1][0].detail).toBe(false); + + // The app bar toggle reopens the drawer; its bubbling click must not + // immediately close the drawer again via the outside click handler + layout.shadowRoot + .querySelector( + ".console-layout__mobile-navigation-button swirl-button" + ) + .click(); + await page.waitForChanges(); + + expect(layoutContainer).toHaveClass("console-layout--sidebar-active"); + expect(spy).toHaveBeenCalledTimes(3); + expect(spy.mock.calls[2][0].detail).toBe(true); + + // A click outside the sidebar closes the drawer + layout.shadowRoot + .querySelector(".console-layout__content") + .click(); + await page.waitForChanges(); + + expect(layoutContainer).toHaveClass("console-layout--sidebar-hidden"); + expect(sidebar.hasAttribute("inert")).toBe(true); + expect(spy).toHaveBeenCalledTimes(4); + expect(spy.mock.calls[3][0].detail).toBe(false); + + // The mobile drawer state is never persisted + expect( + localStorage.getItem("SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE") + ).toBeNull(); + }); + + it("does not emit sidebarVisibilityChange for the initial visible state", async () => { + const page = await newSpecPage({ + components: [SwirlConsoleLayout], + html: "
", + }); + + const spy = jest.fn(); + + // Attach the listener to the document before the component is created, so + // any emission during initial load would be caught + page.doc.addEventListener("sidebarVisibilityChange", spy); + + await page.setContent(` + +
Navigation
+
Content
+
+ `); + await page.waitForChanges(); + + const layout = page.body.querySelector( + "swirl-console-layout" + ); + + // Sanity check: the component is hydrated with the sidebar visible + expect(layout.shadowRoot.querySelector(".console-layout")).toHaveClass( + "console-layout--sidebar-active" + ); + + expect(spy).not.toHaveBeenCalled(); + + // Sanity check: the document level listener does catch actual changes + await layout.hideSidebar(); + await page.waitForChanges(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0].detail).toBe(false); + }); + + it("does not emit sidebarVisibilityChange when restoring a hidden sidebar on load", async () => { + localStorage.setItem("SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE", "false"); + + const page = await newSpecPage({ + components: [SwirlConsoleLayout], + html: "
", + }); + + const spy = jest.fn(); + + page.doc.addEventListener("sidebarVisibilityChange", spy); + + await page.setContent(` + +
Navigation
+
Content
+
+ `); + await page.waitForChanges(); + + const layout = page.body.querySelector( + "swirl-console-layout" + ); + const sidebar = layout.shadowRoot.querySelector("swirl-sidebar-navigation"); + + // Sanity check: the component is hydrated with the sidebar hidden + expect(layout.shadowRoot.querySelector(".console-layout")).toHaveClass( + "console-layout--sidebar-hidden" + ); + expect(sidebar.hasAttribute("inert")).toBe(true); + + expect(spy).not.toHaveBeenCalled(); + }); + + it("deactivates the sidebar when resizing to mobile and restores the stored state when resizing back to desktop", async () => { + const page = await newSpecPage({ + components: [SwirlConsoleLayout], + html: ` + +
Navigation
+
Content
+
+ `, + }); + + const layoutContainer = + page.root.shadowRoot.querySelector(".console-layout"); + const sidebar = page.root.shadowRoot.querySelector( + "swirl-sidebar-navigation" + ); + + expect(layoutContainer).toHaveClass("console-layout--sidebar-active"); + + // Resizing to a mobile viewport deactivates the sidebar + mockMatchMedia(false); + window.dispatchEvent(new Event("resize")); + await page.waitForChanges(); + + expect(layoutContainer).toHaveClass("console-layout--sidebar-hidden"); + expect(sidebar.hasAttribute("inert")).toBe(true); + + // Deactivating via resize must not overwrite the stored desktop state + expect( + localStorage.getItem("SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE") + ).toBeNull(); + + // Resizing back to a desktop viewport restores the stored state + localStorage.setItem("SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE", "true"); + mockMatchMedia(true); + window.dispatchEvent(new Event("resize")); + await page.waitForChanges(); + + expect(layoutContainer).toHaveClass("console-layout--sidebar-active"); + expect(sidebar.hasAttribute("inert")).toBe(false); + }); + + it("controls the desktop sidebar via the public methods and persists the state", async () => { + const page = await newSpecPage({ + components: [SwirlConsoleLayout], + html: ` + +
Navigation
+
Content
+
+ `, + }); + + const layout = page.root as HTMLSwirlConsoleLayoutElement; + const layoutContainer = + layout.shadowRoot.querySelector(".console-layout"); + + const spy = jest.fn(); + + layout.addEventListener("sidebarVisibilityChange", spy); + + await layout.hideSidebar(); + await page.waitForChanges(); + + expect(layoutContainer).toHaveClass("console-layout--sidebar-hidden"); + expect(localStorage.getItem("SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE")).toBe( + "false" + ); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0].detail).toBe(false); + + // Hiding an already hidden sidebar is a no-op + await layout.hideSidebar(); + await page.waitForChanges(); + + expect(spy).toHaveBeenCalledTimes(1); + + await layout.showSidebar(); + await page.waitForChanges(); + + expect(layoutContainer).toHaveClass("console-layout--sidebar-active"); + expect(localStorage.getItem("SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE")).toBe( + "true" + ); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[1][0].detail).toBe(true); + + // Showing an already visible sidebar is a no-op + await layout.showSidebar(); + await page.waitForChanges(); + + expect(spy).toHaveBeenCalledTimes(2); + + // toggleSidebar() flips the state back and forth + await layout.toggleSidebar(); + await page.waitForChanges(); + + expect(layoutContainer).toHaveClass("console-layout--sidebar-hidden"); + expect(localStorage.getItem("SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE")).toBe( + "false" + ); + + await layout.toggleSidebar(); + await page.waitForChanges(); + + expect(layoutContainer).toHaveClass("console-layout--sidebar-active"); + expect(localStorage.getItem("SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE")).toBe( + "true" + ); + + expect(spy).toHaveBeenCalledTimes(4); + }); + + it("starts with a visible sidebar when no state is stored", async () => { + const page = await newSpecPage({ + components: [SwirlConsoleLayout], + html: ` + +
Navigation
+
Content
+
+ `, + }); + + expect( + localStorage.getItem("SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE") + ).toBeNull(); + + expect(page.root.shadowRoot.querySelector(".console-layout")).toHaveClass( + "console-layout--sidebar-active" + ); + }); + + it("starts with a visible sidebar when the stored value is unknown", async () => { + localStorage.setItem("SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE", "banana"); + + const page = await newSpecPage({ + components: [SwirlConsoleLayout], + html: ` + +
Navigation
+
Content
+
+ `, + }); + + const sidebar = page.root.shadowRoot.querySelector( + "swirl-sidebar-navigation" + ); + + expect(page.root.shadowRoot.querySelector(".console-layout")).toHaveClass( + "console-layout--sidebar-active" + ); + expect(sidebar.hasAttribute("inert")).toBe(false); + }); + + it("persists the sidebar state under a custom storage key", async () => { + localStorage.setItem("CUSTOM_SIDEBAR_KEY", "false"); + + const page = await newSpecPage({ + components: [SwirlConsoleLayout], + html: ` + +
Navigation
+
Content
+
+ `, + }); + + const layout = page.root as HTMLSwirlConsoleLayoutElement; + + // The initial state is read from the custom key + expect(layout.shadowRoot.querySelector(".console-layout")).toHaveClass( + "console-layout--sidebar-hidden" + ); + + await layout.showSidebar(); + await page.waitForChanges(); + + // The state is persisted under the custom key, not the default one + expect(localStorage.getItem("CUSTOM_SIDEBAR_KEY")).toBe("true"); + expect( + localStorage.getItem("SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE") + ).toBeNull(); + }); + it("hides content header and applies hide content header class when hideContentHeader is true", async () => { const page = await newSpecPage({ components: [SwirlConsoleLayout], diff --git a/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.stories.ts b/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.stories.ts index d51020643..dd9987d2d 100644 --- a/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.stories.ts +++ b/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.stories.ts @@ -17,23 +17,48 @@ const Template = (args) => { const element = generateStoryElement("swirl-console-layout", args); element.innerHTML = ` - Test -
User
+ + + + + + + + + + + + + + + +
+ + Catherine Carter +
Content - + `; + // Expand the "User management" item to match the design + customElements.whenDefined("swirl-tree-navigation-item").then(async () => { + const userManagementItem = element.querySelector( + '[navigation-item-id="user-management"]' + ) as HTMLSwirlTreeNavigationItemElement; + + await userManagementItem?.componentOnReady(); + await userManagementItem?.expand(); + }); + return element; }; export const SwirlConsoleLayout = Template.bind({}); SwirlConsoleLayout.args = { - appName: "App name", - heading: "Heading", - showBackButton: true, - showHelpButton: true, - subheading: "Subheading", + heading: "Users", + logoText: "Admin Console", + maxContentWidth: "1200px", }; const TemplateWithBothSlots = (args) => { diff --git a/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.tsx b/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.tsx index 1ccc04bdb..7c5fb8dd7 100644 --- a/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.tsx +++ b/packages/swirl-components/src/components/swirl-console-layout/swirl-console-layout.tsx @@ -19,6 +19,7 @@ import { debounce, isMobileViewport } from "../../utils"; * @slot content-header-tools - Button positioned next to the heading * @slot footer - Footer content positioned at the bottom of the layout * @slot heading - The main content's heading (only rendered if "heading" prop is not set). + * @slot logo - Custom logo (forwarded to the sidebar navigation) * @slot navigation - The main navigation * @slot overlays - Overlays like dialogs, modals and toasts * @slot user - The signed in user information at the bottom of the sidebar @@ -42,6 +43,8 @@ export class SwirlConsoleLayout { @Prop() showBackButton?: boolean; @Prop() showHelpButton?: boolean; @Prop() showNavigationButtonLabel?: string = "Show main navigation"; + @Prop() sidebarVisibilityStateStorageKey?: string = + "SWIRL_CONSOLE_LAYOUT_SIDEBAR_STATE"; @Prop() subheading?: string; @Prop() hideContentHeader?: boolean; @@ -52,19 +55,32 @@ export class SwirlConsoleLayout { scrolledToBottom: false, }; @State() hasCustomAppBar: boolean; + @State() hasCustomLogo: boolean; @State() hasFooter: boolean; @Event() backButtonClick: EventEmitter; @Event() helpButtonClick: EventEmitter; + @Event() sidebarVisibilityChange: EventEmitter; - private sidebarEl: HTMLElement; private contentEl: HTMLElement; + private pendingFocus?: "collapse-button" | "toggle"; + private sidebarEl: HTMLElement; + + componentWillLoad() { + // Seed the initial state before the first render to avoid a visible + // collapse/expand transition on load + if (typeof window !== "undefined" && Boolean(window.matchMedia)) { + this.sidebarActive = !isMobileViewport() && this.getStoredSidebarState(); + } + } componentDidLoad() { queueMicrotask(() => { - if (!isMobileViewport()) { - this.activateSidebar(); + // Sync the inert attribute with the initial state seeded in + // componentWillLoad, without emitting sidebarVisibilityChange + if (this.sidebarActive) { + this.sidebarEl?.removeAttribute("inert"); } else { - this.deactivateSidebar(); + this.sidebarEl?.setAttribute("inert", ""); } // Update initial scroll state @@ -72,6 +88,7 @@ export class SwirlConsoleLayout { // Update initial slot states this.updateCustomAppBarStatus(); + this.updateCustomLogoStatus(); this.updateFooterStatus(); }); } @@ -80,6 +97,12 @@ export class SwirlConsoleLayout { this.hasCustomAppBar = Boolean(this.el.querySelector('[slot="app-bar"]')); }; + private updateCustomLogoStatus = () => { + // The forwarding slot is only rendered when a logo is actually slotted; + // otherwise it would suppress the sidebar navigation's logo fallback + this.hasCustomLogo = Boolean(this.el.querySelector('[slot="logo"]')); + }; + private updateFooterStatus = () => { this.hasFooter = Boolean(this.el.querySelector('[slot="footer"]')); }; @@ -114,15 +137,17 @@ export class SwirlConsoleLayout { onWindowResize() { const mobileViewport = isMobileViewport(); - if (!mobileViewport && !this.sidebarActive) { - this.activateSidebar(); - } else if (mobileViewport) { - this.deactivateSidebar(); + if (mobileViewport) { + if (this.sidebarActive) { + this.deactivateSidebar(false); + } + } else if (this.getStoredSidebarState() !== this.sidebarActive) { + this.restoreSidebarState(); } } /** - * Toggle the mobile navigation visibility. + * Toggle the sidebar visibility. */ @Method() async toggleSidebar() { @@ -134,7 +159,7 @@ export class SwirlConsoleLayout { } /** - * Show the mobile navigation. + * Show the sidebar. */ @Method() async showSidebar() { @@ -144,7 +169,7 @@ export class SwirlConsoleLayout { } /** - * Hide the mobile navigation. + * Hide the sidebar. */ @Method() async hideSidebar() { @@ -153,21 +178,99 @@ export class SwirlConsoleLayout { } } - private activateSidebar() { + private restoreSidebarState() { + if (this.getStoredSidebarState()) { + this.activateSidebar(false); + } else { + this.deactivateSidebar(false); + } + } + + private getStoredSidebarState(): boolean { + try { + return ( + localStorage.getItem(this.sidebarVisibilityStateStorageKey) !== "false" + ); + } catch { + return true; + } + } + + private storeSidebarState(active: boolean) { + try { + localStorage.setItem( + this.sidebarVisibilityStateStorageKey, + String(active) + ); + } catch { + // localStorage is unavailable; state is not persisted + } + } + + private activateSidebar(moveFocus: boolean = true) { this.sidebarActive = true; - this.sidebarEl.removeAttribute("inert"); + this.sidebarEl?.removeAttribute("inert"); if (isMobileViewport()) { - this.el.querySelector("swirl-tree-navigation-item")?.focus(); + if (moveFocus) { + this.el.querySelector("swirl-tree-navigation-item")?.focus(); + } + } else { + this.storeSidebarState(true); + + if (moveFocus) { + this.pendingFocus = "collapse-button"; + } } + + this.sidebarVisibilityChange.emit(true); } - private deactivateSidebar() { + private deactivateSidebar(moveFocus: boolean = true) { this.sidebarActive = false; + this.sidebarEl?.setAttribute("inert", ""); - if (isMobileViewport()) { - this.sidebarEl.setAttribute("inert", ""); + if (!isMobileViewport()) { + this.storeSidebarState(false); } + + if (moveFocus) { + this.pendingFocus = "toggle"; + } + + this.sidebarVisibilityChange.emit(false); + } + + componentDidUpdate() { + // Focus moves must happen after the re-render is committed: the toggle + // buttons mount/unmount and the sidebar's visibility changes with the + // sidebar state, so focusing earlier is dropped by the browser + if (this.pendingFocus === "toggle") { + this.focusSidebarToggle(); + } else if (this.pendingFocus === "collapse-button") { + ( + this.sidebarEl as HTMLSwirlSidebarNavigationElement + )?.focusCollapseButton?.(); + } + + this.pendingFocus = undefined; + } + + /** + * Move focus to the visible sidebar toggle after the sidebar was hidden, + * so keyboard users don't lose their place + */ + private focusSidebarToggle() { + const candidates = [ + this.el.shadowRoot.querySelector( + ".console-layout__show-sidebar-button" + ), + this.el.shadowRoot.querySelector( + ".console-layout__mobile-navigation-button button" + ), + ]; + + candidates.find((el) => Boolean(el) && el.offsetWidth > 0)?.focus(); } private onBackButtonClick = (event: MouseEvent) => { @@ -182,7 +285,19 @@ export class SwirlConsoleLayout { this.toggleSidebar(); }; + private onSidebarCollapseButtonClick = () => { + this.hideSidebar(); + }; + + private onShowSidebarButtonClick = () => { + this.showSidebar(); + }; + private onClick = (event: MouseEvent) => { + if (!isMobileViewport()) { + return; + } + const target = event.target as HTMLElement; const clickOnToggle = Boolean( @@ -203,6 +318,10 @@ export class SwirlConsoleLayout { }; private onKeyDown = (event: KeyboardEvent) => { + if (!isMobileViewport()) { + return; + } + if (event.code === "Escape" && this.sidebarActive) { this.deactivateSidebar(); } @@ -218,10 +337,12 @@ export class SwirlConsoleLayout { : undefined; this.updateCustomAppBarStatus(); + this.updateCustomLogoStatus(); this.updateFooterStatus(); const className = classnames("console-layout", { "console-layout--sidebar-active": this.sidebarActive, + "console-layout--sidebar-hidden": !this.sidebarActive, "console-layout--empty-app-bar": !Boolean(this.appName) && !this.showHelpButton && !this.hasCustomAppBar, "console-layout--has-footer": this.hasFooter, @@ -241,47 +362,38 @@ export class SwirlConsoleLayout { onClick={this.onClick} onKeyDown={this.onKeyDown} > - + {this.hasCustomLogo && } + + +
+ {!this.sidebarActive && ( + + )}
+ +# SwirlSidebarNavigation + +The SwirlSidebarNavigation component is a reusable sidebar navigation shell +consisting of a header with a logo and a collapse trigger, a scrollable +navigation area, and a user footer. It is used by SwirlConsoleLayout and can be +used standalone in apps. + +- **[Figma Design Specs](#)** +- **[Source Code](https://github.com/flip-corp/swirl/tree/main/packages/swirl-components/src/components/swirl-sidebar-navigation)** + +## Usage + +Please use the +[canvas view](?path=/story/components-swirlsidebarnavigation--swirl-sidebar-navigation) +to preview this component. + +Place a custom logo in the `logo` slot (falls back to a 32×32 rounded tile +showing the Flip logo mark), navigation content like SwirlTreeNavigation in the +default slot, and user profile content in the `user` slot. The +`collapseButtonClick` event is emitted when the collapse button is activated; +the host app or parent layout decides what to do with it. Set `elevated` to add +a drop shadow for floating/overlay use. + +Use the `focusCollapseButton` method to programmatically move focus to the +collapse button. Parent layouts like SwirlConsoleLayout use it to keep keyboard +focus in place after the sidebar is expanded. + + + +## Related components + +[SwirlConsoleLayout](?path=/docs/admin-swirlconsolelayout--docs), +[SwirlTreeNavigation](?path=/docs/admin-swirltreenavigation--docs) + +## Accessibility + +The navigation area is rendered as a `
+ + +
+ +
+ Navigation +
+
+ User +
+ + `); + }); + + it("emits collapseButtonClick when the collapse button is clicked", async () => { + const page = await newSpecPage({ + components: [SwirlSidebarNavigation], + html: ``, + }); + + const spy = jest.fn(); + + page.root.addEventListener("collapseButtonClick", spy); + + page.root.shadowRoot + .querySelector(".sidebar-navigation__collapse-button") + .click(); + + expect(spy).toHaveBeenCalled(); + }); + + it("hides the collapse button", async () => { + const page = await newSpecPage({ + components: [SwirlSidebarNavigation], + html: ``, + }); + + expect( + page.root.shadowRoot.querySelector(".sidebar-navigation__collapse-button") + ).toBeNull(); + }); + + it("applies the elevated modifier", async () => { + const page = await newSpecPage({ + components: [SwirlSidebarNavigation], + html: ``, + }); + + expect( + page.root.shadowRoot.querySelector(".sidebar-navigation") + ).toHaveClass("sidebar-navigation--elevated"); + }); +}); diff --git a/packages/swirl-components/src/components/swirl-sidebar-navigation/swirl-sidebar-navigation.stories.ts b/packages/swirl-components/src/components/swirl-sidebar-navigation/swirl-sidebar-navigation.stories.ts new file mode 100644 index 000000000..8a158c8db --- /dev/null +++ b/packages/swirl-components/src/components/swirl-sidebar-navigation/swirl-sidebar-navigation.stories.ts @@ -0,0 +1,67 @@ +import { generateStoryElement } from "../../utils"; +import Docs from "./swirl-sidebar-navigation.mdx"; + +export default { + component: "swirl-sidebar-navigation", + decorators: [ + (story) => { + const container = document.createElement("div"); + const sidebarContainer = document.createElement("div"); + + container.style.background = + "linear-gradient(162deg, #e8f1ff 10.92%, #d9e5ff 84.6%)"; + container.style.boxSizing = "border-box"; + container.style.height = "100vh"; + container.style.padding = "0.5rem"; + + sidebarContainer.style.height = "100%"; + sidebarContainer.style.width = "296px"; + + sidebarContainer.appendChild(story()); + container.appendChild(sidebarContainer); + + return container; + }, + ], + parameters: { + docs: { + page: Docs, + }, + layout: "fullscreen", + }, + title: "Components/SwirlSidebarNavigation", +}; + +const Template = (args) => { + const element = generateStoryElement("swirl-sidebar-navigation", args); + + element.innerHTML = ` + + + + + + + + +
+ + Catherine Carter +
+ `; + + return element; +}; + +export const SwirlSidebarNavigation = Template.bind({}); + +SwirlSidebarNavigation.args = { + appName: "Fusion", +}; + +export const Elevated = Template.bind({}); + +Elevated.args = { + appName: "Fusion", + elevated: true, +}; diff --git a/packages/swirl-components/src/components/swirl-sidebar-navigation/swirl-sidebar-navigation.tsx b/packages/swirl-components/src/components/swirl-sidebar-navigation/swirl-sidebar-navigation.tsx new file mode 100644 index 000000000..11beea0a7 --- /dev/null +++ b/packages/swirl-components/src/components/swirl-sidebar-navigation/swirl-sidebar-navigation.tsx @@ -0,0 +1,168 @@ +import { + Component, + Element, + Event, + EventEmitter, + h, + Host, + Listen, + Method, + Prop, + State, +} from "@stencil/core"; +import classnames from "classnames"; +import { debounce } from "../../utils"; + +/** + * @slot logo - Custom logo/brand mark shown in the header (32×32). Falls back to the Flip logo mark. + * @slot (default) - Navigation content (e.g. swirl-tree-navigation) + * @slot user - User profile content pinned to the bottom + */ +@Component({ + shadow: true, + styleUrl: "swirl-sidebar-navigation.css", + tag: "swirl-sidebar-navigation", +}) +export class SwirlSidebarNavigation { + @Element() el: HTMLElement; + + @Prop() appName?: string; + @Prop() collapseButtonLabel?: string = "Hide navigation"; + @Prop() elevated?: boolean; + @Prop() hideCollapseButton?: boolean; + @Prop() navigationLabel?: string = "Main"; + + @Event() collapseButtonClick: EventEmitter; + + @State() contentScrollState = { + scrollable: false, + scrolledToTop: true, + scrolledToBottom: true, + }; + + private contentEl: HTMLElement; + + componentDidLoad() { + this.updateContentScrollState(); + } + + /** + * Focus the collapse button. + */ + @Method() + async focusCollapseButton() { + this.el.shadowRoot + .querySelector(".sidebar-navigation__collapse-button") + ?.focus(); + } + + @Listen("resize", { target: "window" }) + onWindowResize() { + this.updateContentScrollState(); + } + + private updateContentScrollState() { + if (!this.contentEl) { + return; + } + + const newContentScrollState = { + scrollable: this.contentEl.scrollHeight > this.contentEl.clientHeight, + scrolledToTop: this.contentEl.scrollTop === 0, + scrolledToBottom: + Math.round(this.contentEl.scrollTop + this.contentEl.clientHeight) >= + this.contentEl.scrollHeight, + }; + + if ( + Object.keys(newContentScrollState).some( + (key) => newContentScrollState[key] !== this.contentScrollState[key] + ) + ) { + this.contentScrollState = newContentScrollState; + } + } + + private onContentScroll = debounce(() => { + this.updateContentScrollState(); + }, 16); + + private onNavigationSlotChange = () => { + // Items can be added/removed after slotted children hydrate; defer the + // measurement until the next frame so layout has settled + requestAnimationFrame(() => this.updateContentScrollState()); + }; + + private onCollapseButtonClick = (event: MouseEvent) => { + this.collapseButtonClick.emit(event); + }; + + render() { + const className = classnames("sidebar-navigation", { + "sidebar-navigation--elevated": this.elevated, + "sidebar-navigation--scrollable": this.contentScrollState.scrollable, + "sidebar-navigation--scrolled-to-top": + this.contentScrollState.scrolledToTop, + "sidebar-navigation--scrolled-to-bottom": + this.contentScrollState.scrolledToBottom, + }); + + return ( + +
+ + + +
+
+ ); + } +} diff --git a/packages/swirl-components/src/styles/global.css b/packages/swirl-components/src/styles/global.css index 1060c3b54..4fdec88cc 100644 --- a/packages/swirl-components/src/styles/global.css +++ b/packages/swirl-components/src/styles/global.css @@ -45,6 +45,13 @@ --swirl-carousel-gradient: 255, 255, 255; + --swirl-console-layout-background: radial-gradient( + 140% 78% at 50% 116%, + rgba(20, 90, 245, 0.46) 0%, + rgba(20, 90, 245, 0) 68% + ), + var(--s-surface-sunken-default); + --swirl-icon-button-border-top-right-radius: 50%; --swirl-icon-button-border-top-left-radius: 50%; --swirl-icon-button-border-bottom-right-radius: 50%; @@ -100,6 +107,7 @@ html.theme-dark { --swirl-carousel-gradient: 25, 26, 28; + --swirl-console-layout-background: var(--s-surface-sunken-default); color-scheme: dark; } diff --git a/packages/swirl-components/vscode-data.json b/packages/swirl-components/vscode-data.json index 833626f37..1aadf3f9d 100644 --- a/packages/swirl-components/vscode-data.json +++ b/packages/swirl-components/vscode-data.json @@ -2744,6 +2744,10 @@ "name": "show-navigation-button-label", "description": "" }, + { + "name": "sidebar-visibility-state-storage-key", + "description": "" + }, { "name": "subheading", "description": "" @@ -22726,6 +22730,35 @@ } ] }, + { + "name": "swirl-sidebar-navigation", + "description": { + "kind": "markdown", + "value": "" + }, + "attributes": [ + { + "name": "app-name", + "description": "" + }, + { + "name": "collapse-button-label", + "description": "" + }, + { + "name": "elevated", + "description": "" + }, + { + "name": "hide-collapse-button", + "description": "" + }, + { + "name": "navigation-label", + "description": "" + } + ] + }, { "name": "swirl-skeleton-box", "description": {