@dile/ui 3.2.1 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/selector/__screenshots__/selector-overlay.spec.js/dile-selector-overlay-by-default--the-trigger-label-follows-the-selected-item-text-and-updates-when-selection-changes-1.png +0 -0
- package/components/selector/__screenshots__/selector-overlay.spec.js/dile-selector-overlay-renders-a-bare-rounded-icon-trigger-with-the-default-icon-when-label-is-empty-1.png +0 -0
- package/components/selector/index.js +2 -1
- package/components/selector/selector-overlay.js +3 -0
- package/components/selector/selector-overlay.spec.js +139 -0
- package/components/selector/src/DileSelectorOverlay.js +133 -0
- package/package.json +2 -2
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import './selector-overlay.js';
|
|
3
|
+
import './selector-item.js';
|
|
4
|
+
|
|
5
|
+
describe('dile-selector-overlay', () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
document.body.innerHTML = '';
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
async function renderSelectorOverlay(html) {
|
|
11
|
+
document.body.innerHTML = html;
|
|
12
|
+
const el = document.body.querySelector('dile-selector-overlay');
|
|
13
|
+
await el.updateComplete;
|
|
14
|
+
return el;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function wait(ms) {
|
|
18
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
it('renders a bare rounded icon trigger with the default icon when label is empty', async () => {
|
|
22
|
+
const el = await renderSelectorOverlay(`
|
|
23
|
+
<dile-selector-overlay>
|
|
24
|
+
<dile-selector-item>One</dile-selector-item>
|
|
25
|
+
<dile-selector-item>Two</dile-selector-item>
|
|
26
|
+
</dile-selector-overlay>
|
|
27
|
+
`);
|
|
28
|
+
|
|
29
|
+
const trigger = el.shadowRoot.getElementById('trigger');
|
|
30
|
+
const icon = trigger.querySelector('dile-lucide-icon');
|
|
31
|
+
expect(icon).toBeTruthy();
|
|
32
|
+
expect(icon.getAttribute('icon')).toBe('ellipsis-vertical');
|
|
33
|
+
expect(icon.hasAttribute('rounded')).toBe(true);
|
|
34
|
+
// dile-lucide-icon forwards `rounded` to the inner icon it renders in its own shadow DOM
|
|
35
|
+
expect(icon.shadowRoot.querySelector('[rounded]')).toBeTruthy();
|
|
36
|
+
expect(trigger.querySelector('dile-lucide-badge')).toBeFalsy();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('by default, label is shown as static text regardless of the selection', async () => {
|
|
40
|
+
const el = await renderSelectorOverlay(`
|
|
41
|
+
<dile-selector-overlay label="Menu" selected="0">
|
|
42
|
+
<dile-selector-item>One</dile-selector-item>
|
|
43
|
+
<dile-selector-item>Two</dile-selector-item>
|
|
44
|
+
</dile-selector-overlay>
|
|
45
|
+
`);
|
|
46
|
+
|
|
47
|
+
const trigger = el.shadowRoot.getElementById('trigger');
|
|
48
|
+
const badge = trigger.querySelector('dile-lucide-badge');
|
|
49
|
+
expect(badge).toBeTruthy();
|
|
50
|
+
expect(badge.textContent.trim()).toBe('Menu');
|
|
51
|
+
expect(badge.getAttribute('icon')).toBe('ellipsis-vertical');
|
|
52
|
+
expect(trigger.querySelector('dile-lucide-icon')).toBeFalsy();
|
|
53
|
+
|
|
54
|
+
el.querySelectorAll('dile-selector-item')[1].shadowRoot.querySelector('article').click();
|
|
55
|
+
await el.updateComplete;
|
|
56
|
+
|
|
57
|
+
expect(trigger.querySelector('dile-lucide-badge').textContent.trim()).toBe('Menu');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('with dynamicLabel, the trigger label follows the selected item text and updates when selection changes', async () => {
|
|
61
|
+
const el = await renderSelectorOverlay(`
|
|
62
|
+
<dile-selector-overlay dynamicLabel selected="1">
|
|
63
|
+
<dile-selector-item>One</dile-selector-item>
|
|
64
|
+
<dile-selector-item>Two</dile-selector-item>
|
|
65
|
+
</dile-selector-overlay>
|
|
66
|
+
`);
|
|
67
|
+
|
|
68
|
+
const trigger = el.shadowRoot.getElementById('trigger');
|
|
69
|
+
expect(trigger.querySelector('dile-lucide-badge').textContent.trim()).toBe('Two');
|
|
70
|
+
|
|
71
|
+
el.querySelectorAll('dile-selector-item')[0].shadowRoot.querySelector('article').click();
|
|
72
|
+
await el.updateComplete;
|
|
73
|
+
|
|
74
|
+
expect(trigger.querySelector('dile-lucide-badge').textContent.trim()).toBe('One');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('with dynamicLabel and nothing selected, label falls back to the label property', async () => {
|
|
78
|
+
const el = await renderSelectorOverlay(`
|
|
79
|
+
<dile-selector-overlay dynamicLabel label="Choose an option">
|
|
80
|
+
<dile-selector-item>One</dile-selector-item>
|
|
81
|
+
<dile-selector-item>Two</dile-selector-item>
|
|
82
|
+
</dile-selector-overlay>
|
|
83
|
+
`);
|
|
84
|
+
|
|
85
|
+
const trigger = el.shadowRoot.getElementById('trigger');
|
|
86
|
+
expect(trigger.querySelector('dile-lucide-badge').textContent.trim()).toBe('Choose an option');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('uses a custom icon name when the icon property is set', async () => {
|
|
90
|
+
const el = await renderSelectorOverlay(`
|
|
91
|
+
<dile-selector-overlay icon="star" label="Two">
|
|
92
|
+
<dile-selector-item>One</dile-selector-item>
|
|
93
|
+
<dile-selector-item>Two</dile-selector-item>
|
|
94
|
+
</dile-selector-overlay>
|
|
95
|
+
`);
|
|
96
|
+
|
|
97
|
+
const trigger = el.shadowRoot.getElementById('trigger');
|
|
98
|
+
expect(trigger.querySelector('dile-lucide-badge').getAttribute('icon')).toBe('star');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('opens the internal dile-menu-overlay when the trigger is clicked', async () => {
|
|
102
|
+
const el = await renderSelectorOverlay(`
|
|
103
|
+
<dile-selector-overlay>
|
|
104
|
+
<dile-selector-item>One</dile-selector-item>
|
|
105
|
+
<dile-selector-item>Two</dile-selector-item>
|
|
106
|
+
</dile-selector-overlay>
|
|
107
|
+
`);
|
|
108
|
+
|
|
109
|
+
const menuOverlay = el.shadowRoot.querySelector('dile-menu-overlay');
|
|
110
|
+
el.shadowRoot.getElementById('trigger').click();
|
|
111
|
+
await wait(100);
|
|
112
|
+
|
|
113
|
+
expect(menuOverlay.shadowRoot.getElementById('overlay').classList.contains('opened')).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('selecting an item dispatches dile-selected-changed and closes the overlay', async () => {
|
|
117
|
+
const el = await renderSelectorOverlay(`
|
|
118
|
+
<dile-selector-overlay>
|
|
119
|
+
<dile-selector-item>One</dile-selector-item>
|
|
120
|
+
<dile-selector-item>Two</dile-selector-item>
|
|
121
|
+
</dile-selector-overlay>
|
|
122
|
+
`);
|
|
123
|
+
|
|
124
|
+
const menuOverlay = el.shadowRoot.querySelector('dile-menu-overlay');
|
|
125
|
+
el.shadowRoot.getElementById('trigger').click();
|
|
126
|
+
await wait(100);
|
|
127
|
+
|
|
128
|
+
let detail = null;
|
|
129
|
+
el.addEventListener('dile-selected-changed', (e) => { detail = e.detail; });
|
|
130
|
+
|
|
131
|
+
const items = el.querySelectorAll('dile-selector-item');
|
|
132
|
+
items[1].shadowRoot.querySelector('article').click();
|
|
133
|
+
await el.updateComplete;
|
|
134
|
+
|
|
135
|
+
expect(detail.selected).toBe(1);
|
|
136
|
+
expect(el.selected).toBe(1);
|
|
137
|
+
expect(menuOverlay.shadowRoot.getElementById('overlay').classList.contains('opened')).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { html, css, LitElement } from "lit";
|
|
2
|
+
import { DileSelectable } from "../../../mixins/selectable/index.js";
|
|
3
|
+
import "../../menu-overlay/menu-overlay.js";
|
|
4
|
+
import "@dile/iconlib/dile-lucide-badge.js";
|
|
5
|
+
import "@dile/iconlib/dile-lucide-icon.js";
|
|
6
|
+
import "@dile/iconlib/lucide-icons/ellipsis-vertical.js";
|
|
7
|
+
|
|
8
|
+
export class DileSelectorOverlay extends DileSelectable(LitElement) {
|
|
9
|
+
|
|
10
|
+
static get properties() {
|
|
11
|
+
return {
|
|
12
|
+
/** Text shown next to the trigger icon. When null or empty, only the icon is shown.
|
|
13
|
+
* Fixed by default; see dynamicLabel to have it follow the selected item's text instead. */
|
|
14
|
+
label: { type: String },
|
|
15
|
+
|
|
16
|
+
/** When true, label automatically follows the selected item's text instead of staying fixed */
|
|
17
|
+
dynamicLabel: { type: Boolean },
|
|
18
|
+
|
|
19
|
+
/** Lucide icon name used in the trigger. Defaults to "ellipsis-vertical" */
|
|
20
|
+
icon: { type: String },
|
|
21
|
+
|
|
22
|
+
_displayLabel: { state: true },
|
|
23
|
+
|
|
24
|
+
/** Accessible label for the trigger when there is no visible label text */
|
|
25
|
+
triggerAriaLabel: { type: String },
|
|
26
|
+
|
|
27
|
+
/** Forwarded to the internal dile-menu-overlay */
|
|
28
|
+
horizontalAlign: { type: String },
|
|
29
|
+
|
|
30
|
+
/** Forwarded to the internal dile-menu-overlay */
|
|
31
|
+
verticalAlign: { type: String },
|
|
32
|
+
|
|
33
|
+
/** Forwarded to the internal dile-menu-overlay */
|
|
34
|
+
moveTop: { type: Number },
|
|
35
|
+
|
|
36
|
+
/** Forwarded to the internal dile-menu-overlay */
|
|
37
|
+
moveLeft: { type: Number },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static get styles() {
|
|
42
|
+
return css`
|
|
43
|
+
:host {
|
|
44
|
+
display: inline-block;
|
|
45
|
+
}
|
|
46
|
+
#trigger {
|
|
47
|
+
display: inline-flex;
|
|
48
|
+
cursor: pointer;
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
constructor() {
|
|
54
|
+
super();
|
|
55
|
+
this.label = null;
|
|
56
|
+
this.dynamicLabel = false;
|
|
57
|
+
this.icon = 'ellipsis-vertical';
|
|
58
|
+
this._displayLabel = null;
|
|
59
|
+
this.triggerAriaLabel = 'Options';
|
|
60
|
+
this.horizontalAlign = 'under_left';
|
|
61
|
+
this.verticalAlign = 'bottom';
|
|
62
|
+
this.moveTop = 0;
|
|
63
|
+
this.moveLeft = 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
render() {
|
|
67
|
+
return html`
|
|
68
|
+
<dile-menu-overlay
|
|
69
|
+
horizontalAlign="${this.horizontalAlign}"
|
|
70
|
+
verticalAlign="${this.verticalAlign}"
|
|
71
|
+
moveTop="${this.moveTop}"
|
|
72
|
+
moveLeft="${this.moveLeft}"
|
|
73
|
+
closeOnClickInside
|
|
74
|
+
>
|
|
75
|
+
<span
|
|
76
|
+
slot="trigger"
|
|
77
|
+
id="trigger"
|
|
78
|
+
role="button"
|
|
79
|
+
tabindex="0"
|
|
80
|
+
aria-haspopup="true"
|
|
81
|
+
aria-label="${this._displayLabel || this.triggerAriaLabel}"
|
|
82
|
+
@keydown="${this._onTriggerKeydown}"
|
|
83
|
+
>
|
|
84
|
+
${this._displayLabel
|
|
85
|
+
? html`<dile-lucide-badge icon="${this.icon}">${this._displayLabel}</dile-lucide-badge>`
|
|
86
|
+
: html`<dile-lucide-icon icon="${this.icon}" rounded></dile-lucide-icon>`
|
|
87
|
+
}
|
|
88
|
+
</span>
|
|
89
|
+
<div slot="content">
|
|
90
|
+
<slot></slot>
|
|
91
|
+
</div>
|
|
92
|
+
</dile-menu-overlay>
|
|
93
|
+
`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
willUpdate(changedProperties) {
|
|
97
|
+
this._updateDisplayLabel();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
firstUpdated(changedProperties) {
|
|
101
|
+
super.firstUpdated(changedProperties);
|
|
102
|
+
// On the very first render the <slot> didn't exist yet when willUpdate ran,
|
|
103
|
+
// so the initial selection couldn't be resolved. Recompute now that it does.
|
|
104
|
+
this._updateDisplayLabel();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
_updateDisplayLabel() {
|
|
108
|
+
if (!this.dynamicLabel) {
|
|
109
|
+
this._displayLabel = this.label;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
// Computed directly from `selected`/`attrForSelected` (mirroring DileSelectable.setSelectedItem)
|
|
113
|
+
// instead of reading `_items[].selected`, so the trigger label is correct on the very same
|
|
114
|
+
// render pass, without waiting for an extra update cycle.
|
|
115
|
+
const slot = this.shadowRoot.querySelector('slot');
|
|
116
|
+
const items = slot ? slot.assignedElements({ flatten: true }) : [];
|
|
117
|
+
let selectedItem;
|
|
118
|
+
if (this.attrForSelected) {
|
|
119
|
+
selectedItem = items.find((item) => item.getAttribute(this.attrForSelected) == this.selected);
|
|
120
|
+
} else {
|
|
121
|
+
const selectedIndex = parseInt(this.selected);
|
|
122
|
+
selectedItem = !isNaN(selectedIndex) ? items[selectedIndex] : undefined;
|
|
123
|
+
}
|
|
124
|
+
this._displayLabel = selectedItem ? selectedItem.textContent.trim() : this.label;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_onTriggerKeydown(e) {
|
|
128
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
e.currentTarget.click();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dile/ui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "UI Core components from dile-components.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -27,5 +27,5 @@
|
|
|
27
27
|
"publishConfig": {
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
|
-
"gitHead": "
|
|
30
|
+
"gitHead": "e123ebdc49cb479a03e4b5689ddc6fbfb4ab39b1"
|
|
31
31
|
}
|