diff --git a/e2e/nextjs-app/src/app/components/drawer/custom-call-to-action.e2e.tsx b/e2e/nextjs-app/src/app/components/drawer/custom-call-to-action.e2e.tsx new file mode 100644 index 0000000000..3758eee208 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/drawer/custom-call-to-action.e2e.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { Button } from "@lifesg/react-design-system/button"; +import { Drawer } from "@lifesg/react-design-system/drawer"; +import { Typography } from "@lifesg/react-design-system/typography"; +import { useState } from "react"; + +import styles from "./drawer.module.css"; + +export default function Story() { + const [show, setShow] = useState(false); + + return ( + <> + + setShow(false)} + onOverlayClick={() => setShow(false)} + customCallToAction={ +
+ + +
+ } + > +
+ Drawer content +
+
+ + ); +} diff --git a/e2e/nextjs-app/src/app/components/drawer/drawer.module.css b/e2e/nextjs-app/src/app/components/drawer/drawer.module.css index 0227ac8acf..6625475133 100644 --- a/e2e/nextjs-app/src/app/components/drawer/drawer.module.css +++ b/e2e/nextjs-app/src/app/components/drawer/drawer.module.css @@ -2,6 +2,12 @@ padding: 1rem; } +.cta { + display: flex; + gap: 1rem; + white-space: nowrap; +} + .item-margin-bottom { margin-bottom: 1rem; } diff --git a/e2e/nextjs-app/src/app/components/local-nav/dropdown-title-addon.e2e.tsx b/e2e/nextjs-app/src/app/components/local-nav/dropdown-title-addon.e2e.tsx new file mode 100644 index 0000000000..c93c21a1b0 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/local-nav/dropdown-title-addon.e2e.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { LocalNavDropdown } from "@lifesg/react-design-system/local-nav"; +import { DocIcon } from "@lifesg/react-icons/doc"; +import { ExclamationCircleFillIcon } from "@lifesg/react-icons/exclamation-circle-fill"; +import clsx from "clsx"; +import { useState } from "react"; + +import styles from "./title-addon.module.css"; + +const LeftIcon = () => ; +const RightIcon = () => ( + +); + +// One item per combination, with a wrapping title to exercise first-line +// alignment of the left addon and the selected tick. A final short-title item +// captures right-addon alignment on a single line. +const ITEMS = [ + { + id: "section-1", + title: "A long section title that wraps onto multiple lines", + titleAddon: { left: , right: }, + }, + { + id: "section-2", + title: "A long section title that wraps onto multiple lines", + titleAddon: { left: }, + }, + { + id: "section-3", + title: "A long section title that wraps onto multiple lines", + titleAddon: { right: }, + }, + { + id: "section-4", + title: "Short title", + titleAddon: { right: }, + }, +]; + +export default function Story() { + const [selectedItemIndex, setSelectedItemIndex] = useState(0); + + return ( +
+ + setSelectedItemIndex(index) + } + /> +
+ ); +} diff --git a/e2e/nextjs-app/src/app/components/local-nav/menu-title-addon.e2e.tsx b/e2e/nextjs-app/src/app/components/local-nav/menu-title-addon.e2e.tsx new file mode 100644 index 0000000000..b749125bb7 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/local-nav/menu-title-addon.e2e.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { LocalNavMenu } from "@lifesg/react-design-system/local-nav"; +import { DocIcon } from "@lifesg/react-icons/doc"; +import { ExclamationCircleFillIcon } from "@lifesg/react-icons/exclamation-circle-fill"; +import clsx from "clsx"; +import { useState } from "react"; + +import styles from "./title-addon.module.css"; + +const LeftIcon = () => ; +const RightIcon = () => ( + +); + +// One item per combination, with a wrapping title to exercise first-line +// alignment of the left addon and the selected tick. A final short-title item +// captures right-addon alignment on a single line. +const ITEMS = [ + { + id: "section-1", + title: "A long section title that wraps onto multiple lines", + titleAddon: { left: , right: }, + }, + { + id: "section-2", + title: "A long section title that wraps onto multiple lines", + titleAddon: { left: }, + }, + { + id: "section-3", + title: "A long section title that wraps onto multiple lines", + titleAddon: { right: }, + }, + { + id: "section-4", + title: "Short title", + titleAddon: { right: }, + }, +]; + +export default function Story() { + const [selectedItemIndex, setSelectedItemIndex] = useState(0); + + return ( +
+ + setSelectedItemIndex(index) + } + /> +
+ ); +} diff --git a/e2e/nextjs-app/src/app/components/local-nav/title-addon.module.css b/e2e/nextjs-app/src/app/components/local-nav/title-addon.module.css new file mode 100644 index 0000000000..fb010567e3 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/local-nav/title-addon.module.css @@ -0,0 +1,14 @@ +.container { + max-width: 320px; +} + +.icon { + width: 1.25rem; + height: 1.25rem; +} + +.right-icon { + width: 1.25rem; + height: 1.25rem; + color: var(--fds-colour-icon-error); +} diff --git a/e2e/tests/components/drawer/__screenshots__/chromium/Drawer-Custom-call-to-action-Narrow-stacked---cta-open-mobile.png b/e2e/tests/components/drawer/__screenshots__/chromium/Drawer-Custom-call-to-action-Narrow-stacked---cta-open-mobile.png new file mode 100644 index 0000000000..030f12d8bc Binary files /dev/null and b/e2e/tests/components/drawer/__screenshots__/chromium/Drawer-Custom-call-to-action-Narrow-stacked---cta-open-mobile.png differ diff --git a/e2e/tests/components/drawer/__screenshots__/chromium/Drawer-Custom-call-to-action-Wide--cta-open.png b/e2e/tests/components/drawer/__screenshots__/chromium/Drawer-Custom-call-to-action-Wide--cta-open.png new file mode 100644 index 0000000000..994046c903 Binary files /dev/null and b/e2e/tests/components/drawer/__screenshots__/chromium/Drawer-Custom-call-to-action-Wide--cta-open.png differ diff --git a/e2e/tests/components/drawer/drawer.e2e.spec.ts b/e2e/tests/components/drawer/drawer.e2e.spec.ts index 218cf43572..976c278616 100644 --- a/e2e/tests/components/drawer/drawer.e2e.spec.ts +++ b/e2e/tests/components/drawer/drawer.e2e.spec.ts @@ -1,6 +1,5 @@ import { test as base, expect, Locator, Page } from "@playwright/test"; import { AbstractStoryPage, compareScreenshot } from "../../utils"; -import { viewport } from "../../consts"; class StoryPage extends AbstractStoryPage { protected readonly component = "drawer"; @@ -119,6 +118,36 @@ test.describe("Drawer", () => { }); }); + test.describe("Custom call to action", () => { + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("custom-call-to-action"); + }); + + // Wide drawer: CTA sits inline to the right of the (truncated) heading. + test("Wide", async ({ story }) => { + await story.open(); + await compareScreenshot(story, "cta-open", { + fullscreen: true, + }); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("custom-call-to-action", { size: "mobile" }); + }); + + // Narrow drawer: CTA wraps below the heading, buttons flushed left. + test("Narrow (stacked)", async ({ story }) => { + await story.open(); + await compareScreenshot(story, "cta-open-mobile", { + fullscreen: true, + }); + }); + }); + }); + test.describe(() => { test.beforeEach(async ({ story }) => { await story.init("sticky-header"); diff --git a/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Dropdown-with-title-addon-Default--addon-open.png b/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Dropdown-with-title-addon-Default--addon-open.png new file mode 100644 index 0000000000..1141a2b209 Binary files /dev/null and b/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Dropdown-with-title-addon-Default--addon-open.png differ diff --git a/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Menu-with-title-addon-Default--addon-mount.png b/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Menu-with-title-addon-Default--addon-mount.png new file mode 100644 index 0000000000..13a1552033 Binary files /dev/null and b/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Menu-with-title-addon-Default--addon-mount.png differ diff --git a/e2e/tests/components/local-nav/local-nav.e2e.spec.ts b/e2e/tests/components/local-nav/local-nav.e2e.spec.ts index 28ba2604fe..412e08a104 100644 --- a/e2e/tests/components/local-nav/local-nav.e2e.spec.ts +++ b/e2e/tests/components/local-nav/local-nav.e2e.spec.ts @@ -86,6 +86,17 @@ test.describe("Local nav", () => { await compareScreenshot(story, "hover"); }); }); + test.describe("Menu with title addon", () => { + test.beforeEach(async ({ story }) => { + await story.init("menu-title-addon"); + }); + + // Left addon + selected tick align to the first line of a wrapped + // title; right addon stays vertically centred and flushed right. + test("Default", async ({ story }) => { + await compareScreenshot(story, "addon-mount"); + }); + }); test.describe("Dropdown", () => { test.beforeEach(async ({ story }) => { await story.init("dropdown"); @@ -148,6 +159,18 @@ test.describe("Local nav", () => { }); }); }); + test.describe("Dropdown with title addon", () => { + test.beforeEach(async ({ story }) => { + await story.init("dropdown-title-addon"); + }); + + // Left addon + selected tick align to the first line of a wrapped + // title; right addon stays vertically centred and flushed right. + test("Default", async ({ story }) => { + await story.locators.dropdownLabel.click(); + await compareScreenshot(story, "addon-open", { fullscreen: true }); + }); + }); test.describe(() => { test.beforeEach(async ({ story }) => { await story.init("dropdown", { mode: "dark" }); diff --git a/src/drawer/drawer.styles.ts b/src/drawer/drawer.styles.ts index 6e82b46be6..8384933474 100644 --- a/src/drawer/drawer.styles.ts +++ b/src/drawer/drawer.styles.ts @@ -80,6 +80,25 @@ export const header = css` } `; +// Stack the call-to-action below the heading when the drawer is too narrow +// to fit both on one line. +export const headerStacked = css` + flex-direction: column; + align-items: stretch; + gap: ${Spacing["spacing-16"]}; +`; + +export const callToAction = css` + display: flex; + margin-left: auto; +`; + +// When stacked, flush left so the buttons line up with the heading (the +// header's left padding already clears the close icon). +export const callToActionStacked = css` + margin-left: 0; +`; + export const closeButton = css` color: ${Colour["icon"]}; padding: 0; diff --git a/src/drawer/drawer.tsx b/src/drawer/drawer.tsx index 78b40f723b..0811df0b72 100644 --- a/src/drawer/drawer.tsx +++ b/src/drawer/drawer.tsx @@ -8,9 +8,15 @@ import { import { CrossIcon } from "@lifesg/react-icons/cross"; import clsx from "clsx"; import { useEffect, useRef, useState } from "react"; +import { useResizeDetector } from "react-resize-detector"; import { Overlay } from "../overlay"; import { ClickableIcon } from "../shared/clickable-icon"; +import { + Breakpoint, + parsePxOrRemValue, + useResolvedBreakpointToken, +} from "../theme"; import { Typography } from "../typography"; import { useId } from "../util"; import * as styles from "./drawer.styles"; @@ -31,6 +37,7 @@ export const Drawer = ({ show, onClose, onOverlayClick, + customCallToAction, className, ...otherProps }: DrawerProps) => { @@ -38,8 +45,36 @@ export const Drawer = ({ // CONST, STATE, REFS // ========================================================================= const [showOverlay, setShowOverlay] = useState(show); + // vertical centre of the heading, used to align the (last-in-DOM, + // absolutely positioned) close button with it + const [closeButtonTop, setCloseButtonTop] = useState(); const id = useId(); const initialFocusRef = useRef(null); + // Observe the header (whose size tracks the drawer's actual width, which + // consumers may override) to drive layout in one pass: the width decides + // call-to-action stacking, and each resize re-centres the close button on + // the heading. offsetTop/offsetHeight are relative to the drawer (the + // positioned ancestor), so they share the close button's coordinate space. + const { width: headerWidth, ref: headerRef } = + useResizeDetector({ + refreshMode: "throttle", + refreshRate: 300, + onResize: () => { + const headingEl = initialFocusRef.current; + if (headingEl) { + setCloseButtonTop( + headingEl.offsetTop + headingEl.offsetHeight / 2 + ); + } + }, + }); + // stack the call-to-action below the heading once the drawer is too narrow + // to fit both on one line + const stackWidth = parsePxOrRemValue( + useResolvedBreakpointToken(Breakpoint["sm-max"]) + ); + const stackCallToAction = + headerWidth !== undefined && headerWidth <= stackWidth; // ========================================================================= // FLOATING UI CONFIG @@ -113,7 +148,13 @@ export const Drawer = ({ {...getFloatingProps()} {...otherProps} > -
+
{heading} + {customCallToAction ? ( +
+ {customCallToAction} +
+ ) : null}
{children}
+ {/* Rendered last so assistive tech reaches the heading + and content before the close button; centred against + the header via its measured height. */} diff --git a/src/drawer/types.ts b/src/drawer/types.ts index 4bd25542ee..610656a3c0 100644 --- a/src/drawer/types.ts +++ b/src/drawer/types.ts @@ -23,4 +23,11 @@ export interface DrawerProps { onClose?: (() => void) | undefined; /** Called when the user clicks the background overlay. */ onOverlayClick?: (() => void) | undefined; + /** + * Optional element rendered in the header, e.g. a link or button. + * + * Sits beside the title on wider drawers, and wraps onto its own line + * below the title once the drawer is narrow. + */ + customCallToAction?: React.ReactNode | undefined; } diff --git a/src/local-nav/local-nav-dropdown/local-nav-dropdown.styles.ts b/src/local-nav/local-nav-dropdown/local-nav-dropdown.styles.ts index 7be3cc5133..dd799fc2a8 100644 --- a/src/local-nav/local-nav-dropdown/local-nav-dropdown.styles.ts +++ b/src/local-nav/local-nav-dropdown/local-nav-dropdown.styles.ts @@ -99,6 +99,28 @@ export const navItemList = css` max-height: var(${tokens.navItemList.viewportHeight}); `; +export const titleContainer = css` + display: flex; + align-items: center; + gap: ${Spacing["spacing-8"]}; + flex: 1; +`; + +/* Keeps the left addon aligned with the first line when the title wraps. */ +export const leftAddon = css` + display: inline-flex; + align-items: center; + align-self: flex-start; + min-height: ${Font.Spec["body-lh-baseline"]}; +`; + +/* Flushed right; stays vertically centred against the (possibly wrapped) title. */ +export const rightAddon = css` + display: inline-flex; + align-items: center; + margin-left: auto; +`; + export const navItemLabel = css` ${Font["body-baseline-regular"]} color: ${Colour["text"]}; diff --git a/src/local-nav/local-nav-dropdown/local-nav-dropdown.tsx b/src/local-nav/local-nav-dropdown/local-nav-dropdown.tsx index f10e16d61a..4072b454b8 100644 --- a/src/local-nav/local-nav-dropdown/local-nav-dropdown.tsx +++ b/src/local-nav/local-nav-dropdown/local-nav-dropdown.tsx @@ -283,7 +283,7 @@ const Component = ( renderItem, index, }: LocalNavDropdownItemComponentProps) => { - const { id, title } = item; + const { id, title, titleAddon } = item; if (renderItem) { return ( @@ -324,15 +324,42 @@ const Component = ( listItemRefs.current[index] = el as HTMLLIElement; }} > - {isSelected && } -
- {title} -
+ {isSelected && ( + + + + )} + {titleAddon ? ( + + {titleAddon.left && ( + + {titleAddon.left} + + )} +
+ {title} +
+ {titleAddon.right && ( + + {titleAddon.right} + + )} +
+ ) : ( +
+ {title} +
+ )} ); }; diff --git a/src/local-nav/local-nav-menu/local-nav-menu.styles.ts b/src/local-nav/local-nav-menu/local-nav-menu.styles.ts index c4fbe5ddf0..ea1f419312 100644 --- a/src/local-nav/local-nav-menu/local-nav-menu.styles.ts +++ b/src/local-nav/local-nav-menu/local-nav-menu.styles.ts @@ -1,6 +1,6 @@ import { css } from "@linaria/core"; -import { Colour, Radius, Spacing } from "../../theme"; +import { Colour, Font, Radius, Spacing } from "../../theme"; export const nav = css` list-style-type: none; @@ -12,6 +12,28 @@ export const textLabel = css` margin: 0; `; +export const titleContainer = css` + display: flex; + align-items: center; + gap: ${Spacing["spacing-8"]}; + width: 100%; +`; + +/* Keeps the left addon aligned with the first line when the title wraps. */ +export const leftAddon = css` + display: inline-flex; + align-items: center; + align-self: flex-start; + min-height: ${Font.Spec["body-lh-baseline"]}; +`; + +/* Flushed right; stays vertically centred against the (possibly wrapped) title. */ +export const rightAddon = css` + display: inline-flex; + align-items: center; + margin-left: auto; +`; + export const navItem = css` display: block; position: relative; diff --git a/src/local-nav/local-nav-menu/local-nav-menu.tsx b/src/local-nav/local-nav-menu/local-nav-menu.tsx index d4842621c3..a8e7a536f3 100644 --- a/src/local-nav/local-nav-menu/local-nav-menu.tsx +++ b/src/local-nav/local-nav-menu/local-nav-menu.tsx @@ -51,13 +51,13 @@ const Component = ( renderItem, index, }: LocalNavMenuItemComponentProps) => { - const { id, title } = item; + const { id, title, titleAddon } = item; const renderTitle = () => { if (renderItem) { return renderItem(item, { selected: isSelected }); } - return ( + const label = ( ); + if (!titleAddon) { + return label; + } + return ( + + {titleAddon.left && ( + + {titleAddon.left} + + )} + {label} + {titleAddon.right && ( + + {titleAddon.right} + + )} + + ); }; return ( diff --git a/src/local-nav/types.ts b/src/local-nav/types.ts index bf7ab10781..7f67b5a0b4 100644 --- a/src/local-nav/types.ts +++ b/src/local-nav/types.ts @@ -1,3 +1,15 @@ +// @storybookSection LocalNavMenu, LocalNavDropdown +/** + * Configuration for the optional addon elements displayed alongside a + * navigation item's title. Both slots may be used at the same time. + */ +export interface TitleAddonProps { + /** Element rendered immediately before the title. */ + left?: JSX.Element | undefined; + /** Element rendered after the title, flushed to the far right of the item. */ + right?: JSX.Element | undefined; +} + // @storybookSection LocalNavMenu, LocalNavDropdown /** * Data for a single local navigation item. @@ -5,6 +17,13 @@ export interface LocalNavItemProps { title: string | React.ReactNode; id?: string | undefined; + /** + * Optional addon element rendered alongside the item title. + * + * Ignored when a custom `renderItem` is provided, since the caller then + * controls the item's layout. + */ + titleAddon?: TitleAddonProps | undefined; } // @storybookSkipProps diff --git a/stories/drawer/drawer.mdx b/stories/drawer/drawer.mdx index 4e42c62b2f..c3c2dd75d1 100644 --- a/stories/drawer/drawer.mdx +++ b/stories/drawer/drawer.mdx @@ -20,6 +20,12 @@ import { Drawer } from "@lifesg/react-design-system/drawer"; +## Custom call-to-action + +You can also specify a custom call to action that will appear on the header of the `Drawer`. + + + ## Component API diff --git a/stories/drawer/drawer.stories.tsx b/stories/drawer/drawer.stories.tsx index 74bf5c73c6..368139de7b 100644 --- a/stories/drawer/drawer.stories.tsx +++ b/stories/drawer/drawer.stories.tsx @@ -85,3 +85,47 @@ export const HandlingContentOverflow: StoryObj = { ); }, }; + +export const WithCustomCallToAction: StoryObj = { + render: () => { + const [show, setShow] = useState(false); + const openDrawer = () => setShow(true); + const closeDrawer = () => setShow(false); + return ( + <> + + Open drawer + + + + Confirm + + + Cancel + +
+ } + > +
+ + The call-to-action buttons sit beside the heading + when the drawer is wide, and wrap onto a new line + below the heading when the drawer is narrow. Resize + the window to see the layout respond to the drawer + width. + +
+ + + ); + }, +}; diff --git a/stories/local-nav/doc-elements.tsx b/stories/local-nav/doc-elements.tsx index d6632a2bfb..69eedfc458 100644 --- a/stories/local-nav/doc-elements.tsx +++ b/stories/local-nav/doc-elements.tsx @@ -1,7 +1,19 @@ +import { ExclamationCircleFillIcon } from "@lifesg/react-icons/exclamation-circle-fill"; import { css } from "@linaria/core"; -import { MediaQuery } from "src/theme"; +import { Colour, MediaQuery } from "src/theme"; import { Typography } from "src/typography"; +export const ErrorIndicator = () => ( + +); + export const Page = ({ children }: React.ComponentProps<"div">) => (
{children}
); diff --git a/stories/local-nav/local-nav.mdx b/stories/local-nav/local-nav.mdx index 5be7e5a5d5..3389c90847 100644 --- a/stories/local-nav/local-nav.mdx +++ b/stories/local-nav/local-nav.mdx @@ -27,6 +27,12 @@ You are able to customise the display of the nav items. +Use `titleAddon` to render elements alongside an item's title. It has `left` +and `right` slots — both can be used together, and the `right` slot is flushed +to the far right of the item. + + + ## Dropdown The `LocalNavDropdown` displays navigation items in a dropdown. It becomes @@ -40,6 +46,11 @@ You are able to customise the display of the nav items in the dropdown. +`titleAddon` works in the dropdown too — open it to see the addon beside each +item title. + + + ## Combined usage A common use case to to use the menu on desktop, and switch to the dropdown on diff --git a/stories/local-nav/local-nav.stories.tsx b/stories/local-nav/local-nav.stories.tsx index 7a9c72eed2..28b320f6d9 100644 --- a/stories/local-nav/local-nav.stories.tsx +++ b/stories/local-nav/local-nav.stories.tsx @@ -1,10 +1,12 @@ +import { DocIcon } from "@lifesg/react-icons/doc"; +import { PencilIcon } from "@lifesg/react-icons/pencil"; import type { Meta, StoryObj } from "@storybook/react-webpack5"; import { useRef, useState } from "react"; import { ThemeProvider, useMaxWidthMediaQuery } from "src"; import type { LocalNavItemProps } from "src/local-nav"; import { LocalNavDropdown, LocalNavMenu } from "src/local-nav"; -import { Content, Page, TopContent } from "./doc-elements"; +import { Content, ErrorIndicator, Page, TopContent } from "./doc-elements"; type MenuComponent = typeof LocalNavMenu; type DropdownComponent = typeof LocalNavDropdown; @@ -85,6 +87,30 @@ export const MenuWithCustomTitle: StoryObj = { }, }; +// One item per combination: left + right, left only, right only. +const ADDON_ITEMS: LocalNavItemProps[] = [ + { + title: "Title 1", + titleAddon: { left: , right: }, + }, + { title: "Title 2", titleAddon: { left: } }, + { title: "Title 3", titleAddon: { right: } }, +]; + +export const MenuWithTitleAddon: StoryObj = { + render: (_args) => { + const [selectedIndex, setSelectedIndex] = useState(-1); + + return ( + setSelectedIndex(index)} + /> + ); + }, +}; + export const Dropdown: StoryObj = { render: (_args) => { const [selectedIndex, setSelectedIndex] = useState(-1); @@ -199,6 +225,53 @@ export const DropdownWithCustomTitle: StoryObj = { }, }; +export const DropdownWithTitleAddon: StoryObj = { + render: (_args) => { + const [selectedIndex, setSelectedIndex] = useState(-1); + const contentRef = useRef(null); + + const handleNavItemClick = ( + e: React.MouseEvent | React.KeyboardEvent, + item: LocalNavItemProps, + index: number + ) => { + setSelectedIndex(index); + + const section = ADDON_ITEMS[index]; + if (section) { + const element = contentRef.current?.children[index]; + if (element) { + const top = + element.getBoundingClientRect().top + + window.scrollY - + 200; + window.scrollTo({ top, behavior: "smooth" }); + } + } + }; + + return ( +
+ + +
+ +
+
+ ); + }, + parameters: { + layout: "fullscreen", + docs: { story: { inline: false, iframeHeight: 500 } }, + }, +}; + export const CombinedUsage: StoryObj = { render: (_args) => { const [selectedIndex, setSelectedIndex] = useState( diff --git a/tests/drawer/drawer.spec.tsx b/tests/drawer/drawer.spec.tsx index e0665e8f42..0f456f5c75 100644 --- a/tests/drawer/drawer.spec.tsx +++ b/tests/drawer/drawer.spec.tsx @@ -73,4 +73,40 @@ describe("Drawer", () => { expect(onClose).toHaveBeenCalled(); }); + + it("should render customCallToAction content when provided", async () => { + render( + Confirm} + > +
content
+
+ ); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Confirm" }) + ).toBeVisible(); + }); + }); + + it("should keep the dialog labelled by its heading when a call-to-action is present", async () => { + render( + Confirm} + > +
content
+
+ ); + + await waitFor(() => { + expect( + screen.getByRole("dialog", { name: "Accessible title" }) + ).toBeInTheDocument(); + }); + }); }); diff --git a/tests/local-nav/local-nav.spec.tsx b/tests/local-nav/local-nav.spec.tsx index 13edce7a8e..8685c87ce3 100644 --- a/tests/local-nav/local-nav.spec.tsx +++ b/tests/local-nav/local-nav.spec.tsx @@ -79,6 +79,49 @@ describe("LocalNav", () => { expect(screen.getByText("Custom: Section 1")).toBeInTheDocument(); }); + it("should render both left and right titleAddon slots alongside the item title", () => { + const mockOnSelect = jest.fn(); + const items: LocalNavItemProps[] = [ + { + title: "Section 1", + titleAddon: { + left: left-addon, + right: right-addon, + }, + }, + { title: "Section 2" }, + ]; + + render( + + ); + + expect(screen.getByText("left-addon")).toBeInTheDocument(); + expect(screen.getByText("right-addon")).toBeInTheDocument(); + expect(screen.getByText("Section 1")).toBeInTheDocument(); + }); + + it("should not render the titleAddon when renderItem is provided", () => { + const mockOnSelect = jest.fn(); + const items: LocalNavItemProps[] = [ + { + title: "Section 1", + titleAddon: { right: addon }, + }, + ]; + + render( + Custom: {item.title}} + /> + ); + + expect(screen.queryByText("addon")).not.toBeInTheDocument(); + expect(screen.getByText("Custom: Section 1")).toBeInTheDocument(); + }); + it("should handle keyboard navigation and selection", async () => { const user = userEvent.setup(); const mockOnSelect = jest.fn(); @@ -267,6 +310,37 @@ describe("LocalNav", () => { expect(screen.getByText("Custom: Section 1")).toBeInTheDocument(); }); + it("should render both left and right titleAddon slots on a dropdown item", async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + const items: LocalNavItemProps[] = [ + { + title: "Section 1", + titleAddon: { + left: left-addon, + right: right-addon, + }, + }, + { title: "Section 2" }, + ]; + + render( + + ); + + await user.click(screen.getByText("Select section")); + + expect(screen.getByText("left-addon")).toBeInTheDocument(); + expect(screen.getByText("right-addon")).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: /Section 1/ }) + ).toBeInTheDocument(); + }); + it("should handle keyboard navigation and selection", async () => { const user = userEvent.setup(); const mockOnSelect = jest.fn();