diff --git a/.changeset/forty-jokes-sin.md b/.changeset/forty-jokes-sin.md new file mode 100644 index 0000000000..a405cac8f7 --- /dev/null +++ b/.changeset/forty-jokes-sin.md @@ -0,0 +1,6 @@ +--- +'@swisspost/design-system-documentation': patch +'@swisspost/design-system-components': major +--- + +Introduced `` web component to replace the previous `data-popover-target` implementation. diff --git a/packages/components-angular/projects/consumer-app/cypress/e2e/popover.cy.ts b/packages/components-angular/projects/consumer-app/cypress/e2e/popover.cy.ts new file mode 100644 index 0000000000..925b1c3f2a --- /dev/null +++ b/packages/components-angular/projects/consumer-app/cypress/e2e/popover.cy.ts @@ -0,0 +1,112 @@ +describe('Popover', () => { + beforeEach(() => { + cy.visit('/popover'); + cy.get('post-popover[data-hydrated]').as('popover'); + cy.get('post-popovercontainer[data-hydrated]').as('popovercontainer'); + cy.get('#popoverContent').as('popoverContent'); + cy.get('post-popover-trigger[data-hydrated][for="popover-one"]') + .children() + .first() + .as('trigger'); + }); + + it('should contain an HTML element inside the trigger, not just plain text', () => { + cy.get('post-popover-trigger[data-hydrated][for="popover-one"]') + .children() + .should('have.length.at.least', 1); + }); + it('should show up on click', () => { + cy.get('@popoverContent').should('not.be.visible'); + cy.get('@trigger').should('have.attr', 'aria-expanded', 'false'); + cy.get('@trigger').click(); + cy.get('@popover').should('be.visible'); + cy.get('@trigger').should('have.attr', 'aria-expanded', 'true'); + // Void click light dismiss does not work in cypress for closing + }); + + it('should show up when clicking on a nested element inside the trigger', () => { + // Modify trigger by adding a nested span + cy.get('@trigger').then($trigger => { + const originalText = $trigger.text(); + $trigger.html(`${originalText}`); + }); + + cy.get('@popoverContent').should('not.be.visible'); + cy.get('@trigger').should('have.attr', 'aria-expanded', 'false'); + cy.get('.nested-element').click(); + cy.get('@popoverContent').should('be.visible'); + cy.get('@trigger').should('have.attr', 'aria-expanded', 'true'); + cy.get('.btn-close').click(); + cy.get('@popoverContent').should('not.be.visible'); + cy.get('@trigger').should('have.attr', 'aria-expanded', 'false'); + }); + + it('should show up when clicking on a deeply nested element inside the trigger', () => { + // Set up a trigger with a deeply nested structure + cy.get('@trigger').then($trigger => { + const originalText = $trigger.text(); + $trigger.html(` +
+
+ ${originalText} +
+
+ `); + }); + + cy.get('@popoverContent').should('not.be.visible'); + cy.get('.level-3').click(); + cy.get('@popoverContent').should('be.visible'); + cy.get('@trigger').should('have.attr', 'aria-expanded', 'true'); + }); + + it('should close on X click', () => { + cy.get('@trigger').click(); + cy.get('@popoverContent').should('be.visible'); + cy.get('.btn-close').click(); + cy.get('@popoverContent').should('not.be.visible'); + }); + + it('should open on enter and close on escape', () => { + cy.get('@popoverContent').should('not.be.visible'); + cy.get('@trigger').focus().type('{enter}'); + cy.get('@popoverContent').should('be.visible'); + }); + + it('should open and close with the API', () => { + Promise.all([cy.get('@trigger'), cy.get('@popoverContent')]) + .then(([$trigger, $popover]: [JQuery, JQuery]) => [ + $trigger.get(0), + $popover.get(0), + ]) + .then(([trigger, popover]: [HTMLButtonElement, HTMLPostPopoverElement]) => { + cy.get('@popoverContent').should('not.be.visible'); + popover.show(trigger); + cy.get('@popoverContent').should('be.visible'); + popover.hide(); + cy.get('@popoverContent').should('not.be.visible'); + popover.toggle(trigger); + cy.get('@popoverContent').should('be.visible'); + popover.toggle(trigger); + cy.get('@popoverContent').should('not.be.visible'); + }); + + it('should switch position', () => { + cy.get('post-popover').invoke('attr', 'placement', 'top'); + cy.get('@popoverContent').should('not.be.visible'); + + Promise.all([cy.get('@trigger'), cy.get('@popover')]) + .then( + ([$trigger, $popover]: [JQuery, JQuery]) => [ + $trigger.get(0), + $popover.get(0), + ], + ) + .then(([trigger, popover]: [HTMLButtonElement, HTMLPostPopoverElement]) => { + const t = trigger.getBoundingClientRect(); + const p = popover.getBoundingClientRect(); + expect(t.top < p.top); + }); + }); + }); +}); diff --git a/packages/components-angular/projects/consumer-app/src/app/app.module.ts b/packages/components-angular/projects/consumer-app/src/app/app.module.ts index 0fe07a924d..df82c4e8b5 100644 --- a/packages/components-angular/projects/consumer-app/src/app/app.module.ts +++ b/packages/components-angular/projects/consumer-app/src/app/app.module.ts @@ -7,6 +7,7 @@ import { providePostComponents } from '@swisspost/design-system-components-angul import { AppComponent } from './app.component'; import { CardControlComponent } from './routes/card-control/card-control.component'; +import { PopoverComponent } from './routes/popover/popover.component'; @NgModule({ imports: [ @@ -15,6 +16,7 @@ import { CardControlComponent } from './routes/card-control/card-control.compone AppRoutingModule, FormsModule, CardControlComponent, + PopoverComponent, ], declarations: [AppComponent], providers: [providePostComponents()], diff --git a/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html b/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html index fda23b2e7a..81750cbb2d 100644 --- a/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html +++ b/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html @@ -55,17 +55,22 @@

Post Logo

Post Popover

- - -

Optional title

- -

- A longer message that needs more time to read. - Links - are also possible. -

+ + + +
+

Optional title

+ + A longer message that needs more time to read. Links are also + possible. +
diff --git a/packages/components/cypress/e2e/popover.cy.ts b/packages/components/cypress/e2e/popover.cy.ts index d7c1b165f4..3218f0be0e 100644 --- a/packages/components/cypress/e2e/popover.cy.ts +++ b/packages/components/cypress/e2e/popover.cy.ts @@ -7,10 +7,19 @@ describe('popover', { baseUrl: null, includeShadowDom: true }, () => { cy.get('post-popover[data-hydrated]'); // Aria-expanded is set by the web component, therefore it's a good measure to indicate the component is ready - cy.get('[data-popover-target="popover-one"][aria-expanded]').as('trigger'); + cy.get('post-popover-trigger[data-hydrated][for="popover-one"]') + .children() + .first() + .as('trigger'); cy.get('#testtext').as('popover'); }); + it('should contain an HTML element inside the trigger, not just plain text', () => { + cy.get('post-popover-trigger[data-hydrated][for="popover-one"]') + .children() + .should('have.length.at.least', 1); + }); + it('should show up on click', () => { cy.get('@popover').should('not.be.visible'); cy.get('@trigger').should('have.attr', 'aria-expanded', 'false'); @@ -63,12 +72,10 @@ describe('popover', { baseUrl: null, includeShadowDom: true }, () => { cy.get('@popover').should('not.be.visible'); }); - it('should open and close on enter', () => { + it('should open on enter', () => { cy.get('@popover').should('not.be.visible'); cy.get('@trigger').focus().type('{enter}'); cy.get('@popover').should('be.visible'); - cy.get('@trigger').type('{enter}'); - cy.get('@popover').should('not.be.visible'); }); it('should open and close with the API', () => { @@ -119,7 +126,8 @@ describe('popover', { baseUrl: null, includeShadowDom: true }, () => { cy.get('post-popover[data-hydrated]'); // Aria-expanded is set by the web component, therefore it's a good measure to indicate the component is ready - cy.get('[data-popover-target="popover-one"][aria-expanded]').as('trigger'); + + cy.get('post-popover-trigger[data-hydrated][for="popover-one"]').as('trigger'); cy.injectAxe(); }); diff --git a/packages/components/cypress/e2e/popovercontainer.cy.ts b/packages/components/cypress/e2e/popovercontainer.cy.ts index e0cd9f65fb..d9f92aef3d 100644 --- a/packages/components/cypress/e2e/popovercontainer.cy.ts +++ b/packages/components/cypress/e2e/popovercontainer.cy.ts @@ -3,9 +3,17 @@ describe('popovercontainer', { baseUrl: null, includeShadowDom: true }, () => { const selector = isPopoverSupported() ? ':popover-open' : '.\\:popover-open'; beforeEach(() => { - // There is no dedicated docs page for the popovercontainer cy.visit('./cypress/fixtures/post-popover.test.html'); - cy.get('[data-popover-target="popover-one"][aria-expanded]').as('trigger'); + + // Ensure the component is hydrated, which is necessary to ensure the component is ready for interaction + cy.get('post-popover[data-hydrated]'); + + // Aria-expanded is set by the web component, therefore it's a good measure to indicate the component is ready + cy.get('post-popover-trigger[data-hydrated][for="popover-one"]') + .children() + .first() + .as('trigger'); + cy.get('#testtext').as('container'); }); diff --git a/packages/components/cypress/fixtures/post-popover.test.html b/packages/components/cypress/fixtures/post-popover.test.html index 7f5e1d1ca1..e35ed1b8bc 100644 --- a/packages/components/cypress/fixtures/post-popover.test.html +++ b/packages/components/cypress/fixtures/post-popover.test.html @@ -11,7 +11,9 @@ - + + +

This is a test

diff --git a/packages/components/src/components.d.ts b/packages/components/src/components.d.ts index d2cca62794..5c5675c15b 100644 --- a/packages/components/src/components.d.ts +++ b/packages/components/src/components.d.ts @@ -372,16 +372,22 @@ export namespace Components { "placement"?: Placement; /** * Programmatically display the popover - * @param target An element with [data-popover-target="id"] where the popover should be shown + * @param target A component that controls the popover */ "show": (target: HTMLElement) => Promise; /** * Toggle popover display - * @param target An element with [data-popover-target="id"] where the popover should be anchored to + * @param target A component that controls the popover * @param force Pass true to always show or false to always hide */ "toggle": (target: HTMLElement, force?: boolean) => Promise; } + interface PostPopoverTrigger { + /** + * ID of the popover element that this trigger is linked to. Used to open and close the popover. + */ + "for": string; + } interface PostPopovercontainer { /** * Animation style @@ -418,12 +424,12 @@ export namespace Components { "safeSpace"?: 'triangle' | 'trapezoid'; /** * Programmatically display the popovercontainer - * @param target An element with [data-popover-target="id"] where the popovercontainer should be shown + * @param target A component that controls the popover */ "show": (target: HTMLElement) => Promise; /** * Toggle popovercontainer display - * @param target An element with [data-popover-target="id"] where the popovercontainer should be shown + * @param target A component that controls the popover * @param force Pass true to always show or false to always hide */ "toggle": (target: HTMLElement, force?: boolean) => Promise; @@ -805,6 +811,12 @@ declare global { prototype: HTMLPostPopoverElement; new (): HTMLPostPopoverElement; }; + interface HTMLPostPopoverTriggerElement extends Components.PostPopoverTrigger, HTMLStencilElement { + } + var HTMLPostPopoverTriggerElement: { + prototype: HTMLPostPopoverTriggerElement; + new (): HTMLPostPopoverTriggerElement; + }; interface HTMLPostPopovercontainerElementEventMap { "postToggle": { isOpen: boolean; first?: boolean }; } @@ -915,6 +927,7 @@ declare global { "post-menu-item": HTMLPostMenuItemElement; "post-menu-trigger": HTMLPostMenuTriggerElement; "post-popover": HTMLPostPopoverElement; + "post-popover-trigger": HTMLPostPopoverTriggerElement; "post-popovercontainer": HTMLPostPopovercontainerElement; "post-rating": HTMLPostRatingElement; "post-tab-header": HTMLPostTabHeaderElement; @@ -1238,6 +1251,12 @@ declare namespace LocalJSX { */ "placement"?: Placement; } + interface PostPopoverTrigger { + /** + * ID of the popover element that this trigger is linked to. Used to open and close the popover. + */ + "for": string; + } interface PostPopovercontainer { /** * Animation style @@ -1397,6 +1416,7 @@ declare namespace LocalJSX { "post-menu-item": PostMenuItem; "post-menu-trigger": PostMenuTrigger; "post-popover": PostPopover; + "post-popover-trigger": PostPopoverTrigger; "post-popovercontainer": PostPopovercontainer; "post-rating": PostRating; "post-tab-header": PostTabHeader; @@ -1444,6 +1464,7 @@ declare module "@stencil/core" { "post-menu-item": LocalJSX.PostMenuItem & JSXBase.HTMLAttributes; "post-menu-trigger": LocalJSX.PostMenuTrigger & JSXBase.HTMLAttributes; "post-popover": LocalJSX.PostPopover & JSXBase.HTMLAttributes; + "post-popover-trigger": LocalJSX.PostPopoverTrigger & JSXBase.HTMLAttributes; "post-popovercontainer": LocalJSX.PostPopovercontainer & JSXBase.HTMLAttributes; "post-rating": LocalJSX.PostRating & JSXBase.HTMLAttributes; "post-tab-header": LocalJSX.PostTabHeader & JSXBase.HTMLAttributes; diff --git a/packages/components/src/components/post-popover-trigger/post-popover-trigger.scss b/packages/components/src/components/post-popover-trigger/post-popover-trigger.scss new file mode 100644 index 0000000000..3657761313 --- /dev/null +++ b/packages/components/src/components/post-popover-trigger/post-popover-trigger.scss @@ -0,0 +1,3 @@ +:host { + cursor: pointer; +} diff --git a/packages/components/src/components/post-popover-trigger/post-popover-trigger.tsx b/packages/components/src/components/post-popover-trigger/post-popover-trigger.tsx new file mode 100644 index 0000000000..02a61e5ae3 --- /dev/null +++ b/packages/components/src/components/post-popover-trigger/post-popover-trigger.tsx @@ -0,0 +1,126 @@ +import { Component, h, Host, Prop, Watch, Element, State } from '@stencil/core'; +import { version } from '@root/package.json'; +import isFocusable from 'ally.js/is/focusable'; +import { checkRequiredAndType } from '@/utils'; + +@Component({ + tag: 'post-popover-trigger', + styleUrl: 'post-popover-trigger.scss', + shadow: true, +}) +export class PostPopoverTrigger { + @Element() host: HTMLPostPopoverTriggerElement; + + /** + * Reference to the element inside the host that will act as the trigger. + */ + private trigger: HTMLElement; + + /** + * ID of the popover element that this trigger is linked to. Used to open and close the popover. + */ + @Prop({ reflect: true }) for!: string; + + /** + * Manages the accessibility attribute `aria-expanded` to indicate whether the associated popover is expanded or collapsed. + */ + @State() ariaExpanded: boolean = false; + + /** + * Watch for changes to the `for` property to validate its type and ensure it is a string. + * @param forValue - The new value of the `for` property. + */ + @Watch('for') + validateFor() { + checkRequiredAndType(this, 'for', 'string'); + } + + //this gets the associated popover element to the trigger based on 'for' + private get popover(): HTMLPostPopoverElement | null { + const ref = document.getElementById(this.for); + return ref?.localName === 'post-popover' ? (ref as HTMLPostPopoverElement) : null; + } + + private handleToggle() { + if (this.popover) { + this.popover.toggle(this.host); + if (this.ariaExpanded === false) { + this.trigger.focus(); + } + } else { + console.warn(`No post-popover found with ID: ${this.for}`); + } + } + + private readonly handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.handleToggle(); + } + }; + + // when the content of the trigger changes + private handleSlotChange() { + this.setupTrigger(); + } + + // setup the trigger to get the correct aria attributes + private setupTrigger() { + this.trigger = this.host.querySelector('*'); + if (this.trigger) { + this.trigger.setAttribute('aria-expanded', this.ariaExpanded.toString()); + + // check if its not focusable and add aria role and tabindex + if (!isFocusable(this.trigger)) { + this.trigger.setAttribute('tabindex', '0'); + this.trigger.setAttribute('role', 'button'); + } + + // set aria attributes + this.trigger.setAttribute('ariahaspopup', 'true'); + + this.trigger.setAttribute('aria-controls', this.for); + + // add event listeners for click and keydown + this.trigger.addEventListener('click', () => { + this.handleToggle(); + }); + this.trigger.addEventListener('keydown', this.handleKeyDown); + + // Listen to the `toggle` event emitted by the `post-popover` component + if (this.popover && this.trigger) { + this.popover.addEventListener( + 'postToggle', + (event: CustomEvent<{ isOpen: boolean; first: boolean }>) => { + this.ariaExpanded = event.detail.isOpen; + this.trigger.setAttribute('aria-expanded', this.ariaExpanded.toString()); + }, + ); + } + } else { + console.warn( + 'No content found in the post-popover-trigger slot. Please insert a focusable element or content that can receive focus.', + ); + } + } + + componentDidLoad() { + this.validateFor(); + } + + disconnectedCallback() { + // remove event listeners + this.trigger.removeEventListener('click', () => { + this.handleToggle(); + }); + this.trigger.removeEventListener('keydown', this.handleKeyDown); + } + + render() { + return ( + + this.handleSlotChange()}> + + ); + } +} diff --git a/packages/components/src/components/post-popover-trigger/readme.md b/packages/components/src/components/post-popover-trigger/readme.md new file mode 100644 index 0000000000..c3dc54d5da --- /dev/null +++ b/packages/components/src/components/post-popover-trigger/readme.md @@ -0,0 +1,17 @@ +# post-popover-trigger + + + + + + +## Properties + +| Property | Attribute | Description | Type | Default | +| ------------------ | --------- | --------------------------------------------------------------------------------------------- | -------- | ----------- | +| `for` _(required)_ | `for` | ID of the popover element that this trigger is linked to. Used to open and close the popover. | `string` | `undefined` | + + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/packages/components/src/components/post-popover/post-popover.tsx b/packages/components/src/components/post-popover/post-popover.tsx index 62704bbbc3..3b6a62de78 100644 --- a/packages/components/src/components/post-popover/post-popover.tsx +++ b/packages/components/src/components/post-popover/post-popover.tsx @@ -2,30 +2,12 @@ import { Component, Element, h, Host, Method, Prop, Watch } from '@stencil/core' import { Placement } from '@floating-ui/dom'; import { PLACEMENT_TYPES } from '@/types'; import { version } from '@root/package.json'; -import { IS_BROWSER, getAttributeObserver, checkRequiredAndType, checkEmptyOrOneOf } from '@/utils'; +import { checkRequiredAndType, checkEmptyOrOneOf, getFocusableChildren } from '@/utils'; /** * @slot default - Slot for placing content inside the popover. */ -let popoverInstances = 0; -const popoverTargetAttribute = 'data-popover-target'; - -const globalToggleHandler = (e: PointerEvent | KeyboardEvent) => { - let currentElement = e.target as HTMLElement; - - // Traverse up the DOM tree to find if any parent has the popover target attribute - while (currentElement && !currentElement.getAttribute(popoverTargetAttribute)) { - if (currentElement === document.body || !currentElement.parentElement) break; - currentElement = currentElement.parentElement; - } - - const popoverTarget = currentElement?.getAttribute(popoverTargetAttribute); - if (!popoverTarget || ('key' in e && e.key !== 'Enter')) return; - const popover = document.getElementById(popoverTarget) as HTMLPostPopoverElement; - popover?.toggle(currentElement); -}; - @Component({ tag: 'post-popover', styleUrl: 'post-popover.scss', @@ -33,11 +15,6 @@ const globalToggleHandler = (e: PointerEvent | KeyboardEvent) => { }) export class PostPopover { private popoverRef: HTMLPostPopovercontainerElement; - private readonly localBeforeToggleHandler: () => void; - // Initialize a mutation observer for patching accessibility features - private readonly triggerObserver = IS_BROWSER - ? getAttributeObserver(popoverTargetAttribute, this.patchAccessibilityFeatures) - : null; @Element() host: HTMLPostPopoverElement; @@ -68,56 +45,18 @@ export class PostPopover { // eslint-disable-next-line @stencil-community/ban-default-true @Prop() readonly arrow?: boolean = true; - constructor() { - this.localBeforeToggleHandler = this.beforeToggleHandler.bind(this); - } - - connectedCallback() { - // Set up accessibility patcher and event listeners for the first component - if (popoverInstances === 0) { - window.addEventListener('pointerup', globalToggleHandler); - window.addEventListener('keydown', globalToggleHandler); - this.triggerObserver?.observe(document.body, { - subtree: true, - childList: true, - attributeFilter: [popoverTargetAttribute], - }); - } - - popoverInstances++; - - this.triggers.forEach(trigger => trigger.setAttribute('aria-expanded', 'false')); - } - componentDidLoad() { this.validatePlacement(); this.validateCloseButtonCaption(); - this.popoverRef.addEventListener('beforetoggle', this.localBeforeToggleHandler); - } - - disconnectedCallback() { - popoverInstances--; - - // Remove listeners and observer after the last popover has been destructed - if (popoverInstances === 0) { - window.removeEventListener('click', globalToggleHandler); - window.removeEventListener('keydown', globalToggleHandler); - this.triggerObserver?.disconnect(); - } - - this.popoverRef.removeEventListener('beforetoggle', this.localBeforeToggleHandler); - this.triggers.forEach(trigger => trigger.removeAttribute('aria-expanded')); } /** * Programmatically display the popover - * @param target An element with [data-popover-target="id"] where the popover should be shown + * @param target A component that controls the popover */ @Method() async show(target: HTMLElement) { this.popoverRef.show(target); - console.log(this.popoverRef); - target.setAttribute('aria-expanded', 'true'); } /** @@ -126,32 +65,25 @@ export class PostPopover { @Method() async hide() { this.popoverRef.hide(); - this.triggers.forEach(trigger => trigger.setAttribute('aria-expanded', 'false')); } /** * Toggle popover display - * @param target An element with [data-popover-target="id"] where the popover should be anchored to + * @param target A component that controls the popover * @param force Pass true to always show or false to always hide */ @Method() async toggle(target: HTMLElement, force?: boolean) { - const newState = await this.popoverRef.toggle(target, force); - this.triggers.forEach(trigger => trigger.setAttribute('aria-expanded', 'false')); - target.setAttribute('aria-expanded', `${newState}`); - } + await this.popoverRef.toggle(target, force); - private get triggers() { - return document.querySelectorAll(`[${popoverTargetAttribute}="${this.host.id}"]`); - } + const focusableChildren = getFocusableChildren(this.host); - private beforeToggleHandler() { - this.triggers.forEach(trigger => trigger.setAttribute('aria-expanded', 'false')); - } + // find first focusable element + const firstFocusable = focusableChildren[0]; - private patchAccessibilityFeatures(trigger: HTMLElement) { - const force = trigger.hasAttribute(popoverTargetAttribute); - trigger.setAttribute('aria-expanded', force ? 'false' : null); + if (firstFocusable) { + firstFocusable.focus(); + } } render() { diff --git a/packages/components/src/components/post-popover/readme.md b/packages/components/src/components/post-popover/readme.md index 38b8977829..3e1a78e3c3 100644 --- a/packages/components/src/components/post-popover/readme.md +++ b/packages/components/src/components/post-popover/readme.md @@ -1,7 +1,5 @@ # post-popover - - @@ -32,9 +30,9 @@ Programmatically display the popover #### Parameters -| Name | Type | Description | -| -------- | ------------- | ---------------------------------------------------------------------------- | -| `target` | `HTMLElement` | An element with [data-popover-target="id"] where the popover should be shown | +| Name | Type | Description | +| -------- | ------------- | ------------------------------------------------------------ | +| `target` | `HTMLElement` | A component that controls the popover | #### Returns @@ -48,10 +46,10 @@ Toggle popover display #### Parameters -| Name | Type | Description | -| -------- | ------------- | ---------------------------------------------------------------------------------- | -| `target` | `HTMLElement` | An element with [data-popover-target="id"] where the popover should be anchored to | -| `force` | `boolean` | Pass true to always show or false to always hide | +| Name | Type | Description | +| -------- | ------------- | ------------------------------------------------------------ | +| `target` | `HTMLElement` | A component that controls the popover | +| `force` | `boolean` | Pass true to always show or false to always hide | #### Returns @@ -60,6 +58,13 @@ Type: `Promise` +## Slots + +| Slot | Description | +| ----------- | -------------------------------------------- | +| `"default"` | Slot for placing content inside the popover. | + + ## Dependencies ### Depends on diff --git a/packages/components/src/components/post-popovercontainer/post-popovercontainer.tsx b/packages/components/src/components/post-popovercontainer/post-popovercontainer.tsx index 36cef649b7..8eaad3130e 100644 --- a/packages/components/src/components/post-popovercontainer/post-popovercontainer.tsx +++ b/packages/components/src/components/post-popovercontainer/post-popovercontainer.tsx @@ -10,7 +10,7 @@ import { Watch, } from '@stencil/core'; -import { IS_BROWSER, checkEmptyOrOneOf, checkEmptyOrType } from '@/utils'; +import { IS_BROWSER, checkEmptyOrOneOf, checkEmptyOrType, getFocusableChildren } from '@/utils'; import { version } from '@root/package.json'; import { @@ -156,13 +156,12 @@ export class PostPopovercontainer { /** * Programmatically display the popovercontainer - * @param target An element with [data-popover-target="id"] where the popovercontainer should be shown + * @param target A component that controls the popover */ @Method() async show(target: HTMLElement) { - if (this.toggleTimeoutId) return; - this.eventTarget = target; + if (this.toggleTimeoutId) return; this.calculatePosition(); this.host.showPopover(); } @@ -173,6 +172,14 @@ export class PostPopovercontainer { @Method() async hide() { if (!this.toggleTimeoutId) { + if (this.eventTarget && this.eventTarget instanceof HTMLElement) { + const focusableChildren = getFocusableChildren(this.eventTarget); + // find first focusable element + const firstFocusable = focusableChildren[0]; + if (firstFocusable) { + firstFocusable.focus(); + } + } this.eventTarget = null; this.host.hidePopover(); } @@ -180,18 +187,19 @@ export class PostPopovercontainer { /** * Toggle popovercontainer display - * @param target An element with [data-popover-target="id"] where the popovercontainer should be shown + * @param target A component that controls the popover * @param force Pass true to always show or false to always hide */ @Method() async toggle(target: HTMLElement, force?: boolean): Promise { + this.eventTarget = target; // Prevent instant double toggle if (!this.toggleTimeoutId) { - this.eventTarget = target; this.calculatePosition(); this.host.togglePopover(force); this.toggleTimeoutId = null; } + return this.host.matches(':where(:popover-open, .popover-open)'); } @@ -203,30 +211,41 @@ export class PostPopovercontainer { */ private handleToggle(e: ToggleEvent) { this.toggleTimeoutId = window.setTimeout(() => (this.toggleTimeoutId = null), 10); - const isOpen = e.newState === 'open'; + const content = this.host.querySelector('.popover-content'); + if (isOpen) { - const content = this.host.querySelector('.popover-content'); this.startAutoupdates(); + if (content && this.animation === 'pop-in') { popIn(content); } - if (this.safeSpace) + if (this.safeSpace) { window.addEventListener('mousemove', this.mouseTrackingHandler.bind(this)); + } // Emit event with `first` flag only true on the first open if (this.firstOpen) { - this.postToggle.emit({ isOpen, first: this.firstOpen }); + this.postToggle.emit({ isOpen: true, first: true }); this.firstOpen = false; return; } } else { + // Return focus to the trigger on close + if (this.eventTarget instanceof HTMLElement) { + const focusable = getFocusableChildren(this.eventTarget); + (focusable[0] || this.eventTarget).focus(); + } + if (typeof this.clearAutoUpdate === 'function') this.clearAutoUpdate(); - if (this.safeSpace) + + if (this.safeSpace) { window.removeEventListener('mousemove', this.mouseTrackingHandler.bind(this)); + } } - this.postToggle.emit({ isOpen: isOpen, first: false }); + + this.postToggle.emit({ isOpen, first: false }); } /** diff --git a/packages/components/src/components/post-popovercontainer/readme.md b/packages/components/src/components/post-popovercontainer/readme.md index 2fa98dec60..bc66d4c082 100644 --- a/packages/components/src/components/post-popovercontainer/readme.md +++ b/packages/components/src/components/post-popovercontainer/readme.md @@ -40,9 +40,9 @@ Programmatically display the popovercontainer #### Parameters -| Name | Type | Description | -| -------- | ------------- | ------------------------------------------------------------------------------------- | -| `target` | `HTMLElement` | An element with [data-popover-target="id"] where the popovercontainer should be shown | +| Name | Type | Description | +| -------- | ------------- | ------------------------------------------------------------ | +| `target` | `HTMLElement` | A component that controls the popover | #### Returns @@ -56,10 +56,10 @@ Toggle popovercontainer display #### Parameters -| Name | Type | Description | -| -------- | ------------- | ------------------------------------------------------------------------------------- | -| `target` | `HTMLElement` | An element with [data-popover-target="id"] where the popovercontainer should be shown | -| `force` | `boolean` | Pass true to always show or false to always hide | +| Name | Type | Description | +| -------- | ------------- | ------------------------------------------------------------ | +| `target` | `HTMLElement` | A component that controls the popover | +| `force` | `boolean` | Pass true to always show or false to always hide | #### Returns diff --git a/packages/components/src/components/post-tooltip-trigger/post-tooltip-trigger.scss b/packages/components/src/components/post-tooltip-trigger/post-tooltip-trigger.scss index ae175ea7fc..de4380d291 100644 --- a/packages/components/src/components/post-tooltip-trigger/post-tooltip-trigger.scss +++ b/packages/components/src/components/post-tooltip-trigger/post-tooltip-trigger.scss @@ -1,3 +1,7 @@ post-tooltip-trigger { vertical-align: top; } + +:host { + cursor: pointer; +} diff --git a/packages/components/src/components/post-tooltip-trigger/post-tooltip-trigger.tsx b/packages/components/src/components/post-tooltip-trigger/post-tooltip-trigger.tsx index 0ec2bbbe62..b2f3547ab7 100644 --- a/packages/components/src/components/post-tooltip-trigger/post-tooltip-trigger.tsx +++ b/packages/components/src/components/post-tooltip-trigger/post-tooltip-trigger.tsx @@ -66,7 +66,6 @@ export class PostTooltipTrigger { } componentDidLoad() { - this.setupTrigger(); this.attachListeners(); this.attachTooltipListeners(); } @@ -84,7 +83,6 @@ export class PostTooltipTrigger { private handleSlotChange() { this.cleanupTrigger(); - this.setupTrigger(); } diff --git a/packages/documentation/src/stories/components/popover/popover.stories.ts b/packages/documentation/src/stories/components/popover/popover.stories.ts index 594c441015..f1c9f5dd9d 100644 --- a/packages/documentation/src/stories/components/popover/popover.stories.ts +++ b/packages/documentation/src/stories/components/popover/popover.stories.ts @@ -1,5 +1,5 @@ import { Args, StoryObj } from '@storybook/web-components-vite'; -import { html } from 'lit'; +import { html, nothing } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { MetaComponent } from '@root/types'; @@ -36,7 +36,7 @@ const meta: MetaComponent = { id: { name: 'Id', description: - 'The id is used to connect a trigger element with the popover.

` - + + + ${args.title ? html`

Optional title

` : null}

${unsafeHTML(args.innerHtml)}