@dile/ui 3.9.2 → 3.9.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,2 +1,3 @@
1
1
  export { DileRadioGroup } from './src/DileRadioGroup.js';
2
- export { DileRadio } from './src/DileRadio.js';
2
+ export { DileRadio } from './src/DileRadio.js';
3
+ export { DileRadioGroupDialog } from './src/DileRadioGroupDialog.js';
@@ -0,0 +1,6 @@
1
+ import { DileRadioGroupDialog } from "./src/DileRadioGroupDialog.js";
2
+ import './radio.js';
3
+
4
+ if (!customElements.get("dile-radio-group-dialog")) {
5
+ window.customElements.define("dile-radio-group-dialog", DileRadioGroupDialog);
6
+ }
@@ -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 @click=${this.select}>
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 @dile-radio-selected=${this.changeValue}>
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.querySelectorAll('dile-radio').forEach(radio => {
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,247 @@
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-title-color: var(--dile-on-background-color, #303030);
15
+ --dile-radio-group-dialog-title-font-size: 1.25rem;
16
+ --dile-radio-group-dialog-title-font-weight: 600;
17
+ --dile-radio-group-dialog-description-color: var(--dile-on-background-color, #303030);
18
+ --dile-radio-group-dialog-description-font-size: 0.95rem;
19
+ --dile-radio-group-dialog-description-margin-bottom: 1.5rem;
20
+ --dile-radio-group-dialog-item-margin-bottom: 1rem;
21
+ --dile-radio-group-dialog-item-gap: 0.25rem 0.75rem;
22
+ --dile-radio-group-dialog-item-label-color: var(--dile-on-background-color, #303030);
23
+ --dile-radio-group-dialog-item-label-font-size: 1rem;
24
+ --dile-radio-group-dialog-item-label-font-weight: 500;
25
+ --dile-radio-group-dialog-item-description-color: var(--dile-gray-dark-color, #555555);
26
+ --dile-radio-group-dialog-item-description-font-size: 0.875rem;
27
+ --dile-radio-group-dialog-item-description-margin-top: 0.5rem;
28
+ }
29
+
30
+ dile-card {
31
+ width: 100%;
32
+ }
33
+
34
+ .dialog-title {
35
+ color: var(--dile-radio-group-dialog-title-color);
36
+ font-size: var(--dile-radio-group-dialog-title-font-size);
37
+ font-weight: var(--dile-radio-group-dialog-title-font-weight);
38
+ margin: 0;
39
+ margin-bottom: 0.5rem;
40
+ }
41
+
42
+ .dialog-description {
43
+ color: var(--dile-radio-group-dialog-description-color);
44
+ font-size: var(--dile-radio-group-dialog-description-font-size);
45
+ margin: 0;
46
+ margin-bottom: var(--dile-radio-group-dialog-description-margin-bottom);
47
+ }
48
+
49
+ .radio-items {
50
+ display: flex;
51
+ flex-direction: column;
52
+ gap: var(--dile-radio-group-dialog-item-margin-bottom);
53
+ }
54
+
55
+ .radio-item-wrapper {
56
+ display: grid;
57
+ grid-template-columns: auto 1fr;
58
+ grid-template-rows: auto auto;
59
+ gap: var(--dile-radio-group-dialog-item-gap, 0.5rem 0.75rem);
60
+ cursor: pointer;
61
+ }
62
+
63
+ .radio-item-wrapper:focus-visible {
64
+ outline: var(--dile-radio-group-dialog-item-focus-outline, 2px solid #4A90E2);
65
+ outline-offset: 2px;
66
+ }
67
+
68
+ .radio-item-wrapper dile-radio {
69
+ grid-row: 1 / 3;
70
+ grid-column: 1;
71
+ display: flex;
72
+ align-items: flex-start;
73
+ padding-top: 0.15rem;
74
+ }
75
+
76
+ .item-label {
77
+ grid-row: 1;
78
+ grid-column: 2;
79
+ color: var(--dile-radio-group-dialog-item-label-color);
80
+ font-size: var(--dile-radio-group-dialog-item-label-font-size);
81
+ font-weight: var(--dile-radio-group-dialog-item-label-font-weight);
82
+ margin: 0;
83
+ cursor: pointer;
84
+ user-select: none;
85
+ align-self: start;
86
+ }
87
+
88
+ .item-description {
89
+ grid-row: 2;
90
+ grid-column: 2;
91
+ color: var(--dile-radio-group-dialog-item-description-color);
92
+ font-size: var(--dile-radio-group-dialog-item-description-font-size);
93
+ margin: 0;
94
+ }
95
+
96
+ .radio-item-wrapper:not(:has(.item-description)) dile-radio {
97
+ grid-row: 1;
98
+ }
99
+
100
+ dile-radio {
101
+ --dile-radio-label-font-size: 0;
102
+ --dile-radio-space-between-label-and-icon: 0;
103
+ }
104
+
105
+ :host([disabled]) .radio-item-wrapper {
106
+ opacity: 0.6;
107
+ cursor: not-allowed;
108
+ }
109
+
110
+ dile-input-message {
111
+ margin-bottom: -5px;
112
+ }
113
+ `
114
+ ];
115
+
116
+ static get properties() {
117
+ return {
118
+ ...super.properties,
119
+ title: { type: String },
120
+ description: { type: String },
121
+ dialogItems: { type: Array },
122
+ };
123
+ }
124
+
125
+ constructor() {
126
+ super();
127
+ this.title = '';
128
+ this.description = '';
129
+ this.dialogItems = [];
130
+ }
131
+
132
+ render() {
133
+ return html`
134
+ <dile-card>
135
+ ${this.title
136
+ ? html`<h2 class="dialog-title" id="${this._id}-title">${this.title}</h2>`
137
+ : ''
138
+ }
139
+ ${this.description
140
+ ? html`<p class="dialog-description" id="${this._id}-description">${this.description}</p>`
141
+ : ''
142
+ }
143
+ <div
144
+ class="radio-items"
145
+ role="radiogroup"
146
+ aria-labelledby="${ifDefined(this.title ? `${this._id}-title` : undefined)}"
147
+ aria-describedby="${ifDefined(this.getGroupDescribedBy())}"
148
+ aria-invalid="${this.errored ? 'true' : 'false'}"
149
+ aria-disabled="${this.disabled ? 'true' : 'false'}"
150
+ @keydown=${this.handleItemsKeydown}
151
+ >
152
+ ${this.dialogItems.map((item, index) => {
153
+ const labelId = `${this._id}-item-${index}-label`;
154
+ const descriptionId = `${this._id}-item-${index}-description`;
155
+ const selected = this.value === item.value;
156
+ return html`
157
+ <div
158
+ class="radio-item-wrapper"
159
+ role="radio"
160
+ aria-checked="${selected}"
161
+ aria-disabled="${this.disabled}"
162
+ aria-labelledby="${labelId}"
163
+ aria-describedby="${ifDefined(item.description ? descriptionId : undefined)}"
164
+ tabindex="${this.disabled ? -1 : (this.getTabbableIndex() === index ? 0 : -1)}"
165
+ @click="${() => this.selectItem(index)}"
166
+ >
167
+ <dile-radio
168
+ value="${item.value}"
169
+ ?selected=${selected}
170
+ ?disabled=${this.disabled}
171
+ decorative
172
+ ></dile-radio>
173
+ <span class="item-label" id="${labelId}">${item.label}</span>
174
+ ${item.description
175
+ ? html`<p class="item-description" id="${descriptionId}">${item.description}</p>`
176
+ : ''
177
+ }
178
+ </div>
179
+ `;
180
+ })}
181
+ </div>
182
+ ${this.message
183
+ ? html`<dile-input-message id="${this._id}-message" message="${this.message}" ?errored=${this.errored}></dile-input-message>`
184
+ : ''
185
+ }
186
+ </dile-card>
187
+ `;
188
+ }
189
+
190
+ getGroupDescribedBy() {
191
+ const ids = [];
192
+ if (this.description) ids.push(`${this._id}-description`);
193
+ if (this.message) ids.push(`${this._id}-message`);
194
+ return ids.length ? ids.join(' ') : undefined;
195
+ }
196
+
197
+ getTabbableIndex() {
198
+ const selectedIndex = this.dialogItems.findIndex(item => item.value === this.value);
199
+ return selectedIndex !== -1 ? selectedIndex : 0;
200
+ }
201
+
202
+ handleItemsKeydown(e) {
203
+ if (this.disabled) return;
204
+ const wrapper = e.target.closest('.radio-item-wrapper');
205
+ if (!wrapper) return;
206
+ const wrappers = Array.from(this.shadowRoot.querySelectorAll('.radio-item-wrapper'));
207
+ const currentIndex = wrappers.indexOf(wrapper);
208
+
209
+ if (e.key === ' ' || e.key === 'Enter') {
210
+ e.preventDefault();
211
+ this.selectItem(currentIndex);
212
+ return;
213
+ }
214
+
215
+ if (!NAVIGATION_KEYS.includes(e.key)) return;
216
+ e.preventDefault();
217
+ const nextIndex = this.getFocusableIndex(wrappers, e.key, currentIndex);
218
+ if (nextIndex === -1) return;
219
+ wrappers[nextIndex].focus();
220
+ this.selectItem(nextIndex);
221
+ }
222
+
223
+ selectItem(index) {
224
+ if (!this.disabled && this.dialogItems[index]) {
225
+ const item = this.dialogItems[index];
226
+ this.value = item.value;
227
+ if (this.hideErrorOnInput && this.errored) {
228
+ this.message = '';
229
+ this.errored = false;
230
+ }
231
+ this.emmitChange();
232
+ this.dispatchChangeEvent();
233
+ this.internals.setFormValue(this.value);
234
+ }
235
+ }
236
+
237
+ dispatchChangeEvent() {
238
+ this.dispatchEvent(new CustomEvent('dile-radio-group-changed', {
239
+ bubbles: true,
240
+ composed: true,
241
+ detail: {
242
+ name: this.name,
243
+ value: this.value
244
+ }
245
+ }));
246
+ }
247
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dile/ui",
3
- "version": "3.9.2",
3
+ "version": "3.9.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": "b825edcebfd49f3f524ffd552197b3be48ad4e45"
31
+ "gitHead": "6c8664d4efb3dea1f363ed00eee50c8964fa2321"
32
32
  }