diff --git a/skills/carbon-react/components/sidebar.md b/skills/carbon-react/components/sidebar.md index 7b8be4be65..e9888c0490 100644 --- a/skills/carbon-react/components/sidebar.md +++ b/skills/carbon-react/components/sidebar.md @@ -13,49 +13,50 @@ description: Carbon Sidebar component props and usage examples. - Props interface: `SidebarProps` ## Props -| Name | Type | Required | Literals | Description | Default | -| --- | --- | --- | --- | --- | --- | -| open | boolean | Yes | | Sets the open state of the modal | | -| children | React.ReactNode | No | | Modal content | | -| closeButtonDataProps | Pick \| undefined | No | | Data tag prop bag for close Button | | -| disableAutoFocus | boolean \| undefined | No | | | | -| disableEscKey | boolean \| undefined | No | | Determines if the Esc Key closes the modal | | -| enableBackgroundUI | boolean \| undefined | No | | Set this prop to false to hide the translucent background when the dialog is open. | | -| focusableContainers | React.RefObject[] \| undefined | No | | an optional array of refs to containers whose content should also be reachable by tabbing from the sidebar | | -| focusableSelectors | string \| undefined | No | | Optional selector to identify the focusable elements, if not provided a default selector is used | | -| focusFirstElement | React.MutableRefObject \| undefined | No | | Optional reference to an element meant to be focused on open | | -| header | React.ReactNode | No | | Node that will be used as sidebar header. | | -| headerPadding | PaddingProps | No | | Padding to be set on the Sidebar header | | -| headerVariant | "dark" \| "light" \| undefined | No | | Header background variant for the sidebar. | | -| onCancel | ((ev: React.KeyboardEvent \| KeyboardEvent \| React.MouseEvent) => void) \| undefined | No | | A custom close event handler | | -| p | ResponsiveValue \| undefined | No | | Padding on top, left, bottom and right | | -| padding | ResponsiveValue \| undefined | No | | Padding on top, left, bottom and right | | -| paddingBottom | ResponsiveValue \| undefined | No | | Padding on bottom | | -| paddingLeft | ResponsiveValue \| undefined | No | | Padding on left | | -| paddingRight | ResponsiveValue \| undefined | No | | Padding on right | | -| paddingTop | ResponsiveValue \| undefined | No | | Padding on top | | -| paddingX | ResponsiveValue \| undefined | No | | Padding on left and right | | -| paddingY | ResponsiveValue \| undefined | No | | Padding on top and bottom | | -| pb | ResponsiveValue \| undefined | No | | Padding on bottom | | -| pl | ResponsiveValue \| undefined | No | | Padding on left | | -| position | "left" \| "right" \| undefined | No | | Sets the position of sidebar, either left or right. | | -| pr | ResponsiveValue \| undefined | No | | Padding on right | | -| pt | ResponsiveValue \| undefined | No | | Padding on top | | -| px | ResponsiveValue \| undefined | No | | Padding on left and right | | -| py | ResponsiveValue \| undefined | No | | Padding on top and bottom | | -| restoreFocusOnClose | boolean \| undefined | No | | Enables the automatic restoration of focus to the element that invoked the modal when the modal is closed. | | -| role | string \| undefined | No | | The ARIA role to be applied to the component container | | -| size | "small" \| "medium" \| "large" \| "extra-small" \| "medium-small" \| "medium-large" \| "extra-large" \| undefined | No | | Sets the size of the sidebar when open. | | -| subHeader | React.ReactNode | No | | Node that will be used as sidebar subheader. | | -| subHeaderPadding | PaddingProps | No | | Padding to be set on the Sidebar subheader | | -| topModalOverride | boolean \| undefined | No | | Manually override the internal modal stacking order to set this as top | | -| width | ResponsiveValue \| undefined | No | | The width utility parses a component's `width` prop and converts it into a CSS width declaration. - Numbers from 0-1 are converted to percentage widths. - Numbers greater than 1 are converted to pixel values. - String values are passed as raw CSS values. - And arrays are converted to responsive width styles. | | -| widthAnimation | boolean \| undefined | No | | Enables width animation when the sidebar width changes. | | -| data-element | string \| undefined | No | | Identifier used for testing purposes, applied to the root element of the component. | | -| data-role | string \| undefined | No | | Identifier used for testing purposes, applied to the root element of the component. | | -| aria-describedby | string \| undefined | No | | Prop to specify the aria-describedby property of the component | | -| aria-label | string \| undefined | No | | Prop to specify the aria-label of the component. To be used only when the header prop is not defined, and the component is not labelled by any internal element. | | -| aria-labelledby | string \| undefined | No | | Prop to specify the aria-labelledby property of the component To be used when the header prop is a custom React Node, or the component is labelled by an internal element other than the header. | | +| Name | Type | Required | Literals | Deprecated | Deprecation reason | Description | Default | +| --- | --- | --- | --- | --- | --- | --- | --- | +| open | boolean | Yes | | | | Sets the open state of the modal | | +| children | React.ReactNode | No | | | | Modal content | | +| closeButtonDataProps | Pick \| undefined | No | | | | Data tag prop bag for close Button | | +| disableAutoFocus | boolean \| undefined | No | | | | | | +| disableEscKey | boolean \| undefined | No | | | | Determines if the Esc Key closes the modal | | +| enableBackgroundUI | boolean \| undefined | No | | | | Set this prop to false to hide the translucent background when the dialog is open. | | +| focusableContainers | React.RefObject[] \| undefined | No | | | | an optional array of refs to containers whose content should also be reachable by tabbing from the sidebar | | +| focusableSelectors | string \| undefined | No | | | | Optional selector to identify the focusable elements, if not provided a default selector is used | | +| focusFirstElement | React.MutableRefObject \| undefined | No | | | | Optional reference to an element meant to be focused on open | | +| gradientKeyLine | boolean \| undefined | No | | | | Adds the Carbon AI gradient keyline to the header. | | +| header | React.ReactNode | No | | | | Node that will be used as sidebar header. | | +| headerPadding | PaddingProps | No | | | | Padding to be set on the Sidebar header | | +| headerVariant | "typical" \| "dark" \| "light" \| "inverse" \| undefined | No | | | | Header background variant for the sidebar. `light` and `dark` are deprecated aliases - use `typical` and `inverse` instead. | | +| onCancel | ((ev: React.KeyboardEvent \| KeyboardEvent \| React.MouseEvent) => void) \| undefined | No | | | | A custom close event handler | | +| p | ResponsiveValue \| undefined | No | | | | Padding on top, left, bottom and right | | +| padding | ResponsiveValue \| undefined | No | | | | Padding on top, left, bottom and right | | +| paddingBottom | ResponsiveValue \| undefined | No | | | | Padding on bottom | | +| paddingLeft | ResponsiveValue \| undefined | No | | | | Padding on left | | +| paddingRight | ResponsiveValue \| undefined | No | | | | Padding on right | | +| paddingTop | ResponsiveValue \| undefined | No | | | | Padding on top | | +| paddingX | ResponsiveValue \| undefined | No | | | | Padding on left and right | | +| paddingY | ResponsiveValue \| undefined | No | | | | Padding on top and bottom | | +| pb | ResponsiveValue \| undefined | No | | | | Padding on bottom | | +| pl | ResponsiveValue \| undefined | No | | | | Padding on left | | +| pr | ResponsiveValue \| undefined | No | | | | Padding on right | | +| pt | ResponsiveValue \| undefined | No | | | | Padding on top | | +| px | ResponsiveValue \| undefined | No | | | | Padding on left and right | | +| py | ResponsiveValue \| undefined | No | | | | Padding on top and bottom | | +| restoreFocusOnClose | boolean \| undefined | No | | | | Enables the automatic restoration of focus to the element that invoked the modal when the modal is closed. | | +| role | string \| undefined | No | | | | The ARIA role to be applied to the component container | | +| subHeader | React.ReactNode | No | | | | Node that will be used as sidebar subheader. | | +| subHeaderPadding | PaddingProps | No | | | | Padding to be set on the Sidebar subheader | | +| topModalOverride | boolean \| undefined | No | | | | Manually override the internal modal stacking order to set this as top | | +| width | ResponsiveValue \| undefined | No | | | | The width utility parses a component's `width` prop and converts it into a CSS width declaration. - Numbers from 0-1 are converted to percentage widths. - Numbers greater than 1 are converted to pixel values. - String values are passed as raw CSS values. - And arrays are converted to responsive width styles. | | +| widthAnimation | boolean \| undefined | No | | | | Enables width animation when the sidebar width changes. | | +| data-element | string \| undefined | No | | | | Identifier used for testing purposes, applied to the root element of the component. | | +| data-role | string \| undefined | No | | | | Identifier used for testing purposes, applied to the root element of the component. | | +| aria-describedby | string \| undefined | No | | | | Prop to specify the aria-describedby property of the component | | +| aria-label | string \| undefined | No | | | | Provides an explicit accessible name for the component, overriding the automatic association with the header. | | +| aria-labelledby | string \| undefined | No | | | | Identifies the element that provides an explicit accessible name for the component, overriding the automatic association with the header. | | +| position | "left" \| "right" \| undefined | No | | Yes | This prop will be removed in a future release. Sidebar will always be positioned on the right. Update the layout to support a right-positioned Sidebar if it is set to left, otherwise remove the prop. | | | +| size | "small" \| "medium" \| "large" \| "extra-small" \| "medium-small" \| "medium-large" \| "extra-large" \| undefined | No | | Yes | Use `width` to customise the Sidebar width. | | | ## Examples ### Default @@ -94,6 +95,37 @@ description: Carbon Sidebar component props and usage examples. ``` +### Responsive Behavior + +**Render** + +```tsx +() => { + const [isOpen, setIsOpen] = useState(defaultOpenState); + + return ( + <> + + setIsOpen(false)} + header="Responsive sidebar" + > +
Cancel} + saveButton={} + stickyFooter + onSubmit={(event) => event.preventDefault()} + > + Long content +
+
+ + ); +} +``` + + ### With Restore Focus On Close **Render** @@ -118,10 +150,10 @@ description: Carbon Sidebar component props and usage examples. {showMessage && ( setShowMessage(false)} > - Some custom message + Sidebar closed; focus moved to this message. )} { const [isOpen, setIsOpen] = useState(defaultOpenState); + const [contentPadding, setContentPadding] = useState<"none" | "large">( + "large", + ); + return ( <> - - setIsOpen(false)} p={0}> + + + setIsOpen(false)} + p={contentPadding === "none" ? 0 : "var(--global-space-comp-2-xl)"} + > @@ -215,7 +272,7 @@ description: Carbon Sidebar component props and usage examples. setIsOpen(false)} - header={Sidebar header} + header="Sidebar header" subHeader={ setIsOpen(false)} - header={headerNode} - headerVariant="dark" + header="Sidebar header" + headerVariant="inverse" > @@ -276,6 +324,31 @@ description: Carbon Sidebar component props and usage examples. ``` +### With Gradient Keyline + +**Render** + +```tsx +() => { + const [isOpen, setIsOpen] = useState(defaultOpenState); + + return ( + <> + + setIsOpen(false)} + header="Sidebar header" + gradientKeyLine + > + Main Content + + + ); +} +``` + + ### With Scroll **Render** @@ -289,7 +362,7 @@ description: Carbon Sidebar component props and usage examples. setIsOpen(false)} - header={Sidebar header} + header="Sidebar header" > @@ -320,7 +393,7 @@ description: Carbon Sidebar component props and usage examples. position="left" open={isOpen} onCancel={() => setIsOpen(false)} - header={Sidebar Header} + header="Sidebar Header" >
Action button} @@ -414,7 +487,7 @@ description: Carbon Sidebar component props and usage examples. setIsSidebarOpen(false)} - header={Sidebar header} + header="Sidebar header" focusableContainers={[toast1Ref, toast2Ref]} > {}} /> {}} value="" /> {}} value="" /> - - + + + +
setIsOpen(false)} width="25%" - header={Sidebar} + header="Sidebar" > setIsOpen(false)} - header={Sidebar Header} - p={2} - headerPadding={{ p: 2 }} + header="Sidebar Header — 16px padding" + p="var(--global-space-comp-2-xl)" + headerPadding={{ p: "var(--global-space-comp-l)" }} >
Action button} stickyFooter buttonAlignment="right" - footerPadding={{ p: 2 }} > + Content padding: 32px (--global-space-comp-2-xl). Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed lectus massa, suscipit vitae pellentesque quis, facilisis non ante. diff --git a/src/components/sidebar/__internal__/sidebar-header/sidebar-header.component.tsx b/src/components/sidebar/__internal__/sidebar-header/sidebar-header.component.tsx index ba5a0d9900..008bb9d0e7 100644 --- a/src/components/sidebar/__internal__/sidebar-header/sidebar-header.component.tsx +++ b/src/components/sidebar/__internal__/sidebar-header/sidebar-header.component.tsx @@ -1,6 +1,8 @@ import React from "react"; import { PaddingProps } from "styled-system"; +import Typography from "../../../typography"; import StyledSidebarHeader, { + StyledSidebarHeaderDivider, StyledSidebarSubHeader, } from "./sidebar-header.style"; @@ -9,10 +11,12 @@ export interface SidebarHeaderProps extends PaddingProps { children?: React.ReactNode; /** A custom id. */ id: string; - /** Close icon button to be rendered */ - closeIcon?: React.ReactNode; + /** Close button to be rendered. */ + closeButton?: React.ReactNode; /** Header background variant for the sidebar. */ - headerVariant?: "light" | "dark"; + headerVariant?: "typical" | "inverse" | "light" | "dark"; + /** Adds the Carbon AI gradient keyline to the header. */ + gradientKeyLine?: boolean; } export interface SidebarSubHeaderProps extends PaddingProps { @@ -25,32 +29,45 @@ export interface SidebarSubHeaderProps extends PaddingProps { const SidebarHeader = ({ children, id, - closeIcon, + closeButton, headerVariant, + gradientKeyLine, ...rest }: SidebarHeaderProps) => (
- {children} + {typeof children === "string" ? ( + + {children} + + ) : ( + children + )}
- {closeIcon} + {closeButton} +
); const SidebarSubHeader = ({ children, id, ...rest }: SidebarSubHeaderProps) => ( - + {children} ); diff --git a/src/components/sidebar/__internal__/sidebar-header/sidebar-header.style.ts b/src/components/sidebar/__internal__/sidebar-header/sidebar-header.style.ts index 4e77b398b8..b2b30da116 100644 --- a/src/components/sidebar/__internal__/sidebar-header/sidebar-header.style.ts +++ b/src/components/sidebar/__internal__/sidebar-header/sidebar-header.style.ts @@ -5,49 +5,108 @@ import StyledIconButton from "../../../icon-button/icon-button.style"; import StyledIcon from "../../../icon/icon.style"; const StyledSidebarHeader = styled.div.attrs(applyBaseTheme)<{ - hasClose?: boolean; - headerVariant?: "light" | "dark"; + $hasCloseButton?: boolean; + $headerVariant?: "typical" | "inverse" | "light" | "dark"; + $gradientKeyLine?: boolean; }>` - background-color: ${({ headerVariant }) => - headerVariant === "light" - ? "var(--colorsUtilityYang100)" - : "var(--colorsUtilityYin100)"}; - box-shadow: inset 0 -1px 0 0 var(--colorsUtilityMajor100); + ${({ $headerVariant }) => { + const inverse = $headerVariant === "inverse" || $headerVariant === "dark"; + + return css` + background-color: ${inverse + ? "var(--container-standard-inverse-bg-default)" + : "var(--container-standard-bg-default)"}; + color: ${inverse + ? "var(--container-standard-inverse-txt-default)" + : "var(--container-standard-txt-default)"}; + position: relative; + `; + }} box-sizing: border-box; + flex: 0 0 auto; + min-height: calc((2 * var(--global-space-comp-xl)) + var(--global-size-s)); width: 100%; - color: var(--colorsActionMinorYin090); transition: all 0.2s ease; + padding: var(--global-space-comp-xl); ${padding} - ${({ hasClose, headerVariant }) => - hasClose && + ${({ $hasCloseButton, $headerVariant }) => + $hasCloseButton && css` display: flex; justify-content: space-between; - gap: var(--spacing200); + gap: var(--global-space-comp-l); > ${StyledIconButton}:first-of-type { - ${headerVariant === "dark" && + ${($headerVariant === "inverse" || $headerVariant === "dark") && css` ${StyledIcon} { - color: var(--colorsUtilityYang080); + color: var(--container-standard-inverse-txt-default); + } + + &:is(:hover, :active, :focus) ${StyledIcon} { + color: var(--container-standard-inverse-txt-default); + } + + &:disabled ${StyledIcon} { + color: var(--container-standard-inverse-txt-alt); } `} align-self: flex-start; + border-radius: var(--global-radius-action-circle); + flex: 0 0 auto; + height: var(--global-size-s); + min-width: var(--global-size-s); + width: var(--global-size-s); + + ${StyledIcon} { + height: var(--global-size-2-xs); + width: var(--global-size-2-xs); + } } `} div[data-element="sidebar-heading"] { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: var(--global-space-comp-xs); + min-width: 0; width: 100%; + + [data-element="sidebar-title"] { + color: inherit; + } } `; +const StyledSidebarHeaderDivider = styled.hr.attrs(applyBaseTheme)<{ + $gradientKeyLine?: boolean; +}>` + background: ${({ $gradientKeyLine }) => + $gradientKeyLine + ? "var(--container-standard-border-ai-h)" + : "var(--container-standard-border-default)"}; + border: var(--global-borderwidth-none); + bottom: 0; + height: ${({ $gradientKeyLine }) => + $gradientKeyLine + ? "var(--global-borderwidth-s)" + : "var(--global-borderwidth-xs)"}; + left: 0; + margin: 0; + position: absolute; + right: 0; +`; + const StyledSidebarSubHeader = styled.div.attrs(applyBaseTheme)` box-sizing: border-box; width: 100%; - color: var(--colorsActionMinorYin090); - background-color: var(--colorsUtilityMajor050); - border-bottom: 1px solid var(--colorsUtilityMajor075); + color: var(--container-standard-txt-default); + background-color: var(--container-standard-bg-alt); + border-bottom: var(--global-borderwidth-xs) solid + var(--container-standard-border-default); + padding: var(--global-space-comp-s) var(--global-space-comp-2-xl); ${padding} transition: all 0.2s ease; `; export default StyledSidebarHeader; -export { StyledSidebarSubHeader }; +export { StyledSidebarHeaderDivider, StyledSidebarSubHeader }; diff --git a/src/components/sidebar/components.test-pw.tsx b/src/components/sidebar/components.test-pw.tsx index 17304fc041..16a6e8cdf5 100644 --- a/src/components/sidebar/components.test-pw.tsx +++ b/src/components/sidebar/components.test-pw.tsx @@ -1,19 +1,19 @@ import React, { useState, useRef } from "react"; -import Typography from "../../../src/components/typography"; +import Typography from "../typography"; import Button from "../button"; import Sidebar, { SidebarProps } from "."; import Box from "../box"; +import Form from "../form"; import Toast from "../toast"; import Textbox from "../textbox"; -export const Default = ({ +export const ControlledSidebar = ({ open = true, restoreFocusOnClose, onCancel: onCancelProp, -}: { - open?: boolean; - restoreFocusOnClose?: boolean; + ...props +}: Partial & { onCancel?: () => void; }) => { const [isOpen, setIsOpen] = useState(open); @@ -29,6 +29,7 @@ export const Default = ({ open={isOpen} onCancel={handleCancel} restoreFocusOnClose={restoreFocusOnClose} + {...props} > @@ -42,7 +43,7 @@ export const Default = ({ ); }; -export const DefaultNested = () => { +export const NestedSidebars = () => { const [isFirstSidebarOpen, setIsFirstSidebarOpen] = useState(false); const [isNestedSidebarOpen, setIsNestedSidebarOpen] = useState(false); return ( @@ -73,34 +74,7 @@ export const DefaultNested = () => { ); }; -export const SidebarComponentWithOnCancel = (props: Partial) => { - const [isOpen, setIsOpen] = useState(true); - const handleOnCancel = () => { - setIsOpen(false); - }; - return ( - <> - - - - - - Main content - - - ); -}; - -export const SidebarBackgroundScrollTestComponent = () => { +export const SidebarWithBackgroundScrollTarget = () => { const [value, setValue] = useState(""); return ( @@ -126,7 +100,7 @@ export const SidebarBackgroundScrollTestComponent = () => { ); }; -export const SidebarBackgroundScrollWithOtherFocusableContainers = () => { +export const SidebarWithBackgroundScrollTargetAndFocusableContainers = () => { const toast1Ref = useRef(null); const toast2Ref = useRef(null); const [value, setValue] = useState(""); @@ -164,10 +138,10 @@ export const SidebarBackgroundScrollWithOtherFocusableContainers = () => { ); }; -export const SidebarComponentFocusable = (props: Partial) => { - const [setIsDialogOpen] = React.useState(false); - const [isToastOpen, setIsToastOpen] = React.useState(false); - const toastRef = React.useRef(null); +export const SidebarWithFocusableContainer = (props: Partial) => { + const [isSidebarOpen, setIsSidebarOpen] = useState(true); + const [isToastOpen, setIsToastOpen] = useState(false); + const toastRef = useRef(null); const CUSTOM_SELECTOR = "button, .focusable-container input"; const [value, setValue] = useState(""); const [value2, setValue2] = useState(""); @@ -175,8 +149,8 @@ export const SidebarComponentFocusable = (props: Partial) => { return ( <> setIsDialogOpen} + open={isSidebarOpen} + onCancel={() => setIsSidebarOpen(false)} header={Sidebar header} focusableContainers={[toastRef]} focusableSelectors={CUSTOM_SELECTOR} @@ -220,3 +194,26 @@ export const SidebarComponentFocusable = (props: Partial) => { ); }; + +export const SidebarWithStickyForm = () => ( + {}} header="Sidebar with sticky footer"> + Save} + stickyFooter + onSubmit={(event) => event.preventDefault()} + > + Long content + + +); + +export const SidebarWithTallStickyFormFooter = () => ( + {}} header="Sidebar with tall sticky footer"> +
Footer content} + stickyFooter + > + content +
+
+); diff --git a/src/components/sidebar/sidebar-test.stories.tsx b/src/components/sidebar/sidebar-test.stories.tsx index c7c5b5ccbc..9d56d36f7f 100644 --- a/src/components/sidebar/sidebar-test.stories.tsx +++ b/src/components/sidebar/sidebar-test.stories.tsx @@ -5,7 +5,6 @@ import isChromatic from "../../../.storybook/isChromatic"; import allModes from "../../../.storybook/modes"; import Box from "../box"; -import Icon from "../icon"; import Button from "../button"; import Form from "../form"; import Sidebar, { SidebarProps } from "."; @@ -93,6 +92,19 @@ const meta: Meta = { export default meta; +const InteractiveSidebar = ({ children, ...props }: Partial) => { + const [isOpen, setIsOpen] = useState(true); + + return ( + <> + + setIsOpen(false)}> + {children} + + + ); +}; + export const Default = (args: Partial) => { const [isOpen, setIsOpen] = useState(true); const onCancel = () => { @@ -118,8 +130,6 @@ export const Default = (args: Partial) => { Default.storyName = "default"; Default.args = { - position: "right", - size: "medium", enableBackgroundUI: false, disableEscKey: false, }; @@ -131,12 +141,7 @@ Default.parameters = { export const WithStickyForm: StoryObj = { render: (args) => ( - With sticky form
} - open - onCancel={() => {}} - > +
Cancel} @@ -152,18 +157,13 @@ export const WithStickyForm: StoryObj = { {}} /> {}} /> -
+ ), }; export const WithForm: StoryObj = { render: (args) => ( - With form} - open - onCancel={() => {}} - > +
Cancel} @@ -178,7 +178,7 @@ export const WithForm: StoryObj = { {}} /> {}} /> -
+ ), }; WithForm.parameters = { chromatic: { disableSnapshot: true } }; @@ -236,15 +236,7 @@ export const WithStepFlow: StoryObj = { }, }; -export const DarkHeaderExampleImplementation = () => { - const headerNode = ( - - - - Sidebar header - - - ); +export const InverseHeaderExampleImplementation = () => { const footerNode = ( @@ -258,40 +250,35 @@ export const DarkHeaderExampleImplementation = () => { ); return ( - Action } - open - onCancel={() => {}} >
- + ); }; export const WithLongHeader = () => { - const headerNode = ( - - Really long header that should not overlap with the close button - - ); - return ( - {}} width="460px"> + Lorem, ipsum dolor sit amet consectetur adipisicing elit. Sint iure assumenda recusandae veniam deleniti adipisci dicta exercitationem delectus atque, quidem, eaque facilis dignissimos rem, minus cupiditate ad sed dolorem minima? - + ); }; @@ -307,19 +294,17 @@ const DynamicWidthAfterMountComponent = (args: Partial) => { }, []); return ( - Dynamic width after mount} - open - onCancel={() => {}} + header="Dynamic width after mount" width={width} > This story updates the sidebar width a little bit after the initial mount. - + ); }; diff --git a/src/components/sidebar/sidebar.component.tsx b/src/components/sidebar/sidebar.component.tsx index c3c64e1172..9216e9cde9 100644 --- a/src/components/sidebar/sidebar.component.tsx +++ b/src/components/sidebar/sidebar.component.tsx @@ -1,8 +1,12 @@ import React, { useCallback, useRef, RefObject } from "react"; import { PaddingProps, WidthProps } from "styled-system"; -import Modal, { ModalProps } from "../../__internal__/modal"; -import { StyledSidebar, StyledSidebarContent } from "./sidebar.style"; +import type { ModalProps } from "../../__internal__/modal"; +import { + StyledSidebar, + StyledSidebarContent, + StyledSidebarModal, +} from "./sidebar.style"; import IconButton from "../icon-button"; import Icon from "../icon"; import FocusTrap from "../../__internal__/focus-trap"; @@ -10,7 +14,9 @@ import SidebarHeader, { SidebarSubHeader } from "./__internal__/sidebar-header"; import createGuid from "../../__internal__/utils/helpers/guid"; import useLocale from "../../hooks/__internal__/useLocale"; import { filterStyledSystemPaddingProps } from "../../style/utils"; -import tagComponent, { TagProps } from "../../__internal__/utils/helpers/tags"; +import tagComponent, { + type TagProps, +} from "../../__internal__/utils/helpers/tags"; import useModalAria from "../../hooks/__internal__/useModalAria/useModalAria"; import SidebarContext from "./__internal__/sidebar.context"; import useMediaQuery from "../../hooks/useMediaQuery"; @@ -23,14 +29,13 @@ export interface SidebarProps /** Prop to specify the aria-describedby property of the component */ "aria-describedby"?: string; /** - * Prop to specify the aria-label of the component. - * To be used only when the header prop is not defined, and the component is not labelled by any internal element. + * Provides an explicit accessible name for the component, overriding the + * automatic association with the header. */ "aria-label"?: string; /** - * Prop to specify the aria-labelledby property of the component - * To be used when the header prop is a custom React Node, - * or the component is labelled by an internal element other than the header. + * Identifies the element that provides an explicit accessible name for the + * component, overriding the automatic association with the header. */ "aria-labelledby"?: string; /** Modal content */ @@ -59,8 +64,13 @@ export interface SidebarProps header?: React.ReactNode; /** Node that will be used as sidebar subheader. */ subHeader?: React.ReactNode; - /** Header background variant for the sidebar. */ - headerVariant?: "light" | "dark"; + /** + * Header background variant for the sidebar. + * `light` and `dark` are deprecated aliases - use `typical` and `inverse` instead. + */ + headerVariant?: "typical" | "inverse" | "light" | "dark"; + /** Adds the Carbon AI gradient keyline to the header. */ + gradientKeyLine?: boolean; /** A custom close event handler */ onCancel?: ( ev: @@ -70,11 +80,15 @@ export interface SidebarProps ) => void; /** Sets the open state of the modal */ open: boolean; - /** Sets the position of sidebar, either left or right. */ + /** @deprecated This prop will be removed in a future release. + * Sidebar will always be positioned on the right. + * Update the layout to support a right-positioned Sidebar if it is set to + * left, otherwise remove the prop. + * */ position?: "left" | "right"; /** The ARIA role to be applied to the component container */ role?: string; - /** Sets the size of the sidebar when open. */ + /** @deprecated Use `width` to customise the Sidebar width. */ size?: | "extra-small" | "small" @@ -118,10 +132,11 @@ export const Sidebar = React.forwardRef( disableEscKey = false, enableBackgroundUI = false, header, - headerVariant = "light", + headerVariant = "typical", + gradientKeyLine = false, subHeader, position = "right", - size = "medium", + size, children, onCancel, role = "dialog", @@ -163,7 +178,7 @@ export const Sidebar = React.forwardRef( const isTopModal = useModalAria(sidebarRef, hidden); - const closeIcon = () => { + const renderCloseButton = () => { if (!onCancel) return null; return ( ( ); }; + const closeButton = renderCloseButton(); + const hasHeader = Boolean(header); + const hasSubHeader = Boolean(subHeader); + const sidebar = ( ( ref={setRefs} position={position} size={size} - onCancel={onCancel} role={role} width={width} widthAnimation={widthAnimation && allowMotion} className={className} > - {header && ( + {hasHeader && ( {header} )} - {subHeader && ( + {hasSubHeader && ( {subHeader} )} - {!header && closeIcon()} + {!hasHeader && closeButton} ( ); return ( - ( {sidebar} )} - + ); }, ); diff --git a/src/components/sidebar/sidebar.mdx b/src/components/sidebar/sidebar.mdx index 1ff2b787a8..1ab7ea5e33 100644 --- a/src/components/sidebar/sidebar.mdx +++ b/src/components/sidebar/sidebar.mdx @@ -30,7 +30,9 @@ import * as SidebarStories from "./sidebar.stories.tsx"; import Sidebar from "carbon-react/lib/components/sidebar"; ``` -- Sidebar is positioned on the right hand screen of the window by default. To position the sidebar on the left hand side pass `position='left'` to the component. +- Sidebar is positioned on the right and uses a fluid `30vw` width with a `288px` minimum by default. At 768px and below it occupies the full viewport width without a dimmer, and its content regions scroll together. + +- The `position` and preset `size` props are deprecated. Existing explicit values remain supported during migration; use `width` for desktop width customisation. - The background behind the sidebar is disabled by default. To allow the user to interact with all the UI pass `enableBackgroundUI={ true }` to the component @@ -48,6 +50,12 @@ to ensure behaviour is consistent across all browsers. +### Responsive behavior + +Above 768px, the Sidebar uses its desktop width and keeps a sticky footer visible while the content scrolls (see [Quick Start](#quick-start) for the full-viewport behaviour at 768px and below). + + + ### Preventing focus from being restored when Sidebar closes When the `restoreFocusOnClose` prop is `false`, focus will not be restored to the element that was focused before the `Sidebar` was opened. @@ -61,11 +69,21 @@ Focus can instead be programmatically applied to another element if appropriate. ### With header +String headers are rendered as an `h1`. String and custom React node headers are automatically associated with the dialog. Use `aria-labelledby` or `aria-label` to provide an explicit accessible name instead. + -### With dark header +### With inverse header - +Use `headerVariant="inverse"` for the dark treatment. The legacy `dark` and `light` values remain supported as deprecated aliases for `inverse` and `typical`. + + + +### With gradient keyline + +Setting `gradientKeyLine` adds the Carbon AI gradient keyline without changing the header's accessible name. + + ### With header and subheader @@ -92,13 +110,15 @@ This may occasionally be useful with things like Toasts where they persist on th ### Custom width It is possible to set a custom width for the `Sidebar` via the `width` [prop](#props). -Setting this prop will override the preset width value defined via the `size` prop. +The custom width applies above 768px and cannot reduce the rendered width below 288px. -### With header and footer padding overridden +### Custom header and content padding + +The header and content padding can be customized independently. Form footers retain their own component styling. - + ### Top modal override diff --git a/src/components/sidebar/sidebar.pw.tsx b/src/components/sidebar/sidebar.pw.tsx index de45c6d0a6..a09778dea2 100644 --- a/src/components/sidebar/sidebar.pw.tsx +++ b/src/components/sidebar/sidebar.pw.tsx @@ -7,26 +7,27 @@ import { } from "../../../playwright/components"; import { sidebarPreview } from "../../../playwright/components/sidebar"; import { - assertCssValueIsApproximately, checkAccessibility, continuePressingSHIFTTAB, continuePressingTAB, waitForAnimationEnd, } from "../../../playwright/support/helper"; import { - Default, - DefaultNested, - SidebarBackgroundScrollTestComponent, - SidebarBackgroundScrollWithOtherFocusableContainers, - SidebarComponentFocusable, + ControlledSidebar, + NestedSidebars, + SidebarWithBackgroundScrollTarget, + SidebarWithBackgroundScrollTargetAndFocusableContainers, + SidebarWithFocusableContainer, + SidebarWithStickyForm, + SidebarWithTallStickyFormFooter, } from "./components.test-pw"; test.describe("Focus management and interaction tests for Sidebar component", () => { - test("should render component with focusableContainers", async ({ + test("allows an additional focusable container to remain interactive", async ({ mount, page, }) => { - await mount(); + await mount(); const toastElement = getComponent(page, "toast"); @@ -45,11 +46,11 @@ test.describe("Focus management and interaction tests for Sidebar component", () await expect(toastElement).toBeHidden(); }); - test("should render component with first input and button as focusableSelectors", async ({ + test("uses focusableSelectors to skip excluded inputs during tab navigation", async ({ mount, page, }) => { - await mount(); + await mount(); const sidebarPreviewElement = sidebarPreview(page); await sidebarPreviewElement.press("Tab"); @@ -69,11 +70,11 @@ test.describe("Focus management and interaction tests for Sidebar component", () await expect(openToastElement).toBeFocused(); }); - test("should return focus to the Toast within component after non-focusable content has been selected", async ({ + test("includes the Toast close button in the tab order", async ({ mount, page, }) => { - await mount(); + await mount(); const toastElement = getComponent(page, "toast"); @@ -94,7 +95,7 @@ test.describe("Focus management and interaction tests for Sidebar component", () mount, page, }) => { - await mount(); + await mount(); const button = page.getByRole("button").filter({ hasText: "Open sidebar" }); const sidebar = sidebarPreview(page); @@ -113,7 +114,7 @@ test.describe("Focus management and interaction tests for Sidebar component", () mount, page, }) => { - await mount(); + await mount(); const sidebar = sidebarPreview(page); await expect(sidebar).toBeVisible(); @@ -130,11 +131,11 @@ test.describe("Focus management and interaction tests for Sidebar component", () await expect(button).toBeFocused(); }); - test("when nested Sidebar's are opened/closed their respective call to action elements should be focused correctly", async ({ + test("when nested Sidebars are opened and closed, focus returns to their respective call-to-action elements", async ({ mount, page, }) => { - await mount(); + await mount(); const firstButton = page .getByRole("button") @@ -166,7 +167,7 @@ test.describe("Focus management and interaction tests for Sidebar component", () mount, page, }) => { - await mount(); + await mount(); const button = page.getByRole("button").filter({ hasText: "Open sidebar" }); const sidebar = sidebarPreview(page); @@ -183,20 +184,104 @@ test.describe("Focus management and interaction tests for Sidebar component", () }); test.describe("Browser-specific rendering", () => { - test("check component has correctly styling when zoom is 400%", async ({ + test("uses the fluid right-positioned desktop presentation above 768px", async ({ mount, page, }) => { - await mount(); + await page.setViewportSize({ width: 1000, height: 800 }); + await mount(); - // 4.0 zoom is equal to 400% - await page.evaluate("document.body.style.zoom=4.0"); + const sidebar = sidebarPreview(page); + await expect(sidebar).toHaveCSS("width", "300px"); + await expect(sidebar).toHaveCSS("min-width", "288px"); + await expect(sidebar).toHaveCSS("right", "0px"); + await expect(sidebar).toHaveCSS("border-radius", "24px 0px 0px 24px"); + await expect(sidebar).toHaveCSS("overflow", "hidden"); + await expect(getDataElementByValue(page, "modal-background")).toBeVisible(); + }); + + test("uses the 288px desktop minimum width immediately above 768px", async ({ + mount, + page, + }) => { + await page.setViewportSize({ width: 769, height: 800 }); + await mount(); + + const sidebar = sidebarPreview(page); + await expect(sidebar).toHaveCSS("width", "288px"); + await expect(sidebar).toHaveCSS("right", "0px"); + await expect(sidebar).toHaveCSS("border-radius", "24px 0px 0px 24px"); + await expect(getDataElementByValue(page, "modal-background")).toBeVisible(); + }); + + test("caps an oversized legacy preset width to the viewport", async ({ + mount, + page, + }) => { + await page.setViewportSize({ width: 769, height: 800 }); + await mount(); + + await expect(sidebarPreview(page)).toHaveCSS("width", "769px"); + }); + + test("caps an oversized custom width to the viewport", async ({ + mount, + page, + }) => { + await page.setViewportSize({ width: 769, height: 800 }); + await mount(); + + await expect(sidebarPreview(page)).toHaveCSS("width", "769px"); + }); + + test("uses the fullscreen presentation without a dimmer at 768px", async ({ + mount, + page, + }) => { + await page.setViewportSize({ width: 768, height: 800 }); + await mount(); + + const sidebar = sidebarPreview(page); + await expect(sidebar).toHaveCSS("width", "768px"); + await expect(sidebar).toHaveCSS("border-radius", "0px"); + await expect(sidebar).toHaveCSS("overflow-y", "auto"); + await expect(getDataElementByValue(page, "modal-background")).toBeHidden(); + await expect(sidebar).toHaveAttribute("aria-modal", "true"); + }); + + test("makes a sticky footer part of the whole Sidebar scroll at 768px", async ({ + mount, + page, + }) => { + await page.setViewportSize({ width: 769, height: 800 }); + await mount(); + + const sidebar = sidebarPreview(page); + const footer = page.getByTestId("form-footer"); + await expect(footer).toHaveCSS("position", "sticky"); + + await page.setViewportSize({ width: 768, height: 800 }); + await expect(footer).toHaveCSS("position", "static"); + await expect(sidebar).toHaveCSS("overflow-y", "auto"); + await expect + .poll(() => + sidebar.evaluate( + (element) => element.scrollHeight > element.clientHeight, + ), + ) + .toBe(true); + }); + + test("does not constrain custom sticky footer height", async ({ + mount, + page, + }) => { + await mount(); - await assertCssValueIsApproximately( - sidebarPreview(page), - "max-width", - 1366, - ); + const footer = page.getByTestId("form-footer"); + await expect + .poll(() => footer.evaluate((element) => element.clientHeight)) + .toBeGreaterThanOrEqual(128); }); test.describe("Check background scroll when tabbing", () => { @@ -205,7 +290,7 @@ test.describe("Browser-specific rendering", () => { mount, page, }) => { - await mount(); + await mount(); await continuePressingTAB(page, 3); const closeIconButtonElement = closeIconButton(page); @@ -221,7 +306,7 @@ test.describe("Browser-specific rendering", () => { mount, page, }) => { - await mount(); + await mount(); await continuePressingSHIFTTAB(page, 1); const closeIconButtonElement = closeIconButton(page); @@ -237,7 +322,7 @@ test.describe("Browser-specific rendering", () => { mount, page, }) => { - await mount(); + await mount(); await continuePressingTAB(page, 6); await waitForAnimationEnd(sidebarPreview(page)); @@ -254,7 +339,7 @@ test.describe("Browser-specific rendering", () => { mount, page, }) => { - await mount(); + await mount(); await continuePressingSHIFTTAB(page, 7); const closeIconButtonElement = closeIconButton(page).nth(0); @@ -272,7 +357,7 @@ test.describe("Accessibility tests for Sidebar component", () => { mount, page, }) => { - await mount(); + await mount(); await checkAccessibility(page); }); diff --git a/src/components/sidebar/sidebar.stories.tsx b/src/components/sidebar/sidebar.stories.tsx index 1c5c674fe3..1bfd9c01ba 100644 --- a/src/components/sidebar/sidebar.stories.tsx +++ b/src/components/sidebar/sidebar.stories.tsx @@ -15,7 +15,6 @@ import Confirm from "../confirm"; import Message from "../message"; import Sidebar from "."; -import Icon from "../icon"; const styledSystemProps = generateStyledSystemProps({ padding: true, @@ -85,7 +84,45 @@ export const DefaultStory: Story = () => { ); }; DefaultStory.storyName = "Default"; -DefaultStory.parameters = { chromatic: { disableSnapshot: true } }; + +export const ResponsiveBehavior: Story = () => { + const [isOpen, setIsOpen] = useState(defaultOpenState); + + return ( + <> + + setIsOpen(false)} + header="Responsive sidebar" + > + Cancel} + saveButton={} + stickyFooter + onSubmit={(event) => event.preventDefault()} + > + Long content + + + + ); +}; +ResponsiveBehavior.storyName = "Responsive Behavior"; +ResponsiveBehavior.parameters = { + chromatic: { + modes: { + aboveBreakpoint: { + ...allModes.chromatic, + viewport: { ...allModes.chromatic.viewport, width: 769 }, + }, + atBreakpoint: { + ...allModes.chromatic, + viewport: { ...allModes.chromatic.viewport, width: 768 }, + }, + }, + }, +}; export const RestoreFocusOnCloseStory: Story = () => { const [isOpen, setIsOpen] = useState(false); @@ -106,10 +143,10 @@ export const RestoreFocusOnCloseStory: Story = () => { {showMessage && ( setShowMessage(false)} > - Some custom message + Sidebar closed; focus moved to this message. )} { const [isOpen, setIsOpen] = useState(defaultOpenState); + const [contentPadding, setContentPadding] = useState<"none" | "large">( + "large", + ); + return ( <> - - setIsOpen(false)} p={0}> + + + setIsOpen(false)} + p={contentPadding === "none" ? 0 : "var(--global-space-comp-2-xl)"} + > @@ -177,7 +239,6 @@ export const WithHeader: Story = () => { ); }; WithHeader.storyName = "With Header"; -WithHeader.parameters = { chromatic: { disableSnapshot: true } }; export const WithHeaderAndSubheader: Story = () => { const [isOpen, setIsOpen] = useState(defaultOpenState); @@ -187,7 +248,7 @@ export const WithHeaderAndSubheader: Story = () => { setIsOpen(false)} - header={Sidebar header} + header="Sidebar header" subHeader={ setIsOpen(false)} - header={headerNode} - headerVariant="dark" + header="Sidebar header" + headerVariant="inverse" > @@ -239,7 +291,26 @@ export const WithDarkHeader: Story = () => { ); }; -WithDarkHeader.storyName = "With Dark Header"; +WithInverseHeader.storyName = "With Inverse Header"; + +export const WithGradientKeyLine: Story = () => { + const [isOpen, setIsOpen] = useState(defaultOpenState); + + return ( + <> + + setIsOpen(false)} + header="Sidebar header" + gradientKeyLine + > + Main Content + + + ); +}; +WithGradientKeyLine.storyName = "With Gradient Keyline"; export const WithScroll: Story = () => { const [isOpen, setIsOpen] = useState(defaultOpenState); @@ -249,7 +320,7 @@ export const WithScroll: Story = () => { setIsOpen(false)} - header={Sidebar header} + header="Sidebar header" > @@ -275,7 +346,7 @@ export const WithTypography: Story = () => { position="left" open={isOpen} onCancel={() => setIsOpen(false)} - header={Sidebar Header} + header="Sidebar Header" >
Action button} @@ -363,7 +434,7 @@ export const OtherFocusableContainers: Story = () => { setIsSidebarOpen(false)} - header={Sidebar header} + header="Sidebar header" focusableContainers={[toast1Ref, toast2Ref]} > { {}} /> {}} value="" /> {}} value="" /> - - + + + +
{ open={isOpen} onCancel={() => setIsOpen(false)} width="25%" - header={Sidebar} + header="Sidebar" > { }; CustomWidth.storyName = "Custom Width"; -export const WithHeaderAndFooterPadding: Story = () => { +export const CustomHeaderAndContentPadding: Story = () => { const [isOpen, setIsOpen] = useState(defaultOpenState); return ( <> setIsOpen(false)} - header={Sidebar Header} - p={2} - headerPadding={{ p: 2 }} + header="Sidebar Header — 16px padding" + p="var(--global-space-comp-2-xl)" + headerPadding={{ p: "var(--global-space-comp-l)" }} >
Action button} stickyFooter buttonAlignment="right" - footerPadding={{ p: 2 }} > + Content padding: 32px (--global-space-comp-2-xl). Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed lectus massa, suscipit vitae pellentesque quis, facilisis non ante. @@ -489,7 +556,7 @@ export const WithHeaderAndFooterPadding: Story = () => { ); }; -WithHeaderAndFooterPadding.storyName = "With Header and Footer Padding"; +CustomHeaderAndContentPadding.storyName = "Custom Header and Content Padding"; export const TopModalOverride: Story = () => { const [isOpenAll, setIsOpenAll] = useState(defaultOpenState); diff --git a/src/components/sidebar/sidebar.style.ts b/src/components/sidebar/sidebar.style.ts index 213521f5b7..bea8abe9a0 100644 --- a/src/components/sidebar/sidebar.style.ts +++ b/src/components/sidebar/sidebar.style.ts @@ -5,13 +5,21 @@ import computeSizing from "../../style/utils/element-sizing"; import { SidebarProps } from "./sidebar.component"; import applyBaseTheme from "../../style/themes/apply-base-theme"; import StyledIconButton from "../icon-button/icon-button.style"; +import StyledIcon from "../icon/icon.style"; import { SIDEBAR_SIZES_CSS } from "./sidebar.config"; -import { StyledForm, StyledFormContent } from "../form/form.style"; +import { + StyledForm, + StyledFormContent, + StyledFormFooter, +} from "../form/form.style"; +import Modal from "../../__internal__/modal"; + +const smallScreenBreakpoint = "768px"; type StyledSidebarProps = Pick< SidebarProps, - "onCancel" | "position" | "size" | "width" | "widthAnimation" + "position" | "size" | "width" | "widthAnimation" >; const StyledSidebar = styled.div.attrs(applyBaseTheme)` @@ -20,9 +28,11 @@ const StyledSidebar = styled.div.attrs(applyBaseTheme)` outline: none; } - ${({ onCancel, position, size, theme, width, widthAnimation }) => css` - background: var(--colorsUtilityYang100); - border-radius: 1px; + ${({ position, size, theme, width, widthAnimation }) => css` + background: var(--container-standard-bg-default); + border-radius: ${position === "left" + ? "var(--global-radius-none) var(--global-radius-container-xl) var(--global-radius-container-xl) var(--global-radius-none)" + : "var(--global-radius-container-xl) var(--global-radius-none) var(--global-radius-none) var(--global-radius-container-xl)"}; bottom: 0; position: fixed; display: flex; @@ -30,13 +40,18 @@ const StyledSidebar = styled.div.attrs(applyBaseTheme)` top: 0; z-index: ${theme.zIndex.fullScreenModal}; max-width: 100vw; + overflow: hidden; - ${!width && - size && + ${(!size || width) && css` - width: ${SIDEBAR_SIZES_CSS[size]}; + min-width: 288px; `} - ${width && computeSizing({ width })} + + ${width + ? computeSizing({ width }) + : css` + width: ${size ? SIDEBAR_SIZES_CSS[size] : "30vw"}; + `} ${widthAnimation && css` @@ -45,19 +60,33 @@ const StyledSidebar = styled.div.attrs(applyBaseTheme)` ${position && css` - box-shadow: var(--boxShadow300); + box-shadow: var(--global-depth-lvl3); ${position}: 0; `} - ${onCancel && - css` - > ${StyledIconButton}:first-of-type { - position: absolute; - z-index: 1; - right: 25px; - top: 25px; + > ${StyledIconButton}:first-of-type { + border-radius: var(--global-radius-action-circle); + height: var(--global-size-s); + min-width: var(--global-size-s); + position: absolute; + right: var(--global-space-comp-xl); + top: var(--global-space-comp-xl); + width: var(--global-size-s); + z-index: 1; + + ${StyledIcon} { + height: var(--global-size-2-xs); + width: var(--global-size-2-xs); } - `} + } + + @media screen and (max-width: ${smallScreenBreakpoint}) { + border-radius: var(--global-radius-none); + height: 100%; + min-width: 100%; + overflow-y: auto; + width: 100%; + } `} `; @@ -67,7 +96,9 @@ const StyledSidebarContent = styled.div` overflow-y: auto; flex-grow: 1; - padding: var(--spacing300) var(--spacing400) var(--spacing400); + color: var(--container-standard-txt-default); + font: var(--global-font-static-body-regular-m); + padding: var(--global-space-comp-xl); ${paddingFn} &:has(${StyledForm}.sticky) { @@ -78,11 +109,54 @@ const StyledSidebarContent = styled.div` ${StyledForm}.sticky { ${StyledFormContent} { - padding: var(--spacing300) var(--spacing400) var(--spacing400); + padding: var(--global-space-comp-xl); ${paddingFn} } + + ${StyledFormFooter} { + background: var(--container-standard-bg-default); + border-top: var(--global-borderwidth-xs) solid + var(--container-standard-border-default); + gap: var(--global-space-layout-2-xs); + padding: var(--global-space-comp-l) var(--global-space-comp-xl); + } + } + } + + @media screen and (max-width: ${smallScreenBreakpoint}) { + flex-grow: 0; + overflow-y: visible; + + &:has(${StyledForm}.sticky) { + overflow-y: visible; + + ${StyledForm}.sticky { + height: auto; + + ${StyledFormContent} { + overflow-y: visible; + } + + ${StyledFormFooter} { + box-shadow: none; + position: static; + } + } + } + } +`; + +const StyledSidebarModal = styled(Modal)` + @media screen and (max-width: ${smallScreenBreakpoint}) { + [data-element="modal-background"] { + display: none; } } `; -export { StyledSidebar, StyledSidebarContent }; +export { + StyledSidebar, + StyledSidebarContent, + StyledSidebarModal, + smallScreenBreakpoint, +}; diff --git a/src/components/sidebar/sidebar.test.tsx b/src/components/sidebar/sidebar.test.tsx index 382cd95f37..10aaa0c049 100644 --- a/src/components/sidebar/sidebar.test.tsx +++ b/src/components/sidebar/sidebar.test.tsx @@ -69,6 +69,14 @@ test("renders with header when prop is provided", () => { ).toBeVisible(); }); +test("renders a string header as a semantic heading", () => { + render(); + + expect( + screen.getByRole("heading", { level: 1, name: "My sidebar" }), + ).toBeVisible(); +}); + test("renders with a subheader when the `subHeader` prop is provided", () => { render(My subheader} />); @@ -77,7 +85,19 @@ test("renders with a subheader when the `subHeader` prop is provided", () => { ).toBeVisible(); }); -test("sidebar uses header prop as accessible name when an HTML element is provided as header", () => { +test("sidebar uses aria-labelledby to associate a custom header", () => { + render( + My sidebar} + />, + ); + + expect(screen.getByRole("dialog")).toHaveAccessibleName("My sidebar"); +}); + +test("sidebar uses a custom header as its accessible name", () => { render(My sidebar} />); expect(screen.getByRole("dialog")).toHaveAccessibleName("My sidebar"); @@ -352,8 +372,73 @@ test("ensures correct background color is applied", () => { const sidebarContent = screen.getByRole("dialog"); expect(sidebarContent).toHaveStyleRule( "background", - "var(--colorsUtilityYang100)", + "var(--container-standard-bg-default)", + ); +}); + +test("uses the fluid desktop width and minimum width by default", () => { + render(); + + const sidebar = screen.getByRole("dialog"); + expect(sidebar).toHaveStyleRule("width", "30vw"); + expect(sidebar).toHaveStyleRule("min-width", "288px"); + expect(sidebar).toHaveStyleRule("max-width", "100vw"); + expect(sidebar).toHaveStyleRule("right", "0"); + expect(sidebar).toHaveStyleRule("box-shadow", "var(--global-depth-lvl3)"); + expect(sidebar).toHaveStyleRule("overflow", "hidden"); +}); + +test("rounds the exposed right corners for legacy left positioning", () => { + render(); + + expect(screen.getByRole("dialog")).toHaveStyleRule( + "border-radius", + "var(--global-radius-none) var(--global-radius-container-xl) var(--global-radius-container-xl) var(--global-radius-none)", + ); +}); + +test("retains explicitly supplied legacy size values", () => { + render(); + + const sidebar = screen.getByRole("dialog"); + expect(sidebar).toHaveStyleRule("width", "214px"); + expect(sidebar).not.toHaveStyleRule("min-width", "288px"); +}); + +test("uses custom width with a 288px minimum on desktop", () => { + render(); + + const sidebar = screen.getByRole("dialog"); + expect(sidebar).toHaveStyleRule("width", "200px"); + expect(sidebar).toHaveStyleRule("min-width", "288px"); + expect(sidebar).toHaveStyleRule("max-width", "100vw"); +}); + +test("applies the full-width, square, scrollable small-screen styles", () => { + render(); + + const sidebar = screen.getByRole("dialog"); + const media = "screen and (max-width: 768px)"; + expect(sidebar).toHaveStyleRule("width", "100%", { media }); + expect(sidebar).toHaveStyleRule("min-width", "100%", { media }); + expect(sidebar).toHaveStyleRule( + "border-radius", + "var(--global-radius-none)", + { + media, + }, ); + expect(sidebar).toHaveStyleRule("overflow-y", "auto", { media }); +}); + +test("retains modal semantics when responsive dimmer styling is enabled", () => { + render( + + + , + ); + + expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true"); }); test("applies a width transition when `widthAnimation` is true and motion is allowed", () => { @@ -404,29 +489,113 @@ testStyledSystemPadding( ); // for coverage - the `headerVariant` prop will be captured by Chromatic` -test('renders with correct styles when `headerVariant` is "light"', () => { - render(); +test.each(["typical", "light"] as const)( + 'renders with typical styles when `headerVariant` is "%s"', + (headerVariant) => { + render(); + + const sidebarHeader = screen.getByTestId("sidebar-header"); + + expect(sidebarHeader).toHaveStyle({ + "background-color": "var(--container-standard-bg-default)", + }); + }, +); + +// for coverage - the `headerVariant` prop will be captured by Chromatic` +test.each(["inverse", "dark"] as const)( + 'renders with inverse styles when `headerVariant` is "%s"', + (headerVariant) => { + render( + {}} + />, + ); - const sidebarHeader = screen.getByTestId("sidebar-header"); + const sidebarHeader = screen.getByTestId("sidebar-header"); + const closeIcon = screen.getByTestId("icon"); - expect(sidebarHeader).toHaveStyle({ - "background-color": "var(--colorsUtilityYang100)", + expect(sidebarHeader).toHaveStyle( + "background-color: var(--container-standard-inverse-bg-default)", + ); + expect(closeIcon).toHaveStyle( + "color: var(--container-standard-inverse-txt-default)", + ); + }, +); + +test("renders an AI gradient keyline on the header", () => { + render(); + + const divider = screen.getByRole("separator", { hidden: true }); + + expect(divider).toHaveAttribute("aria-hidden", "true"); + expect(divider).toHaveStyleRule( + "background", + "var(--container-standard-border-ai-h)", + ); + expect(divider).toHaveStyleRule("height", "var(--global-borderwidth-s)"); + expect(screen.getByRole("dialog")).toHaveAccessibleName("My sidebar"); +}); + +test("renders the standard header divider", () => { + render(); + + const divider = screen.getByRole("separator", { hidden: true }); + + expect(divider).toHaveStyleRule( + "background", + "var(--container-standard-border-default)", + ); + expect(divider).toHaveStyleRule("height", "var(--global-borderwidth-xs)"); +}); + +test("applies the tokenized header layout and typography", () => { + render( {}} />); + + const header = screen.getByTestId("sidebar-header"); + + expect(header).toHaveStyleRule("padding", "var(--global-space-comp-xl)"); + expect(header).toHaveStyleRule( + "min-height", + "calc((2 * var(--global-space-comp-xl)) + var(--global-size-s))", + ); + expect(header).toHaveStyleRule("gap", "var(--global-space-comp-l)"); + expect(screen.getByRole("heading", { name: "My sidebar" })).toHaveStyleRule( + "font", + "var(--global-font-static-heading-m)", + ); + expect(header).toHaveStyleRule("gap", "var(--global-space-comp-xs)", { + modifier: 'div[data-element="sidebar-heading"]', + }); + expect(header).toHaveStyleRule("flex", "1 1 auto", { + modifier: 'div[data-element="sidebar-heading"]', + }); + expect(header).toHaveStyleRule("min-width", "0", { + modifier: 'div[data-element="sidebar-heading"]', }); }); -// for coverage - the `headerVariant` prop will be captured by Chromatic` -test('renders with correct styles when `headerVariant` is "dark"', () => { +test("applies tokenized content spacing and typography", () => { render( - {}} />, + + Content + , ); - const sidebarHeader = screen.getByTestId("sidebar-header"); - const closeIcon = screen.getByTestId("icon"); - - expect(sidebarHeader).toHaveStyle( - "background-color: var(--colorsUtilityYin100)", + const content = screen.getByTestId("sidebar-content"); + expect(content).toHaveStyleRule("padding", "var(--global-space-comp-xl)"); + expect(content).toHaveStyleRule( + "font", + "var(--global-font-static-body-regular-m)", + ); + expect(content).toHaveStyleRule( + "color", + "var(--container-standard-txt-default)", ); - expect(closeIcon).toHaveStyle("color: var(--colorsUtilityYang080)"); }); test("close button has correct data-* props, when the closeButtonDataProps prop is passed", () => {