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 ebfa11cd0a6..dac6b3e811a 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 @@ -286,12 +286,6 @@ class DDSDotcomShellComposite extends LitElement { @property({ attribute: 'selected-menu-item' }) selectedMenuItem!: string; - /** - * `true` to open the locale modal. This goes to footer. - */ - @property({ type: Boolean, attribute: 'open-locale-modal' }) - openLocaleModal = false; - /** * Footer size. This goes to footer. */ @@ -385,7 +379,6 @@ class DDSDotcomShellComposite extends LitElement { localeList, footerLinks, footerSize, - openLocaleModal, openSearchDropdown, navLinks, hasProfile, @@ -452,7 +445,6 @@ class DDSDotcomShellComposite extends LitElement { legalLinks, links: footerLinks, localeList, - openLocaleModal, selectedLanguage, size: footerSize, _loadLocaleList, diff --git a/packages/web-components/src/components/expressive-modal/expressive-modal.ts b/packages/web-components/src/components/expressive-modal/expressive-modal.ts index 385d5a3d574..e338897673f 100644 --- a/packages/web-components/src/components/expressive-modal/expressive-modal.ts +++ b/packages/web-components/src/components/expressive-modal/expressive-modal.ts @@ -484,10 +484,10 @@ class DDSExpressiveModal extends StableSelectorMixin( this.ownerDocument.body.style.overflow = 'hidden'; this.removeAttribute('aria-hidden'); this._launcher = this.ownerDocument!.activeElement; + await this._waitForTransitionEnd(); const primaryFocusNode = this.querySelector( (this.constructor as typeof DDSExpressiveModal).selectorPrimaryFocus ); - await this._waitForTransitionEnd(); if (primaryFocusNode) { // For cases where a `carbon-web-components` component (e.g. ``) being `primaryFocusNode`, // where its first update/render cycle that makes it focusable happens after ``'s first update/render cycle diff --git a/packages/web-components/src/components/footer/footer-composite.ts b/packages/web-components/src/components/footer/footer-composite.ts index 710037e1c40..60459d55603 100644 --- a/packages/web-components/src/components/footer/footer-composite.ts +++ b/packages/web-components/src/components/footer/footer-composite.ts @@ -45,9 +45,13 @@ import './language-selector-mobile'; import '../../internal/vendor/@carbon/web-components/components/combo-box/combo-box-item.js'; import '../../internal/vendor/@carbon/web-components/components/select/select-item.js'; import { carbonElement as customElement } from '../../internal/vendor/@carbon/web-components/globals/decorators/carbon-element.js'; +import { moderate02 } from '@carbon/motion'; const { stablePrefix: ddsPrefix } = ddsSettings; +// Delay matches the CSS animation timing for fadein/out of modal. +const delay = parseInt(moderate02, 10); + /** * Component that rendres footer from inks data. * @@ -61,9 +65,15 @@ class DDSFooterComposite extends MediaQueryMixin( /** * Handles `click` event on the locale button. */ - private _handleClickLocaleButton = () => { + private async _handleClickLocaleButton() { this.openLocaleModal = true; - }; + // Set 'open' attribute after modal is in dom so CSS can fade it in. + await this.updateComplete; + const composite = this.modalRenderRoot?.querySelector( + 'dds-locale-modal-composite' + ); + composite?.setAttribute('open', ''); + } @state() _isMobile = this.carbonBreakpoints.lg.matches; @@ -79,7 +89,10 @@ class DDSFooterComposite extends MediaQueryMixin( // @ts-ignore: The decorator refers to this method but TS thinks this method is not referred to private _handleCloseModal = (event: CustomEvent) => { if ((this.modalRenderRoot as Element).contains(event.target as Node)) { - this.openLocaleModal = false; + // Timeout here ensures the modal closing animation is visible. + setTimeout(() => { + this.openLocaleModal = false; + }, delay); } }; @@ -194,7 +207,12 @@ class DDSFooterComposite extends MediaQueryMixin( * `true` to open the locale modal. */ @property({ type: Boolean, attribute: 'open-locale-modal' }) - openLocaleModal = false; + openLocaleModal; + + /** + * @inheritdoc + */ + modalTriggerProps = ['openLocaleModal', 'localeList']; /** * Footer size. @@ -245,16 +263,17 @@ class DDSFooterComposite extends MediaQueryMixin( openLocaleModal, _loadLocaleList: loadLocaleList, } = this; - return html` - - - `; + return openLocaleModal + ? html` + + + ` + : html``; } renderLanguageSelector(slot = 'language-selector') { diff --git a/packages/web-components/src/components/locale-modal/locale-modal-composite.ts b/packages/web-components/src/components/locale-modal/locale-modal-composite.ts index 6fe5f3cc482..b39bec89ba3 100644 --- a/packages/web-components/src/components/locale-modal/locale-modal-composite.ts +++ b/packages/web-components/src/components/locale-modal/locale-modal-composite.ts @@ -12,12 +12,15 @@ import ifNonNull from '../../internal/vendor/@carbon/web-components/globals/dire import LocaleAPI from '@carbon/ibmdotcom-services/es/services/Locale/Locale.js'; import ddsSettings from '../../internal/vendor/@carbon/ibmdotcom-utilities/utilities/settings/settings'; import altlangs from '../../internal/vendor/@carbon/ibmdotcom-utilities/utilities/altlangs/altlangs.js'; +import HostListener from '../../internal/vendor/@carbon/web-components/globals/decorators/host-listener.js'; +import HostListenerMixin from '../../internal/vendor/@carbon/web-components/globals/mixins/host-listener.js'; import HybridRenderMixin from '../../globals/mixins/hybrid-render'; import { Country, LocaleList, } from '../../internal/vendor/@carbon/ibmdotcom-services-store/types/localeAPI.d'; import './locale-modal'; +import DDSLocaleModal from './locale-modal'; import './regions'; import './region-item'; import './locale-search'; @@ -33,7 +36,9 @@ const { stablePrefix: ddsPrefix } = ddsSettings; * @element dds-locale-modal-composite */ @customElement(`${ddsPrefix}-locale-modal-composite`) -class DDSLocaleModalComposite extends HybridRenderMixin(LitElement) { +class DDSLocaleModalComposite extends HostListenerMixin( + HybridRenderMixin(LitElement) +) { /** * @param countries A country list. * @returns Sorted version of the given country list. @@ -88,6 +93,17 @@ class DDSLocaleModalComposite extends HybridRenderMixin(LitElement) { @property({ type: Boolean }) open = false; + /** + * The region chosen by user. + */ + @property() + chosenRegion?: string; + + @HostListener(DDSLocaleModal.eventRegionUpdated) + protected _handleRegionUpdatedEvent(event) { + this.chosenRegion = event.detail.region || undefined; + } + // eslint-disable-next-line class-methods-use-this async getLangDisplay() { const response = await LocaleAPI.getLangDisplay(); @@ -117,7 +133,7 @@ class DDSLocaleModalComposite extends HybridRenderMixin(LitElement) { } renderLightDOM() { - const { langDisplay, localeList, open } = this; + const { chosenRegion, langDisplay, localeList, open } = this; const { localeModal, regionList } = localeList ?? {}; const { availabilityText, @@ -167,7 +183,6 @@ class DDSLocaleModalComposite extends HybridRenderMixin(LitElement) { language: string; }[] ); - return html` ${regionList?.map(({ countryList, name }) => { + const isInvalid = + countryList.length === 0 || + massagedCountryList?.find(({ region }) => region === name) === + undefined; return html` `; })} + - ${massagedCountryList?.map( - ({ country, href, language, locale, region }) => html` - - - ` - )} + ${chosenRegion + ? html` + ${massagedCountryList + ?.filter(({ region }) => { + return region === chosenRegion; + }) + .map( + ({ country, href, language, locale, region }) => html` + + + ` + )} + ` + : ``} `; diff --git a/packages/web-components/src/components/locale-modal/locale-modal.ts b/packages/web-components/src/components/locale-modal/locale-modal.ts index 43b2b47c427..294f5d27924 100644 --- a/packages/web-components/src/components/locale-modal/locale-modal.ts +++ b/packages/web-components/src/components/locale-modal/locale-modal.ts @@ -49,8 +49,8 @@ class DDSLocaleModal extends DDSExpressiveModal { * Handles `click` event on the back button. */ private _handleClickBackButton(e) { - this._currentRegion = undefined; e.preventDefault(); + this._currentRegion = undefined; } /** @@ -71,6 +71,31 @@ class DDSLocaleModal extends DDSExpressiveModal { this._currentRegion = undefined; } + /** + * Sets focus on primary selectable element. + */ + private async _setPrimaryFocus() { + const { selectorPrimaryFocus } = DDSLocaleModal; + const focusTarget = this.querySelector(selectorPrimaryFocus); + if (focusTarget) { + (focusTarget as HTMLElement).tabIndex = 0; + (focusTarget as HTMLElement).focus(); + } + } + + /** + * Sets focus on locale selector search. + */ + private async _setSearchFocus() { + const { selectorLocaleSearch } = DDSLocaleModal; + await this.updateComplete; + const localeSearch = this.querySelector(selectorLocaleSearch); + if (localeSearch) { + (localeSearch as DDSLocaleSearch).reset(); + (localeSearch as HTMLElement).focus(); + } + } + /** * @returns The heading content for the region selector page. */ @@ -198,29 +223,38 @@ class DDSLocaleModal extends DDSExpressiveModal { async updated(changedProperties) { super.updated(changedProperties); if (changedProperties.has('_currentRegion')) { + // Allow listening components to update their state. + this.dispatchEvent( + new CustomEvent( + (this.constructor as typeof DDSLocaleModal).eventRegionUpdated, + { + bubbles: true, + composed: true, + cancelable: true, + detail: { + region: this._currentRegion, + }, + } + ) + ); + + // Pass state to search element. const { selectorLocaleSearch } = this .constructor as typeof DDSLocaleModal; const localeSearch = this.querySelector(selectorLocaleSearch); if (localeSearch) { (localeSearch as DDSLocaleSearch).region = this._currentRegion ?? ''; - - if (this.open) { - (localeSearch as DDSLocaleSearch).reset(); - (localeSearch as HTMLElement).focus(); - } } - // re-focus on first region-item when navigating back to the first modal pane - const { selectorPrimaryFocus } = this - .constructor as typeof DDSLocaleModal; - const activeRegion = this.querySelector(selectorPrimaryFocus); - if (activeRegion && this.open) { - (activeRegion as HTMLElement).tabIndex = 0; - (activeRegion as HTMLElement).focus(); - } + // Set element focus. + this._currentRegion ? this._setSearchFocus() : this._setPrimaryFocus(); } } + static get stableSelector() { + return `${ddsPrefix}--locale-modal`; + } + /** * A selector selecting the locale search UI. */ @@ -238,10 +272,6 @@ class DDSLocaleModal extends DDSExpressiveModal { `; } - static get stableSelector() { - return `${ddsPrefix}--locale-modal`; - } - /** * A selector selecting tabbable nodes. */ @@ -256,6 +286,13 @@ class DDSLocaleModal extends DDSExpressiveModal { `; } + /** + * Name for event fired when a region is selected. + */ + static get eventRegionUpdated() { + return `${ddsPrefix}-locale-modal-region-updated`; + } + static styles = styles; // `styles` here is a `CSSResult` generated by custom WebPack loader } diff --git a/packages/web-components/src/components/locale-modal/locale-search.ts b/packages/web-components/src/components/locale-modal/locale-search.ts index 7df1c1e0da4..e4efa51f626 100644 --- a/packages/web-components/src/components/locale-modal/locale-search.ts +++ b/packages/web-components/src/components/locale-modal/locale-search.ts @@ -75,19 +75,17 @@ class DDSLocaleSearch extends ThrottedInputMixin( private _updateSearchResults(searchText: string) { const { selectorItem } = this.constructor as typeof DDSLocaleSearch; const { region: currentRegion, _liveRegion: liveRegion } = this; - let hasMatch = false; let count = 0; forEach(this.querySelectorAll(selectorItem), (item) => { const { country, language, region } = item as DDSLocaleItem; const matches = region === currentRegion && search([country, language], searchText); if (matches) { - hasMatch = true; count++; } (item as HTMLElement).hidden = !matches; }); - this._hasAvailableItem = hasMatch; + this._hasAvailableItem = count > 0; if (liveRegion) { const announcement = count === 1 ? `${count} result` : `${count} results`; liveRegion.innerText = announcement; diff --git a/packages/web-components/src/globals/mixins/modal-render.ts b/packages/web-components/src/globals/mixins/modal-render.ts index e5a9ae597f8..ce16761c196 100644 --- a/packages/web-components/src/globals/mixins/modal-render.ts +++ b/packages/web-components/src/globals/mixins/modal-render.ts @@ -1,7 +1,7 @@ /** * @license * - * Copyright IBM Corp. 2020, 2022 + * Copyright IBM Corp. 2020, 2023 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -67,6 +67,12 @@ const ModalRenderMixin = >(Base: T) => { */ modalRenderRoot: Element | null | void = null; + /** + * Defines properties which should trigger modal renders. If none are specified, + * modal renders on any property update. + */ + modalTriggerProps: string[] = []; + /** * The DOM element to put the modal into. */ @@ -111,11 +117,26 @@ const ModalRenderMixin = >(Base: T) => { } update(changedProperties) { + const { modalTriggerProps } = this; // TODO: Figure out how to inherit `LitElement` for this mix-in class // @ts-ignore super.update(changedProperties); if (!this._disconnectedAfterCreation) { - this._createAndRenderModal(); + if (modalTriggerProps.length > 0) { + // React only on updates to specified properties. + const changedPropNames = Array.from( + changedProperties.keys() as string[] + ); + const matches = modalTriggerProps.filter((prop) => + changedPropNames.includes(prop) + ); + if (matches.length > 0) { + this._createAndRenderModal(); + } + } else { + // React on every property update. + this._createAndRenderModal(); + } } } diff --git a/packages/web-components/tests/e2e-storybook/cypress/integration/footer/footer-short.e2e.js b/packages/web-components/tests/e2e-storybook/cypress/integration/footer/footer-short.e2e.js index 9567a817275..926eac3f84a 100755 --- a/packages/web-components/tests/e2e-storybook/cypress/integration/footer/footer-short.e2e.js +++ b/packages/web-components/tests/e2e-storybook/cypress/integration/footer/footer-short.e2e.js @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2021, 2022 + * Copyright IBM Corp. 2021, 2023 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -91,14 +91,20 @@ describe('dds-footer | Short (desktop)', () => { cy.wait(500); - cy.get('dds-locale-search') + cy.get('dds-locale-search').as('search') .find('dds-search') .shadow() .find('input') - .type('gu') - .get('[country="Brazil (Brasil)"]') - .should('not.have.attr', 'hidden') - .get('[country="Guyana"]') + .type('gu', { + force: true, + }); + + cy.get('@search') + .find('[country="Brazil (Brasil)"]') + .should('not.have.attr', 'hidden'); + + cy.get('@search') + .find('[country="Guyana"]') .should('not.have.attr', 'hidden'); cy.takeSnapshots(); @@ -204,14 +210,20 @@ describe('dds-footer | Short (mobile)', () => { cy.wait(500); - cy.get('dds-locale-search') + cy.get('dds-locale-search').as('search') .find('dds-search') .shadow() .find('input') - .type('gu') - .get('[country="Brazil (Brasil)"]') - .should('not.have.attr', 'hidden') - .get('[country="Guyana"]') + .type('gu', { + force: true, + }); + + cy.get('@search') + .find('[country="Brazil (Brasil)"]') + .should('not.have.attr', 'hidden'); + + cy.get('@search') + .find('[country="Guyana"]') .should('not.have.attr', 'hidden'); cy.takeSnapshots('mobile'); diff --git a/packages/web-components/tests/e2e-storybook/cypress/integration/locale-modal/locale-modal.e2e.js b/packages/web-components/tests/e2e-storybook/cypress/integration/locale-modal/locale-modal.e2e.js index e549fe0b149..f95d71d845f 100644 --- a/packages/web-components/tests/e2e-storybook/cypress/integration/locale-modal/locale-modal.e2e.js +++ b/packages/web-components/tests/e2e-storybook/cypress/integration/locale-modal/locale-modal.e2e.js @@ -77,9 +77,10 @@ describe('dds-locale-modal | default', () => { const closeButton = cy .get('dds-locale-modal') .shadow() - .find('dds-expressive-modal-close-button'); - closeButton + .find('dds-expressive-modal-close-button') .shadow() + .find('button'); + closeButton .find('svg path') .then($icon => { expect($icon).to.have.attr( diff --git a/packages/web-components/tests/snapshots/dds-locale-modal-composite.md b/packages/web-components/tests/snapshots/dds-locale-modal-composite.md index 4f5cabbbeb6..80173431c00 100644 --- a/packages/web-components/tests/snapshots/dds-locale-modal-composite.md +++ b/packages/web-components/tests/snapshots/dds-locale-modal-composite.md @@ -55,46 +55,6 @@ placeholder="search-placeholder-foo" unavailability-label-text="unavailability-text-foo" > - - - - - - - - - -