From 141489fcc5417abe5d8ab7e6cbfb44057b9b46ca Mon Sep 17 00:00:00 2001 From: Jessica Kuelz <15003460+jkuelz@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:16:14 -0700 Subject: [PATCH 1/3] feat: Add SideNavigation collapsed state, icon prop, variant, expandIconPosition, and design tokens - collapsed?: boolean prop for icon-only rail mode - icon?: ReactNode on Link, LinkGroup, ExpandableLinkGroup - expandIconPosition?: 'start' | 'end' prop - variant?: 'default' | 'highlighted' prop - Collapsed state: deduplicate dividers, hide sections/groups, flatten items - Design tokens: sizeSideNavigationItemHeight, spaceSideNavigationItemGap, spaceSideNavigationItemCollapsedGap - CSS refactor: BEM naming, height-based item model, transition support --- pages/app/templates.tsx | 6 +- .../__snapshots__/documenter.test.ts.snap | 58 ++- .../expandable-section-header.tsx | 136 +++++-- src/expandable-section/internal.tsx | 8 + src/expandable-section/styles.scss | 39 ++ src/panel-layout/internal.tsx | 19 +- src/panel-layout/styles.scss | 14 + src/side-navigation/implementation.tsx | 16 +- src/side-navigation/index.tsx | 15 +- src/side-navigation/interfaces.tsx | 47 ++- src/side-navigation/parts.tsx | 347 +++++++++++++++--- src/side-navigation/styles.scss | 258 ++++++++++--- src/side-navigation/test-classes/styles.scss | 4 + style-dictionary/utils/token-names.ts | 5 +- .../visual-refresh/metadata/colors.ts | 2 + .../visual-refresh/metadata/sizes.ts | 5 + .../visual-refresh/metadata/spacing.ts | 10 + style-dictionary/visual-refresh/sizes.ts | 1 + style-dictionary/visual-refresh/spacing.ts | 4 +- 19 files changed, 867 insertions(+), 127 deletions(-) diff --git a/pages/app/templates.tsx b/pages/app/templates.tsx index dd6d0689ce..5196f25acb 100644 --- a/pages/app/templates.tsx +++ b/pages/app/templates.tsx @@ -3,7 +3,7 @@ import React from 'react'; -import { Box, SpaceBetween } from '~components'; +import { Box, Divider, SpaceBetween } from '~components'; import I18nProvider, { I18nProviderProps } from '~components/i18n'; import messages from '~components/i18n/messages/all.all'; @@ -32,7 +32,9 @@ export function SimplePage({ title, subtitle, settings, children, screenshotArea {settings ? (
{settings}
-
+ + +
) : null} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index a327e6590a..8e58ade842 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -26151,6 +26151,38 @@ property explicitly set to \`false\`.", "optional": true, "type": "string", }, + { + "defaultValue": "false", + "description": "If true, the navigation is rendered in a compact, icon-only state: +- Item text labels and the header title are hidden. +- Section, \`Section group\`, \`Link group\`, and \`Expandable link group\` + children are not rendered. +- \`Items control\` is not rendered. +- Items without an \`icon\` are not rendered. Provide an icon for any + item that should remain visible while collapsed.", + "name": "collapsed", + "optional": true, + "type": "boolean", + }, + { + "defaultValue": "'start'", + "description": "Controls the placement of the expand/collapse icon for \`Section\` and +\`ExpandableLinkGroup\` items. +- \`start\` (default) - The icon is rendered before the section title. +- \`end\` - The icon is rendered after the section title and pulled to the + inline-end of a full-width header.", + "inlineType": { + "name": "SideNavigationProps.ExpandIconPosition", + "type": "union", + "values": [ + "end", + "start", + ], + }, + "name": "expandIconPosition", + "optional": true, + "type": "string", + }, { "description": "Controls the header that appears at the top of the navigation component. @@ -26230,6 +26262,7 @@ Links are rendered as \`\` tags. - \`externalIconAriaLabel\` (string) - Adds an aria-label to the external icon. - \`info\` (ReactNode) - Enables you to display content next to the link. Although it is technically possible to insert any content, our UX guidelines allow only to add a Badge and/or a "New" label. +- \`icon\` (ReactNode) - Optional content rendered before the link text. Accepts any React node (for example, an \`\` component or a custom \`\`). #### Divider Object that represents a horizontal divider between navigation content. @@ -26243,6 +26276,7 @@ Object that represents a section within the navigation. - \`items\` (array) - Specifies the content of the section. You can use any valid item from this list. Although there is no technical limitation to the nesting level, our UX recommendation is to use only one level. +- \`icon\` (ReactNode) - Optional content rendered before the section title. Accepts any React node. #### Section Group Aggregates a set of items that are conceptually related to each other, and can be displayed under a single heading to provide further organization. @@ -26250,6 +26284,7 @@ You can nest sections, links, link groups and expandable link groups within a se - \`type\`: \`'section-group'\`. - \`title\` (string) - Specifies the text to display as a title of the section group. - \`items\` (array) - Specifies the content of the section header group. You can use \`Section\`, \`Link\`, \`LinkGroup\`, \`ExpandableLinkGroup\`. +- \`icon\` (ReactNode) - Optional content rendered before the section group title. Accepts any React node. #### LinkGroup Object that represents a group of links. @@ -26261,6 +26296,7 @@ Object that represents a group of links. - \`items\` (array) - Specifies the content of the section. You can use any valid item from this list. Although there is no technical limitation to the nesting level, our UX recommendation is to use only one level. +- \`icon\` (ReactNode) - Optional content rendered before the link group text. Accepts any React node. #### ExpandableLinkGroup @@ -26272,11 +26308,31 @@ Object that represents an expandable group of links. If not explicitly set, the group is collapsed by default, unless one of the nested links is active. - \`items\` (array) - Specifies the content of the section. You can use any valid item from this list. Although there is no technical limitation to the nesting level, - our UX recommendation is to use only one level.", + our UX recommendation is to use only one level. +- \`icon\` (ReactNode) - Optional content rendered before the expandable link group text. Accepts any React node.", "name": "items", "optional": true, "type": "ReadonlyArray", }, + { + "defaultValue": "'default'", + "description": "Visual variant of the navigation: +- \`default\` (default) - The active item uses a text-highlight style. +- \`highlighted\` - The active item uses a background fill with rounded + corners. Each item is padded so the background fills a comfortable area + around the label and icon.", + "inlineType": { + "name": "SideNavigationProps.Variant", + "type": "union", + "values": [ + "default", + "highlighted", + ], + }, + "name": "variant", + "optional": true, + "type": "string", + }, ], "regions": [ { diff --git a/src/expandable-section/expandable-section-header.tsx b/src/expandable-section/expandable-section-header.tsx index 357620cce9..6f2ca5c167 100644 --- a/src/expandable-section/expandable-section-header.tsx +++ b/src/expandable-section/expandable-section-header.tsx @@ -39,6 +39,7 @@ interface ExpandableDefaultHeaderProps { onClick: MouseEventHandler; icon: JSX.Element; variant: InternalVariant; + expandIconPosition?: 'start' | 'end'; } interface ExpandableNavigationHeaderProps extends Omit { @@ -124,22 +125,47 @@ const ExpandableNavigationHeader = ({ expanded, children, icon, + expandIconPosition = 'start', }: ExpandableNavigationHeaderProps) => { + const iconButton = ( + + ); return ( -
- - {children} +
+ {expandIconPosition === 'end' ? ( + <> + {children} + {iconButton} + + ) : ( + <> + {iconButton} + {children} + + )}
); }; @@ -162,6 +188,7 @@ const ExpandableHeaderTextWrapper = ({ headingTagOverride, onKeyUp, onKeyDown, + expandIconPosition = 'start', }: ExpandableHeaderTextWrapperProps) => { const isContainer = variant === 'container'; const HeadingTag = headingTagOverride || 'div'; @@ -174,6 +201,11 @@ const ExpandableHeaderTextWrapper = ({ ); const listeners = { onClick, onKeyDown, onKeyUp }; + const iconAtEnd = expandIconPosition === 'end'; + // For the container variant with end placement, the icon is rendered outside the + // InternalHeader so it appears at the inline-end of the wrapper, past any header + // actions. Other variants render the icon at the end of the headerButton itself. + const renderIconOutsideHeader = isContainer && iconAtEnd; // If interactive elements are present, constrain the clickable area to only the icon and the header text // to prevent nesting interactive elements. @@ -184,11 +216,28 @@ const ExpandableHeaderTextWrapper = ({ const headingTagListeners = !headerButtonListeners && !isContainer && description ? listeners : undefined; // For all other cases, make the entire header clickable for backwards compatibility. const wrapperListeners = !headerButtonListeners && !headingTagListeners ? listeners : undefined; + const iconElement = ( + + {icon} + + ); + const textElement = ( + + {children} + + ); const headerButton = ( - {icon} - - {children} - + {renderIconOutsideHeader ? ( + textElement + ) : iconAtEnd ? ( + <> + {textElement} + {iconElement} + + ) : ( + <> + {iconElement} + {textElement} + + )} ); return (
{isContainer ? ( - - {headerButton} - + renderIconOutsideHeader ? ( + <> +
+ + {headerButton} + +
+ {iconElement} + + ) : ( + + {headerButton} + + ) ) : ( <>
@@ -263,6 +339,7 @@ export const ExpandableSectionHeader = ({ onKeyUp, onKeyDown, onClick, + expandIconPosition, }: ExpandableSectionHeaderProps) => { const alwaysShowDivider = variantRequiresActionsDivider(variant) && headerActions; const icon = ( @@ -280,6 +357,7 @@ export const ExpandableSectionHeader = ({ ariaLabel: ariaLabel, onClick: onClick, variant, + expandIconPosition, }; if ((headerCounter || headerInfo) && !variantSupportsInfoLink(variant) && isDevelopment) { diff --git a/src/expandable-section/internal.tsx b/src/expandable-section/internal.tsx index b821c11419..a8c22b0745 100644 --- a/src/expandable-section/internal.tsx +++ b/src/expandable-section/internal.tsx @@ -24,6 +24,12 @@ export type InternalExpandableSectionProps = Omit(null); @@ -121,6 +128,7 @@ export default function InternalExpandableSection({ headerInfo={headerInfo} headerActions={headerActions} headingTagOverride={headingTagOverride} + expandIconPosition={__expandIconPosition} {...triggerProps} /> } diff --git a/src/expandable-section/styles.scss b/src/expandable-section/styles.scss index 0c4c76a441..dabb5aecec 100644 --- a/src/expandable-section/styles.scss +++ b/src/expandable-section/styles.scss @@ -285,3 +285,42 @@ $icon-total-space-medium: calc(#{$icon-width-medium} + #{$icon-margin-left} + #{ color: awsui.$color-text-expandable-section-hover; } } + +// When the expand/collapse icon is rendered at the end of the header. +// For non-container variants the icon is rendered at the end of the headerButton +// (taking the full width of the header). For the container variant the icon is +// rendered as a sibling of the InternalHeader so it appears past any actions. +.header-icon-end { + &.wrapper-default, + &.wrapper-inline, + &.wrapper-footer, + &.wrapper-compact { + padding-inline-start: 0; + } + + &.wrapper-container { + display: flex; + align-items: flex-start; + + &:not(.header-deprecated) { + padding-inline-start: container.$header-padding-horizontal; + } + } +} + +.header-button-icon-end { + display: flex; + margin-inline-start: 0; + inline-size: 100%; + align-items: center; +} + +.icon-container-end { + margin-inline-start: auto; + margin-inline-end: 0; +} + +.internal-header-flex-grow { + flex: 1; + min-inline-size: 0; +} diff --git a/src/panel-layout/internal.tsx b/src/panel-layout/internal.tsx index 1fc71027ac..f90a34dada 100644 --- a/src/panel-layout/internal.tsx +++ b/src/panel-layout/internal.tsx @@ -50,6 +50,13 @@ const InternalPanelLayout = React.forwardRef(null); const panelRef = React.useRef(null); const [containerWidth, rootRef] = useContainerWidth(); + const [isResizing, setIsResizing] = React.useState(false); + const [hasMounted, setHasMounted] = React.useState(false); + + React.useEffect(() => { + const frame = requestAnimationFrame(() => setHasMounted(true)); + return () => cancelAnimationFrame(frame); + }, []); React.useImperativeHandle( ref, @@ -92,11 +99,21 @@ const InternalPanelLayout = React.forwardRef { + setIsResizing(true); setPanelSize(size); fireNonCancelableEvent(onPanelResize, { totalSize: containerWidth, panelSize: size }); }, }); + React.useEffect(() => { + if (!isResizing) { + return; + } + const onPointerUp = () => setIsResizing(false); + document.addEventListener('pointerup', onPointerUp); + return () => document.removeEventListener('pointerup', onPointerUp); + }, [isResizing]); + const mergedRef = useMergeRefs(rootRef, __internalRootRef, ref); const wrappedPanelContent = ( @@ -145,7 +162,7 @@ const InternalPanelLayout = React.forwardRef {panelPosition === 'side-end' && wrappedMainContent}
diff --git a/src/panel-layout/styles.scss b/src/panel-layout/styles.scss index 62e17be8c4..fcd953c53a 100644 --- a/src/panel-layout/styles.scss +++ b/src/panel-layout/styles.scss @@ -30,6 +30,17 @@ .panel { display: flex; flex-shrink: 0; + @include styles.with-motion { + transition: inline-size awsui.$motion-duration-complex awsui.$motion-easing-responsive; + } + &-resizing { + @include styles.with-motion { + transition: none; + } + > .panel-content { + pointer-events: none; + } + } > .handle { display: flex; align-items: center; @@ -59,6 +70,9 @@ .display-panel-only > & { display: none; } + .panel-resizing ~ & { + pointer-events: none; + } @include focus-visible.when-visible { @include container-inner-focus-ring; } diff --git a/src/side-navigation/implementation.tsx b/src/side-navigation/implementation.tsx index 4b2d11a066..80c73065ce 100644 --- a/src/side-navigation/implementation.tsx +++ b/src/side-navigation/implementation.tsx @@ -24,6 +24,9 @@ export function SideNavigationImplementation({ items = [], onFollow, onChange, + expandIconPosition = 'start', + collapsed = false, + variant = 'default', __internalRootRef, ...props }: SideNavigationInternalProps) { @@ -66,9 +69,15 @@ export function SideNavigationImplementation({ ref={__internalRootRef} > {header && ( -
+
)} - {itemsControl &&
{itemsControl}
} + {!collapsed && itemsControl &&
{itemsControl}
} {items && (
)} diff --git a/src/side-navigation/index.tsx b/src/side-navigation/index.tsx index ea6ff5c36c..f0a7ccfd1d 100644 --- a/src/side-navigation/index.tsx +++ b/src/side-navigation/index.tsx @@ -15,8 +15,16 @@ import analyticsSelectors from './analytics-metadata/styles.css.js'; export { SideNavigationProps }; -export default function SideNavigation({ items = [], ...props }: SideNavigationProps) { - const internalProps = useBaseComponent('SideNavigation'); +export default function SideNavigation({ + items = [], + expandIconPosition = 'start', + collapsed = false, + variant = 'default', + ...props +}: SideNavigationProps) { + const internalProps = useBaseComponent('SideNavigation', { + props: { expandIconPosition, collapsed, variant }, + }); const componentAnalyticMetadata: GeneratedAnalyticsMetadataSideNavigationComponent = { name: 'awsui.SideNavigation', @@ -30,6 +38,9 @@ export default function SideNavigation({ items = [], ...props }: SideNavigationP {...props} {...internalProps} items={items} + expandIconPosition={expandIconPosition} + collapsed={collapsed} + variant={variant} {...getAnalyticsMetadataAttribute({ component: componentAnalyticMetadata })} /> ); diff --git a/src/side-navigation/interfaces.tsx b/src/side-navigation/interfaces.tsx index c3241d9bc5..e6f378b026 100644 --- a/src/side-navigation/interfaces.tsx +++ b/src/side-navigation/interfaces.tsx @@ -52,6 +52,7 @@ export interface SideNavigationProps extends BaseComponentProps { * - `externalIconAriaLabel` (string) - Adds an aria-label to the external icon. * - `info` (ReactNode) - Enables you to display content next to the link. Although it is technically possible to insert any content, * our UX guidelines allow only to add a Badge and/or a "New" label. + * - `icon` (ReactNode) - Optional content rendered before the link text. Accepts any React node (for example, an `` component or a custom ``). * * #### Divider * Object that represents a horizontal divider between navigation content. @@ -65,6 +66,7 @@ export interface SideNavigationProps extends BaseComponentProps { * - `items` (array) - Specifies the content of the section. You can use any valid item from this list. * Although there is no technical limitation to the nesting level, * our UX recommendation is to use only one level. + * - `icon` (ReactNode) - Optional content rendered before the section title. Accepts any React node. * * #### Section Group * Aggregates a set of items that are conceptually related to each other, and can be displayed under a single heading to provide further organization. @@ -72,6 +74,7 @@ export interface SideNavigationProps extends BaseComponentProps { * - `type`: `'section-group'`. * - `title` (string) - Specifies the text to display as a title of the section group. * - `items` (array) - Specifies the content of the section header group. You can use `Section`, `Link`, `LinkGroup`, `ExpandableLinkGroup`. + * - `icon` (ReactNode) - Optional content rendered before the section group title. Accepts any React node. * * #### LinkGroup * Object that represents a group of links. @@ -83,6 +86,7 @@ export interface SideNavigationProps extends BaseComponentProps { * - `items` (array) - Specifies the content of the section. You can use any valid item from this list. * Although there is no technical limitation to the nesting level, * our UX recommendation is to use only one level. + * - `icon` (ReactNode) - Optional content rendered before the link group text. Accepts any React node. * * #### ExpandableLinkGroup * @@ -95,6 +99,7 @@ export interface SideNavigationProps extends BaseComponentProps { * - `items` (array) - Specifies the content of the section. You can use any valid item from this list. * Although there is no technical limitation to the nesting level, * our UX recommendation is to use only one level. + * - `icon` (ReactNode) - Optional content rendered before the expandable link group text. Accepts any React node. */ items?: ReadonlyArray; @@ -124,12 +129,47 @@ export interface SideNavigationProps extends BaseComponentProps { * upon changing the `activeHref` property, this event isn't raised. */ onChange?: NonCancelableEventHandler; + + /** + * Controls the placement of the expand/collapse icon for `Section` and + * `ExpandableLinkGroup` items. + * - `start` (default) - The icon is rendered before the section title. + * - `end` - The icon is rendered after the section title and pulled to the + * inline-end of a full-width header. + */ + expandIconPosition?: SideNavigationProps.ExpandIconPosition; + + /** + * If true, the navigation is rendered in a compact, icon-only state: + * - Item text labels and the header title are hidden. + * - Section, `Section group`, `Link group`, and `Expandable link group` + * children are not rendered. + * - `Items control` is not rendered. + * - Items without an `icon` are not rendered. Provide an icon for any + * item that should remain visible while collapsed. + * + * @defaultValue false + */ + collapsed?: boolean; + + /** + * Visual variant of the navigation: + * - `default` (default) - The active item uses a text-highlight style. + * - `highlighted` - The active item uses a background fill with rounded + * corners. Each item is padded so the background fills a comfortable area + * around the label and icon. + */ + variant?: SideNavigationProps.Variant; } export namespace SideNavigationProps { + export type ExpandIconPosition = 'start' | 'end'; + export type Variant = 'default' | 'highlighted'; + export interface Logo { - src: string; + src?: string; alt?: string; + svg?: React.ReactNode; } export interface Header { text?: string; @@ -148,6 +188,7 @@ export namespace SideNavigationProps { external?: boolean; externalIconAriaLabel?: string; info?: React.ReactNode; + icon?: React.ReactNode; } export interface Section { @@ -155,12 +196,14 @@ export namespace SideNavigationProps { text: string; items: ReadonlyArray; defaultExpanded?: boolean; + icon?: React.ReactNode; } export interface SectionGroup { type: 'section-group'; title: string; items: ReadonlyArray
; + icon?: React.ReactNode; } export interface LinkGroup { type: 'link-group'; @@ -168,6 +211,7 @@ export namespace SideNavigationProps { href: string; info?: React.ReactNode; items: ReadonlyArray; + icon?: React.ReactNode; } export interface ExpandableLinkGroup { @@ -176,6 +220,7 @@ export namespace SideNavigationProps { href: string; items: ReadonlyArray; defaultExpanded?: boolean; + icon?: React.ReactNode; } export type Item = Divider | Link | Section | LinkGroup | ExpandableLinkGroup | SectionGroup; diff --git a/src/side-navigation/parts.tsx b/src/side-navigation/parts.tsx index 484d42494a..0151d42a2a 100644 --- a/src/side-navigation/parts.tsx +++ b/src/side-navigation/parts.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import clsx from 'clsx'; import { getAnalyticsMetadataAttribute } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; @@ -13,6 +13,7 @@ import InternalIcon from '../icon/internal'; import { isPlainLeftClick, NonCancelableCustomEvent } from '../internal/events'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { checkSafeUrl } from '../internal/utils/check-safe-url'; +import Tooltip from '../tooltip/internal'; import { GeneratedAnalyticsMetadataSideNavigationClick } from './analytics-metadata/interfaces'; import { SideNavigationProps } from './interfaces'; import { hasActiveLink } from './util'; @@ -33,13 +34,18 @@ interface BaseItemComponentProps { event: React.SyntheticEvent | Event ) => void; position?: string; + expandIconPosition?: SideNavigationProps.ExpandIconPosition; + collapsed?: boolean; + // Renamed from `variant` (which is already used for the list variant) so we + // can plumb the SideNavigation public `variant` prop down to each link. + highlightVariant?: SideNavigationProps.Variant; } interface HeaderProps extends BaseItemComponentProps { definition: SideNavigationProps.Header; } -export function Header({ definition, activeHref, fireFollow }: HeaderProps) { +export function Header({ definition, activeHref, fireFollow, collapsed }: HeaderProps) { checkSafeUrl('SideNavigation', definition.href); const onClick = useCallback( (event: React.MouseEvent) => { @@ -62,25 +68,38 @@ export function Header({ definition, activeHref, fireFollow }: HeaderProps) { return ( <> -

+

- {definition.logo && ( - + {definition.logo && + (definition.logo.svg ? ( + + {definition.logo.svg} + + ) : ( + {definition.logo.alt} + ))} + {!collapsed && ( + + {definition.text} + )} - - {definition.text} -

@@ -106,6 +125,9 @@ export function NavigationItemsList({ fireChange, fireFollow, position = '', + expandIconPosition, + collapsed, + highlightVariant, }: NavigationItemsListProps) { const lists: Array = []; let currentListIndex = 0; @@ -117,13 +139,21 @@ export function NavigationItemsList({ items.forEach((item, index) => { const itemid = index + 1; const itemPosition = `${position ? `${position},` : ''}${itemid}`; + // In collapsed mode, only selectable items are visible (links, link-groups, ELGs). + // Sections and section-groups are non-selectable containers — skip them. + if (collapsed && (item.type === 'section' || item.type === 'section-group')) { + return; + } + if (collapsed && item.type !== 'divider' && !item.icon) { + return; + } switch (item.type) { case 'divider': { const dividerIndex = lists.length; lists[dividerIndex] = { element: (
- +
), }; @@ -137,13 +167,19 @@ export function NavigationItemsList({ case 'link': { lists[currentListIndex].items?.push({ element: ( -
  • +
  • ), @@ -153,7 +189,11 @@ export function NavigationItemsList({ case 'section': { lists[currentListIndex].items?.push({ element: ( -
  • +
  • ), @@ -170,13 +213,20 @@ export function NavigationItemsList({ case 'section-group': { lists[currentListIndex].items?.push({ element: ( -
  • +
  • ), @@ -186,13 +236,20 @@ export function NavigationItemsList({ case 'link-group': { lists[currentListIndex].items?.push({ element: ( -
  • +
  • ), @@ -202,7 +259,11 @@ export function NavigationItemsList({ case 'expandable-link-group': { lists[currentListIndex].items?.push({ element: ( -
  • +
  • ), @@ -219,15 +283,32 @@ export function NavigationItemsList({ } }); + // In collapsed mode, skip empty item segments and deduplicate consecutive dividers. + const filteredLists = collapsed + ? lists.filter((list, index) => { + if (list.items) { + return list.items.length > 0; + } + // Divider — skip if preceded by another divider or empty segment. + const prevVisible = lists + .slice(0, index) + .reverse() + .find(l => !l.items || l.items.length > 0); + return !prevVisible || (prevVisible.items !== undefined && prevVisible.items.length > 0); + }) + : lists; + return ( <> - {lists.map((list, index) => { + {filteredLists.map((list, index) => { if (!list.items || list.items.length === 0) { return (
    {list.element} @@ -239,6 +320,9 @@ export function NavigationItemsList({ key={`list-${index}`} className={clsx(styles.list, styles[`list-variant-${list.listVariant}`], { [styles['list-variant-root--first']]: list.listVariant === 'root' && index === 0, + [styles['list-variant-root--symmetric']]: + list.listVariant === 'root' && (collapsed || expandIconPosition === 'end'), + [styles[`expand-icon-end`]]: expandIconPosition === 'end', })} > {list.items.map(item => item.element)} @@ -253,12 +337,13 @@ export function NavigationItemsList({ interface DividerProps { variant: 'default' | 'header'; isPresentational?: boolean; + collapsed?: boolean; } -function Divider({ variant = 'default', isPresentational = false }: DividerProps) { +function Divider({ variant = 'default', isPresentational = false, collapsed }: DividerProps) { return (
    ); @@ -268,10 +353,61 @@ interface LinkProps extends BaseItemComponentProps { definition: SideNavigationProps.Link; } -function Link({ definition, activeHref, fireFollow, position }: LinkProps) { +interface ItemIconProps extends React.HTMLAttributes { + icon: React.ReactNode; + collapsed?: boolean; +} + +const ItemIcon = React.forwardRef(function ItemIcon( + { icon, collapsed, className, ...rest }, + ref +) { + if (!icon) { + return null; + } + return ( + + {icon} + + ); +}); + +// Manages a tooltip that shows the item's text label on focus or hover. +// Used in the collapsed state, where the visible labels are hidden, to give +// pointer and keyboard users a way to identify each item without relying on +// their browser's native title popup. +function useCollapsedTooltip(label: React.ReactNode) { + const [show, setShow] = useState(false); + const triggerRef = useRef(null); + + const triggerProps = { + onFocus: () => setShow(true), + onBlur: () => setShow(false), + onMouseEnter: () => setShow(true), + onMouseLeave: () => setShow(false), + }; + + const tooltip = show ? ( + triggerRef.current} content={label} position="right" onEscape={() => setShow(false)} /> + ) : null; + + return { triggerRef, triggerProps, tooltip }; +} + +function Link({ definition, activeHref, fireFollow, position, collapsed, highlightVariant }: LinkProps) { checkSafeUrl('SideNavigation', definition.href); const isActive = definition.href === activeHref; const i18n = useInternalI18n('link'); + const collapsedTooltip = useCollapsedTooltip(definition.text); const onClick = useCallback( (event: React.MouseEvent) => { @@ -297,22 +433,33 @@ function Link({ definition, activeHref, fireFollow, position }: LinkProps) { return ( <> - {definition.text} - {definition.external && ( + + {!collapsed && {definition.text}} + {!collapsed && definition.external && ( )} - {definition.info && {definition.info}} + {!collapsed && definition.info && ( + {definition.info} + )} + {collapsed && collapsedTooltip.tooltip} ); } @@ -322,9 +469,20 @@ interface SectionProps extends BaseItemComponentProps { variant: 'section' | 'section-group' | 'link-group' | 'expandable-link-group' | 'root'; } -function Section({ definition, activeHref, fireFollow, fireChange, variant, position }: SectionProps) { +function Section({ + definition, + activeHref, + fireFollow, + fireChange, + variant, + position, + expandIconPosition, + collapsed, + highlightVariant, +}: SectionProps) { const [expanded, setExpanded] = useState(definition.defaultExpanded ?? true); const isVisualRefresh = useVisualRefresh(); + const collapsedTooltip = useCollapsedTooltip(definition.text); const onExpandedChange = useCallback( (e: NonCancelableCustomEvent) => { @@ -338,6 +496,23 @@ function Section({ definition, activeHref, fireFollow, fireChange, variant, posi setExpanded(definition.defaultExpanded ?? true); }, [definition]); + if (collapsed) { + return ( + <> + + {collapsedTooltip.tooltip} + + ); + } + + const isNestedInSectionGroup = variant === 'section-group'; + return ( + + {definition.text} + + ) : ( + definition.text + ) + } + __expandIconPosition={expandIconPosition} > ); @@ -366,10 +554,36 @@ interface SectionGroupProps extends BaseItemComponentProps { definition: SideNavigationProps.SectionGroup; } -function SectionGroup({ definition, activeHref, fireFollow, fireChange, position }: SectionGroupProps) { +function SectionGroup({ + definition, + activeHref, + fireFollow, + fireChange, + position, + expandIconPosition, + collapsed, + highlightVariant, +}: SectionGroupProps) { + const collapsedTooltip = useCollapsedTooltip(definition.title); + + if (collapsed) { + return ( + <> + + {collapsedTooltip.tooltip} + + ); + } return (
    + {definition.title}
    ); @@ -388,26 +604,47 @@ interface LinkGroupProps extends BaseItemComponentProps { definition: SideNavigationProps.LinkGroup; } -function LinkGroup({ definition, activeHref, fireFollow, fireChange, position }: LinkGroupProps) { +function LinkGroup({ + definition, + activeHref, + fireFollow, + fireChange, + position, + expandIconPosition, + collapsed, + highlightVariant, +}: LinkGroupProps) { checkSafeUrl('SideNavigation', definition.href); return ( <> fireFollow(definition, event)} fireChange={fireChange} activeHref={activeHref} position={position} + collapsed={collapsed} + highlightVariant={highlightVariant} /> - + {!collapsed && ( + + )} ); } @@ -424,6 +661,9 @@ function ExpandableLinkGroup({ activeHref, variant, position, + expandIconPosition, + collapsed, + highlightVariant, }: ExpandableLinkGroupProps) { // Check whether the definition contains an active link and memoize it to avoid // rechecking every time. @@ -468,24 +708,43 @@ function ExpandableLinkGroup({ } }; + if (collapsed) { + return ( + + ); + } + return ( } + __expandIconPosition={expandIconPosition} > ); diff --git a/src/side-navigation/styles.scss b/src/side-navigation/styles.scss index 1595911163..e347cccc2a 100644 --- a/src/side-navigation/styles.scss +++ b/src/side-navigation/styles.scss @@ -7,11 +7,37 @@ @use '../internal/styles/tokens' as awsui; @use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; +// ========================================================================== +// Item sizing — single source of truth for navigation density. +// Adjust $item-height to scale all items uniformly. +// ========================================================================== +$item-icon-text-gap: awsui.$space-xs; +$item-height: awsui.$size-side-navigation-item-height; +$item-margin-block: awsui.$space-side-navigation-item-gap; +$item-collapsed-gap: awsui.$space-side-navigation-item-collapsed-gap; +$item-padding-inline: awsui.$space-xs; +$item-indent: calc(16px + #{$item-icon-text-gap} - #{$item-padding-inline}); +$expandable-icon-negative-margin: 19px; + +@mixin item-radius { + border-start-start-radius: awsui.$border-radius-item; + border-start-end-radius: awsui.$border-radius-item; + border-end-start-radius: awsui.$border-radius-item; + border-end-end-radius: awsui.$border-radius-item; +} + .root { @include styles.styles-reset; @include styles.text-wrapping; } +.with-toolbar { + /* Structural class — parent context for toolbar margin resets. */ +} + +// ========================================================================== +// Header +// ========================================================================== .header { @include styles.font-panel-header; margin-block: 0; @@ -20,13 +46,19 @@ padding-inline-start: awsui.$space-panel-nav-left; // Additional xl space to prevent text from overlapping the close button. padding-inline-end: calc(#{awsui.$space-scaled-xxl} + #{awsui.$space-xl}); + + &--collapsed { + padding-inline: 0; + display: flex; + justify-content: center; + align-items: center; + } } .header-link { @include styles.font-panel-header; color: awsui.$color-text-heading-default; min-block-size: awsui.$font-panel-header-line-height; - display: flex; &--has-logo { @@ -49,6 +81,9 @@ } } +// ========================================================================== +// List structure +// ========================================================================== .items-control { padding-inline: awsui.$space-l; } @@ -71,93 +106,216 @@ margin-inline: 0; padding-block: 0; padding-inline-end: 0; - padding-inline-start: awsui.$space-l; + padding-inline-start: $item-indent; } .list-variant-root { margin-block: 0; margin-inline: 0; padding-block: 0; - padding-inline-start: awsui.$space-panel-nav-left; - padding-inline-end: awsui.$space-panel-side-right; + padding-inline-start: calc(#{awsui.$space-panel-nav-left} - #{$item-padding-inline}); + padding-inline-end: calc(#{awsui.$space-panel-side-right} - #{$item-padding-inline}); &--first { margin-block-start: 0; } + + &--symmetric { + padding-inline-start: calc(#{awsui.$space-panel-side-right} - #{$item-padding-inline}); + } + + &--collapsed { + padding-inline-start: 0; + padding-inline-end: 0; + } } .list-variant-expandable-link-group { - padding-inline-start: awsui.$space-xxxl; + // 2 levels of nested indent (header + child) + icon/text offset. + padding-inline-start: calc(#{$item-indent} + #{$expandable-icon-negative-margin}); + &.expand-icon-end { + padding-inline-start: $item-indent; + } +} + +.list-variant-section { + padding-inline-start: calc(#{$expandable-icon-negative-margin} - #{$item-padding-inline}); + &.expand-icon-end { + padding-inline-start: 0; + margin-inline-start: calc(-1 * #{$item-padding-inline}); + } } +.list-variant-link-group { + padding-inline-start: $item-indent; +} + +.list-variant-section-group { + margin-block: 0; + margin-inline: calc(-1 * #{$item-padding-inline}); + padding-block: 0; + padding-inline: 0; + + &-collapsed { + margin-block: awsui.$space-scaled-xxxl; + margin-inline: 0; + padding-inline-start: 0; + padding-inline-end: 0; + } +} + +// ========================================================================== +// List items +// ========================================================================== .list-item { - margin-block: awsui.$space-scaled-xs; margin-inline: 0; + margin-block: $item-margin-block; padding-block: 0; - padding-inline: 0; + padding-inline: $item-padding-inline; list-style: none; - + @include styles.with-motion { + transition: margin awsui.$motion-duration-expressive awsui.$motion-easing-responsive; + } // Remove margin from first item in side nav, outer block margins are covered by list-container .list-variant-root--first > &:first-child { margin-block-start: 0px; } + + &--collapsed { + margin-block: $item-collapsed-gap; + display: flex; + justify-content: center; + padding-block: 0; + padding-inline: 0; + @include styles.with-motion { + transition: margin awsui.$motion-duration-expressive awsui.$motion-easing-responsive; + } + } } +// ========================================================================== +// Sections and expandable link groups (InternalExpandableSection) +// ========================================================================== .section, .expandable-link-group { - margin-inline-start: calc(-1 * #{awsui.$space-l}); + margin-inline-start: calc(-1 * #{$expandable-icon-negative-margin}); + /* stylelint-disable-next-line selector-max-type */ + & > div { + padding-block: 0; + padding-inline: 0; + &:first-child { + display: grid; + align-items: center; + min-block-size: $item-height; + } + } - &--no-ident { + &--no-ident, + &--expand-icon-end { margin-inline-start: 0; } } +.expandable-link-group > div:first-child { + @include item-radius; + display: flex; + padding-inline: $item-padding-inline; + margin-inline: calc(-1 * #{$item-padding-inline}); +} + +.expandable-link-group--expand-icon-end { + margin-inline-end: calc(-2 * #{$item-padding-inline}); +} + +.expandable-link-group-active > div:first-child { + background-color: color-mix(in srgb, #{awsui.$color-item-selected} 10%, transparent); +} + .section { margin-block: calc(#{awsui.$space-scaled-2x-l} - #{awsui.$border-divider-section-width}); &.refresh { - margin-block: calc(#{awsui.$space-scaled-2x-m} - #{awsui.$border-divider-section-width}); + margin-block: calc(#{awsui.$space-scaled-s} - #{awsui.$border-divider-section-width}); } // Remove margin from section if it is the first item in side nav to prevent double margin stacking .list-variant-root--first > .list-item:first-child > & { margin-block-start: 0px; } - // HACK: Remove padding from section header and content to rely on margin collapsing rules. - /* stylelint-disable-next-line selector-max-type */ - > div { - padding-block: 0; - padding-inline: 0; - } } -.list-variant-section-group { - margin-block: 0; +// ========================================================================== +// Section groups +// ========================================================================== +.section-group { + @include styles.font-heading-s; + margin-block: calc(#{awsui.$space-scaled-2x-l} - #{awsui.$border-divider-section-width}); margin-inline: 0; - padding-block: 0; - padding-inline: 0; + + .list-variant-root--first > .list-item:first-child > & { + margin-block-start: 0px; + } } -.section-group { - @include styles.font-heading-m; +.section-group-title { + display: flex; + min-block-size: $item-height; + align-items: center; margin-block: 0; - margin-inline: 0; + padding-block: awsui.$space-scaled-xxs; + &--eyebrow { + @include styles.font-body-s; + text-transform: uppercase; + letter-spacing: 0.35px; + color: awsui.$color-text-body-secondary; + } } -.section-group-title { - /* used in test-utils */ +.section-header-text { + display: inline-flex; } +// ========================================================================== +// Links — all links use $item-height for consistent row sizing and collapse icon size. +// ========================================================================== .link { @include styles.font-body-m; color: awsui.$color-text-body-secondary; + display: inline-flex; + min-block-size: $item-height; + min-inline-size: $item-height; + padding-inline: $item-padding-inline; + margin-inline: calc(-1 * #{$item-padding-inline}); + align-items: center; + @include item-radius; font-weight: styles.$font-weight-normal; -webkit-font-smoothing: auto; -moz-osx-font-smoothing: auto; -} + @include styles.with-motion { + transition: + background-color awsui.$motion-duration-expressive awsui.$motion-easing-responsive, + color awsui.$motion-duration-expressive awsui.$motion-easing-expressive; + } + + &--active { + font-weight: awsui.$font-wayfinding-link-active-weight; + @include styles.font-smoothing; + color: awsui.$color-text-accent; + &.link--pill { + background-color: color-mix(in srgb, #{awsui.$color-background-item-selected} 50%, transparent); + color: awsui.$color-item-selected; + } + } -.link-active { - font-weight: awsui.$font-wayfinding-link-active-weight; - @include styles.font-smoothing; - color: awsui.$color-text-accent; + &--collapsed { + padding-block: 0; + padding-inline: 0; + margin-inline: 0; + justify-content: center; + align-items: center; + } + + &--pill { + display: flex; + } } .header-link, @@ -182,10 +340,21 @@ } } +// ========================================================================== +// Misc +// ========================================================================== .info { margin-inline-start: awsui.$space-xs; } +.item-icon { + display: inline-flex; + flex-shrink: 0; + &:not(.item-icon-collapsed) { + margin-inline-end: $item-icon-text-gap; + } +} + .external-icon { margin-inline-start: awsui.$space-xxs; } @@ -193,18 +362,19 @@ .divider { border-block: none; border-inline: none; -} - -.divider-default { - margin-block: awsui.$space-scaled-2x-xl; - margin-inline: calc(-1 * #{awsui.$space-panel-divider-margin-horizontal}); - border-block-start: awsui.$border-divider-section-width solid awsui.$color-border-divider-default; -} - -.divider-header { - margin-block: 0; - border-block-start: awsui.$border-divider-section-width solid awsui.$color-border-panel-header; - .with-toolbar > & { - border-color: transparent; + &-default { + margin-block: awsui.$space-scaled-2x-xl; + margin-inline: calc(-1 * #{awsui.$space-panel-divider-margin-horizontal}); + border-block-start: awsui.$border-divider-section-width solid awsui.$color-border-divider-default; + &.divider-collapsed { + margin-block: awsui.$space-scaled-2x-m; + } + } + &-header { + margin-block: 0; + border-block-start: awsui.$border-divider-section-width solid awsui.$color-border-panel-header; + .with-toolbar > & { + border-color: transparent; + } } } diff --git a/src/side-navigation/test-classes/styles.scss b/src/side-navigation/test-classes/styles.scss index 63ac853f7b..7f06e59e0e 100644 --- a/src/side-navigation/test-classes/styles.scss +++ b/src/side-navigation/test-classes/styles.scss @@ -5,3 +5,7 @@ .info { /* used in test-utils */ } + +.item-icon { + /* used in test-utils */ +} diff --git a/style-dictionary/utils/token-names.ts b/style-dictionary/utils/token-names.ts index 35b14f9a09..3fd56cf707 100644 --- a/style-dictionary/utils/token-names.ts +++ b/style-dictionary/utils/token-names.ts @@ -1057,7 +1057,8 @@ export type SizesTokenName = | 'sizeIconNormal' | 'sizeTableSelectionHorizontal' | 'sizeVerticalInput' - | 'sizeVerticalPanelIconOffset'; + | 'sizeVerticalPanelIconOffset' + | 'sizeSideNavigationItemHeight'; export type SpacingTokenName = | 'spaceAlertActionLeft' | 'spaceAlertHorizontal' @@ -1130,6 +1131,8 @@ export type SpacingTokenName = | 'spaceTableHorizontal' | 'spaceTileGutter' | 'spaceTreeViewIndentation' + | 'spaceSideNavigationItemGap' + | 'spaceSideNavigationItemCollapsedGap' | 'spaceL' | 'spaceM' | 'spaceNone' diff --git a/style-dictionary/visual-refresh/metadata/colors.ts b/style-dictionary/visual-refresh/metadata/colors.ts index e583f0039e..c2cd227abd 100644 --- a/style-dictionary/visual-refresh/metadata/colors.ts +++ b/style-dictionary/visual-refresh/metadata/colors.ts @@ -168,6 +168,8 @@ const metadata: StyleDictionary.MetadataIndex = { colorBackgroundLayoutPanelContent: { description: 'The background color of app layout panel content area. For example: The side navigation and tools panel content background color.', + public: true, + themeable: true, }, colorBackgroundLayoutToolbar: { description: diff --git a/style-dictionary/visual-refresh/metadata/sizes.ts b/style-dictionary/visual-refresh/metadata/sizes.ts index 5231424e5b..f4e41b8d7e 100644 --- a/style-dictionary/visual-refresh/metadata/sizes.ts +++ b/style-dictionary/visual-refresh/metadata/sizes.ts @@ -9,6 +9,11 @@ const metadata: StyleDictionary.MetadataIndex = { public: true, themeable: true, }, + sizeSideNavigationItemHeight: { + description: 'The minimum height of side navigation items.', + public: true, + themeable: true, + }, }; export default metadata; diff --git a/style-dictionary/visual-refresh/metadata/spacing.ts b/style-dictionary/visual-refresh/metadata/spacing.ts index 8b68212714..02a8569c40 100644 --- a/style-dictionary/visual-refresh/metadata/spacing.ts +++ b/style-dictionary/visual-refresh/metadata/spacing.ts @@ -174,6 +174,16 @@ const metadata: StyleDictionary.MetadataIndex = { public: true, themeable: false, }, + spaceSideNavigationItemGap: { + description: 'The vertical gap between side navigation items.', + public: true, + themeable: true, + }, + spaceSideNavigationItemCollapsedGap: { + description: 'The vertical gap between side navigation items in collapsed state.', + public: true, + themeable: true, + }, spaceButtonHorizontal: { description: 'The horizontal padding inside buttons.', public: true, diff --git a/style-dictionary/visual-refresh/sizes.ts b/style-dictionary/visual-refresh/sizes.ts index d80230db33..ccf991f19b 100644 --- a/style-dictionary/visual-refresh/sizes.ts +++ b/style-dictionary/visual-refresh/sizes.ts @@ -13,6 +13,7 @@ const tokens: StyleDictionary.SizesDictionary = { sizeTableSelectionHorizontal: '40px', sizeVerticalInput: { comfortable: '32px', compact: '28px' }, sizeVerticalPanelIconOffset: { comfortable: '15px', compact: '13px' }, + sizeSideNavigationItemHeight: '28px', }; const expandedTokens: StyleDictionary.ExpandedDensityScopeDictionary = expandDensityDictionary(tokens); diff --git a/style-dictionary/visual-refresh/spacing.ts b/style-dictionary/visual-refresh/spacing.ts index 2dae31ea8b..0e37387a4c 100644 --- a/style-dictionary/visual-refresh/spacing.ts +++ b/style-dictionary/visual-refresh/spacing.ts @@ -52,7 +52,7 @@ const tokens: StyleDictionary.SpacingDictionary = { spaceLayoutTogglePadding: '{spaceStaticS}', spaceModalContentBottom: '{spaceScaled2xM}', spaceModalHorizontal: '{spaceContainerHorizontal}', - spacePanelContentBottom: '{spaceScaledXxxl}', + spacePanelContentBottom: '{spaceScaledL}', spacePanelContentTop: '{spaceScaledL}', spacePanelDividerMarginHorizontal: '{spaceXs}', spacePanelHeaderVertical: '{spaceScaledL}', @@ -74,6 +74,8 @@ const tokens: StyleDictionary.SpacingDictionary = { spaceTableHeaderToolsFullPageBottom: '4px', spaceTableHorizontal: '{spaceContainerHorizontal}', spaceTreeViewIndentation: '{spaceXl}', + spaceSideNavigationItemGap: '{spaceScaledXxs}', + spaceSideNavigationItemCollapsedGap: '{spaceXs}', spaceTileGutter: { comfortable: '{spaceXl}', compact: '{spaceM}' }, spaceActionCardHorizontalDefault: '{spaceCardHorizontalDefault}', spaceActionCardHorizontalEmbedded: '{spaceCardHorizontalEmbedded}', From ada39f38b61bdbcf07ad8936a1ed848d94500310 Mon Sep 17 00:00:00 2001 From: Jessica Kuelz <15003460+jkuelz@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:19:22 -0700 Subject: [PATCH 2/3] feat: Add minimal demo pages for SideNavigation API review - collapsed.page.tsx: basic collapsed/expanded toggle with focus management - collapsed-toggle-placement.page.tsx: toggle in 4 positions (top/bottom/above-items/below-items) - collapsed-panel-layout.page.tsx: integration with PanelLayout for resizable panel - collapsed-permutations.page.tsx: all item types showing collapsed behavior rules --- .../collapsed-panel-layout.page.tsx | 101 +++++++++++++ .../collapsed-permutations.page.tsx | 138 ++++++++++++++++++ .../collapsed-toggle-placement.page.tsx | 121 +++++++++++++++ pages/side-navigation/collapsed.page.tsx | 100 +++++++++++++ 4 files changed, 460 insertions(+) create mode 100644 pages/side-navigation/collapsed-panel-layout.page.tsx create mode 100644 pages/side-navigation/collapsed-permutations.page.tsx create mode 100644 pages/side-navigation/collapsed-toggle-placement.page.tsx create mode 100644 pages/side-navigation/collapsed.page.tsx diff --git a/pages/side-navigation/collapsed-panel-layout.page.tsx b/pages/side-navigation/collapsed-panel-layout.page.tsx new file mode 100644 index 0000000000..16f5b43fcd --- /dev/null +++ b/pages/side-navigation/collapsed-panel-layout.page.tsx @@ -0,0 +1,101 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useState } from 'react'; + +import { Box, Button, Icon, PanelLayout, SpaceBetween } from '~components'; +import SideNavigation, { SideNavigationProps } from '~components/side-navigation'; + +const items: SideNavigationProps.Item[] = [ + { type: 'link', text: 'Home', href: '#/home', icon: }, + { type: 'link', text: 'Projects', href: '#/projects', icon: }, + { type: 'link', text: 'Settings', href: '#/settings', icon: }, + { + type: 'section', + text: 'Account', + items: [ + { type: 'link', text: 'Profile', href: '#/profile', icon: }, + { type: 'link', text: 'Billing', href: '#/billing', icon: }, + ], + }, +]; + +const COLLAPSED_SIZE = 52; +const EXPANDED_SIZE = 220; +const MAX_SIZE = 400; +const COLLAPSE_THRESHOLD = 140; +const SNAP_BUFFER = 30; + +export default function SideNavigationWithPanelLayoutPage() { + const [activeHref, setActiveHref] = useState('#/home'); + const [panelSize, setPanelSize] = useState(EXPANDED_SIZE); + + const collapsed = panelSize < COLLAPSE_THRESHOLD; + + function handleResize({ detail }: { detail: { panelSize: number } }) { + const size = detail.panelSize; + if (collapsed && size > COLLAPSED_SIZE + SNAP_BUFFER) { + setPanelSize(Math.max(size, EXPANDED_SIZE)); + } else if (!collapsed && size < COLLAPSE_THRESHOLD) { + setPanelSize(COLLAPSED_SIZE); + } else { + setPanelSize(size); + } + } + + function toggleCollapse() { + setPanelSize(prev => (prev < COLLAPSE_THRESHOLD ? EXPANDED_SIZE : COLLAPSED_SIZE)); + } + + const nav = ( +
    +
    +
    + { + e.preventDefault(); + setActiveHref(e.detail.href); + }} + /> +
    + ); + + const content = ( +
    + + SideNavigation with PanelLayout + + Panel size: {panelSize}px — Collapsed: {String(collapsed)} + + + Drag the resize handle to resize the panel. Dragging below {COLLAPSE_THRESHOLD}px snaps to collapsed state. + + +
    + ); + + return ( + + ); +} diff --git a/pages/side-navigation/collapsed-permutations.page.tsx b/pages/side-navigation/collapsed-permutations.page.tsx new file mode 100644 index 0000000000..7c886f6555 --- /dev/null +++ b/pages/side-navigation/collapsed-permutations.page.tsx @@ -0,0 +1,138 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useState } from 'react'; + +import { Box, Button, Icon, SpaceBetween, Toggle } from '~components'; +import SideNavigation, { SideNavigationProps } from '~components/side-navigation'; + +// Items covering all types to verify collapsed behavior +const allTypesItems: SideNavigationProps.Item[] = [ + // Links with and without icons + { type: 'link', text: 'Link with icon', href: '#/icon', icon: }, + { type: 'link', text: 'Link without icon (hidden collapsed)', href: '#/no-icon' }, + { type: 'divider' }, + // Link group + { + type: 'link-group', + text: 'Link group (with icon)', + href: '#/group', + icon: , + items: [ + { type: 'link', text: 'Child 1', href: '#/child1' }, + { type: 'link', text: 'Child 2', href: '#/child2' }, + ], + }, + { + type: 'link-group', + text: 'Link group (no icon, hidden)', + href: '#/group-no-icon', + items: [{ type: 'link', text: 'Child', href: '#/child' }], + }, + { type: 'divider' }, + // Expandable link group + { + type: 'expandable-link-group', + text: 'Expandable group (with icon)', + href: '#/expandable', + icon: , + items: [ + { type: 'link', text: 'Sub 1', href: '#/sub1' }, + { type: 'link', text: 'Sub 2', href: '#/sub2' }, + ], + }, + { + type: 'expandable-link-group', + text: 'Expandable group (no icon, hidden)', + href: '#/expandable-no-icon', + items: [{ type: 'link', text: 'Sub', href: '#/sub' }], + }, + { type: 'divider' }, + // Section + { + type: 'section', + text: 'Section (header hidden in collapsed)', + items: [ + { type: 'link', text: 'Section child with icon', href: '#/s1', icon: }, + { type: 'link', text: 'Section child without icon (hidden)', href: '#/s2' }, + { type: 'link', text: 'Another with icon', href: '#/s3', icon: }, + ], + }, + { type: 'divider' }, + // Section group + { + type: 'section-group', + title: 'Section group', + items: [ + { + type: 'section', + text: 'Nested section', + items: [{ type: 'link', text: 'Nested item with icon', href: '#/n1', icon: }], + }, + ], + }, +]; + +export default function SideNavigationCollapsedPermutationsPage() { + const [activeHref, setActiveHref] = useState('#/icon'); + const [collapsed, setCollapsed] = useState(false); + const [highlighted, setHighlighted] = useState(true); + + return ( +
    +
    +
    +
    + { + e.preventDefault(); + setActiveHref(e.detail.href); + }} + /> +
    + +
    + + Collapsed state — all item types + + setCollapsed(detail.checked)}> + Collapsed + + setHighlighted(detail.checked)}> + Highlighted variant + + + In collapsed mode: + ✓ Links with icons → shown as icon only + ✗ Links without icons → hidden + ✓ Link groups with icons → shown as icon only + ✗ Link groups without icons → hidden + ✓ Expandable groups with icons → shown as icon, non-expandable + ✗ Expandable groups without icons → hidden + ✓ Sections → header hidden, children with icons shown as flat list + ✓ Dividers → deduplicated + +
    +
    + ); +} diff --git a/pages/side-navigation/collapsed-toggle-placement.page.tsx b/pages/side-navigation/collapsed-toggle-placement.page.tsx new file mode 100644 index 0000000000..89de1ad0b8 --- /dev/null +++ b/pages/side-navigation/collapsed-toggle-placement.page.tsx @@ -0,0 +1,121 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useContext, useRef, useState } from 'react'; + +import { Box, Button, Icon, Select, SpaceBetween } from '~components'; +import SideNavigation, { SideNavigationProps } from '~components/side-navigation'; + +import AppContext, { AppContextType } from '../app/app-context'; + +const items: SideNavigationProps.Item[] = [ + { type: 'link', text: 'Dashboard', href: '#/dashboard', icon: }, + { type: 'link', text: 'Workspaces', href: '#/workspaces', icon: }, + { type: 'link', text: 'Team', href: '#/team', icon: }, + { type: 'link', text: 'Billing', href: '#/billing', icon: }, + { type: 'link', text: 'Item without icon — hidden when collapsed', href: '#/no-icon' }, +]; + +type TogglePosition = 'top' | 'bottom' | 'above-items' | 'below-items'; +type PageContext = React.Context>; + +const COLLAPSED_WIDTH = 52; +const EXPANDED_WIDTH = 220; + +export default function SideNavigationTogglePlacementPage() { + const { + urlParams: { togglePosition = 'top' }, + setUrlParams, + } = useContext(AppContext as PageContext); + + const [activeHref, setActiveHref] = useState('#/dashboard'); + const [collapsed, setCollapsed] = useState(false); + const [panelWidth, setPanelWidth] = useState(EXPANDED_WIDTH); + const navRef = useRef(null); + const toggleRef = useRef(null); + + function handleToggle() { + const willCollapse = !collapsed; + if (willCollapse) { + const focused = document.activeElement; + if (navRef.current?.contains(focused)) { + toggleRef.current?.focus(); + } + } + setCollapsed(willCollapse); + setPanelWidth(willCollapse ? COLLAPSED_WIDTH : EXPANDED_WIDTH); + } + + const toggleButton = ( +