@dile/ui 3.18.1 → 3.18.3

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.
@@ -1,47 +1,41 @@
1
1
  import { LitElement, html, css } from 'lit';
2
+ import { DileOverlay } from '../../../mixins/overlay/index.js';
3
+ import { DileCloseOnEscPressed } from '../../../mixins/close-on-esc-pressed/index.js';
2
4
  import './DileFloatingMenuItem.js';
3
5
 
4
- export class DileFloatingMenu extends LitElement {
6
+ // Distance between the floating button and the overlay.
7
+ const OVERLAY_GAP = 10;
8
+
9
+ // DileOverlay places the overlay next to the button and keeps it inside the viewport.
10
+ // The opened property is the source of truth: open(), close() and toggle() only change it.
11
+ export class DileFloatingMenu extends DileOverlay(DileCloseOnEscPressed(LitElement)) {
5
12
  static styles = [
6
13
  css`
7
14
  :host {
8
- --dile-floating-menu-margin: 1.5rem;
9
- --dile-floating-menu-margin-bottom: var(--dile-floating-menu-margin-bottom);
10
- --dile-floating-menu-margin-top: var(--dile-floating-menu-margin-top);
11
- --dile-floating-menu-margin-left: var(--dile-floating-menu-margin-left);
12
- --dile-floating-menu-margin-right: var(--dile-floating-menu-margin-right);
13
- --dile-floating-menu-background-color: navy;
14
- --dile-floating-menu-background-color-hover: mediumblue;
15
- --dile-floating-menu-icon-background-color: aliceblue;
16
-
15
+ display: block;
17
16
  position: fixed;
18
- overflow: hidden;
19
- display: flex;
20
- flex-direction: column;
21
- align-items: flex-start;
22
- gap: 1rem;
23
- top: var(--dile-floating-menu-margin-top, var(--dile-floating-menu-margin));
24
- left: var(--dile-floating-menu-margin-left, var(--dile-floating-menu-margin));
17
+ top: var(--dile-floating-menu-margin-top, var(--dile-floating-menu-margin, 1.5rem));
18
+ left: var(--dile-floating-menu-margin-left, var(--dile-floating-menu-margin, 1.5rem));
25
19
  z-index: var(--dile-floating-menu-zindex, 2);
26
20
  }
21
+ :host([hidden]) {
22
+ display: none;
23
+ }
27
24
  :host([bottom]) {
28
- bottom: var(--dile-floating-menu-margin-bottom, var(--dile-floating-menu-margin));
29
- flex-direction: column-reverse;
30
- top: inherit;
25
+ top: auto;
26
+ bottom: var(--dile-floating-menu-margin-bottom, var(--dile-floating-menu-margin, 1.5rem));
31
27
  }
32
28
  :host([right]) {
33
- right: var(--dile-floating-menu-margin-right, var(--dile-floating-menu-margin));
34
- align-items: flex-end;
35
- left: inherit;
29
+ left: auto;
30
+ right: var(--dile-floating-menu-margin-right, var(--dile-floating-menu-margin, 1.5rem));
36
31
  }
37
32
  :host([left]) {
38
- left: var(--dile-floating-menu-margin-left, var(--dile-floating-menu-margin));
39
- right: inherit;
33
+ left: var(--dile-floating-menu-margin-left, var(--dile-floating-menu-margin, 1.5rem));
34
+ right: auto;
40
35
  }
41
36
  :host([top]) {
42
- top: var(--dile-floating-menu-margin-top, var(--dile-floating-menu-margin));
43
- flex-direction: column;
44
- bottom: inherit;
37
+ top: var(--dile-floating-menu-margin-top, var(--dile-floating-menu-margin, 1.5rem));
38
+ bottom: auto;
45
39
  }
46
40
 
47
41
  :host([opened]) #topline {
@@ -53,47 +47,72 @@ export class DileFloatingMenu extends LitElement {
53
47
  }
54
48
 
55
49
  .line {
56
- background-color: var(--dile-floating-menu-icon-background-color);
50
+ background-color: var(--dile-floating-menu-icon-background-color, aliceblue);
57
51
  width: 24px;
58
52
  height: 2px;
59
53
  transition: transform 0.2s ease-in-out;
60
54
  }
61
55
 
62
- button {
56
+ #trigger {
57
+ box-sizing: border-box;
63
58
  display: flex;
64
59
  flex-direction: column;
65
60
  justify-content: center;
66
61
  align-items: center;
67
62
  gap: 4px;
68
- background-color: var(--dile-floating-menu-background-color);
69
- border-radius: 50%;
70
- width: 48px;
71
- height: 48px;
63
+ min-width: var(--dile-floating-menu-button-size, 48px);
64
+ min-height: var(--dile-floating-menu-button-size, 48px);
65
+ padding: var(--dile-floating-menu-button-padding, 0);
66
+ background-color: var(--dile-floating-menu-background-color, navy);
67
+ color: var(--dile-floating-menu-icon-background-color, aliceblue);
68
+ --dile-icon-color: var(--dile-floating-menu-icon-background-color, aliceblue);
69
+ border-radius: var(--dile-floating-menu-button-border-radius, 999px);
72
70
  border: none;
71
+ font: inherit;
73
72
  cursor: pointer;
74
- transition: background-color 1s, gap 0.5s;
73
+ transition: background-color 0.3s;
75
74
  }
76
75
 
77
- button:hover {
78
- background-color: var(--dile-floating-menu-background-color-hover);
76
+ #trigger:hover {
77
+ background-color: var(--dile-floating-menu-background-color-hover, mediumblue);
79
78
  }
80
79
 
81
- @keyframes opacity {
82
- from {opacity: 0; scale: 90%;}
83
- to {opacity: 1; scale:100%;}
80
+ #trigger:focus-visible {
81
+ outline: 2px solid var(--dile-floating-menu-background-color, navy);
82
+ outline-offset: 2px;
84
83
  }
85
84
 
86
- .opened {
87
- display: block
88
- animation: opacity;
89
- animation-duration: 0.5s;
90
- background-color: var(--dile-floating-menu-background-color);
91
- border-radius: 4px;
92
- padding: 24px;
85
+ #overlay {
86
+ box-sizing: border-box;
87
+ display: none;
88
+ position: fixed;
89
+ width: var(--dile-floating-menu-overlay-width, max-content);
90
+ max-width: var(--dile-floating-menu-overlay-max-width, calc(100vw - 25px));
91
+ max-height: var(--dile-floating-menu-overlay-max-height, calc(100vh - 100px));
92
+ overflow-y: auto;
93
+ background-color: var(--dile-floating-menu-overlay-background-color, var(--dile-floating-menu-background-color, navy));
94
+ color: var(--dile-floating-menu-overlay-color, aliceblue);
95
+ border-radius: var(--dile-floating-menu-overlay-border-radius, 4px);
96
+ box-shadow: var(--dile-floating-menu-overlay-box-shadow, 0 0 20px rgba(0, 0, 0, 0.3));
97
+ padding: var(--dile-floating-menu-overlay-padding, 24px);
98
+ opacity: 0;
99
+ transform: translateY(-10px);
100
+ transition: transform 0.3s ease, opacity 0.3s ease;
93
101
  }
94
102
 
95
- .closed {
96
- display: none;
103
+ :host([bottom]) #overlay {
104
+ transform: translateY(10px);
105
+ }
106
+
107
+ :host #overlay.opened {
108
+ opacity: 1;
109
+ transform: translateY(0);
110
+ }
111
+
112
+ @media (prefers-reduced-motion: reduce) {
113
+ .line, #trigger, #overlay {
114
+ transition: none;
115
+ }
97
116
  }
98
117
  `
99
118
  ];
@@ -102,28 +121,43 @@ export class DileFloatingMenu extends LitElement {
102
121
 
103
122
  opened: {
104
123
  type: Boolean,
105
- reflect: true,
124
+ reflect: true,
106
125
  },
107
126
 
108
127
  top: {
109
128
  type: Boolean,
110
- reflect: true,
129
+ reflect: true,
111
130
  },
112
131
 
113
132
  bottom: {
114
133
  type: Boolean,
115
- reflect: true,
134
+ reflect: true,
116
135
  },
117
136
 
118
137
  right: {
119
138
  type: Boolean,
120
- reflect: true,
139
+ reflect: true,
121
140
  },
122
141
 
123
142
  left: {
124
143
  type: Boolean,
125
- reflect: true,
126
- },
144
+ reflect: true,
145
+ },
146
+
147
+ /**
148
+ * Accessible name of the floating button.
149
+ */
150
+ label: {
151
+ type: String,
152
+ },
153
+
154
+ /**
155
+ * If true, the menu closes when any link or button inside it is clicked.
156
+ */
157
+ closeOnClick: {
158
+ type: Boolean,
159
+ attribute: 'close-on-click',
160
+ },
127
161
  }
128
162
 
129
163
  constructor() {
@@ -133,24 +167,124 @@ export class DileFloatingMenu extends LitElement {
133
167
  this.left = false;
134
168
  this.right = false;
135
169
  this.bottom = false;
170
+ this.label = 'Menu';
171
+ this.closeOnClick = false;
172
+ this.documentClickHandler = this._onDocumentClick.bind(this);
173
+ this.addEventListener('focusout', this._onFocusOut);
174
+ }
175
+
176
+ connectedCallback() {
177
+ super.connectedCallback();
178
+ document.addEventListener('click', this.documentClickHandler);
179
+ }
180
+
181
+ disconnectedCallback() {
182
+ super.disconnectedCallback();
183
+ document.removeEventListener('click', this.documentClickHandler);
184
+ this.opened = false;
185
+ }
186
+
187
+ updated(changedProperties) {
188
+ if (changedProperties.has('opened')) {
189
+ if (this.opened) {
190
+ this._showOverlay();
191
+ } else if (changedProperties.get('opened')) {
192
+ this._hideOverlay();
193
+ }
194
+ }
136
195
  }
137
196
 
138
197
  render() {
139
198
  return html`
140
- <button @click="${this.toggle}" type="button">
141
- ${this._menuIconTpl}
199
+ <button
200
+ id="trigger"
201
+ type="button"
202
+ aria-label="${this.label}"
203
+ aria-expanded="${String(this.opened)}"
204
+ aria-controls="overlay"
205
+ @click="${this.toggle}"
206
+ >
207
+ <slot name="icon">${this._menuIconTpl}</slot>
142
208
  </button>
143
209
  ${this._listTpl}
144
210
  `;
145
211
  }
146
212
 
147
- toggle(e) {
148
- this.opened = !this.opened;
213
+ toggle() {
214
+ if (this.opened) {
215
+ this.close();
216
+ } else {
217
+ this.open();
218
+ }
219
+ }
220
+
221
+ open() {
222
+ this.opened = true;
223
+ }
224
+
225
+ async close() {
226
+ if (!this.opened) {
227
+ return;
228
+ }
229
+ const hadFocus = this.matches(':focus-within');
230
+ this.opened = false;
231
+ await this.updateComplete;
232
+ if (hadFocus) {
233
+ this.trigger.focus({ preventScroll: true });
234
+ }
235
+ }
236
+
237
+ _showOverlay() {
238
+ this.verticalAlign = this.bottom ? 'top' : 'bottom';
239
+ this.horizontalAlign = this.right ? 'under_right' : 'under_left';
240
+ // DileOverlay only leaves a gap by itself when the overlay is under the button.
241
+ this.moveTop = this.bottom ? -OVERLAY_GAP : 0;
242
+ super.open();
243
+ }
244
+
245
+ _hideOverlay() {
246
+ // Closed during the opening delay of DileOverlay, where its close() would be ignored.
247
+ if (this._opening) {
248
+ this.cancelDelay();
249
+ this._opening = false;
250
+ this._overlayClass = 'opened';
251
+ }
252
+ super.close();
253
+ }
254
+
255
+ _onDocumentClick(e) {
256
+ if (this.opened && !e.composedPath().includes(this)) {
257
+ this.opened = false;
258
+ }
259
+ }
260
+
261
+ _onFocusOut(e) {
262
+ // relatedTarget is null when the focus does not go to another element, like when the window loses it.
263
+ if (this.opened && e.relatedTarget && !this.contains(e.relatedTarget)) {
264
+ this.opened = false;
265
+ }
266
+ }
267
+
268
+ _onOverlayClick(e) {
269
+ const path = e.composedPath();
270
+ const nodes = path.slice(0, path.indexOf(e.currentTarget));
271
+ const closer = nodes.find(node =>
272
+ node instanceof Element &&
273
+ (node.hasAttribute('data-close') || (this.closeOnClick && (node.tagName === 'A' || node.tagName === 'BUTTON')))
274
+ );
275
+ if (closer) {
276
+ this.close();
277
+ }
149
278
  }
150
279
 
151
280
  get _listTpl() {
152
281
  return html`
153
- <div class="${this.opened ? 'opened' : 'closed'}">
282
+ <div
283
+ id="overlay"
284
+ class="${this._overlayClass}"
285
+ ?inert="${!this.opened}"
286
+ @click="${this._onOverlayClick}"
287
+ >
154
288
  <slot name="content-top"></slot>
155
289
  <slot></slot>
156
290
  <slot name="content-bottom"></slot>
@@ -165,4 +299,4 @@ export class DileFloatingMenu extends LitElement {
165
299
  `
166
300
  }
167
301
 
168
- }
302
+ }
@@ -1,4 +1,5 @@
1
1
  import { LitElement, html, css } from 'lit';
2
+ import { ifDefined } from 'lit/directives/if-defined.js';
2
3
 
3
4
  export class DileFloatingMenuItem extends LitElement {
4
5
  static styles = [
@@ -6,8 +7,9 @@ export class DileFloatingMenuItem extends LitElement {
6
7
  :host {
7
8
  display: block;
8
9
  }
9
-
10
+
10
11
  a {
12
+ display: block;
11
13
  text-decoration: none;
12
14
  color: var(--dile-floating-menu-item-color, aliceblue);
13
15
  margin: var(--dile-floating-menu-item-margin, 0.5rem 0);
@@ -16,6 +18,11 @@ export class DileFloatingMenuItem extends LitElement {
16
18
  a:hover {
17
19
  color: var(--dile-floating-menu-item-color-hover, lightblue);
18
20
  }
21
+
22
+ a:focus-visible {
23
+ outline: 2px solid currentColor;
24
+ outline-offset: 2px;
25
+ }
19
26
  `
20
27
  ];
21
28
 
@@ -23,7 +30,7 @@ export class DileFloatingMenuItem extends LitElement {
23
30
 
24
31
  href: {
25
32
  type: String,
26
- reflect: true,
33
+ reflect: true,
27
34
  },
28
35
 
29
36
  target: {
@@ -36,7 +43,7 @@ export class DileFloatingMenuItem extends LitElement {
36
43
  super();
37
44
  this.href = '';
38
45
  this.target = '';
39
-
46
+
40
47
  }
41
48
 
42
49
  connectedCallback() {
@@ -46,7 +53,7 @@ export class DileFloatingMenuItem extends LitElement {
46
53
 
47
54
  render() {
48
55
  return html`
49
- <a href="${this.href}" target="${this.target}" ><slot></slot></a>
56
+ <a href="${ifDefined(this.href || undefined)}" target="${ifDefined(this.target || undefined)}"><slot></slot></a>
50
57
  `;
51
58
  }
52
- }
59
+ }
@@ -46,6 +46,28 @@ export class DileMenuOverlay extends DileOverlay(DileCloseDocumentClick(LitEleme
46
46
  constructor() {
47
47
  super();
48
48
  this.closeOnClickInside = false;
49
+ this._escKeydownHandler = this._onEscKeydown.bind(this);
50
+ }
51
+
52
+ connectedCallback() {
53
+ super.connectedCallback();
54
+ // In the capture phase, so an opened menu gets the key before the layers that contain it, like a modal.
55
+ window.addEventListener('keydown', this._escKeydownHandler, true);
56
+ }
57
+
58
+ disconnectedCallback() {
59
+ super.disconnectedCallback();
60
+ window.removeEventListener('keydown', this._escKeydownHandler, true);
61
+ }
62
+
63
+ updated(changedProperties) {
64
+ super.updated(changedProperties);
65
+ if (changedProperties.has('_overlayClass') && this._overlayClass === 'opened') {
66
+ this.dispatchEvent(new CustomEvent('overlay-ready', {
67
+ bubbles: true,
68
+ composed: true,
69
+ }));
70
+ }
49
71
  }
50
72
 
51
73
  render() {
@@ -56,6 +78,7 @@ export class DileMenuOverlay extends DileOverlay(DileCloseDocumentClick(LitEleme
56
78
  <div
57
79
  id="overlay"
58
80
  class="${this._overlayClass}"
81
+ ?inert="${!this._overlayClass && !this._opening}"
59
82
  @click="${this.clickHandler}"
60
83
  >
61
84
  <slot name="content"></slot>
@@ -63,6 +86,33 @@ export class DileMenuOverlay extends DileOverlay(DileCloseDocumentClick(LitEleme
63
86
  `;
64
87
  }
65
88
 
89
+ open() {
90
+ if (!this._overlayClass && !this._opening) {
91
+ this._focusBeforeOpen = this._getActiveElement();
92
+ }
93
+ super.open();
94
+ }
95
+
96
+ _getActiveElement() {
97
+ let active = document.activeElement;
98
+ while (active?.shadowRoot?.activeElement) {
99
+ active = active.shadowRoot.activeElement;
100
+ }
101
+ return active;
102
+ }
103
+
104
+ _onEscKeydown(e) {
105
+ if (e.key !== 'Escape' || this._overlayClass !== 'opened') {
106
+ return;
107
+ }
108
+ // Only the menu closes: a modal or drawer that contains it stays opened until the next Escape.
109
+ e.stopPropagation();
110
+ this.close();
111
+ if (this.overlay.matches(':focus-within') && this._focusBeforeOpen?.isConnected) {
112
+ this._focusBeforeOpen.focus();
113
+ }
114
+ }
115
+
66
116
  clickHandler(e) {
67
117
  if(! this.closeOnClickInside) {
68
118
  e.stopPropagation();
@@ -1,4 +1,5 @@
1
1
  import { html, css, LitElement } from "lit";
2
+ import { ifDefined } from "lit/directives/if-defined.js";
2
3
  import { DileSelectableItem } from "../../../mixins/selectable/index.js";
3
4
  import '@dile/iconlib/dile-iconlib.js';
4
5
 
@@ -21,7 +22,19 @@ export class DileSelectorItem extends DileSelectableItem(LitElement) {
21
22
  display: block;
22
23
  cursor: pointer;
23
24
  }
24
- article {
25
+ .item {
26
+ appearance: none;
27
+ box-sizing: border-box;
28
+ width: 100%;
29
+ margin: 0;
30
+ border: 0;
31
+ border-radius: 0;
32
+ font: inherit;
33
+ letter-spacing: inherit;
34
+ word-spacing: inherit;
35
+ text-align: inherit;
36
+ text-transform: inherit;
37
+ cursor: inherit;
25
38
  display: flex;
26
39
  align-items: center;
27
40
  padding-top: var(--dile-selector-padding-y, 0.2rem);
@@ -32,6 +45,11 @@ export class DileSelectorItem extends DileSelectableItem(LitElement) {
32
45
  color: var(--dile-selector-text-color, var(--dile-on-background-color, #303030));
33
46
  transition: all 0.3s ease-in;
34
47
  }
48
+ .item:focus-visible {
49
+ outline: var(--dile-selector-focus-ring-width, 2px) solid var(--dile-selector-focus-ring-color, currentColor);
50
+ outline-offset: var(--dile-selector-focus-ring-offset, -2px);
51
+ transition-property: background-color, color;
52
+ }
35
53
  .selected {
36
54
  background-color: var(--dile-selector-selected-background-color, #039be5);
37
55
  color: var(--dile-selector-selected-text-color, #fff);
@@ -59,8 +77,7 @@ export class DileSelectorItem extends DileSelectableItem(LitElement) {
59
77
  .selected .icon dile-iconlib {
60
78
  --dile-icon-color: var(--dile-selector-selected-icon-color, #fff);
61
79
  }
62
- a {
63
- display: block;
80
+ a.item {
64
81
  text-decoration: var(--dile-selector-text-decoration, none);
65
82
  }
66
83
  `;
@@ -71,6 +88,8 @@ export class DileSelectorItem extends DileSelectableItem(LitElement) {
71
88
  icon: { type: String },
72
89
  iconlibIcon: { type: String },
73
90
  href: { type: String },
91
+ /** ARIA role of the inner control. dile-selector-overlay sets it to "menuitem" */
92
+ itemRole: { type: String },
74
93
  };
75
94
  }
76
95
 
@@ -80,29 +99,55 @@ export class DileSelectorItem extends DileSelectableItem(LitElement) {
80
99
  }
81
100
 
82
101
  render() {
83
- return html`
84
- ${this.href == ''
85
- ? this.itemTemplate
86
- : html`<a href="${this.href}">${this.itemTemplate}</a>`
87
- }
88
- `;
102
+ const itemClass = `item ${this.selected ? 'selected' : ''}`;
103
+ const ariaCurrent = ifDefined(this.selected ? 'true' : undefined);
104
+ const role = ifDefined(this.itemRole || undefined);
105
+ return this.href == ''
106
+ ? html`<button
107
+ type="button"
108
+ class="${itemClass}"
109
+ role="${role}"
110
+ aria-current="${ariaCurrent}"
111
+ @click="${this.select}"
112
+ >${this.itemTemplate}</button>`
113
+ : html`<a
114
+ href="${this.href}"
115
+ class="${itemClass}"
116
+ role="${role}"
117
+ aria-current="${ariaCurrent}"
118
+ @click="${this.select}"
119
+ @keydown="${this._onLinkKeydown}"
120
+ >${this.itemTemplate}</a>`;
89
121
  }
90
122
 
91
123
  get itemTemplate() {
92
124
  return html`
93
- <article @click='${this.select}' class="${this.selected ? 'selected' : ''}">
94
- ${this.icon
95
- ? html`<div class="icon">${this.iconElement(this.icon)}</div>`
96
- : ''
97
- }
98
- ${this.iconlibIcon
99
- ? html`<div class="icon">${this.renderIconlibIcon(this.iconlibIcon)}</div>`
100
- : ''
101
- }
102
- <div class="line"><slot></slot></div>
103
- </article>
125
+ ${this.icon
126
+ ? html`<span class="icon">${this.iconElement(this.icon)}</span>`
127
+ : ''
128
+ }
129
+ ${this.iconlibIcon
130
+ ? html`<span class="icon">${this.renderIconlibIcon(this.iconlibIcon)}</span>`
131
+ : ''
132
+ }
133
+ <span class="line"><slot></slot></span>
104
134
  `;
105
135
  }
136
+
137
+ // A native link is only activated with Enter
138
+ _onLinkKeydown(e) {
139
+ if (e.key === ' ') {
140
+ e.preventDefault();
141
+ if (!e.repeat) {
142
+ e.currentTarget.click();
143
+ }
144
+ }
145
+ }
146
+
147
+ focus(options) {
148
+ this.shadowRoot?.querySelector('.item')?.focus(options);
149
+ }
150
+
106
151
  iconElement(icon) {
107
152
  return icons[icon];
108
153
  }
@@ -21,7 +21,11 @@ export class DileSelectorOverlay extends DileSelectable(LitElement) {
21
21
 
22
22
  _displayLabel: { state: true },
23
23
 
24
- /** Accessible label for the trigger when there is no visible label text */
24
+ _menuOpened: { state: true },
25
+
26
+ /** Accessible text saying that the trigger opens a menu of options. It's the whole accessible name
27
+ * when there is no visible label text, and it follows the label text when there is one.
28
+ * Set it to translate it, or to an empty string to expose only the label text. */
25
29
  triggerAriaLabel: { type: String },
26
30
 
27
31
  /** Forwarded to the internal dile-menu-overlay */
@@ -56,6 +60,7 @@ export class DileSelectorOverlay extends DileSelectable(LitElement) {
56
60
  this.dynamicLabel = false;
57
61
  this.icon = 'ellipsis-vertical';
58
62
  this._displayLabel = null;
63
+ this._menuOpened = false;
59
64
  this.triggerAriaLabel = 'Options';
60
65
  this.horizontalAlign = 'under_left';
61
66
  this.verticalAlign = 'bottom';
@@ -71,6 +76,9 @@ export class DileSelectorOverlay extends DileSelectable(LitElement) {
71
76
  moveTop="${this.moveTop}"
72
77
  moveLeft="${this.moveLeft}"
73
78
  closeOnClickInside
79
+ @overlay-opened="${this._onOverlayOpened}"
80
+ @overlay-ready="${this._onOverlayReady}"
81
+ @overlay-closed="${this._onOverlayClosed}"
74
82
  >
75
83
  <span
76
84
  slot="trigger"
@@ -78,7 +86,8 @@ export class DileSelectorOverlay extends DileSelectable(LitElement) {
78
86
  role="button"
79
87
  tabindex="0"
80
88
  aria-haspopup="true"
81
- aria-label="${this._displayLabel || this.triggerAriaLabel}"
89
+ aria-expanded="${this._menuOpened ? 'true' : 'false'}"
90
+ aria-label="${this._triggerAccessibleName}"
82
91
  @keydown="${this._onTriggerKeydown}"
83
92
  >
84
93
  ${this._displayLabel
@@ -86,8 +95,8 @@ export class DileSelectorOverlay extends DileSelectable(LitElement) {
86
95
  : html`<dile-lucide-icon icon="${this.icon}" rounded></dile-lucide-icon>`
87
96
  }
88
97
  </span>
89
- <div slot="content">
90
- <slot></slot>
98
+ <div slot="content" role="menu" aria-labelledby="trigger" @keydown="${this._onMenuKeydown}">
99
+ <slot @slotchange="${this._onSlotChange}"></slot>
91
100
  </div>
92
101
  </dile-menu-overlay>
93
102
  `;
@@ -124,10 +133,88 @@ export class DileSelectorOverlay extends DileSelectable(LitElement) {
124
133
  this._displayLabel = selectedItem ? selectedItem.textContent.trim() : this.label;
125
134
  }
126
135
 
136
+ get _triggerAccessibleName() {
137
+ if (!this._displayLabel) {
138
+ return this.triggerAriaLabel;
139
+ }
140
+ return !this.triggerAriaLabel || this.triggerAriaLabel === this._displayLabel
141
+ ? this._displayLabel
142
+ : `${this._displayLabel}, ${this.triggerAriaLabel}`;
143
+ }
144
+
145
+ get _menu() {
146
+ return this.shadowRoot.querySelector('dile-menu-overlay');
147
+ }
148
+
149
+ // Children that aren't options (e.g. a separator) and hidden options are skipped.
150
+ get _focusableItems() {
151
+ return this.getItems().filter((item) => 'selected' in item && item.getClientRects().length > 0);
152
+ }
153
+
154
+ _onSlotChange() {
155
+ this.getItems().forEach((item) => {
156
+ item.itemRole = 'menuitem';
157
+ });
158
+ }
159
+
160
+ _onOverlayOpened(e) {
161
+ if (e.target === e.currentTarget) {
162
+ this._menuOpened = true;
163
+ }
164
+ }
165
+
166
+ _onOverlayReady(e) {
167
+ if (e.target === e.currentTarget) {
168
+ const items = this._focusableItems;
169
+ (items.find((item) => item.selected) ?? items[0])?.focus();
170
+ }
171
+ }
172
+
173
+ _onOverlayClosed(e) {
174
+ if (e.target !== e.currentTarget) {
175
+ return;
176
+ }
177
+ this._menuOpened = false;
178
+ // When the menu closes by clicking elsewhere, the focus is already out of here and has to stay there.
179
+ if (this.matches(':focus-within')) {
180
+ this.shadowRoot.getElementById('trigger').focus();
181
+ }
182
+ }
183
+
127
184
  _onTriggerKeydown(e) {
128
185
  if (e.key === 'Enter' || e.key === ' ') {
129
186
  e.preventDefault();
130
- e.currentTarget.click();
187
+ if (!e.repeat) {
188
+ e.currentTarget.click();
189
+ }
190
+ }
191
+ }
192
+
193
+ _onMenuKeydown(e) {
194
+ if (e.key === 'Tab') {
195
+ // The focus goes back to the trigger, so the browser moves it from there to the next element of the page.
196
+ this._menu.close();
197
+ return;
198
+ }
199
+ if (e.altKey || e.ctrlKey || e.metaKey) {
200
+ return;
201
+ }
202
+ if ((e.key === 'Enter' || e.key === ' ') && e.repeat) {
203
+ // Keeping the key pressed on the trigger must not activate the option that received the focus.
204
+ e.preventDefault();
205
+ return;
206
+ }
207
+ const items = this._focusableItems;
208
+ const current = items.findIndex((item) => item.matches(':focus-within'));
209
+ const nextIndex = {
210
+ ArrowDown: (current + 1) % items.length,
211
+ ArrowUp: (current - 1 + items.length) % items.length,
212
+ Home: 0,
213
+ End: items.length - 1,
214
+ }[e.key];
215
+ if (items[nextIndex]) {
216
+ e.preventDefault();
217
+ items[nextIndex].focus();
131
218
  }
132
219
  }
133
220
  }
@@ -8,16 +8,36 @@ export class DileTab extends DileSelectableItem(LitElement) {
8
8
  return {
9
9
  href: { type: String },
10
10
  target: { type: String },
11
+ /** If this tab is in the page tab sequence. dile-tabs keeps it true only on one of its tabs */
12
+ tabbable: { type: Boolean, attribute: false },
11
13
  };
12
14
  }
13
15
 
16
+ constructor() {
17
+ super();
18
+ this.tabbable = true;
19
+ }
20
+
14
21
  static get styles() {
15
22
  return css`
16
23
  :host {
17
24
  display: inline-block;
18
25
  margin: 0 3px;
19
26
  }
20
- article {
27
+ .tab {
28
+ appearance: none;
29
+ display: block;
30
+ box-sizing: border-box;
31
+ width: 100%;
32
+ margin: 0;
33
+ padding: 0;
34
+ border-radius: 0;
35
+ font: inherit;
36
+ letter-spacing: inherit;
37
+ word-spacing: inherit;
38
+ text-align: inherit;
39
+ text-transform: inherit;
40
+ text-decoration: none;
21
41
  border-top-left-radius: var(--dile-tab-border-radius, 4px);
22
42
  border-top-right-radius: var(--dile-tab-border-radius, 4px);
23
43
  transition: all 0.3s ease;
@@ -28,7 +48,13 @@ export class DileTab extends DileSelectableItem(LitElement) {
28
48
  font-weight: var(--dile-tab-font-weight, normal);
29
49
  font-size: var(--dile-tab-font-size, 1rem);
30
50
  }
31
- div.label {
51
+ .tab:focus-visible {
52
+ outline: var(--dile-tab-focus-ring-width, 2px) solid var(--dile-tab-focus-ring-color, currentColor);
53
+ outline-offset: var(--dile-tab-focus-ring-offset, -2px);
54
+ transition-property: background-color, color;
55
+ }
56
+ .label {
57
+ display: block;
32
58
  padding: var(--dile-tab-padding, 8px 12px 6px 12px);
33
59
  text-transform: var(--dile-tab-text-transform, uppercase);
34
60
  white-space: nowrap;
@@ -38,7 +64,7 @@ export class DileTab extends DileSelectableItem(LitElement) {
38
64
  color: var(--dile-tab-selected-text-color, var(--dile-on-primary-dark-color, #000));
39
65
  border: var(--dile-tab-selected-border, none);
40
66
  }
41
- span {
67
+ .mark {
42
68
  display: block;
43
69
  height: var(--dile-tab-selected-line-height, 5px);
44
70
  width: 0;
@@ -48,11 +74,6 @@ export class DileTab extends DileSelectableItem(LitElement) {
48
74
  .markselected {
49
75
  width: 100%;
50
76
  }
51
- a {
52
- display: block;
53
- color: inherit;
54
- text-decoration: none;
55
- }
56
77
  .line {
57
78
  display: flex;
58
79
  justify-content: center;
@@ -61,22 +82,55 @@ export class DileTab extends DileSelectableItem(LitElement) {
61
82
  }
62
83
 
63
84
  render() {
85
+ const tabClass = `tab ${this.selected ? 'selected' : ''}`;
86
+ const ariaSelected = this.selected ? 'true' : 'false';
87
+ const tabindex = this.tabbable ? '0' : '-1';
64
88
  if (this.href) {
65
89
  return html`
66
- <a href="${this.href}" target="${ifDefined(this.target)}">${this.tabTemplate}</a>
90
+ <a
91
+ href="${this.href}"
92
+ target="${ifDefined(this.target)}"
93
+ class="${tabClass}"
94
+ role="tab"
95
+ aria-selected="${ariaSelected}"
96
+ tabindex="${tabindex}"
97
+ @click="${this.select}"
98
+ @keydown="${this._onLinkKeydown}"
99
+ >${this.tabTemplate}</a>
67
100
  `;
68
101
  }
69
- return this.tabTemplate;
102
+ return html`
103
+ <button
104
+ type="button"
105
+ class="${tabClass}"
106
+ role="tab"
107
+ aria-selected="${ariaSelected}"
108
+ tabindex="${tabindex}"
109
+ @click="${this.select}"
110
+ >${this.tabTemplate}</button>
111
+ `;
70
112
  }
71
113
 
72
114
  get tabTemplate() {
73
115
  return html`
74
- <article @click='${this.select}' class="${this.selected ? 'selected' : ''}">
75
- <div class="label"><slot></slot></div>
76
- <div class="line">
77
- <span class="${this.selected ? 'markselected' : ''}"></span>
78
- </div>
79
- </article>
116
+ <span class="label"><slot></slot></span>
117
+ <span class="line">
118
+ <span class="mark ${this.selected ? 'markselected' : ''}"></span>
119
+ </span>
80
120
  `;
81
121
  }
122
+
123
+ // A native link is only activated with Enter
124
+ _onLinkKeydown(e) {
125
+ if (e.key === ' ') {
126
+ e.preventDefault();
127
+ if (!e.repeat) {
128
+ e.currentTarget.click();
129
+ }
130
+ }
131
+ }
132
+
133
+ focus(options) {
134
+ this.shadowRoot?.querySelector('.tab')?.focus(options);
135
+ }
82
136
  }
@@ -9,4 +9,67 @@ export class DileTabs extends DileSelectable(LitElement) {
9
9
  }
10
10
  `;
11
11
  }
12
+
13
+ constructor() {
14
+ super();
15
+ this.addEventListener('keydown', this._onKeydown);
16
+ this.addEventListener('focusin', () => this._updateTabbable());
17
+ this.addEventListener('focusout', this._onFocusOut);
18
+ }
19
+
20
+ connectedCallback() {
21
+ super.connectedCallback();
22
+ if (!this.hasAttribute('role')) {
23
+ this.setAttribute('role', 'tablist');
24
+ }
25
+ }
26
+
27
+ render() {
28
+ return html`<slot @slotchange="${() => this._updateTabbable()}"></slot>`;
29
+ }
30
+
31
+ setSelectedItem() {
32
+ super.setSelectedItem();
33
+ this._updateTabbable();
34
+ }
35
+
36
+ // Roving tabindex: the tablist is only one stop in the page tab sequence.
37
+ _updateTabbable(ignoreFocus = false) {
38
+ const tabs = this.getItems();
39
+ const focused = ignoreFocus ? undefined : tabs.find((tab) => tab.matches(':focus-within'));
40
+ const tabbable = focused ?? tabs.find((tab) => tab.selected) ?? tabs[0];
41
+ tabs.forEach((tab) => {
42
+ tab.tabbable = tab === tabbable;
43
+ });
44
+ }
45
+
46
+ _onFocusOut(e) {
47
+ if (!e.relatedTarget || !this.contains(e.relatedTarget)) {
48
+ this._updateTabbable(true);
49
+ }
50
+ }
51
+
52
+ // The arrows only move the focus. Enter or Space select the focused tab, so moving
53
+ // through the tabs doesn't dispatch changes or follow the links of the tabs with href.
54
+ _onKeydown(e) {
55
+ if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) {
56
+ return;
57
+ }
58
+ if (e.composedPath()[0].getAttribute?.('role') !== 'tab') {
59
+ return;
60
+ }
61
+ const tabs = this.getItems();
62
+ const current = tabs.findIndex((tab) => tab.matches(':focus-within'));
63
+ const step = getComputedStyle(this).direction === 'rtl' ? -1 : 1;
64
+ const nextIndex = {
65
+ ArrowRight: (current + step + tabs.length) % tabs.length,
66
+ ArrowLeft: (current - step + tabs.length) % tabs.length,
67
+ Home: 0,
68
+ End: tabs.length - 1,
69
+ }[e.key];
70
+ if (tabs[nextIndex]) {
71
+ e.preventDefault();
72
+ tabs[nextIndex].focus();
73
+ }
74
+ }
12
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dile/ui",
3
- "version": "3.18.1",
3
+ "version": "3.18.3",
4
4
  "description": "UI Core components from dile-components.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -28,5 +28,5 @@
28
28
  "publishConfig": {
29
29
  "access": "public"
30
30
  },
31
- "gitHead": "6724d25c93731ed35e6710228a6d0b18e5b6ebb1"
31
+ "gitHead": "978a1d5b553cf39a5614223130f3446ec0bb89f2"
32
32
  }