From 365c9b684bfc0e59785009c05a0ebe05cc0b6df9 Mon Sep 17 00:00:00 2001 From: John Kaeser Date: Tue, 28 May 2024 14:23:37 -0400 Subject: [PATCH] chore(sticky-header): port refactor to v2 (#11773) ### Description Ports changes made to feat/masthead-v2-dev branch in https://github.com/carbon-design-system/carbon-for-ibm-dotcom/pull/11734 to v2 branch. In order to adequately test the new Sticky Header Sandbox story, I had to clean up the Dotcom Shell stories. The `mock` secret menu item works again now, and I've reduced a good amount of bloat in the stories' markup. While I was at it, I noticed I never updated Dotcom Shell's `navLinks` prop for L0 items to Masthead's new `l0Data` prop, so I added support for that as well. ### Testing The code changes here are mostly identical to the aforementioned PR, with the only divergences being changes necessary for v2. Please verify each of the Dotcom Shell stories works as expected, taking particular care to test the sticky elements behaviors on both desktop and mobile. --- .../components/masthead/_masthead-l1.scss | 18 + .../utilities/StickyHeader/StickyHeader.js | 480 +++++--- .../dotcom-shell/__stories__/data/content.ts | 2 + .../__stories__/dotcom-shell.stories.ts | 1096 +++++++---------- .../dotcom-shell/dotcom-shell-composite.ts | 16 +- .../leadspace-with-search.ts | 2 +- .../src/components/masthead/masthead-l1.ts | 7 + 7 files changed, 805 insertions(+), 816 deletions(-) diff --git a/packages/styles/scss/components/masthead/_masthead-l1.scss b/packages/styles/scss/components/masthead/_masthead-l1.scss index 060d17ecb9f..d4630828b6a 100644 --- a/packages/styles/scss/components/masthead/_masthead-l1.scss +++ b/packages/styles/scss/components/masthead/_masthead-l1.scss @@ -141,10 +141,27 @@ $search-transition-timing: 95ms; } .#{$prefix}--masthead__l1-dropdown { + position: absolute; + background-color: $background; + color: $text-secondary; + inset-block-start: 100%; + inset-inline: 0; + + &.is-open { + box-shadow: 0 2px 6px 0 $shadow; + } + &:not(.is-open) { display: none; } + &:last-child { + > a, + > button { + border-block-end: initial; + } + } + // Height of viewport, minus the L0/L1 combo, minus additional space to match L0 megapanels max-block-size: calc(100vh - 98px - #{$spacing-10} - #{$spacing-09}); overflow-y: auto; @@ -343,6 +360,7 @@ $search-transition-timing: 95ms; @include type-style(body-short-01); background-color: $background-brand; + border-block-end: initial; color: $layer-02; svg { diff --git a/packages/utilities/src/utilities/StickyHeader/StickyHeader.js b/packages/utilities/src/utilities/StickyHeader/StickyHeader.js index 71237e70d79..37a72bb39c9 100644 --- a/packages/utilities/src/utilities/StickyHeader/StickyHeader.js +++ b/packages/utilities/src/utilities/StickyHeader/StickyHeader.js @@ -15,20 +15,35 @@ const gridBreakpoint = parseFloat(breakpoints.lg.width) * baseFontSize; class StickyHeader { constructor() { this.ownerDocument = root.document; - this._banner = undefined; - this._cumulativeHeight = 0; - this._hasBanner = false; - this._lastScrollPosition = 0; - this._leadspaceWithSearch = undefined; - this._leadspaceSearchBar = undefined; - this._leadspaceWithSearchStickyThreshold = 0; - this._localeModal = undefined; - this._masthead = undefined; - this._mastheadL0 = undefined; - this._mastheadL1 = undefined; - this._tableOfContents = undefined; - this._tableOfContentsInnerBar = undefined; - this._tableOfContentsLayout = undefined; + + this._state = { + cumulativeOffset: 0, + hasBanner: false, + leadspaceSearchThreshold: 0, + mastheadL0IsActive: false, + mastheadL1IsActive: false, + maxScrollaway: 0, + scrollPosPrevious: 0, + scrollPos: 0, + searchIsAtTop: false, + tocShouldStick: false, + tocIsAtTop: false, + tocIsAtSearch: false, + }; + + this._elements = { + banner: undefined, + leadspaceSearch: undefined, + leadspaceSearchBar: undefined, + leadspaceSearchInput: undefined, + localeModal: undefined, + masthead: undefined, + mastheadL0: undefined, + mastheadL1: undefined, + tableOfContents: undefined, + tableOfContentsInnerBar: undefined, + }; + this._throttled = false; this._resizeObserver = new ResizeObserver(this._handleResize.bind(this)); root.addEventListener('scroll', this._throttledHandler.bind(this)); @@ -49,7 +64,7 @@ class StickyHeader { } get height() { - return this._cumulativeHeight; + return this._state.cumulativeOffset; } /** @@ -69,85 +84,81 @@ class StickyHeader { } } - _tableOfContentsStickyUpdate() { - const { _tableOfContents: toc } = this; - + /** + * Stores references to TOC sub-elements that are relevant to current viewport + * dimensions. + */ + _updateTableOfContentsRefs() { + const { tableOfContents: toc } = this._elements; const tocRoot = toc.shadowRoot; - - this._tableOfContentsInnerBar = tocRoot.querySelector( - `.${prefix}--tableofcontents__navbar` + this._elements.tableOfContentsInnerBar = tocRoot.querySelector( + window.innerWidth >= gridBreakpoint && toc?.layout !== 'horizontal' + ? `.${c4dPrefix}-ce--table-of-contents__items-container` + : `.${prefix}--tableofcontents__navbar` ); - if (window.innerWidth > gridBreakpoint) { - if (toc.layout === 'horizontal') { - this._tableOfContentsLayout = 'horizontal'; - } else { - this._tableOfContentsInnerBar = tocRoot.querySelector( - `.${c4dPrefix}-ce--table-of-contents__items-container` - ); - } - } } set banner(component) { if (this._validateComponent(component, `${c4dPrefix}-global-banner`)) { - this._banner = component; - this.hasBanner = true; + this._elements.banner = component; + this._state.hasBanner = true; - if (this._masthead) { - this._masthead.setAttribute('with-banner', ''); + if (this._elements.masthead) { + this._elements.masthead.setAttribute('with-banner', ''); } - this._calculateCumulativeHeight(); + this._manageStickyElements(); } } - set leadspaceWithSearch(component) { + set leadspaceSearch(component) { if ( this._validateComponent(component, `${c4dPrefix}-leadspace-with-search`) ) { - this._leadspaceWithSearch = component; + this._elements.leadspaceSearch = component; const leadspaceSearchBar = component.shadowRoot.querySelector( `.${prefix}--search-container` ); - this._leadspaceSearchBar = leadspaceSearchBar; - this._leadspaceWithSearchInput = component.querySelector( + this._elements.leadspaceSearchBar = leadspaceSearchBar; + this._elements.leadspaceSearchInput = component.querySelector( `${c4dPrefix}-search-with-typeahead` ); - this._leadspaceWithSearchStickyThreshold = + this._state.leadspaceSearchThreshold = parseInt(window.getComputedStyle(leadspaceSearchBar).paddingBottom) - 16; - this._calculateCumulativeHeight(); + this._manageStickyElements(); } } set localeModal(component) { if (this._validateComponent(component, `${c4dPrefix}-locale-modal`)) { - this._localeModal = component; - this._calculateCumulativeHeight(); + this._elements.localeModal = component; + this._manageStickyElements(); } } set masthead(component) { if (this._validateComponent(component, `${c4dPrefix}-masthead`)) { - this._masthead = component; - if (this._banner) { - this._masthead.setAttribute('with-banner', ''); + this._elements.masthead = component; + if (this._elements.banner) { + this._elements.masthead.setAttribute('with-banner', ''); } - - this._mastheadL0 = component.shadowRoot.querySelector( + this._elements.mastheadL0 = component.shadowRoot.querySelector( `.${prefix}--masthead__l0` ); - this._mastheadL1 = component.querySelector(`${c4dPrefix}-masthead-l1`); - this._calculateCumulativeHeight(); + this._elements.mastheadL1 = component.querySelector( + `${c4dPrefix}-masthead-l1` + ); + this._manageStickyElements(); } } set tableOfContents(component) { if (this._validateComponent(component, `${c4dPrefix}-table-of-contents`)) { - this._tableOfContents = component; - this._tableOfContentsStickyUpdate(); - this._resizeObserver.observe(this._tableOfContents); - this._calculateCumulativeHeight(); + this._elements.tableOfContents = component; + this._updateTableOfContentsRefs(); + this._resizeObserver.observe(this._elements.tableOfContents); + this._manageStickyElements(); } } @@ -157,7 +168,7 @@ class StickyHeader { _throttledHandler() { if (!this._throttled) { this._throttled = true; - this._calculateCumulativeHeight(); + this._manageStickyElements(); setTimeout(() => { this._throttled = false; @@ -166,118 +177,225 @@ class StickyHeader { } _handleResize() { + const { _hasBanner: hasBanner } = this._state; + const { - _hasBanner: hasBanner, - _masthead: masthead, - _tableOfContents: toc, - _tableOfContentsLayout: tocLayout, - _leadspaceSearchBar: leadspaceSearchBar, - } = this; + masthead, + tableOfContents: toc, + leadspaceSearchBar, + } = this._elements; if (toc && masthead) { - this._tableOfContentsStickyUpdate(); + this._updateTableOfContentsRefs(); if ( window.innerWidth >= gridBreakpoint && - tocLayout !== 'horizontal' && + toc.layout !== 'horizontal' && !hasBanner ) { - masthead.style.top = '0'; + masthead.style.insetBlockStart = '0'; } else { - // This has to happen after the tocStickyUpdate method. - const { _tableOfContentsInnerBar: tocInner } = this; + // This has to happen after the _updateTableOfContentsRefs method. + const { tableOfContentsInnerBar: tocInner } = this._elements; if (masthead.offsetTop === 0) { - tocInner.style.top = `${masthead.offsetHeight}px`; + tocInner.style.insetBlockStart = `${masthead.offsetHeight}px`; } } - this._calculateCumulativeHeight(); + this._manageStickyElements(); } if (leadspaceSearchBar) { - this._leadspaceWithSearchStickyThreshold = + this._state.leadspaceSearchThreshold = parseInt(window.getComputedStyle(leadspaceSearchBar).paddingBottom) - 16; } } - _calculateCumulativeHeight() { - const { - _lastScrollPosition: oldY, - _banner: banner, - _masthead: masthead, - _mastheadL0: mastheadL0, - _mastheadL1: mastheadL1, - _localeModal: localeModal, - _tableOfContents: toc, - _tableOfContentsInnerBar: tocInner, - _leadspaceWithSearch: leadspaceSearch, - _leadspaceSearchBar: leadspaceSearchBar, - _leadspaceWithSearchInput: leadspaceSearchInput, - _leadspaceWithSearchStickyThreshold: leadspaceSearchThreshold, - } = StickyHeader.global; - - const { customPropertyName } = this.constructor; + /** + * Handles the banner given the current scroll position. + */ + _handleBanner() { + const { banner } = this._elements; + const { scrollPos } = this._state; + this._state.cumulativeOffset += Math.max( + banner.offsetHeight - scrollPos, + 0 + ); + } - if (localeModal && localeModal.hasAttribute('open')) { - return; - } + /** + * Handles the masthead given the current scroll position. + */ + _handleMasthead() { + const { masthead } = this._elements; - const newY = window.scrollY; - this._lastScrollPosition = Math.max(0, newY); + masthead.style.transition = 'none'; + masthead.style.insetBlockStart = `${this._state.cumulativeOffset}px`; - /** - * maxScrollaway is a calculated value matching the height of all components - * that are allowed to hide above the viewport. - * - * We should only have one sticky header showing as the page scrolls down. - * - * Items that stick, in order - * - L0 - * - L1 - * - The TOC in horizontal bar form - * - The leadspace with search (if no TOC) - */ - let maxScrollaway = 0; + // Masthead always sticks, therefore always add its height. + this._state.cumulativeOffset += masthead.offsetHeight; + } - // Calculate maxScrollaway values based on TOC positon - let tocIsAtTop = false; - let tocShouldStick = false; + /** + * Handles the table of contents given the current scroll position. + */ + _handleToc() { + const { tableOfContentsInnerBar } = this._elements; + const { tocShouldStick } = this._state; - if (tocInner) { - tocIsAtTop = - tocInner.getBoundingClientRect().top <= - (masthead ? masthead.offsetTop + masthead.offsetHeight : 0) + 1; + tableOfContentsInnerBar.style.transition = 'none'; + tableOfContentsInnerBar.style.insetBlockStart = `${this._state.cumulativeOffset}px`; - tocShouldStick = - toc.layout === 'horizontal' || window.innerWidth < gridBreakpoint; + const tocIsStuck = + Math.round(tableOfContentsInnerBar.getBoundingClientRect().top) <= + this._state.cumulativeOffset + 1; - if (masthead && tocIsAtTop && (tocShouldStick || mastheadL1)) { - maxScrollaway += masthead.offsetHeight; + if (tocShouldStick && tocIsStuck) { + this._state.cumulativeOffset += tableOfContentsInnerBar.offsetHeight; + } + } - if (mastheadL1 && !tocShouldStick) { - maxScrollaway -= mastheadL1.offsetHeight; - } - } else if (mastheadL0 && mastheadL1) { - maxScrollaway += mastheadL0.offsetHeight; + /** + * Handles the leadspace search given the current scroll position. + */ + _handleLeadspaceSearch() { + const { leadspaceSearch, leadspaceSearchBar, leadspaceSearchInput } = + this._elements; + const { leadspaceSearchThreshold } = this._state; + const searchShouldBeSticky = + leadspaceSearch.getBoundingClientRect().bottom <= + leadspaceSearchThreshold; + const searchIsSticky = leadspaceSearch.hasAttribute('sticky-search'); + + if (searchShouldBeSticky) { + if (!searchIsSticky) { + leadspaceSearch.style.paddingBottom = `${leadspaceSearchBar.offsetHeight}px`; + leadspaceSearch.setAttribute('sticky-search', ''); + leadspaceSearchInput.setAttribute('large', ''); + + window.requestAnimationFrame(() => { + leadspaceSearchBar.style.transitionDuration = '110ms'; + leadspaceSearchBar.style.transform = 'translateY(0)'; + }); } + leadspaceSearchBar.style.insetBlockStart = `${this._state.cumulativeOffset}px`; + this._state.cumulativeOffset += leadspaceSearchBar.offsetHeight; + } else if (searchIsSticky) { + leadspaceSearch.style.paddingBottom = ''; + leadspaceSearch.removeAttribute('sticky-search'); + leadspaceSearchInput.removeAttribute('large'); + + leadspaceSearchBar.style.transitionDuration = ''; + leadspaceSearchBar.style.transform = ''; + leadspaceSearchBar.style.insetBlockStart = ''; } + } - // Calculate maxScrollaway values based on leadspace search position - if (!tocInner && leadspaceSearchBar) { - const searchIsAtTop = - leadspaceSearchBar.getBoundingClientRect().top <= - (masthead ? masthead.offsetTop + masthead.offsetHeight : 0) + 1; + /** + * Calculates a value matching the height of all components that are allowed + * to hide above the viewport. + * + * Adding an item's height to this value indicates we expect it to be hidden + * above the viewport. + * + * Items that stick, in order + * - L0 + * - L1 + * - The TOC in horizontal bar form + * - The leadspace with search (if no TOC) + */ + _calculateMaxScrollaway() { + const { + masthead, + mastheadL0, + mastheadL1, + tableOfContents, + tableOfContentsInnerBar, + leadspaceSearchBar, + } = this._elements; + + // Reset the value before performing any further calculations. + this._state.maxScrollaway = 0; + + // Collect conditions we may want to test for to make logic easier to read. + this._state.tocShouldStick = tableOfContents + ? tableOfContents.layout === 'horizontal' || + window.innerWidth < gridBreakpoint + : false; + this._state.tocIsAtTop = tableOfContentsInnerBar + ? tableOfContentsInnerBar.getBoundingClientRect().top <= this.height + 1 + : false; + this._state.searchIsAtTop = leadspaceSearchBar + ? leadspaceSearchBar.getBoundingClientRect().top <= this.height + 1 + : false; + this._state.tocIsAtSearch = + leadspaceSearchBar && tableOfContentsInnerBar + ? tableOfContentsInnerBar.getBoundingClientRect().top <= + leadspaceSearchBar.getBoundingClientRect().bottom + : false; + this._state.mastheadL0IsActive = Boolean( + masthead?.querySelector('[expanded]') + ); + this._state.mastheadL1IsActive = + mastheadL1 && mastheadL1.hasAttribute('active'); + + const { + tocShouldStick, + tocIsAtTop, + searchIsAtTop, + tocIsAtSearch, + mastheadL0IsActive, + mastheadL1IsActive, + } = this._state; + + // Begin calculating maxScrollAway. + + // If L0 is open, lock it to the top of the page. + if (mastheadL0 && mastheadL0IsActive) { + this._state.maxScrollaway = 0; + } + // If L1 is open, lock it to the top of the page. + else if (mastheadL1IsActive && mastheadL0) { + this._state.maxScrollaway = mastheadL0.offsetHeight; + } else { + // In cases where we have both an eligible ToC and leadspace search, we want + // the ToC to take precedence. Scroll away leadspace search. + if (searchIsAtTop && tocIsAtSearch && tocShouldStick) { + this._state.maxScrollaway += leadspaceSearchBar.offsetHeight; + } - if (masthead && searchIsAtTop) { - maxScrollaway += masthead.offsetHeight; + // Scroll away entire masthead if either ToC or leadspace search is eligible + // to be the stuck element (unless L1 is open). Otherwise, scroll away the + // L0 if we have an L1. + if (searchIsAtTop || (tocIsAtTop && tocShouldStick)) { + if (masthead) { + this._state.maxScrollaway += masthead.offsetHeight; + } + } else if (masthead && mastheadL0 && mastheadL1) { + this._state.maxScrollaway += mastheadL0.offsetHeight; } } + } + + /** + * Positions sticky elements. Does so by checking the scroll position and where + * tracked elements are in relation to it, then applying the correct styles to + * each element in succession to ensure that only one element is stuck to the + * top of the page, and all other elements that have been scrolled past can be + * revealed when scrolling back up. + */ + _positionElements() { + const { + banner, + masthead, + tableOfContentsInnerBar: tocInner, + leadspaceSearchBar, + } = this._elements; + const { scrollPosPrevious: oldY } = this._state; /** - * Cumulative offset is a calculated value used to set the `top` property of - * components that stick to the top of the viewport. - * - * This value is equal to the difference between the previous scrollY and - * the current scrollY values, but is positively and negatively limited. + * Reset to a value that is equal to the difference between the previous + * scrollY and the current scrollY values, but is positively and negatively + * limited. * * Positive limit: 0 * all elements visible, starting at the top of the viewport. @@ -287,76 +405,64 @@ class StickyHeader { * with the elements that should be visible starting at the top of the * viewport. */ - let cumulativeOffset = Math.max( - Math.min((masthead ? masthead.offsetTop : 0) + oldY - newY, 0), - maxScrollaway * -1 + this._state.cumulativeOffset = Math.max( + Math.min( + (masthead ? masthead.offsetTop : 0) + oldY - this._state.scrollPos, + 0 + ), + this._state.maxScrollaway * -1 ); + /** + * Handle each potentially sticky element in the order we expect them to + * appear on the page. Important to do this sequentially for + * cumulativeOffset to be correctly calculated by the time each of these + * methods accesses it. + * + * To-do: One idea for improving this so the execution order doesn't matter + * is to collect our elements into an array ordered by document position, + * then loop over that array and execute a corresponding handler method. + */ if (banner) { - cumulativeOffset += Math.max(banner.offsetHeight - newY, 0); + this._handleBanner(); } - if (masthead) { - masthead.style.transition = 'none'; - masthead.style.top = `${cumulativeOffset}px`; - cumulativeOffset += masthead.offsetHeight; + this._handleMasthead(); + } + if (leadspaceSearchBar) { + this._handleLeadspaceSearch(); } - if (tocInner) { - tocInner.style.transition = 'none'; - tocInner.style.top = `${cumulativeOffset}px`; - - tocShouldStick = - toc.layout === 'horizontal' || window.innerWidth < gridBreakpoint; - - const tocIsStuck = - Math.round(tocInner.getBoundingClientRect().top) <= - cumulativeOffset + 1; - - if (tocShouldStick && tocIsStuck) { - cumulativeOffset += tocInner.offsetHeight; - } + this._handleToc(); } + } - if (!tocInner && leadspaceSearchBar) { - const searchShouldBeSticky = - leadspaceSearch.getBoundingClientRect().bottom <= - leadspaceSearchThreshold; - const searchIsSticky = leadspaceSearch.hasAttribute('sticky-search'); - - if (searchShouldBeSticky) { - if (!searchIsSticky) { - leadspaceSearch.style.paddingBottom = `${leadspaceSearchBar.offsetHeight}px`; - leadspaceSearch.setAttribute('sticky-search', ''); - leadspaceSearchInput.setAttribute('large', ''); - - window.requestAnimationFrame(() => { - leadspaceSearchBar.style.transitionDuration = '110ms'; - leadspaceSearchBar.style.transform = 'translateY(0)'; - }); - } - - leadspaceSearchBar.style.top = `${cumulativeOffset}px`; - cumulativeOffset += leadspaceSearchBar.offsetHeight; - } + /** + * Manages which elements are stuck and where they are positioned. We should + * only have one element stuck to the top of the viewport as the page scrolls + * down. + */ + _manageStickyElements() { + const { localeModal } = this._elements; + const { scrollPos: scrollPosPrevious } = this._state; - if (!searchShouldBeSticky && searchIsSticky) { - leadspaceSearch.removeAttribute('sticky-search'); - leadspaceSearch.style.paddingBottom = ''; - leadspaceSearchBar.style.top = ''; - leadspaceSearchBar.style.transitionDuration = ''; - leadspaceSearchBar.style.transform = ''; - leadspaceSearchInput.removeAttribute('large'); - } + // Exit early if locale modal is open. + if (localeModal && localeModal.hasAttribute('open')) { + return; } - // Set internal property for use in scripts - this._cumulativeHeight = cumulativeOffset; + // Store scroll positions. + this._state.scrollPosPrevious = scrollPosPrevious; + this._state.scrollPos = Math.max(0, window.scrollY); + + // Given the current state, calculate how elements should be positioned. + this._calculateMaxScrollaway(); + this._positionElements(); // Set custom property for use in stylesheets root.document.documentElement.style.setProperty( - customPropertyName, - `${this._cumulativeHeight}px` + this.constructor.customPropertyName, + `${this._state.cumulativeOffset}px` ); } } diff --git a/packages/web-components/src/components/dotcom-shell/__stories__/data/content.ts b/packages/web-components/src/components/dotcom-shell/__stories__/data/content.ts index d9732bc3b77..04bc8e6a15a 100644 --- a/packages/web-components/src/components/dotcom-shell/__stories__/data/content.ts +++ b/packages/web-components/src/components/dotcom-shell/__stories__/data/content.ts @@ -384,6 +384,7 @@ export const StoryContent = ( config = { l1: false, leadspace: false, + leadspaceSearch: false, tocLayout: TOC_TYPES.DEFAULT, } ) => { @@ -399,6 +400,7 @@ export const StoryContent = ( return html`
${config?.leadspace ? contentLeadspace : null} + ${config?.leadspaceSearch ? contentLeadspaceSearch : null} ${config?.tocLayout === TOC_TYPES.HORIZONTAL ? html` { +export const Default = (args, story) => { const { platform, hasProfile, userStatus, - navLinks, hasSearch, searchPlaceholder, selectedMenuItem, - langDisplay, language, footerSize, + disableLocaleButton, + } = args?.DotcomShell ?? {}; + + const { + navLinks, + langDisplay, legalLinks, links: footerLinks, localeList, - disableLocaleButton, - } = args?.DotcomShell ?? {}; - const { useMock } = args?.Other ?? {}; + } = story.parameters.props.DotcomShell; + return html` - ${useMock - ? html` - - ${StoryContent()} - - ` - : html` - - ${StoryContent()} - - `} + + ${StoryContent()} + `; }; -export const DefaultFooterLanguageOnly = (args) => { +export const DefaultFooterLanguageOnly = (args, story) => { const { platform, hasProfile, userStatus, - navLinks, hasSearch, searchPlaceholder, selectedMenuItem, - langDisplay, language, + } = args?.DotcomShell ?? {}; + + const { langList, disableLocaleButton } = args?.FooterComposite ?? {}; + + const { + navLinks, + langDisplay, legalLinks, links: footerLinks, localeList, - } = args?.DotcomShell ?? {}; - const { langList, disableLocaleButton } = args?.FooterComposite ?? {}; - const { useMock } = args?.Other ?? {}; + } = story.parameters.props.DotcomShell; + return html` - ${useMock - ? html` - - ${StoryContent()} - - ` - : html` - - ${StoryContent()} - - `} + + ${StoryContent()} + `; }; DefaultFooterLanguageOnly.story = { @@ -252,75 +208,50 @@ DefaultFooterLanguageOnly.story = { }, }; -export const searchOpenOnload = (args) => { +export const searchOpenOnload = (args, story) => { const { platform, hasProfile, userStatus, - navLinks, hasSearch, searchPlaceholder, selectedMenuItem, - langDisplay, language, + disableLocaleButton, + } = args?.DotcomShell ?? {}; + + const { + navLinks, + langDisplay, legalLinks, links: footerLinks, localeList, - disableLocaleButton, - } = args?.DotcomShell ?? {}; - const { useMock } = args?.Other ?? {}; + } = story.parameters.props.DotcomShell; + return html` - ${useMock - ? html` - - ${StoryContent()} - - ` - : html` - - ${StoryContent()} - - `} + + ${StoryContent()} + `; }; @@ -333,68 +264,48 @@ searchOpenOnload.story = { }, }; -export const withPlatform = (args) => { +export const withPlatform = (args, story) => { const { hasProfile, userStatus, - navLinks, hasSearch, searchPlaceholder, selectedMenuItem, - langDisplay, language, + disableLocaleButton, + } = args?.DotcomShell ?? {}; + + const { + navLinks, + langDisplay, legalLinks, links: footerLinks, localeList, - disableLocaleButton, - } = args?.DotcomShell ?? {}; - const { useMock } = args?.Other ?? {}; + } = story.parameters.props.DotcomShell; + return html` - ${useMock - ? html` - - ${StoryContent()} - - ` - : html` - - ${StoryContent()} - - `} + + ${StoryContent()} + `; }; @@ -437,71 +348,50 @@ withPlatform.story = { }, }; -export const withShortFooter = (args) => { +export const withShortFooter = (args, story) => { const { platform, hasProfile, userStatus, - navLinks, hasSearch, searchPlaceholder, selectedMenuItem, - langDisplay, language, + disableLocaleButton, + } = args?.DotcomShell ?? {}; + + const { + navLinks, + langDisplay, legalLinks, links: footerLinks, localeList, - disableLocaleButton, - } = args?.DotcomShell ?? {}; - const { useMock } = args?.Other ?? {}; + } = story.parameters.props.DotcomShell; + return html` - ${useMock - ? html` - - ${StoryContent()} - - ` - : html` - - ${StoryContent()} - - `} + + ${StoryContent()} + `; }; @@ -514,81 +404,55 @@ withShortFooter.story = { }, }; -export const withShortFooterLanguageOnly = (args) => { +export const withShortFooterLanguageOnly = (args, story) => { const { platform, hasProfile, userStatus, - navLinks, hasSearch, searchPlaceholder, selectedMenuItem, - langDisplay, language, - legalLinks, - links: footerLinks, - localeList, } = args?.DotcomShell ?? {}; const { langList, disableLocaleButton } = args?.FooterComposite ?? {}; - const { useMock } = args?.Other ?? {}; + const { + navLinks, + langDisplay, + legalLinks, + links: footerLinks, + localeList, + } = story.parameters.props.DotcomShell; + return html` - ${useMock - ? html` - - ${StoryContent()} - - ` - : html` - - ${StoryContent()} - - `} + + ${StoryContent()} + `; }; withShortFooterLanguageOnly.story = { @@ -617,150 +481,104 @@ withShortFooterLanguageOnly.story = { }, }; -export const withMicroFooter = (args) => { +export const withMicroFooter = (args, story) => { const { platform, hasProfile, userStatus, - navLinks, hasSearch, searchPlaceholder, selectedMenuItem, - langDisplay, language, + disableLocaleButton, + } = args?.DotcomShell ?? {}; + + const { + navLinks, + langDisplay, legalLinks, links: footerLinks, localeList, - disableLocaleButton, - } = args?.DotcomShell ?? {}; - const { useMock } = args?.Other ?? {}; + } = story.parameters.props.DotcomShell; + return html` - ${useMock - ? html` - - ${StoryContent()} - - ` - : html` - - ${StoryContent()} - - `} + + ${StoryContent()} + `; }; withMicroFooter.story = { name: 'With micro footer' }; -export const withMicroFooterLanguageOnly = (args) => { +export const withMicroFooterLanguageOnly = (args, story) => { const { platform, hasProfile, userStatus, - navLinks, hasSearch, searchPlaceholder, selectedMenuItem, - langDisplay, language, + } = args?.DotcomShell ?? {}; + + const { langList, disableLocaleButton } = args?.FooterComposite ?? {}; + + const { + navLinks, + langDisplay, legalLinks, links: footerLinks, localeList, - } = args?.DotcomShell ?? {}; - const { langList, disableLocaleButton } = args?.FooterComposite ?? {}; + } = story.parameters.props.DotcomShell; - const { useMock } = args?.Other ?? {}; return html` - ${useMock - ? html` - - ${StoryContent()} - - ` - : html` - - ${StoryContent()} - - `} + + ${StoryContent()} + `; }; @@ -790,71 +608,54 @@ withMicroFooterLanguageOnly.story = { }, }; -export const withL1 = (args) => { +export const withL1 = (args, story) => { const { hasProfile, userStatus, - navLinks, hasSearch, searchPlaceholder, selectedMenuItem, - langDisplay, language, + disableLocaleButton, + } = args?.DotcomShell ?? {}; + + const { + navLinks, + langDisplay, legalLinks, links: footerLinks, localeList, - disableLocaleButton, - } = args?.DotcomShell ?? {}; - const { useMock } = args?.Other ?? {}; + } = story.parameters.props.DotcomShell; + const contentConfig = { l1: true, leadspace: false, + leadspaceSearch: false, tocLayout: TOC_TYPES.DEFAULT, }; + return html` - ${useMock - ? html` - - ${StoryContent(contentConfig)} - - ` - : html` - - ${StoryContent(contentConfig)} - - `} + + ${StoryContent(contentConfig)} + `; }; @@ -900,77 +701,58 @@ withL1.story = { }, }; -export const WithHorizontalTOC = (args) => { +export const WithHorizontalTOC = (args, story) => { const { platform, hasProfile, userStatus, - navLinks, hasSearch, searchPlaceholder, selectedMenuItem, - langDisplay, language, footerSize, + disableLocaleButton, + } = args?.DotcomShell ?? {}; + + const { + navLinks, + langDisplay, legalLinks, links: footerLinks, localeList, - disableLocaleButton, - } = args?.DotcomShell ?? {}; - const { useMock } = args?.Other ?? {}; + } = story.parameters.props.DotcomShell; + const contentConfig = { - l1: false, + l1: true, leadspace: true, + leadspaceSearch: false, tocLayout: TOC_TYPES.HORIZONTAL, }; + return html` - ${useMock - ? html` - - ${StoryContent(contentConfig)} - - ` - : html` - - ${StoryContent(contentConfig)} - - `} + + ${StoryContent(contentConfig)} + `; }; @@ -984,69 +766,48 @@ WithHorizontalTOC.story = { }, }; -export const WithLeadspaceSearch = (args) => { +export const WithLeadspaceSearch = (args, story) => { const { platform, hasProfile, userStatus, - navLinks, hasSearch, searchPlaceholder, selectedMenuItem, - langDisplay, language, footerSize, + disableLocaleButton, + } = args?.DotcomShell ?? {}; + + const { + navLinks, + langDisplay, legalLinks, links: footerLinks, localeList, - disableLocaleButton, - } = args?.DotcomShell ?? {}; - const { useMock } = args?.Other ?? {}; + } = story.parameters.props.DotcomShell; + return html` - ${useMock - ? html` - - ${StoryContentNoToC()} - - ` - : html` - - ${StoryContentNoToC()} - - `} + + ${StoryContentNoToC()} + `; }; @@ -1061,28 +822,30 @@ WithLeadspaceSearch.story = { }, }; -export const WithGlobalBanner = (args) => { +export const WithGlobalBanner = (args, story) => { const { platform, hasProfile, userStatus, - navLinks, hasSearch, searchPlaceholder, selectedMenuItem, - langDisplay, language, footerSize, - legalLinks, - links: footerLinks, - localeList, disableLocaleButton, imageWidth, heading, copy, ctaCopy, } = args?.DotcomShell ?? {}; - const { useMock } = args?.Other ?? {}; + + const { + navLinks, + langDisplay, + legalLinks, + links: footerLinks, + localeList, + } = story.parameters.props.DotcomShell; const bannerHeading = document.querySelector('c4d-global-banner-heading'); const bannerCopy = document.querySelector('c4d-global-banner-copy'); @@ -1115,50 +878,26 @@ export const WithGlobalBanner = (args) => { ${ctaCopy} - ${useMock - ? html` - - ${StoryContent()} - - ` - : html` - - ${StoryContent()} - - `} + + ${StoryContent()} + `; }; @@ -1261,6 +1000,7 @@ export const WithoutShell = (args) => { ? StoryContent({ l1: false, leadspace: true, + leadspaceSearch: false, tocLayout: TOC_TYPES.HORIZONTAL, }) : ''} @@ -1297,6 +1037,113 @@ WithoutShell.story = { }, }; +export const StickyElementSandbox = (args, story) => { + const { + platform, + hasProfile, + userStatus, + hasSearch, + searchPlaceholder, + selectedMenuItem, + language, + footerSize, + disableLocaleButton, + } = args?.DotcomShell ?? {}; + + const { + navLinks, + langDisplay, + legalLinks, + links: footerLinks, + localeList, + } = story.parameters.props.DotcomShell; + + const { globalBanner, l1, leadspaceSearch, tocLayout } = + args?.StickyElementSandbox ?? {}; + + const contentConfig = { + l1: l1, + leadspace: false, + leadspaceSearch: leadspaceSearch, + tocLayout: tocLayout || '', + }; + + return html` + + + ${globalBanner + ? html` + + + + Hybrid cloud and AI for smarter business + + + Las Vegas, June 15-18, 2025 + + + Register for Think. Free + + + ` + : ''} + ${StoryContent(contentConfig)} + + `; +}; + +StickyElementSandbox.story = { + name: 'Sticky Element Sandbox', + parameters: { + knobs: { + StickyElementSandbox: () => ({ + globalBanner: boolean('Has Global Banner', true), + l1: boolean('Has Masthead L1', true), + leadspaceSearch: boolean('Has Leadspace With Search', true), + tocLayout: select( + 'Table of Contents Layout', + { Vertical: null, Horizontal: 'horizontal' }, + null + ), + }), + }, + propsSet: { + default: { + StickyElementSandbox: { + globalBanner: true, + l1: true, + leadspaceSearch: true, + tocLayout: null, + }, + }, + }, + }, +}; + export default { title: 'Components/Dotcom shell', decorators: [ @@ -1382,9 +1229,6 @@ export default { links: !useMock ? undefined : mockFooterLinks, localeList: !useMock ? undefined : mockLocaleList, }, - Other: { - useMock, - }, }; })(), propsSet: { diff --git a/packages/web-components/src/components/dotcom-shell/dotcom-shell-composite.ts b/packages/web-components/src/components/dotcom-shell/dotcom-shell-composite.ts index c3c82bb666c..2e17b595684 100644 --- a/packages/web-components/src/components/dotcom-shell/dotcom-shell-composite.ts +++ b/packages/web-components/src/components/dotcom-shell/dotcom-shell-composite.ts @@ -68,7 +68,7 @@ class C4DDotcomShellComposite extends LitElement { */ private _createMastheadRenderRoot() { const masthead = this.ownerDocument!.createElement( - `${c4dPrefix}-masthead-composite` + `${c4dPrefix}-masthead-container` ); this.parentNode?.insertBefore(masthead, this); return masthead; @@ -319,10 +319,20 @@ class C4DDotcomShellComposite extends LitElement { * The navigation links. This goes to masthead. * The data typically comes from `@carbon/ibmdotcom-services` and thus you don't need to set this property by default, * but if you need an alternate way of integration (e.g. rendering Web Components tags in server-side) this property helps. + * + * @deprecated Use l0Data instead. */ @property({ attribute: false }) navLinks?: L0MenuItem[]; + /** + * The navigation links. This goes to masthead. + * The data typically comes from `@carbon/ibmdotcom-services` and thus you don't need to set this property by default, + * but if you need an alternate way of integration (e.g. rendering Web Components tags in server-side) this property helps. + */ + @property({ attribute: false }) + l0Data?: L0MenuItem[]; + /** * The parameters passed to the search-with-typeahead for search scope */ @@ -382,6 +392,7 @@ class C4DDotcomShellComposite extends LitElement { footerSize, openSearchDropdown, navLinks, + l0Data, hasProfile, hasSearch, searchPlaceholder, @@ -414,6 +425,7 @@ class C4DDotcomShellComposite extends LitElement { l1Data, language, navLinks, + l0Data, hasProfile, hasSearch, searchPlaceholder, @@ -463,7 +475,7 @@ class C4DDotcomShellComposite extends LitElement { // moving global banner outside of dotcom shell if placed within if (this.querySelector(`${c4dPrefix}-global-banner`)) { this.ownerDocument - .querySelector(`${c4dPrefix}-masthead-composite`) + .querySelector(`${c4dPrefix}-masthead-container`) ?.before( this.querySelector(`${c4dPrefix}-global-banner`) as HTMLElement ); diff --git a/packages/web-components/src/components/leadspace-with-search/leadspace-with-search.ts b/packages/web-components/src/components/leadspace-with-search/leadspace-with-search.ts index dd7aa6fd6a0..f37bf985284 100644 --- a/packages/web-components/src/components/leadspace-with-search/leadspace-with-search.ts +++ b/packages/web-components/src/components/leadspace-with-search/leadspace-with-search.ts @@ -87,7 +87,7 @@ class C4DLeadspaceWithSearch extends StableSelectorMixin(LitElement) { } protected firstUpdated() { - StickyHeader.global.leadspaceWithSearch = this; + StickyHeader.global.leadspaceSearch = this; this.querySelector(`${c4dPrefix}-leadspace-heading`)?.setAttribute( 'type-style', diff --git a/packages/web-components/src/components/masthead/masthead-l1.ts b/packages/web-components/src/components/masthead/masthead-l1.ts index f44f15c210c..914c3188798 100644 --- a/packages/web-components/src/components/masthead/masthead-l1.ts +++ b/packages/web-components/src/components/masthead/masthead-l1.ts @@ -90,6 +90,12 @@ function handleDropdownClose(event: FocusEvent | KeyboardEvent) { */ @customElement(`${c4dPrefix}-masthead-l1`) class C4DMastheadL1 extends StableSelectorMixin(LitElement) { + /** + * Whether an L1 menu is open or not. + */ + @property({ attribute: 'active', reflect: true, type: Boolean }) + active = false; + /** * The L1 menu data, passed from the masthead-composite. */ @@ -744,6 +750,7 @@ class C4DMastheadL1 extends StableSelectorMixin(LitElement) { }) ); + this.active = !isOpen; button.classList.toggle('is-open', !isOpen); dropdown.classList.toggle('is-open', !isOpen); }