From bf63f197124cc780562d2d172b6e6566f080d7bf Mon Sep 17 00:00:00 2001 From: Erik Tallang Date: Fri, 17 Jan 2025 08:36:39 +0100 Subject: [PATCH 1/7] feat(popover): add initial popover --- packages/lib/components/popover/Overview.mdx | 31 +++++++ packages/lib/components/popover/index.ts | 2 + .../lib/components/popover/popover.stories.ts | 28 ++++++ packages/lib/components/popover/popover.ts | 88 +++++++++++++++++++ packages/lib/components/popover/react.ts | 10 +++ 5 files changed, 159 insertions(+) create mode 100644 packages/lib/components/popover/Overview.mdx create mode 100644 packages/lib/components/popover/index.ts create mode 100644 packages/lib/components/popover/popover.stories.ts create mode 100644 packages/lib/components/popover/popover.ts create mode 100644 packages/lib/components/popover/react.ts diff --git a/packages/lib/components/popover/Overview.mdx b/packages/lib/components/popover/Overview.mdx new file mode 100644 index 0000000..6d994c9 --- /dev/null +++ b/packages/lib/components/popover/Overview.mdx @@ -0,0 +1,31 @@ +import { Canvas, Meta, Source, Subtitle, Title } from "@storybook/blocks"; + +import * as stories from "./popover.stories"; + + + + +<Subtitle> + A dropdown presents a list of options the user can select from and can be used to submit data, + filter, in a menu and so on. +</Subtitle> + +## How to get started +Start by importing the component. If you import the web component version, both the `<cx-dropdown>` component and `<cx-option>` is available for use +from the point of the import and further down the application-tree. +<Source + code={` +// Web component +import '@computas/designsystem/dropdown'; + +// React +import { CxDropdown, CxOption } from '@computas/designsystem/dropdown/react'; +`} +language="typescript" +dark +/> + +## Default +The default dropdown is an `cx-dropdown` component with a set of `cx-option` child components. Each `cx-option` needs a `value` that contains the +value of the option. If the user selects an option, a `change` event is emitted, containing the selected value (if you use React, the event name is `onChange`). +<Canvas of={stories.Default} /> diff --git a/packages/lib/components/popover/index.ts b/packages/lib/components/popover/index.ts new file mode 100644 index 0000000..ae7f002 --- /dev/null +++ b/packages/lib/components/popover/index.ts @@ -0,0 +1,2 @@ +export * from './popover'; +export * from './option'; diff --git a/packages/lib/components/popover/popover.stories.ts b/packages/lib/components/popover/popover.stories.ts new file mode 100644 index 0000000..ef51502 --- /dev/null +++ b/packages/lib/components/popover/popover.stories.ts @@ -0,0 +1,28 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { Meta, StoryObj } from '@storybook/web-components'; +import { html } from 'lit'; + +import './popover'; +import '../icon'; + +export default { + title: 'Components/Popover', +} satisfies Meta; + +export const Default: StoryObj = { + render: () => html` + <cx-popover header="Edit settings" withCloseBtn> + <button slot="trigger" class="cx-btn__secondary cx-btn__icon"> + <cx-icon name="edit"></cx-icon> + </button> + + <p>Edit the settings below:</p> + <label class="cx-form-field"> + <div class="cx-form-field__label">E-mail</div> + <div class="cx-form-field__input-container"> + <input type="email"> + </div> + </label> + </cx-popover> + `, +}; diff --git a/packages/lib/components/popover/popover.ts b/packages/lib/components/popover/popover.ts new file mode 100644 index 0000000..a05fbfe --- /dev/null +++ b/packages/lib/components/popover/popover.ts @@ -0,0 +1,88 @@ +import { LitElement, css, html, unsafeCSS } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import '../icon'; + +import a11yStyles from '../../global-css/a11y.css?inline'; + +@customElement('cx-popover') +export class Popover extends LitElement { + static styles = [ + unsafeCSS(a11yStyles), + css` + .trigger { + anchor-name: --trigger; + display: inline-flex; + } + + [popover] { + --translate-curve: ease; + --translate-duration: 200ms; + + position-anchor: --trigger; + position: absolute; + opacity: 0; + translate: 0px 6px; + inset: unset; + left: anchor(left); + top: anchor(bottom); + margin: var(--cx-spacing-2) 0 0 0; + position-try-fallbacks: --top; + transition: + display 200ms allow-discrete, + overlay 200ms allow-discrete, + opacity 200ms ease, + translate var(--translate-duration) var(--translate-curve); + background: var(--cx-color-background-primary); + border: 1px solid var(--cx-color-border-primary); + border-radius: var(--cx-radius-medium); + max-height: 500px; + padding: var(--cx-spacing-6) var(--cx-spacing-8); + + &:popover-open { + --translate-curve: var(--ease-spring-3); + --translate-duration: 500ms; + opacity: 1; + translate: 0px; + + @starting-style { + opacity: 0; + translate: 0px -6px; + } + } + } + + @position-try --top { + inset: unset; + left: anchor(left); + bottom: anchor(top); + margin: 0 0 var(--cx-spacing-2) 0; + } + `, + ]; + + @query('[popover]') + private popoverElement!: HTMLDivElement; + + @property({ type: String, reflect: true }) + invalidText = ''; + + private onTriggerClick() { + this.popoverElement.togglePopover(); + } + + render() { + return html` + <slot class="trigger" name="trigger" @click=${this.onTriggerClick}></slot> + + <div popover><slot></slot></div> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'cx-popover': Popover; + } +} diff --git a/packages/lib/components/popover/react.ts b/packages/lib/components/popover/react.ts new file mode 100644 index 0000000..fa50f79 --- /dev/null +++ b/packages/lib/components/popover/react.ts @@ -0,0 +1,10 @@ +import { createComponent } from '@lit/react'; +import * as React from 'react'; + +import { Popover } from './index'; + +export const CxPopover = createComponent({ + tagName: 'cx-popover', + elementClass: Popover, + react: React, +}); From 8aa354e9949a4905d0d9a10ab330b20e91ce7f25 Mon Sep 17 00:00:00 2001 From: Erik Tallang <e.tallang@gmail.com> Date: Fri, 17 Jan 2025 12:33:06 +0100 Subject: [PATCH 2/7] feat(popover): add header and close button --- packages/lib/components/popover/popover.ts | 74 ++++++++++++++++++++-- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/packages/lib/components/popover/popover.ts b/packages/lib/components/popover/popover.ts index a05fbfe..61588e8 100644 --- a/packages/lib/components/popover/popover.ts +++ b/packages/lib/components/popover/popover.ts @@ -1,21 +1,48 @@ import { LitElement, css, html, unsafeCSS } from 'lit'; -import { customElement, property, query, state } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; +import { customElement, property, query } from 'lit/decorators.js'; -import '../icon'; +import { addIcons } from "../icon"; +import { close } from "../icon/iconRegistry"; +addIcons(close); import a11yStyles from '../../global-css/a11y.css?inline'; +import buttonStyles from '../button/button.css?inline'; @customElement('cx-popover') export class Popover extends LitElement { static styles = [ unsafeCSS(a11yStyles), + unsafeCSS(buttonStyles), css` .trigger { anchor-name: --trigger; display: inline-flex; } + header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--cx-spacing-2); + margin-bottom: var(--cx-spacing-4); + padding-block: var(--cx-spacing-4); + } + + h1 { + /** From typography */ + margin: 0; + font-family: inherit; + font-weight: 600; + font-size: 1.125rem; + line-height: 1rem; + color: var(--cx-color-text-primary); + } + + button { + background: transparent; + border: none; + } + [popover] { --translate-curve: ease; --translate-duration: 200ms; @@ -62,21 +89,54 @@ export class Popover extends LitElement { `, ]; + @property({ type: String, reflect: true }) + header = ''; + + @property({ type: Boolean, reflect: true }) + hasCloseBtn = false; + @query('[popover]') private popoverElement!: HTMLDivElement; - @property({ type: String, reflect: true }) - invalidText = ''; + @query('button') + private closeButton!: HTMLButtonElement; + + @query('slot[name="trigger"]') + private triggerWrapper!: HTMLSlotElement; + + private isOpen = false; private onTriggerClick() { - this.popoverElement.togglePopover(); + if (this.isOpen) { + this.popoverElement.hidePopover(); + } else { + this.popoverElement.showPopover(); + } + } + + private popoverToggle(event: ToggleEvent) { + if (event.newState === 'open') { + this.closeButton.focus(); + this.isOpen = true; + } else { + (this.triggerWrapper.assignedElements()[0] as HTMLElement).focus(); + this.isOpen = false; + } } render() { return html` <slot class="trigger" name="trigger" @click=${this.onTriggerClick}></slot> - <div popover><slot></slot></div> + <div popover @toggle=${this.popoverToggle}> + <header> + <h1>${this.header}</h1> + <button class="cx-btn__tertiary cx-btn__icon"> + <cx-icon name="close"></cx-icon> + </button> + </header> + <slot></slot> + </div> `; } } From 7fc4184d1ad93306ddc0d852af4b38741ae834ef Mon Sep 17 00:00:00 2001 From: Erik Tallang <e.tallang@gmail.com> Date: Sun, 19 Jan 2025 15:42:19 +0100 Subject: [PATCH 3/7] chore(popover): fix minor changes --- packages/lib/components/popover/Overview.mdx | 8 +++ .../lib/components/popover/popover.stories.ts | 68 +++++++++++++++---- packages/lib/components/popover/popover.ts | 40 +++++++---- 3 files changed, 88 insertions(+), 28 deletions(-) diff --git a/packages/lib/components/popover/Overview.mdx b/packages/lib/components/popover/Overview.mdx index 6d994c9..4c5a671 100644 --- a/packages/lib/components/popover/Overview.mdx +++ b/packages/lib/components/popover/Overview.mdx @@ -29,3 +29,11 @@ dark The default dropdown is an `cx-dropdown` component with a set of `cx-option` child components. Each `cx-option` needs a `value` that contains the value of the option. If the user selects an option, a `change` event is emitted, containing the selected value (if you use React, the event name is `onChange`). <Canvas of={stories.Default} /> + +## With header +Add a `header` attribute to the `cx-popover` to display a header to the popover. +<Canvas of={stories.WithHeader} /> + +## With close button +Add the `withCloseBtn` attribute to display a close button in the popover. +<Canvas of={stories.WithCloseBtn} /> diff --git a/packages/lib/components/popover/popover.stories.ts b/packages/lib/components/popover/popover.stories.ts index ef51502..df38d48 100644 --- a/packages/lib/components/popover/popover.stories.ts +++ b/packages/lib/components/popover/popover.stories.ts @@ -7,22 +7,64 @@ import '../icon'; export default { title: 'Components/Popover', + parameters: { + actions: { + handles: ['open', 'close'], + }, + }, + decorators: [withActions], } satisfies Meta; export const Default: StoryObj = { render: () => html` - <cx-popover header="Edit settings" withCloseBtn> - <button slot="trigger" class="cx-btn__secondary cx-btn__icon"> - <cx-icon name="edit"></cx-icon> - </button> - - <p>Edit the settings below:</p> - <label class="cx-form-field"> - <div class="cx-form-field__label">E-mail</div> - <div class="cx-form-field__input-container"> - <input type="email"> - </div> - </label> - </cx-popover> +<cx-popover> + <button slot="trigger" class="cx-btn__secondary cx-btn__icon"> + <cx-icon name="edit"></cx-icon> + </button> + + <p>Edit the settings below:</p> + <label class="cx-form-field"> + <div class="cx-form-field__label">E-mail</div> + <div class="cx-form-field__input-container"> + <input autofocus type="email"> + </div> + </label> +</cx-popover> + `, +}; + +export const WithHeader: StoryObj = { + render: () => html` +<cx-popover header="Edit settings"> + <button slot="trigger" class="cx-btn__secondary cx-btn__icon"> + <cx-icon name="edit"></cx-icon> + </button> + + <p>Edit the settings below:</p> + <label class="cx-form-field"> + <div class="cx-form-field__label">E-mail</div> + <div class="cx-form-field__input-container"> + <input autofocus type="email"> + </div> + </label> +</cx-popover> + `, +}; + +export const WithCloseBtn: StoryObj = { + render: () => html` +<cx-popover header="Edit settings" withCloseBtn> + <button slot="trigger" class="cx-btn__secondary cx-btn__icon"> + <cx-icon name="edit"></cx-icon> + </button> + + <p>Edit the settings below:</p> + <label class="cx-form-field"> + <div class="cx-form-field__label">E-mail</div> + <div class="cx-form-field__input-container"> + <input autofocus type="email"> + </div> + </label> +</cx-popover> `, }; diff --git a/packages/lib/components/popover/popover.ts b/packages/lib/components/popover/popover.ts index 61588e8..6703f39 100644 --- a/packages/lib/components/popover/popover.ts +++ b/packages/lib/components/popover/popover.ts @@ -1,8 +1,8 @@ -import { LitElement, css, html, unsafeCSS } from 'lit'; +import { LitElement, css, html, nothing, unsafeCSS } from 'lit'; import { customElement, property, query } from 'lit/decorators.js'; -import { addIcons } from "../icon"; -import { close } from "../icon/iconRegistry"; +import { addIcons } from '../icon'; +import { close } from '../icon/iconRegistry'; addIcons(close); import a11yStyles from '../../global-css/a11y.css?inline'; @@ -25,7 +25,6 @@ export class Popover extends LitElement { justify-content: space-between; gap: var(--cx-spacing-2); margin-bottom: var(--cx-spacing-4); - padding-block: var(--cx-spacing-4); } h1 { @@ -43,6 +42,10 @@ export class Popover extends LitElement { border: none; } + [hidden] { + visibility: hidden; + } + [popover] { --translate-curve: ease; --translate-duration: 200ms; @@ -93,14 +96,11 @@ export class Popover extends LitElement { header = ''; @property({ type: Boolean, reflect: true }) - hasCloseBtn = false; + withCloseBtn = false; @query('[popover]') private popoverElement!: HTMLDivElement; - @query('button') - private closeButton!: HTMLButtonElement; - @query('slot[name="trigger"]') private triggerWrapper!: HTMLSlotElement; @@ -116,25 +116,35 @@ export class Popover extends LitElement { private popoverToggle(event: ToggleEvent) { if (event.newState === 'open') { - this.closeButton.focus(); this.isOpen = true; + this.dispatchEvent(new CustomEvent('open', { bubbles: true, composed: true })); } else { (this.triggerWrapper.assignedElements()[0] as HTMLElement).focus(); this.isOpen = false; + this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true })); } } render() { + const closeBtn = html` + <button ?hidden=${!this.withCloseBtn} class="cx-btn__tertiary cx-btn__icon" @click=${() => this.popoverElement.hidePopover()}> + <cx-icon name="close"></cx-icon> + </button>`; + + const header = this.header + ? html` + <header> + <h1>${this.header}</h1> + ${closeBtn} + </header>` + : nothing; + return html` <slot class="trigger" name="trigger" @click=${this.onTriggerClick}></slot> <div popover @toggle=${this.popoverToggle}> - <header> - <h1>${this.header}</h1> - <button class="cx-btn__tertiary cx-btn__icon"> - <cx-icon name="close"></cx-icon> - </button> - </header> + ${header} + <slot></slot> </div> `; From 094a25fc3d893369a95dbb7b948e0db8e78431e1 Mon Sep 17 00:00:00 2001 From: Erik Tallang <e.tallang@gmail.com> Date: Sun, 19 Jan 2025 17:49:38 +0100 Subject: [PATCH 4/7] docs(popover): document browser support --- packages/lib/components/popover/Overview.mdx | 13 +++++++++++++ packages/lib/components/popover/index.ts | 1 - 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/lib/components/popover/Overview.mdx b/packages/lib/components/popover/Overview.mdx index 4c5a671..4147da0 100644 --- a/packages/lib/components/popover/Overview.mdx +++ b/packages/lib/components/popover/Overview.mdx @@ -25,6 +25,19 @@ language="typescript" dark /> +## Browser support +The popover component uses the new [Anchor Position API](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning/Using) +to position the popover relative to the trigger. This API is not supported in Safari and Firefox as writing these docs. If you want to support +all major browsers, please include the official anchor position API polyfill in the `<head>` of your application: +``` html +<script type="module"> + if (!("anchorName" in document.documentElement.style)) { + import("https://unpkg.com/@oddbird/css-anchor-positioning"); + } +</script> +``` +Read more about this polyfill [here](https://github.com/oddbird/css-anchor-positioning). + ## Default The default dropdown is an `cx-dropdown` component with a set of `cx-option` child components. Each `cx-option` needs a `value` that contains the value of the option. If the user selects an option, a `change` event is emitted, containing the selected value (if you use React, the event name is `onChange`). diff --git a/packages/lib/components/popover/index.ts b/packages/lib/components/popover/index.ts index ae7f002..1f4904c 100644 --- a/packages/lib/components/popover/index.ts +++ b/packages/lib/components/popover/index.ts @@ -1,2 +1 @@ export * from './popover'; -export * from './option'; From fe8ab4420fc0994d97679cf3439cf837d32963d2 Mon Sep 17 00:00:00 2001 From: Erik Tallang <e.tallang@gmail.com> Date: Mon, 20 Jan 2025 22:02:51 +0100 Subject: [PATCH 5/7] feat(popover): improve docs --- CHANGELOG.md | 1 + packages/lib/components/popover/Overview.mdx | 35 ++++++------ .../lib/components/popover/popover.stories.ts | 53 ++++++++++++------ packages/lib/components/popover/popover.ts | 56 ++++++++++++++++--- packages/lib/shared/getFocusableElement.ts | 13 +++++ 5 files changed, 115 insertions(+), 43 deletions(-) create mode 100644 packages/lib/shared/getFocusableElement.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 49f7ef8..23c7e8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # 0.0.9 - xx.01.2025 - Lagt til select-komponent +- Lagt til popover-komponent # 0.0.8 - 13.01.2025 diff --git a/packages/lib/components/popover/Overview.mdx b/packages/lib/components/popover/Overview.mdx index 4147da0..0b3b443 100644 --- a/packages/lib/components/popover/Overview.mdx +++ b/packages/lib/components/popover/Overview.mdx @@ -6,29 +6,25 @@ import * as stories from "./popover.stories"; <Title /> <Subtitle> - A dropdown presents a list of options the user can select from and can be used to submit data, - filter, in a menu and so on. + A popover is a non-modal dialog that appears above the content on the screen without losing the context of their original view. + It can contain rich data such as text, selection controls, and buttons. A popover is used with a clickable trigger element and + should position itself relative to where there is free space on the screen. </Subtitle> ## How to get started -Start by importing the component. If you import the web component version, both the `<cx-dropdown>` component and `<cx-option>` is available for use -from the point of the import and further down the application-tree. -<Source - code={` +Start by importing the component. +``` ts // Web component -import '@computas/designsystem/dropdown'; +import '@computas/designsystem/popover'; // React -import { CxDropdown, CxOption } from '@computas/designsystem/dropdown/react'; -`} -language="typescript" -dark -/> +import { CxPopover } from '@computas/designsystem/popover/react'; +``` ## Browser support The popover component uses the new [Anchor Position API](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning/Using) -to position the popover relative to the trigger. This API is not supported in Safari and Firefox as writing these docs. If you want to support -all major browsers, please include the official anchor position API polyfill in the `<head>` of your application: +to position the popover relative to the trigger. This API is not supported in Safari and Firefox as of 20th January 2025. If you want to support +all major browsers, please include the official anchor position API polyfill in the `<head>` of your document: ``` html <script type="module"> if (!("anchorName" in document.documentElement.style)) { @@ -39,14 +35,19 @@ all major browsers, please include the official anchor position API polyfill in Read more about this polyfill [here](https://github.com/oddbird/css-anchor-positioning). ## Default -The default dropdown is an `cx-dropdown` component with a set of `cx-option` child components. Each `cx-option` needs a `value` that contains the -value of the option. If the user selects an option, a `change` event is emitted, containing the selected value (if you use React, the event name is `onChange`). +A popover requires a clickable element to act as the trigger for opening/closing the popover. This trigger is registered through the "trigger" +slot by adding the attribute `slot="trigger"`. The remaining content inside the `cx-popover` element is placed into the popover as its content. <Canvas of={stories.Default} /> ## With header -Add a `header` attribute to the `cx-popover` to display a header to the popover. +Add a `header` attribute to the `cx-popover` to display a header. <Canvas of={stories.WithHeader} /> ## With close button Add the `withCloseBtn` attribute to display a close button in the popover. <Canvas of={stories.WithCloseBtn} /> + +## Autofocus +By default, the popover does not move focus into the popover when it opens. To automatically move the focus into the popover when it opens, +simply add the `autofocus` attribute. +<Canvas of={stories.Autofocus} /> diff --git a/packages/lib/components/popover/popover.stories.ts b/packages/lib/components/popover/popover.stories.ts index df38d48..7467931 100644 --- a/packages/lib/components/popover/popover.stories.ts +++ b/packages/lib/components/popover/popover.stories.ts @@ -22,13 +22,7 @@ export const Default: StoryObj = { <cx-icon name="edit"></cx-icon> </button> - <p>Edit the settings below:</p> - <label class="cx-form-field"> - <div class="cx-form-field__label">E-mail</div> - <div class="cx-form-field__input-container"> - <input autofocus type="email"> - </div> - </label> + <p>Here comes the popover content</p> </cx-popover> `, }; @@ -40,13 +34,20 @@ export const WithHeader: StoryObj = { <cx-icon name="edit"></cx-icon> </button> - <p>Edit the settings below:</p> - <label class="cx-form-field"> + <label class="cx-form-field cx-mb-4"> <div class="cx-form-field__label">E-mail</div> <div class="cx-form-field__input-container"> - <input autofocus type="email"> + <input type="email"> </div> </label> + <div> + <button class="cx-btn__secondary cx-btn__sm cx-mr-2"> + Cancel + </button> + <button class="cx-btn__primary cx-btn__sm"> + Save + </button> + </div> </cx-popover> `, }; @@ -58,13 +59,31 @@ export const WithCloseBtn: StoryObj = { <cx-icon name="edit"></cx-icon> </button> - <p>Edit the settings below:</p> - <label class="cx-form-field"> - <div class="cx-form-field__label">E-mail</div> - <div class="cx-form-field__input-container"> - <input autofocus type="email"> - </div> - </label> + <p class="cx-mb-4">Would you like to save the changes?</p> + <button class="cx-btn__secondary cx-btn__sm cx-mr-2"> + Cancel + </button> + <button class="cx-btn__primary cx-btn__sm"> + Save + </button> +</cx-popover> + `, +}; + +export const Autofocus: StoryObj = { + render: () => html` +<cx-popover autofocus> + <button slot="trigger" class="cx-btn__secondary cx-btn__icon"> + <cx-icon name="edit"></cx-icon> + </button> + + <p class="cx-mb-4">Would you like to save the changes?</p> + <button class="cx-btn__secondary cx-btn__sm cx-mr-2"> + Cancel + </button> + <button class="cx-btn__primary cx-btn__sm"> + Save + </button> </cx-popover> `, }; diff --git a/packages/lib/components/popover/popover.ts b/packages/lib/components/popover/popover.ts index 6703f39..bb302b0 100644 --- a/packages/lib/components/popover/popover.ts +++ b/packages/lib/components/popover/popover.ts @@ -7,6 +7,7 @@ addIcons(close); import a11yStyles from '../../global-css/a11y.css?inline'; import buttonStyles from '../button/button.css?inline'; +import { getFocusableElements } from '../../shared/getFocusableElement'; @customElement('cx-popover') export class Popover extends LitElement { @@ -55,10 +56,9 @@ export class Popover extends LitElement { opacity: 0; translate: 0px 6px; inset: unset; - left: anchor(left); - top: anchor(bottom); margin: var(--cx-spacing-2) 0 0 0; - position-try-fallbacks: --top; + position-area: bottom span-right; + position-try-fallbacks: --bottom-left, --top-right, --top-left, --center-right, --center-left; transition: display 200ms allow-discrete, overlay 200ms allow-discrete, @@ -83,12 +83,30 @@ export class Popover extends LitElement { } } - @position-try --top { - inset: unset; - left: anchor(left); - bottom: anchor(top); + @position-try --bottom-left { + position-area: bottom span-left; + margin: var(--cx-spacing-2) 0 0 0; + } + + @position-try --top-right { + position-area: top span-right; margin: 0 0 var(--cx-spacing-2) 0; } + + @position-try --top-left { + position-area: top span-left; + margin: 0 0 var(--cx-spacing-2) 0; + } + + @position-try --center-right { + position-area: span-bottom right; + margin: 0 0 0 var(--cx-spacing-2); + } + + @position-try --center-left { + position-area: span-bottom left; + margin: 0 var(--cx-spacing-2) 0 0; + } `, ]; @@ -101,6 +119,12 @@ export class Popover extends LitElement { @query('[popover]') private popoverElement!: HTMLDivElement; + @query('button') + private closeButton!: HTMLButtonElement; + + @query('#dialog-content') + private dialogContent!: HTMLSlotElement; + @query('slot[name="trigger"]') private triggerWrapper!: HTMLSlotElement; @@ -118,6 +142,10 @@ export class Popover extends LitElement { if (event.newState === 'open') { this.isOpen = true; this.dispatchEvent(new CustomEvent('open', { bubbles: true, composed: true })); + + if (this.autofocus) { + this.focusFirstElement(); + } } else { (this.triggerWrapper.assignedElements()[0] as HTMLElement).focus(); this.isOpen = false; @@ -125,6 +153,17 @@ export class Popover extends LitElement { } } + private focusFirstElement() { + if (this.withCloseBtn) { + this.closeButton.focus(); + } else { + const focusableElements = getFocusableElements(this.dialogContent); + if (focusableElements.length) { + (focusableElements.at(0) as HTMLElement).focus(); + } + } + } + render() { const closeBtn = html` <button ?hidden=${!this.withCloseBtn} class="cx-btn__tertiary cx-btn__icon" @click=${() => this.popoverElement.hidePopover()}> @@ -144,8 +183,7 @@ export class Popover extends LitElement { <div popover @toggle=${this.popoverToggle}> ${header} - - <slot></slot> + <slot id="dialog-content"></slot> </div> `; } diff --git a/packages/lib/shared/getFocusableElement.ts b/packages/lib/shared/getFocusableElement.ts new file mode 100644 index 0000000..e136e66 --- /dev/null +++ b/packages/lib/shared/getFocusableElement.ts @@ -0,0 +1,13 @@ +const focusableItemMatcher = + 'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'; + +export const getFocusableElements = (slot: HTMLSlotElement) => { + const focusableElements: Element[] = [ + ...slot.assignedElements().filter((slotChild) => slotChild.matches(focusableItemMatcher)), + ...slot.assignedElements().flatMap((slotChild) => [...slotChild.querySelectorAll(focusableItemMatcher)]), + ]; + + return focusableElements.filter( + (element) => !element.hasAttribute('disabled') && !element.hasAttribute('aria-hidden'), + ); +}; From 08a8f1e05cb34ef3929f3218a2b26261a8372ab1 Mon Sep 17 00:00:00 2001 From: Erik Tallang <e.tallang@gmail.com> Date: Tue, 21 Jan 2025 12:54:56 +0100 Subject: [PATCH 6/7] chore(popover): fix lint issues --- packages/lib/components/popover/popover.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lib/components/popover/popover.ts b/packages/lib/components/popover/popover.ts index bb302b0..76e51f2 100644 --- a/packages/lib/components/popover/popover.ts +++ b/packages/lib/components/popover/popover.ts @@ -6,8 +6,8 @@ import { close } from '../icon/iconRegistry'; addIcons(close); import a11yStyles from '../../global-css/a11y.css?inline'; -import buttonStyles from '../button/button.css?inline'; import { getFocusableElements } from '../../shared/getFocusableElement'; +import buttonStyles from '../button/button.css?inline'; @customElement('cx-popover') export class Popover extends LitElement { From 10b858b9d7cb7a624ad9b3656891e743c288d5da Mon Sep 17 00:00:00 2001 From: Erik Tallang <e.tallang@gmail.com> Date: Tue, 28 Jan 2025 19:56:24 +0100 Subject: [PATCH 7/7] feat(popover): add autofocus property --- packages/lib/components/popover/popover.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/lib/components/popover/popover.ts b/packages/lib/components/popover/popover.ts index bb302b0..8208630 100644 --- a/packages/lib/components/popover/popover.ts +++ b/packages/lib/components/popover/popover.ts @@ -1,5 +1,5 @@ import { LitElement, css, html, nothing, unsafeCSS } from 'lit'; -import { customElement, property, query } from 'lit/decorators.js'; +import { customElement, property, query, state } from 'lit/decorators.js'; import { addIcons } from '../icon'; import { close } from '../icon/iconRegistry'; @@ -116,6 +116,9 @@ export class Popover extends LitElement { @property({ type: Boolean, reflect: true }) withCloseBtn = false; + @property({ type: Boolean, reflect: true }) + autofocus = false; + @query('[popover]') private popoverElement!: HTMLDivElement; @@ -166,7 +169,7 @@ export class Popover extends LitElement { render() { const closeBtn = html` - <button ?hidden=${!this.withCloseBtn} class="cx-btn__tertiary cx-btn__icon" @click=${() => this.popoverElement.hidePopover()}> + <button aria-label="Close" ?hidden=${!this.withCloseBtn} class="cx-btn__tertiary cx-btn__icon" @click=${() => this.popoverElement.hidePopover()}> <cx-icon name="close"></cx-icon> </button>`; @@ -181,7 +184,7 @@ export class Popover extends LitElement { return html` <slot class="trigger" name="trigger" @click=${this.onTriggerClick}></slot> - <div popover @toggle=${this.popoverToggle}> + <div role="dialog" popover @toggle=${this.popoverToggle}> ${header} <slot id="dialog-content"></slot> </div>