Skip to content

Commit 63a0352

Browse files
committed
feat: floating panel element
1 parent fd8ee90 commit 63a0352

File tree

16 files changed

+533
-24
lines changed

16 files changed

+533
-24
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- Add Floating panel element
12+
913
### Fixed
1014

1115
- Export tabs element from `package.json` file

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import 'tailwindcss-elements/elements/dropdown';
2929

3030
- [Dialog](./packages/core/src/elements/dialog/README.md)
3131
- [Dropdown](./packages/core/src/elements/dropdown/README.md)
32+
- [Floating panel](./packages/core/src/elements/floating_panel/README.md)
3233
- [Popover](./packages/core/src/elements/popover/README.md)
3334
- [Switch](./packages/core/src/elements/switch/README.md)
3435
- [Tabs](./packages/core/src/elements/tabs/README.md)

packages/core/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
"types": "./dist/elements/dropdown/index.d.ts",
3131
"default": "./dist/elements/dropdown/index.js"
3232
},
33+
"./elements/floating_panel": {
34+
"types": "./dist/elements/floating_panel/index.d.ts",
35+
"default": "./dist/elements/floating_panel/index.js"
36+
},
3337
"./elements/popover": {
3438
"types": "./dist/elements/popover/index.d.ts",
3539
"default": "./dist/elements/popover/index.js"
@@ -60,6 +64,8 @@
6064
},
6165
"dependencies": {
6266
"@ambiki/impulse": "^0.2.0",
67+
"@floating-ui/dom": "^1.5.4",
68+
"composed-offset-position": "^0.0.4",
6369
"tabbable": "^6.2.0"
6470
}
6571
}

