@dile/ui 3.9.2 → 3.9.4
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/radio-group/__screenshots__/radio-group-dialog.spec.js/dile-radio-group-dialog-renders-dialog-items-with-labels-and-values-1.png +0 -0
- package/components/radio-group/__screenshots__/radio-group-dialog.spec.js/dile-radio-group-dialog-renders-items-with-optional-description-1.png +0 -0
- package/components/radio-group/__screenshots__/radio-group-dialog.spec.js/dile-radio-group-dialog-updates-value-when-an-option-is-selected-1.png +0 -0
- package/components/radio-group/index.js +2 -1
- package/components/radio-group/radio-group-dialog.js +6 -0
- package/components/radio-group/radio-group-dialog.spec.js +126 -0
- package/components/radio-group/radio-group.spec.js +84 -0
- package/components/radio-group/src/DileRadio.js +45 -2
- package/components/radio-group/src/DileRadioGroup.js +79 -5
- package/components/radio-group/src/DileRadioGroupDialog.js +249 -0
- package/package.json +3 -3
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import './radio-group-dialog.js';
|
|
3
|
+
|
|
4
|
+
describe('dile-radio-group-dialog', () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
document.body.innerHTML = '';
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
async function renderRadioGroupDialog(attrs = '', dialogItems = []) {
|
|
10
|
+
document.body.innerHTML = `<dile-radio-group-dialog ${attrs}></dile-radio-group-dialog>`;
|
|
11
|
+
const el = document.body.querySelector('dile-radio-group-dialog');
|
|
12
|
+
el.dialogItems = dialogItems;
|
|
13
|
+
await el.updateComplete;
|
|
14
|
+
return el;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
it('renders the title and description', async () => {
|
|
18
|
+
const el = await renderRadioGroupDialog('title="Select Option" description="Choose one option below"');
|
|
19
|
+
|
|
20
|
+
expect(el.shadowRoot.querySelector('.dialog-title').textContent).toBe('Select Option');
|
|
21
|
+
expect(el.shadowRoot.querySelector('.dialog-description').textContent).toBe('Choose one option below');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('renders dialog items with labels and values', async () => {
|
|
25
|
+
const dialogItems = [
|
|
26
|
+
{ value: 'a', label: 'Option A', description: 'First option' },
|
|
27
|
+
{ value: 'b', label: 'Option B', description: 'Second option' },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const el = await renderRadioGroupDialog('title="Select Option"', dialogItems);
|
|
31
|
+
|
|
32
|
+
const items = el.shadowRoot.querySelectorAll('.radio-item-wrapper');
|
|
33
|
+
expect(items.length).toBe(2);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('updates value when an option is clicked', async () => {
|
|
37
|
+
const dialogItems = [
|
|
38
|
+
{ value: 'a', label: 'Option A' },
|
|
39
|
+
{ value: 'b', label: 'Option B' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const el = await renderRadioGroupDialog('', dialogItems);
|
|
43
|
+
|
|
44
|
+
const wrappers = el.shadowRoot.querySelectorAll('.radio-item-wrapper');
|
|
45
|
+
wrappers[1].click();
|
|
46
|
+
await el.updateComplete;
|
|
47
|
+
|
|
48
|
+
expect(el.value).toBe('b');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('renders items with optional description', async () => {
|
|
52
|
+
const dialogItems = [
|
|
53
|
+
{ value: 'a', label: 'Option A', description: 'Description for A' },
|
|
54
|
+
{ value: 'b', label: 'Option B' },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const el = await renderRadioGroupDialog('', dialogItems);
|
|
58
|
+
|
|
59
|
+
const descriptions = el.shadowRoot.querySelectorAll('.item-description');
|
|
60
|
+
expect(descriptions.length).toBe(1);
|
|
61
|
+
expect(descriptions[0].textContent).toBe('Description for A');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('exposes radiogroup and radio roles with the accessible name wired up', async () => {
|
|
65
|
+
const el = await renderRadioGroupDialog('title="Select Option"', [
|
|
66
|
+
{ value: 'a', label: 'Option A' },
|
|
67
|
+
{ value: 'b', label: 'Option B' },
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
const group = el.shadowRoot.querySelector('.radio-items');
|
|
71
|
+
expect(group.getAttribute('role')).toBe('radiogroup');
|
|
72
|
+
expect(group.getAttribute('aria-labelledby')).toBe(el.shadowRoot.querySelector('.dialog-title').id);
|
|
73
|
+
|
|
74
|
+
const wrappers = el.shadowRoot.querySelectorAll('.radio-item-wrapper');
|
|
75
|
+
wrappers.forEach(wrapper => {
|
|
76
|
+
expect(wrapper.getAttribute('role')).toBe('radio');
|
|
77
|
+
const labelId = wrapper.getAttribute('aria-labelledby');
|
|
78
|
+
expect(el.shadowRoot.getElementById(labelId).textContent.trim()).not.toBe('');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('reflects the selected item via aria-checked and keeps a single tab stop', async () => {
|
|
83
|
+
const el = await renderRadioGroupDialog('', [
|
|
84
|
+
{ value: 'a', label: 'Option A' },
|
|
85
|
+
{ value: 'b', label: 'Option B' },
|
|
86
|
+
]);
|
|
87
|
+
el.value = 'b';
|
|
88
|
+
await el.updateComplete;
|
|
89
|
+
|
|
90
|
+
const wrappers = el.shadowRoot.querySelectorAll('.radio-item-wrapper');
|
|
91
|
+
expect(wrappers[0].getAttribute('aria-checked')).toBe('false');
|
|
92
|
+
expect(wrappers[1].getAttribute('aria-checked')).toBe('true');
|
|
93
|
+
expect(wrappers[0].getAttribute('tabindex')).toBe('-1');
|
|
94
|
+
expect(wrappers[1].getAttribute('tabindex')).toBe('0');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('selects an option on Space/Enter and moves selection with arrow keys', async () => {
|
|
98
|
+
const el = await renderRadioGroupDialog('', [
|
|
99
|
+
{ value: 'a', label: 'Option A' },
|
|
100
|
+
{ value: 'b', label: 'Option B' },
|
|
101
|
+
{ value: 'c', label: 'Option C' },
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const wrappers = el.shadowRoot.querySelectorAll('.radio-item-wrapper');
|
|
105
|
+
wrappers[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, composed: true }));
|
|
106
|
+
await el.updateComplete;
|
|
107
|
+
expect(el.value).toBe('a');
|
|
108
|
+
|
|
109
|
+
el.shadowRoot.querySelector('.radio-item-wrapper[aria-checked="true"]')
|
|
110
|
+
.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, composed: true }));
|
|
111
|
+
await el.updateComplete;
|
|
112
|
+
expect(el.value).toBe('b');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('marks the group and items as disabled', async () => {
|
|
116
|
+
const el = await renderRadioGroupDialog('disabled', [
|
|
117
|
+
{ value: 'a', label: 'Option A' },
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
const group = el.shadowRoot.querySelector('.radio-items');
|
|
121
|
+
expect(group.getAttribute('aria-disabled')).toBe('true');
|
|
122
|
+
const wrapper = el.shadowRoot.querySelector('.radio-item-wrapper');
|
|
123
|
+
expect(wrapper.getAttribute('aria-disabled')).toBe('true');
|
|
124
|
+
expect(wrapper.getAttribute('tabindex')).toBe('-1');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -41,4 +41,88 @@ describe('dile-radio-group', () => {
|
|
|
41
41
|
expect(radios[1].selected).toBe(true);
|
|
42
42
|
expect(radios[0].selected).toBe(false);
|
|
43
43
|
});
|
|
44
|
+
|
|
45
|
+
it('exposes radiogroup role wired to the visible label', async () => {
|
|
46
|
+
const el = await renderRadioGroup(`
|
|
47
|
+
<dile-radio-group name="opt" label="Choose one">
|
|
48
|
+
<dile-radio value="a" label="A"></dile-radio>
|
|
49
|
+
<dile-radio value="b" label="B"></dile-radio>
|
|
50
|
+
</dile-radio-group>
|
|
51
|
+
`);
|
|
52
|
+
|
|
53
|
+
const group = el.shadowRoot.querySelector('div');
|
|
54
|
+
const labelId = el.shadowRoot.querySelector('.label').id;
|
|
55
|
+
expect(group.getAttribute('role')).toBe('radiogroup');
|
|
56
|
+
expect(group.getAttribute('aria-labelledby')).toBe(labelId);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('gives each radio role=radio with aria-checked reflecting selection', async () => {
|
|
60
|
+
const el = await renderRadioGroup(`
|
|
61
|
+
<dile-radio-group name="opt" value="a">
|
|
62
|
+
<dile-radio value="a" label="A"></dile-radio>
|
|
63
|
+
<dile-radio value="b" label="B"></dile-radio>
|
|
64
|
+
</dile-radio-group>
|
|
65
|
+
`);
|
|
66
|
+
await new Promise(resolve => setTimeout(resolve, 210));
|
|
67
|
+
const radios = el.querySelectorAll('dile-radio');
|
|
68
|
+
await Promise.all([...radios].map(r => r.updateComplete));
|
|
69
|
+
|
|
70
|
+
const articles = [...radios].map(r => r.shadowRoot.querySelector('article'));
|
|
71
|
+
expect(articles[0].getAttribute('role')).toBe('radio');
|
|
72
|
+
expect(articles[0].getAttribute('aria-checked')).toBe('true');
|
|
73
|
+
expect(articles[1].getAttribute('aria-checked')).toBe('false');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('keeps a single roving tab stop that follows the selection', async () => {
|
|
77
|
+
const el = await renderRadioGroup(`
|
|
78
|
+
<dile-radio-group name="opt">
|
|
79
|
+
<dile-radio value="a" label="A"></dile-radio>
|
|
80
|
+
<dile-radio value="b" label="B"></dile-radio>
|
|
81
|
+
</dile-radio-group>
|
|
82
|
+
`);
|
|
83
|
+
const radios = el.querySelectorAll('dile-radio');
|
|
84
|
+
await Promise.all([...radios].map(r => r.updateComplete));
|
|
85
|
+
let articles = [...radios].map(r => r.shadowRoot.querySelector('article'));
|
|
86
|
+
expect(articles[0].getAttribute('tabindex')).toBe('0');
|
|
87
|
+
expect(articles[1].getAttribute('tabindex')).toBe('-1');
|
|
88
|
+
|
|
89
|
+
radios[1].shadowRoot.querySelector('article').click();
|
|
90
|
+
await el.updateComplete;
|
|
91
|
+
await Promise.all([...radios].map(r => r.updateComplete));
|
|
92
|
+
articles = [...radios].map(r => r.shadowRoot.querySelector('article'));
|
|
93
|
+
expect(articles[0].getAttribute('tabindex')).toBe('-1');
|
|
94
|
+
expect(articles[1].getAttribute('tabindex')).toBe('0');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('moves selection with arrow keys', async () => {
|
|
98
|
+
const el = await renderRadioGroup(`
|
|
99
|
+
<dile-radio-group name="opt" value="a">
|
|
100
|
+
<dile-radio value="a" label="A"></dile-radio>
|
|
101
|
+
<dile-radio value="b" label="B"></dile-radio>
|
|
102
|
+
</dile-radio-group>
|
|
103
|
+
`);
|
|
104
|
+
await new Promise(resolve => setTimeout(resolve, 210));
|
|
105
|
+
|
|
106
|
+
el.shadowRoot.querySelector('div').dispatchEvent(
|
|
107
|
+
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, composed: true })
|
|
108
|
+
);
|
|
109
|
+
await el.updateComplete;
|
|
110
|
+
|
|
111
|
+
expect(el.value).toBe('b');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('propagates disabled state to the slotted radios', async () => {
|
|
115
|
+
const el = await renderRadioGroup(`
|
|
116
|
+
<dile-radio-group name="opt" disabled>
|
|
117
|
+
<dile-radio value="a" label="A"></dile-radio>
|
|
118
|
+
</dile-radio-group>
|
|
119
|
+
`);
|
|
120
|
+
const radio = el.querySelector('dile-radio');
|
|
121
|
+
await radio.updateComplete;
|
|
122
|
+
|
|
123
|
+
expect(radio.disabled).toBe(true);
|
|
124
|
+
const article = radio.shadowRoot.querySelector('article');
|
|
125
|
+
expect(article.getAttribute('tabindex')).toBe('-1');
|
|
126
|
+
expect(article.getAttribute('aria-disabled')).toBe('true');
|
|
127
|
+
});
|
|
44
128
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LitElement, html, css } from 'lit';
|
|
2
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
2
3
|
import { radioCheckedIcon, circleBorderIcon } from '@dile/icons/index.js';
|
|
3
4
|
import '../../icon/icon.js';
|
|
4
5
|
|
|
@@ -12,6 +13,14 @@ export class DileRadio extends LitElement {
|
|
|
12
13
|
article {
|
|
13
14
|
display: flex;
|
|
14
15
|
align-items: center;
|
|
16
|
+
cursor: pointer;
|
|
17
|
+
}
|
|
18
|
+
article:focus-visible {
|
|
19
|
+
outline: var(--dile-radio-focus-outline, 2px solid #4A90E2);
|
|
20
|
+
outline-offset: 2px;
|
|
21
|
+
}
|
|
22
|
+
:host([disabled]) article {
|
|
23
|
+
cursor: not-allowed;
|
|
15
24
|
}
|
|
16
25
|
.radio {
|
|
17
26
|
margin-right: var(--dile-radio-space-between-label-and-icon, 0.4rem);
|
|
@@ -36,10 +45,16 @@ export class DileRadio extends LitElement {
|
|
|
36
45
|
return {
|
|
37
46
|
label: { type: String },
|
|
38
47
|
value: { type: String },
|
|
39
|
-
selected: {
|
|
48
|
+
selected: {
|
|
49
|
+
type: Boolean,
|
|
50
|
+
reflect: true
|
|
51
|
+
},
|
|
52
|
+
disabled: {
|
|
40
53
|
type: Boolean,
|
|
41
54
|
reflect: true
|
|
42
55
|
},
|
|
56
|
+
tabbable: { type: Boolean },
|
|
57
|
+
decorative: { type: Boolean, reflect: true },
|
|
43
58
|
};
|
|
44
59
|
}
|
|
45
60
|
|
|
@@ -48,11 +63,22 @@ export class DileRadio extends LitElement {
|
|
|
48
63
|
this.label = '';
|
|
49
64
|
this.value = '';
|
|
50
65
|
this.selected = false;
|
|
66
|
+
this.disabled = false;
|
|
67
|
+
this.tabbable = false;
|
|
68
|
+
this.decorative = false;
|
|
51
69
|
}
|
|
52
70
|
|
|
53
71
|
render() {
|
|
54
72
|
return html`
|
|
55
|
-
<article
|
|
73
|
+
<article
|
|
74
|
+
@click=${this.handleClick}
|
|
75
|
+
@keydown=${this.handleKeydown}
|
|
76
|
+
role="radio"
|
|
77
|
+
aria-checked="${this.selected}"
|
|
78
|
+
aria-disabled="${this.disabled}"
|
|
79
|
+
aria-hidden="${ifDefined(this.decorative ? 'true' : undefined)}"
|
|
80
|
+
tabindex="${this.decorative || this.disabled ? -1 : (this.tabbable ? 0 : -1)}"
|
|
81
|
+
>
|
|
56
82
|
<span class="radio">
|
|
57
83
|
<dile-icon .icon="${this.computeIcon(this.selected)}"></dile-icon>
|
|
58
84
|
</span>
|
|
@@ -63,6 +89,19 @@ export class DileRadio extends LitElement {
|
|
|
63
89
|
`;
|
|
64
90
|
}
|
|
65
91
|
|
|
92
|
+
handleClick() {
|
|
93
|
+
if (this.disabled || this.decorative) return;
|
|
94
|
+
this.select();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
handleKeydown(e) {
|
|
98
|
+
if (this.disabled || this.decorative) return;
|
|
99
|
+
if (e.key === ' ' || e.key === 'Enter') {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
this.select();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
66
105
|
select() {
|
|
67
106
|
this.dispatchEvent(new CustomEvent('dile-radio-selected', {
|
|
68
107
|
bubbles: true,
|
|
@@ -74,6 +113,10 @@ export class DileRadio extends LitElement {
|
|
|
74
113
|
}));
|
|
75
114
|
}
|
|
76
115
|
|
|
116
|
+
focus(options) {
|
|
117
|
+
this.shadowRoot?.querySelector('article')?.focus(options);
|
|
118
|
+
}
|
|
119
|
+
|
|
77
120
|
computeIcon(selected) {
|
|
78
121
|
return selected ? radioCheckedIcon : circleBorderIcon;
|
|
79
122
|
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { LitElement, html, css } from 'lit';
|
|
2
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
2
3
|
import { DileEmmitChange } from '../../../mixins/form/index.js';
|
|
3
4
|
import '../radio.js';
|
|
4
5
|
import '../../input/input-message.js';
|
|
5
6
|
import { labelStyles } from '../../input/index.js';
|
|
6
7
|
|
|
8
|
+
const NAVIGATION_KEYS = ['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft', 'Home', 'End'];
|
|
9
|
+
|
|
7
10
|
export class DileRadioGroup extends DileEmmitChange(LitElement) {
|
|
8
11
|
static styles = [
|
|
9
12
|
labelStyles,
|
|
@@ -43,6 +46,7 @@ export class DileRadioGroup extends DileEmmitChange(LitElement) {
|
|
|
43
46
|
this.disabled = false;
|
|
44
47
|
this.internals = this.attachInternals();
|
|
45
48
|
this.message = '';
|
|
49
|
+
this._id = `dile-radio-group-${Math.random().toString(36).substr(2, 9)}`;
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
updated(changedProperties) {
|
|
@@ -52,6 +56,9 @@ export class DileRadioGroup extends DileEmmitChange(LitElement) {
|
|
|
52
56
|
this.dispatchChangeEvent();
|
|
53
57
|
this.internals.setFormValue(this.value);
|
|
54
58
|
}
|
|
59
|
+
if(changedProperties.has('disabled')) {
|
|
60
|
+
this.propagateDisabled();
|
|
61
|
+
}
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
firstUpdated() {
|
|
@@ -60,20 +67,30 @@ export class DileRadioGroup extends DileEmmitChange(LitElement) {
|
|
|
60
67
|
setTimeout(() => {
|
|
61
68
|
this.doSelection(this.value);
|
|
62
69
|
}, 200);
|
|
70
|
+
} else {
|
|
71
|
+
this.updateTabbable(this.getRadios());
|
|
63
72
|
}
|
|
64
73
|
}
|
|
65
|
-
|
|
74
|
+
|
|
66
75
|
render() {
|
|
67
76
|
return html`
|
|
68
|
-
<div
|
|
77
|
+
<div
|
|
78
|
+
role="radiogroup"
|
|
79
|
+
aria-labelledby="${ifDefined(this.label ? `${this._id}-label` : undefined)}"
|
|
80
|
+
aria-describedby="${ifDefined(this.message ? `${this._id}-message` : undefined)}"
|
|
81
|
+
aria-invalid="${this.errored ? 'true' : 'false'}"
|
|
82
|
+
aria-disabled="${this.disabled ? 'true' : 'false'}"
|
|
83
|
+
@dile-radio-selected=${this.changeValue}
|
|
84
|
+
@keydown=${this.handleKeydown}
|
|
85
|
+
>
|
|
69
86
|
${this.label
|
|
70
|
-
? html`<span class="label">${this.label}</span>`
|
|
87
|
+
? html`<span class="label" id="${this._id}-label">${this.label}</span>`
|
|
71
88
|
: ""
|
|
72
89
|
}
|
|
73
90
|
<slot></slot>
|
|
74
91
|
</div>
|
|
75
92
|
${this.message
|
|
76
|
-
? html`<dile-input-message message="${this.message}" ?errored=${this.errored}></dile-input-message>`
|
|
93
|
+
? html`<dile-input-message id="${this._id}-message" message="${this.message}" ?errored=${this.errored}></dile-input-message>`
|
|
77
94
|
: ''
|
|
78
95
|
}
|
|
79
96
|
`;
|
|
@@ -91,7 +108,8 @@ export class DileRadioGroup extends DileEmmitChange(LitElement) {
|
|
|
91
108
|
|
|
92
109
|
doSelection(newValue) {
|
|
93
110
|
let numSelected = 0;
|
|
94
|
-
this.
|
|
111
|
+
const radios = this.getRadios();
|
|
112
|
+
radios.forEach(radio => {
|
|
95
113
|
if (radio.value === newValue) {
|
|
96
114
|
radio.selected = true;
|
|
97
115
|
numSelected++;
|
|
@@ -102,6 +120,62 @@ export class DileRadioGroup extends DileEmmitChange(LitElement) {
|
|
|
102
120
|
if (numSelected > 1) {
|
|
103
121
|
throw new Error('More than one radio selected because have same value')
|
|
104
122
|
}
|
|
123
|
+
this.updateTabbable(radios);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getRadios() {
|
|
127
|
+
return Array.from(this.querySelectorAll('dile-radio'));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
updateTabbable(radios) {
|
|
131
|
+
if (!radios.length) return;
|
|
132
|
+
const selectedIndex = radios.findIndex(radio => radio.selected);
|
|
133
|
+
const tabbableIndex = selectedIndex !== -1 ? selectedIndex : 0;
|
|
134
|
+
radios.forEach((radio, index) => {
|
|
135
|
+
radio.tabbable = index === tabbableIndex;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
propagateDisabled() {
|
|
140
|
+
this.getRadios().forEach(radio => {
|
|
141
|
+
radio.disabled = this.disabled;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
getFocusableIndex(items, key, currentIndex) {
|
|
146
|
+
const count = items.length;
|
|
147
|
+
if (!count) return -1;
|
|
148
|
+
switch (key) {
|
|
149
|
+
case 'ArrowDown':
|
|
150
|
+
case 'ArrowRight':
|
|
151
|
+
return (currentIndex + 1) % count;
|
|
152
|
+
case 'ArrowUp':
|
|
153
|
+
case 'ArrowLeft':
|
|
154
|
+
return (currentIndex - 1 + count) % count;
|
|
155
|
+
case 'Home':
|
|
156
|
+
return 0;
|
|
157
|
+
case 'End':
|
|
158
|
+
return count - 1;
|
|
159
|
+
default:
|
|
160
|
+
return -1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
handleKeydown(e) {
|
|
165
|
+
if (this.disabled) return;
|
|
166
|
+
if (!NAVIGATION_KEYS.includes(e.key)) return;
|
|
167
|
+
e.preventDefault();
|
|
168
|
+
const radios = this.getRadios();
|
|
169
|
+
if (!radios.length) return;
|
|
170
|
+
const currentIndex = radios.findIndex(radio => radio.value === this.value);
|
|
171
|
+
const nextIndex = currentIndex === -1
|
|
172
|
+
? (e.key === 'End' ? radios.length - 1 : 0)
|
|
173
|
+
: this.getFocusableIndex(radios, e.key, currentIndex);
|
|
174
|
+
if (nextIndex === -1) return;
|
|
175
|
+
const nextRadio = radios[nextIndex];
|
|
176
|
+
if (nextRadio.disabled) return;
|
|
177
|
+
nextRadio.focus();
|
|
178
|
+
this.value = nextRadio.value;
|
|
105
179
|
}
|
|
106
180
|
|
|
107
181
|
dispatchChangeEvent() {
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { html, css } from 'lit';
|
|
2
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
3
|
+
import { DileRadioGroup } from './DileRadioGroup.js';
|
|
4
|
+
import '../../card/card.js';
|
|
5
|
+
import '../radio.js';
|
|
6
|
+
|
|
7
|
+
const NAVIGATION_KEYS = ['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft', 'Home', 'End'];
|
|
8
|
+
|
|
9
|
+
export class DileRadioGroupDialog extends DileRadioGroup {
|
|
10
|
+
static styles = [
|
|
11
|
+
...DileRadioGroup.styles,
|
|
12
|
+
css`
|
|
13
|
+
:host {
|
|
14
|
+
--dile-radio-group-dialog-background-color: var(--dile-background-color, #fff);
|
|
15
|
+
--dile-radio-group-dialog-title-color: var(--dile-on-background-color, #303030);
|
|
16
|
+
--dile-radio-group-dialog-title-font-size: 1.25rem;
|
|
17
|
+
--dile-radio-group-dialog-title-font-weight: 600;
|
|
18
|
+
--dile-radio-group-dialog-description-color: var(--dile-on-background-color, #303030);
|
|
19
|
+
--dile-radio-group-dialog-description-font-size: 0.95rem;
|
|
20
|
+
--dile-radio-group-dialog-description-margin-bottom: 1.5rem;
|
|
21
|
+
--dile-radio-group-dialog-item-margin-bottom: 1rem;
|
|
22
|
+
--dile-radio-group-dialog-item-gap: 0.25rem 0.75rem;
|
|
23
|
+
--dile-radio-group-dialog-item-label-color: var(--dile-on-background-color, #303030);
|
|
24
|
+
--dile-radio-group-dialog-item-label-font-size: 1rem;
|
|
25
|
+
--dile-radio-group-dialog-item-label-font-weight: 500;
|
|
26
|
+
--dile-radio-group-dialog-item-description-color: var(--dile-gray-dark-color, #555555);
|
|
27
|
+
--dile-radio-group-dialog-item-description-font-size: 0.875rem;
|
|
28
|
+
--dile-radio-group-dialog-item-description-margin-top: 0.5rem;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
dile-card {
|
|
32
|
+
width: 100%;
|
|
33
|
+
--dile-card-background-color: var(--dile-radio-group-dialog-background-color);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.dialog-title {
|
|
37
|
+
color: var(--dile-radio-group-dialog-title-color);
|
|
38
|
+
font-size: var(--dile-radio-group-dialog-title-font-size);
|
|
39
|
+
font-weight: var(--dile-radio-group-dialog-title-font-weight);
|
|
40
|
+
margin: 0;
|
|
41
|
+
margin-bottom: 0.5rem;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.dialog-description {
|
|
45
|
+
color: var(--dile-radio-group-dialog-description-color);
|
|
46
|
+
font-size: var(--dile-radio-group-dialog-description-font-size);
|
|
47
|
+
margin: 0;
|
|
48
|
+
margin-bottom: var(--dile-radio-group-dialog-description-margin-bottom);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.radio-items {
|
|
52
|
+
display: flex;
|
|
53
|
+
flex-direction: column;
|
|
54
|
+
gap: var(--dile-radio-group-dialog-item-margin-bottom);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.radio-item-wrapper {
|
|
58
|
+
display: grid;
|
|
59
|
+
grid-template-columns: auto 1fr;
|
|
60
|
+
grid-template-rows: auto auto;
|
|
61
|
+
gap: var(--dile-radio-group-dialog-item-gap, 0.5rem 0.75rem);
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.radio-item-wrapper:focus-visible {
|
|
66
|
+
outline: var(--dile-radio-group-dialog-item-focus-outline, 2px solid #4A90E2);
|
|
67
|
+
outline-offset: 2px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.radio-item-wrapper dile-radio {
|
|
71
|
+
grid-row: 1 / 3;
|
|
72
|
+
grid-column: 1;
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: flex-start;
|
|
75
|
+
padding-top: 0.15rem;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.item-label {
|
|
79
|
+
grid-row: 1;
|
|
80
|
+
grid-column: 2;
|
|
81
|
+
color: var(--dile-radio-group-dialog-item-label-color);
|
|
82
|
+
font-size: var(--dile-radio-group-dialog-item-label-font-size);
|
|
83
|
+
font-weight: var(--dile-radio-group-dialog-item-label-font-weight);
|
|
84
|
+
margin: 0;
|
|
85
|
+
cursor: pointer;
|
|
86
|
+
user-select: none;
|
|
87
|
+
align-self: start;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.item-description {
|
|
91
|
+
grid-row: 2;
|
|
92
|
+
grid-column: 2;
|
|
93
|
+
color: var(--dile-radio-group-dialog-item-description-color);
|
|
94
|
+
font-size: var(--dile-radio-group-dialog-item-description-font-size);
|
|
95
|
+
margin: 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.radio-item-wrapper:not(:has(.item-description)) dile-radio {
|
|
99
|
+
grid-row: 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
dile-radio {
|
|
103
|
+
--dile-radio-label-font-size: 0;
|
|
104
|
+
--dile-radio-space-between-label-and-icon: 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
:host([disabled]) .radio-item-wrapper {
|
|
108
|
+
opacity: 0.6;
|
|
109
|
+
cursor: not-allowed;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
dile-input-message {
|
|
113
|
+
margin-bottom: -5px;
|
|
114
|
+
}
|
|
115
|
+
`
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
static get properties() {
|
|
119
|
+
return {
|
|
120
|
+
...super.properties,
|
|
121
|
+
title: { type: String },
|
|
122
|
+
description: { type: String },
|
|
123
|
+
dialogItems: { type: Array },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
constructor() {
|
|
128
|
+
super();
|
|
129
|
+
this.title = '';
|
|
130
|
+
this.description = '';
|
|
131
|
+
this.dialogItems = [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
render() {
|
|
135
|
+
return html`
|
|
136
|
+
<dile-card>
|
|
137
|
+
${this.title
|
|
138
|
+
? html`<h2 class="dialog-title" id="${this._id}-title">${this.title}</h2>`
|
|
139
|
+
: ''
|
|
140
|
+
}
|
|
141
|
+
${this.description
|
|
142
|
+
? html`<p class="dialog-description" id="${this._id}-description">${this.description}</p>`
|
|
143
|
+
: ''
|
|
144
|
+
}
|
|
145
|
+
<div
|
|
146
|
+
class="radio-items"
|
|
147
|
+
role="radiogroup"
|
|
148
|
+
aria-labelledby="${ifDefined(this.title ? `${this._id}-title` : undefined)}"
|
|
149
|
+
aria-describedby="${ifDefined(this.getGroupDescribedBy())}"
|
|
150
|
+
aria-invalid="${this.errored ? 'true' : 'false'}"
|
|
151
|
+
aria-disabled="${this.disabled ? 'true' : 'false'}"
|
|
152
|
+
@keydown=${this.handleItemsKeydown}
|
|
153
|
+
>
|
|
154
|
+
${this.dialogItems.map((item, index) => {
|
|
155
|
+
const labelId = `${this._id}-item-${index}-label`;
|
|
156
|
+
const descriptionId = `${this._id}-item-${index}-description`;
|
|
157
|
+
const selected = this.value === item.value;
|
|
158
|
+
return html`
|
|
159
|
+
<div
|
|
160
|
+
class="radio-item-wrapper"
|
|
161
|
+
role="radio"
|
|
162
|
+
aria-checked="${selected}"
|
|
163
|
+
aria-disabled="${this.disabled}"
|
|
164
|
+
aria-labelledby="${labelId}"
|
|
165
|
+
aria-describedby="${ifDefined(item.description ? descriptionId : undefined)}"
|
|
166
|
+
tabindex="${this.disabled ? -1 : (this.getTabbableIndex() === index ? 0 : -1)}"
|
|
167
|
+
@click="${() => this.selectItem(index)}"
|
|
168
|
+
>
|
|
169
|
+
<dile-radio
|
|
170
|
+
value="${item.value}"
|
|
171
|
+
?selected=${selected}
|
|
172
|
+
?disabled=${this.disabled}
|
|
173
|
+
decorative
|
|
174
|
+
></dile-radio>
|
|
175
|
+
<span class="item-label" id="${labelId}">${item.label}</span>
|
|
176
|
+
${item.description
|
|
177
|
+
? html`<p class="item-description" id="${descriptionId}">${item.description}</p>`
|
|
178
|
+
: ''
|
|
179
|
+
}
|
|
180
|
+
</div>
|
|
181
|
+
`;
|
|
182
|
+
})}
|
|
183
|
+
</div>
|
|
184
|
+
${this.message
|
|
185
|
+
? html`<dile-input-message id="${this._id}-message" message="${this.message}" ?errored=${this.errored}></dile-input-message>`
|
|
186
|
+
: ''
|
|
187
|
+
}
|
|
188
|
+
</dile-card>
|
|
189
|
+
`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
getGroupDescribedBy() {
|
|
193
|
+
const ids = [];
|
|
194
|
+
if (this.description) ids.push(`${this._id}-description`);
|
|
195
|
+
if (this.message) ids.push(`${this._id}-message`);
|
|
196
|
+
return ids.length ? ids.join(' ') : undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
getTabbableIndex() {
|
|
200
|
+
const selectedIndex = this.dialogItems.findIndex(item => item.value === this.value);
|
|
201
|
+
return selectedIndex !== -1 ? selectedIndex : 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
handleItemsKeydown(e) {
|
|
205
|
+
if (this.disabled) return;
|
|
206
|
+
const wrapper = e.target.closest('.radio-item-wrapper');
|
|
207
|
+
if (!wrapper) return;
|
|
208
|
+
const wrappers = Array.from(this.shadowRoot.querySelectorAll('.radio-item-wrapper'));
|
|
209
|
+
const currentIndex = wrappers.indexOf(wrapper);
|
|
210
|
+
|
|
211
|
+
if (e.key === ' ' || e.key === 'Enter') {
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
this.selectItem(currentIndex);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!NAVIGATION_KEYS.includes(e.key)) return;
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
const nextIndex = this.getFocusableIndex(wrappers, e.key, currentIndex);
|
|
220
|
+
if (nextIndex === -1) return;
|
|
221
|
+
wrappers[nextIndex].focus();
|
|
222
|
+
this.selectItem(nextIndex);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
selectItem(index) {
|
|
226
|
+
if (!this.disabled && this.dialogItems[index]) {
|
|
227
|
+
const item = this.dialogItems[index];
|
|
228
|
+
this.value = item.value;
|
|
229
|
+
if (this.hideErrorOnInput && this.errored) {
|
|
230
|
+
this.message = '';
|
|
231
|
+
this.errored = false;
|
|
232
|
+
}
|
|
233
|
+
this.emmitChange();
|
|
234
|
+
this.dispatchChangeEvent();
|
|
235
|
+
this.internals.setFormValue(this.value);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
dispatchChangeEvent() {
|
|
240
|
+
this.dispatchEvent(new CustomEvent('dile-radio-group-changed', {
|
|
241
|
+
bubbles: true,
|
|
242
|
+
composed: true,
|
|
243
|
+
detail: {
|
|
244
|
+
name: this.name,
|
|
245
|
+
value: this.value
|
|
246
|
+
}
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dile/ui",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.4",
|
|
4
4
|
"description": "UI Core components from dile-components.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
},
|
|
22
22
|
"homepage": "https://dile-components.com/",
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@dile/iconlib": "^2.0.
|
|
24
|
+
"@dile/iconlib": "^2.0.1",
|
|
25
25
|
"@dile/icons": "^2.5.1",
|
|
26
26
|
"lit": "^2.7.0 || ^3.0.0"
|
|
27
27
|
},
|
|
28
28
|
"publishConfig": {
|
|
29
29
|
"access": "public"
|
|
30
30
|
},
|
|
31
|
-
"gitHead": "
|
|
31
|
+
"gitHead": "57e70d708196bb9f563fea1f41516595753af891"
|
|
32
32
|
}
|