Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement popover #27

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# 0.0.9 - xx.01.2025

- Lagt til select-komponent
- Lagt til popover-komponent

# 0.0.8 - 13.01.2025

Expand Down
53 changes: 53 additions & 0 deletions packages/lib/components/popover/Overview.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Canvas, Meta, Source, Subtitle, Title } from "@storybook/blocks";

import * as stories from "./popover.stories";

<Meta of={stories} />

<Title />
<Subtitle>
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.
``` ts
// Web component
import '@computas/designsystem/popover';

// React
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 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)) {
import("https://unpkg.com/@oddbird/css-anchor-positioning");
}
</script>
```
Read more about this polyfill [here](https://github.com/oddbird/css-anchor-positioning).

## Default
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.
<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} />
1 change: 1 addition & 0 deletions packages/lib/components/popover/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './popover';
89 changes: 89 additions & 0 deletions packages/lib/components/popover/popover.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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',
parameters: {
actions: {
handles: ['open', 'close'],
},
},
decorators: [withActions],
} satisfies Meta;

export const Default: StoryObj = {
render: () => html`
<cx-popover>
<button slot="trigger" class="cx-btn__secondary cx-btn__icon">
<cx-icon name="edit"></cx-icon>
</button>

<p>Here comes the popover content</p>
</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>

<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 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>
`,
};

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 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>
`,
};
196 changes: 196 additions & 0 deletions packages/lib/components/popover/popover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
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';
addIcons(close);

import a11yStyles from '../../global-css/a11y.css?inline';
import { getFocusableElements } from '../../shared/getFocusableElement';
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);
}

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;
}

[hidden] {
visibility: hidden;
}

[popover] {
--translate-curve: ease;
--translate-duration: 200ms;

position-anchor: --trigger;
position: absolute;
opacity: 0;
translate: 0px 6px;
inset: unset;
margin: var(--cx-spacing-2) 0 0 0;
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,
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 --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;
}
`,
];

@property({ type: String, reflect: true })
header = '';

@property({ type: Boolean, reflect: true })
withCloseBtn = false;

@query('[popover]')
private popoverElement!: HTMLDivElement;

@query('button')
private closeButton!: HTMLButtonElement;

@query('#dialog-content')
private dialogContent!: HTMLSlotElement;

@query('slot[name="trigger"]')
private triggerWrapper!: HTMLSlotElement;

private isOpen = false;

private onTriggerClick() {
if (this.isOpen) {
this.popoverElement.hidePopover();
} else {
this.popoverElement.showPopover();
}
}

private popoverToggle(event: ToggleEvent) {
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;
this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }));
}
}

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()}>
<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}
<slot id="dialog-content"></slot>
</div>
`;
}
}

declare global {
interface HTMLElementTagNameMap {
'cx-popover': Popover;
}
}
10 changes: 10 additions & 0 deletions packages/lib/components/popover/react.ts
Original file line number Diff line number Diff line change
@@ -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,
});
13 changes: 13 additions & 0 deletions packages/lib/shared/getFocusableElement.ts
Original file line number Diff line number Diff line change
@@ -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'),
);
};
Loading