packages/core/src/elements/dropdown/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,24 @@ element.open = true;
4343
element.open = false;
4444
```
4545

46+
### Positioning the panel
47+
48+
By default, the positioning logic is not taken care of when you use the `twc-dropdown` element. But, you can do so by
49+
wrapping the trigger and panel with the [`twc-floating-panel`](../floating_panel/README.md) element.
50+
51+
```html
52+
<twc-dropdown class="relative">
53+
<twc-floating-panel>
54+
<button type="button" data-target="twc-dropdown.trigger twc-floating-panel.trigger">Toggle dropdown</button>
55+
<div data-target="twc-dropdown.menu twc-floating-panel.panel" class="absolute hidden data-[headlessui-state='open']:block">
56+
<a href="/dashboard" data-target="twc-dropdown.menuItems">Dashboard</a>
57+
<a href="/settings" data-target="twc-dropdown.menuItems">Settings</a>
58+
<a href="/profile" data-target="twc-dropdown.menuItems">Profile</a>
59+
</div>
60+
</twc-floating-panel>
61+
</twc-dropdown>
62+
```
63+
4664
## Styling different states
4765

4866
Each component exposes the `data-headlessui-state` attribute that you can use to conditionally apply the classes. You

packages/core/src/elements/dropdown/index.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { expect, fixture, html } from '@open-wc/testing';
22
import { sendKeys, sendMouse } from '@web/test-runner-commands';
33
import Sinon from 'sinon';
4+
import FloatingPanelElement from '../floating_panel';
45
import DropdownElement from './index';
56

67
function assertDropdownShown(el: DropdownElement, trigger: Element, menu: Element) {
@@ -489,4 +490,25 @@ describe('Dropdown', () => {
489490
el.deactivateAllMenuItems();
490491
expect(el.activeMenuItem).to.be.undefined;
491492
});
493+
494+
it('starts and stops the positioning logic', async () => {
495+
const el = await fixture<DropdownElement>(html`
496+
<twc-dropdown>
497+
<twc-floating-panel>
498+
<button type="button" data-target="twc-dropdown.trigger twc-floating-panel.trigger">Button</button>
499+
<div data-target="twc-dropdown.menu twc-floating-panel.panel">
500+
<a href="#" data-target="twc-dropdown.menuItems">First</a>
501+
</div>
502+
</twc-floating-panel>
503+
</twc-dropdown>
504+
`);
505+
506+
const floatingPanel = el.querySelector('twc-floating-panel')! as FloatingPanelElement;
507+
508+
el.open = true;
509+
expect(floatingPanel).to.have.attribute('active');
510+
511+
el.open = false;
512+
expect(floatingPanel).not.to.have.attribute('active');
513+
});
492514
});

packages/core/src/elements/dropdown/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,18 @@ export default class DropdownElement extends ImpulseElement {
5656

5757
openChanged(value: boolean) {
5858
if (value) {
59+
if (this.floatingPanel) {
60+
this.floatingPanel.active = true;
61+
}
5962
this.syncState(true);
6063
} else {
6164
this._focusTrap?.abort();
6265
this.syncState(false);
6366
this.menu.removeAttribute('aria-activedescendant');
6467
this.deactivateAllMenuItems();
68+
if (this.floatingPanel) {
69+
this.floatingPanel.active = false;
70+
}
6571
}
6672
}
6773

@@ -294,6 +300,10 @@ export default class DropdownElement extends ImpulseElement {
294300
return this.menuItems.filter((menuItem) => !isDisabled(menuItem));
295301
}
296302

303+
get floatingPanel() {
304+
return this.querySelector('twc-floating-panel');
305+
}
306+
297307
private syncState(state: boolean) {
298308
this.trigger.setAttribute('aria-expanded', state.toString());
299309
this.trigger.setAttribute('data-headlessui-state', state ? 'open' : '');
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Floating panel
2+
3+
A thin wrapper around [Floating UI](https://floating-ui.com/) that positions the floating panel with respect to the
4+
anchor (trigger).
5+
6+
## Imports
7+
8+
```js
9+
import 'tailwindcss-elements/elements/floating_panel';
10+
```
11+
12+
## Usage
13+
14+
```html
15+
<twc-floating-panel>
16+
<button type="button" data-target="twc-floating-panel.trigger">Anchor</button>
17+
<div data-target="twc-floating-panel.panel">Contents!</div>
18+
</twc-floating-panel>
19+
```
20+
21+
```js
22+
const element = document.querySelector('twc-floating-panel');
23+
24+
// Start the positioning logic
25+
element.active = true;
26+
27+
// Stop the positioning logic
28+
element.active = false;
29+
30+
// Update the position of the panel
31+
await element.position();
32+
```
33+
34+
## Examples
35+
36+
### Setting the initial position
37+
38+
By default, the initial placement is set to `bottom-start`, but you can set it to one of `top`, `top-start`, `top-end`,
39+
`right`, `right-start`, `right-end`, `bottom`, `bottom-start`, `bottom-end`, `left`, `left-start`, or `left-end` by
40+
setting the `placement` attibute on the element.
41+
42+
```html
43+
<twc-floating-panel placement="top-start">
44+
...
45+
</twc-floating-panel>
46+
```
47+
48+
For more info, visit the [official docs](https://floating-ui.com/docs/computePosition#placement).
49+
50+
### Setting the positioning strategy
51+
52+
By default, the positioning strategy is set as `absolute`, but you can set it to one of `absolute` or `fixed` by
53+
setting the `strategy` attribute on the element.
54+
55+
```html
56+
<twc-floating-panel strategy="fixed">
57+
...
58+
</twc-floating-panel>
59+
```
60+
61+
For more info, visit the [official docs](https://floating-ui.com/docs/computeposition#strategy).
62+
63+
### Adding space between the trigger and the panel element
64+
65+
You can set the `offset-options` attribute as a number of a JSON object.
66+
67+
```html
68+
<twc-floating-panel offset-options="10">
69+
...
70+
</twc-floating-panel>
71+
72+
<twc-floating-panel offset-options='{"mainAxis": 10, "crossAxis": 20}'>
73+
...
74+
</twc-floating-panel>
75+
```
76+
77+
For more info, visit the [official docs](https://floating-ui.com/docs/offset).
78+
79+
### Syncing the width, height, or both with respect to the trigger element
80+
81+
You can set the `sync` attribute on the element which accepts one of `width`, `height`, or `both`.
82+
83+
- `width` -> Make the width of the panel equal to that of the trigger.
84+
- `height` -> Make the height of the panel equal to that of the trigger.
85+
- `both` -> Make both the width and height of the panel equal to that of the trigger.
86+
87+
```html
88+
<twc-floating-panel sync="width">
89+
...
90+
</twc-floating-panel>
91+
```
92+
93+
## Events
94+
95+
| Name | Bubbles | Description |
96+
| ------ | --------- | ------------ |
97+
| `twc-floating-panel:changed` | `true` | This event fires when the panel is positioned with respect to the trigger element. |
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
2+
import FloatingPanelElement from './index';
3+
import Sinon from 'sinon';
4+
5+
describe('Floating panel', () => {
6+
it('starts and stops the positioning of the panel', async () => {
7+
const el = await fixture<FloatingPanelElement>(html`
8+
<twc-floating-panel>
9+
<button type="button" data-target="twc-floating-panel.trigger">Trigger</button>
10+
<div data-target="twc-floating-panel.panel"></div>
11+
</twc-floating-panel>
12+
`);
13+
14+
const panel = el.querySelector('div')!;
15+
16+
expect(el).not.to.have.attribute('active');
17+
expect(el).not.to.have.attribute('data-current-placement');
18+
19+
el.active = true;
20+
expect(el).to.have.attribute('active');
21+
await waitUntil(() => el.hasAttribute('data-current-placement'));
22+
expect(el).to.have.attribute('data-current-placement', 'bottom-start');
23+
expect(panel).to.have.style('position', 'absolute');
24+
25+
el.active = false;
26+
expect(el).not.to.have.attribute('active');
27+
expect(el).not.to.have.attribute('data-current-placement');
28+
});
29+
30+
it('setting the initial placement', async () => {
31+
const el = await fixture<FloatingPanelElement>(html`
32+
<twc-floating-panel placement="bottom-end">
33+
<button type="button" data-target="twc-floating-panel.trigger">Trigger</button>
34+
<div data-target="twc-floating-panel.panel"></div>
35+
</twc-floating-panel>
36+
`);
37+
38+
el.active = true;
39+
await waitUntil(() => el.hasAttribute('data-current-placement'));
40+
expect(el).to.have.attribute('data-current-placement', 'bottom-end');
41+
});
42+
43+
it('setting the positioning strategy', async () => {
44+
const el = await fixture<FloatingPanelElement>(html`
45+
<twc-floating-panel strategy="fixed">
46+
<button type="button" data-target="twc-floating-panel.trigger">Trigger</button>
47+
<div data-target="twc-floating-panel.panel"></div>
48+
</twc-floating-panel>
49+
`);
50+
51+
const panel = el.querySelector('div')!;
52+
53+
el.active = true;
54+
expect(panel).to.have.style('position', 'fixed');
55+
});
56+
57+
it('emits the changed event after positioning the panel', async () => {
58+
const el = await fixture<FloatingPanelElement>(html`
59+
<twc-floating-panel>
60+
<button type="button" data-target="twc-floating-panel.trigger">Trigger</button>
61+
<div data-target="twc-floating-panel.panel"></div>
62+
</twc-floating-panel>
63+
`);
64+
65+
const handler = Sinon.spy();
66+
document.addEventListener(`${el.identifier}:changed`, handler);
67+
68+
el.active = true;
69+
await waitUntil(() => el.hasAttribute('data-current-placement'));
70+
expect(handler.called).to.be.true;
71+
});
72+
});

0 commit comments

Comments
 (0)