@lion/ui 0.0.1 → 0.0.2

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.
@@ -0,0 +1,217 @@
1
+ import { html } from 'lit';
2
+ import { LionCollapsible } from '@lion/ui/collapsible.js';
3
+ import { drawerStyle } from './drawerStyle.js';
4
+
5
+ const EVENT = {
6
+ TRANSITION_END: 'transitionend',
7
+ TRANSITION_START: 'transitionstart',
8
+ };
9
+
10
+ export class LionDrawer extends LionCollapsible {
11
+ static get properties() {
12
+ return {
13
+ transitioning: {
14
+ type: Boolean,
15
+ reflect: true,
16
+ },
17
+ opened: {
18
+ type: Boolean,
19
+ reflect: true,
20
+ },
21
+ position: {
22
+ type: String,
23
+ reflect: true,
24
+ },
25
+ };
26
+ }
27
+
28
+ constructor() {
29
+ super();
30
+
31
+ /** @private */
32
+ this.__toggle = () => {
33
+ this.opened = !this.opened;
34
+ };
35
+ }
36
+
37
+ connectedCallback() {
38
+ super.connectedCallback();
39
+
40
+ if (!this.hasAttribute('position')) {
41
+ this.position = 'left';
42
+ }
43
+
44
+ if (this._contentNode) {
45
+ this._contentNode.style.setProperty('display', '');
46
+ }
47
+
48
+ this.__setBoundaries();
49
+ }
50
+
51
+ /**
52
+ * Update aria labels on state change.
53
+ * @param {import('lit').PropertyValues } changedProperties
54
+ */
55
+ updated(changedProperties) {
56
+ if (changedProperties.has('opened')) {
57
+ this._openedChanged();
58
+ }
59
+ }
60
+
61
+ static get styles() {
62
+ return [drawerStyle];
63
+ }
64
+
65
+ __setBoundaries() {
66
+ const host = this.shadowRoot?.host;
67
+
68
+ if (this.position === 'top') {
69
+ this.minHeight = host ? getComputedStyle(host).getPropertyValue('--min-height') : '0px';
70
+ this.maxHeight = host ? getComputedStyle(host).getPropertyValue('--max-height') : '0px';
71
+ this.minWidth = '0px';
72
+ this.maxWidth = 'none';
73
+ } else {
74
+ this.minWidth = host ? getComputedStyle(host).getPropertyValue('--min-width') : '0px';
75
+ this.maxWidth = host ? getComputedStyle(host).getPropertyValue('--max-width') : '0px';
76
+ this.minHeight = 'auto';
77
+ this.maxHeight = 'fit-content';
78
+ }
79
+
80
+ setTimeout(() => {
81
+ const prop = this.position === 'top' ? 'width' : 'height';
82
+
83
+ if (this.__contentNode) {
84
+ this.__contentNode.style.setProperty(prop, '');
85
+ }
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Setter for position property, available values are 'top', 'left' and 'right'
91
+ * @param {String} position
92
+ */
93
+ set position(position) {
94
+ const stale = this.position;
95
+ this._position = position;
96
+ this.setAttribute('position', position);
97
+
98
+ this.__setBoundaries();
99
+ this.requestUpdate('position', stale);
100
+ }
101
+
102
+ get position() {
103
+ return this._position ?? 'left';
104
+ }
105
+
106
+ /**
107
+ * Trigger show animation and wait for transition to be finished.
108
+ * @param {Object} options - element node and its options
109
+ * @param {HTMLElement} options.contentNode
110
+ * @override
111
+ */
112
+ async _showAnimation({ contentNode }) {
113
+ const min = this.position === 'top' ? this.minHeight : this.minWidth;
114
+ const max = this.position === 'top' ? this.maxHeight : this.maxWidth;
115
+ const prop = this.position === 'top' ? 'height' : 'width';
116
+
117
+ contentNode.style.setProperty(prop, /** @type {string} */ (min));
118
+ await new Promise(resolve => requestAnimationFrame(() => resolve(true)));
119
+ contentNode.style.setProperty(prop, /** @type {string} */ (max));
120
+ await this._waitForTransition({ contentNode });
121
+ }
122
+
123
+ /**
124
+ * Trigger hide animation and wait for transition to be finished.
125
+ * @param {Object} options - element node and its options
126
+ * @param {HTMLElement} options.contentNode
127
+ * @override
128
+ */
129
+ async _hideAnimation({ contentNode }) {
130
+ if (
131
+ ((this.position === 'left' || this.position === 'right') &&
132
+ this._contentWidth === this.minWidth) ||
133
+ (this.position === 'top' && this._contentHeight === this.minHeight)
134
+ ) {
135
+ return;
136
+ }
137
+
138
+ const min = this.position === 'top' ? this.minHeight : this.minWidth;
139
+ const prop = this.position === 'top' ? 'height' : 'width';
140
+
141
+ contentNode.style.setProperty(prop, /** @type {string} */ (min));
142
+ await this._waitForTransition({ contentNode });
143
+ }
144
+
145
+ /**
146
+ * Wait until the transition event is finished.
147
+ * @param {Object} options - element node and its options
148
+ * @param {HTMLElement} options.contentNode
149
+ * @returns {Promise<void>} transition event
150
+ */
151
+ _waitForTransition({ contentNode }) {
152
+ return new Promise(resolve => {
153
+ const transitionStarted = () => {
154
+ contentNode.removeEventListener(EVENT.TRANSITION_START, transitionStarted);
155
+ this.transitioning = true;
156
+ };
157
+ contentNode.addEventListener(EVENT.TRANSITION_START, transitionStarted);
158
+
159
+ const transitionEnded = () => {
160
+ contentNode.removeEventListener(EVENT.TRANSITION_END, transitionEnded);
161
+ this.transitioning = false;
162
+ resolve();
163
+ };
164
+ contentNode.addEventListener(EVENT.TRANSITION_END, transitionEnded);
165
+ });
166
+ }
167
+
168
+ /**
169
+ * @protected
170
+ */
171
+ get __contentNode() {
172
+ return /** @type {HTMLElement} */ (this.shadowRoot?.querySelector('.container'));
173
+ }
174
+
175
+ get _contentWidth() {
176
+ const size = this.__contentNode?.getBoundingClientRect().width || 0;
177
+ return `${size}px`;
178
+ }
179
+
180
+ get _contentHeight() {
181
+ const size = this.__contentNode?.getBoundingClientRect().height || 0;
182
+ return `${size}px`;
183
+ }
184
+
185
+ _openedChanged() {
186
+ this._updateContentSize();
187
+ if (this._invokerNode) {
188
+ this._invokerNode.setAttribute('aria-expanded', `${this.opened}`);
189
+ }
190
+
191
+ this.dispatchEvent(new CustomEvent('opened-changed'));
192
+ }
193
+
194
+ async _updateContentSize() {
195
+ if (this.__contentNode) {
196
+ if (this.opened) {
197
+ await this._showAnimation({ contentNode: this.__contentNode });
198
+ } else {
199
+ await this._hideAnimation({ contentNode: this.__contentNode });
200
+ }
201
+ }
202
+ }
203
+
204
+ render() {
205
+ return html`
206
+ <div class="container">
207
+ <div class="headline-container">
208
+ <slot name="invoker"></slot>
209
+ <slot name="headline"></slot>
210
+ </div>
211
+ <div class="content-container">
212
+ <slot name="content"></slot>
213
+ </div>
214
+ </div>
215
+ `;
216
+ }
217
+ }
@@ -0,0 +1,59 @@
1
+ import { css } from 'lit';
2
+
3
+ export const drawerStyle = css`
4
+ :host {
5
+ display: block;
6
+ height: 100%;
7
+ --min-width: 72px;
8
+ --max-width: 320px;
9
+ --min-height: auto;
10
+ --max-height: fit-content;
11
+ --start-width: var(--min-width);
12
+ --start-height: 100%;
13
+ --transition-property: width;
14
+ }
15
+
16
+ :host([position='top']) {
17
+ width: 100%;
18
+ --min-width: 0px;
19
+ --max-width: none;
20
+ --min-height: 50px;
21
+ --max-height: 200px;
22
+ --start-width: 100%;
23
+ --start-height: var(--min-height);
24
+ --transition-property: height;
25
+ }
26
+
27
+ .container {
28
+ display: flex;
29
+ flex-direction: column;
30
+ width: var(--start-width);
31
+ height: var(--start-height);
32
+ min-width: var(--min-width);
33
+ max-width: var(--max-width);
34
+ min-height: var(--min-height);
35
+ max-height: var(--max-height);
36
+ overflow: hidden;
37
+ box-sizing: border-box;
38
+ transition: var(--transition-property) 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
39
+ }
40
+
41
+ .headline-container {
42
+ display: flex;
43
+ align-items: center;
44
+ flex-wrap: nowrap;
45
+ }
46
+
47
+ :host([position='right']) .headline-container {
48
+ flex-direction: row-reverse;
49
+ }
50
+
51
+ .content-container {
52
+ overflow: hidden;
53
+ flex-grow: 1;
54
+ }
55
+
56
+ ::slotted([slot='content']) {
57
+ width: var(--max-width);
58
+ }
59
+ `;
@@ -0,0 +1,109 @@
1
+ import { expect, fixture as _fixture } from '@open-wc/testing';
2
+ import { html } from 'lit/static-html.js';
3
+ import '@lion/ui/define/lion-drawer.js';
4
+
5
+ /**
6
+ * @typedef {import('../src/LionDrawer.js').LionDrawer} LionDrawer
7
+ * @typedef {import('lit').TemplateResult} TemplateResult
8
+ */
9
+ const fixture = /** @type {(arg: TemplateResult) => Promise<LionDrawer>} */ (_fixture);
10
+
11
+ const template = html`
12
+ <lion-drawer>
13
+ <button slot="invoker">Open</button>
14
+ <p slot="headline">Headline</p>
15
+ <div slot="content" class="drawer">This is the content of the drawer</div>
16
+ </lion-drawer>
17
+ `;
18
+
19
+ describe('<lion-drawer>', () => {
20
+ describe('Drawer', () => {
21
+ it('sets position to "left" by default', async () => {
22
+ const drawer = await fixture(template);
23
+ expect(drawer.position).to.equal('left');
24
+ });
25
+
26
+ it('has [position] attribute which serves as styling hook', async () => {
27
+ const drawer = await fixture(template);
28
+ expect(drawer).to.have.attribute('position').equal('left');
29
+ });
30
+
31
+ it('sets the minimum and maximum width when position=left', async () => {
32
+ const drawer = await fixture(template);
33
+ const minWidth = getComputedStyle(drawer).getPropertyValue('--min-width');
34
+ const maxWidth = getComputedStyle(drawer).getPropertyValue('--max-width');
35
+
36
+ expect(drawer.minWidth).to.equal(minWidth);
37
+ expect(drawer.maxWidth).to.equal(maxWidth);
38
+ });
39
+
40
+ it('sets the minimum and maximum width when position=right', async () => {
41
+ const drawer = await fixture(template);
42
+ drawer.position = 'right';
43
+ await drawer.updateComplete;
44
+
45
+ const minWidth = getComputedStyle(drawer).getPropertyValue('--min-width');
46
+ const maxWidth = getComputedStyle(drawer).getPropertyValue('--max-width');
47
+
48
+ expect(drawer.minWidth).to.equal(minWidth);
49
+ expect(drawer.maxWidth).to.equal(maxWidth);
50
+ });
51
+
52
+ it('sets the minimum and maximum height when position=top', async () => {
53
+ const drawer = await fixture(template);
54
+ drawer.position = 'top';
55
+ await drawer.updateComplete;
56
+
57
+ const minHeight = getComputedStyle(drawer).getPropertyValue('--min-height');
58
+ const maxHeight = getComputedStyle(drawer).getPropertyValue('--max-height');
59
+
60
+ expect(drawer.minHeight).to.equal(minHeight);
61
+ expect(drawer.maxHeight).to.equal(maxHeight);
62
+ });
63
+ });
64
+
65
+ describe('Accessibility', () => {
66
+ it('[collapsed] is a11y AXE accessible', async () => {
67
+ const drawer = await fixture(template);
68
+ await expect(drawer).to.be.accessible();
69
+ });
70
+
71
+ it('[opened] is a11y AXE accessible', async () => {
72
+ const drawer = await fixture(template);
73
+ drawer.opened = true;
74
+ await expect(drawer).to.be.accessible();
75
+ });
76
+
77
+ describe('Invoker', () => {
78
+ it('links id of content items to invoker via [aria-controls]', async () => {
79
+ const drawerElement = await fixture(template);
80
+ const invoker = drawerElement.querySelector('[slot=invoker]');
81
+ const content = drawerElement.querySelector('[slot=content]');
82
+ expect(invoker?.getAttribute('aria-controls')).to.equal(content?.id);
83
+ });
84
+
85
+ it('adds aria-expanded="false" to invoker when its content is not expanded', async () => {
86
+ const drawerElement = await fixture(template);
87
+ const invoker = drawerElement.querySelector('[slot=invoker]');
88
+ expect(invoker).to.have.attribute('aria-expanded', 'false');
89
+ });
90
+
91
+ it('adds aria-expanded="true" to invoker when its content is expanded', async () => {
92
+ const drawerElement = await fixture(template);
93
+ const invoker = drawerElement.querySelector('[slot=invoker]');
94
+ drawerElement.opened = true;
95
+ await drawerElement.updateComplete;
96
+ expect(invoker).to.have.attribute('aria-expanded', 'true');
97
+ });
98
+ });
99
+
100
+ describe('Contents', () => {
101
+ it('adds aria-labelledby referring to invoker id', async () => {
102
+ const drawerElement = await fixture(template);
103
+ const invoker = drawerElement.querySelector('[slot=invoker]');
104
+ const content = drawerElement.querySelector('[slot=content]');
105
+ expect(content).to.have.attribute('aria-labelledby', invoker?.id);
106
+ });
107
+ });
108
+ });
109
+ });