@brightspace-ui/core 3.116.6 → 3.118.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/form/README.md +3 -4
- package/components/form/docs/form-element-mixin.md +5 -7
- package/components/form/docs/form-element-nesting.md +1 -1
- package/components/form/docs/form.md +1 -74
- package/components/form/form.js +163 -2
- package/components/inputs/demo/input-radio-label-test.js +1 -2
- package/components/inputs/demo/input-radio.html +63 -21
- package/components/inputs/input-radio-group.js +239 -0
- package/components/inputs/input-radio.js +177 -0
- package/components/validation/README.md +2 -2
- package/custom-elements.json +186 -110
- package/package.json +1 -1
- package/components/form/demo/form-native-demo.js +0 -80
- package/components/form/demo/form-native.html +0 -29
- package/components/form/form-mixin.js +0 -202
- package/components/form/form-native.js +0 -148
- package/components/inputs/demo/input-radio-label-simple-test.js +0 -25
- package/components/inputs/demo/input-radio-spacer-test.js +0 -42
@@ -0,0 +1,239 @@
|
|
1
|
+
import { css, html, LitElement } from 'lit';
|
2
|
+
import { FormElementMixin } from '../form/form-element-mixin.js';
|
3
|
+
import { getUniqueId } from '../../helpers/uniqueId.js';
|
4
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
5
|
+
import { inputLabelStyles } from './input-label-styles.js';
|
6
|
+
import { PropertyRequiredMixin } from '../../mixins/property-required/property-required-mixin.js';
|
7
|
+
import { SkeletonMixin } from '../skeleton/skeleton-mixin.js';
|
8
|
+
|
9
|
+
/**
|
10
|
+
* A group of <d2l-input-radio> components.
|
11
|
+
* @slot - Radio components
|
12
|
+
* @fires change - Dispatched when the radio group's state changes
|
13
|
+
*/
|
14
|
+
class InputRadioGroup extends PropertyRequiredMixin(SkeletonMixin(FormElementMixin(LitElement))) {
|
15
|
+
|
16
|
+
static get properties() {
|
17
|
+
return {
|
18
|
+
/**
|
19
|
+
* REQUIRED: Label for the input
|
20
|
+
* @type {string}
|
21
|
+
*/
|
22
|
+
label: { required: true, type: String },
|
23
|
+
/**
|
24
|
+
* Hides the label visually
|
25
|
+
* @type {boolean}
|
26
|
+
*/
|
27
|
+
labelHidden: { attribute: 'label-hidden', reflect: true, type: Boolean },
|
28
|
+
/**
|
29
|
+
* Indicates that a value is required
|
30
|
+
* @type {boolean}
|
31
|
+
*/
|
32
|
+
required: { type: Boolean, reflect: true }
|
33
|
+
};
|
34
|
+
}
|
35
|
+
|
36
|
+
static get styles() {
|
37
|
+
return [super.styles, inputLabelStyles, css`
|
38
|
+
:host {
|
39
|
+
display: block;
|
40
|
+
}
|
41
|
+
:host([hidden]) {
|
42
|
+
display: none;
|
43
|
+
}
|
44
|
+
div[role="radiogroup"] {
|
45
|
+
display: flex;
|
46
|
+
flex-direction: column;
|
47
|
+
gap: 0.6rem;
|
48
|
+
}
|
49
|
+
.d2l-input-label[hidden] {
|
50
|
+
display: none;
|
51
|
+
}
|
52
|
+
::slotted(:not(d2l-input-radio)) {
|
53
|
+
display: none;
|
54
|
+
}
|
55
|
+
`];
|
56
|
+
}
|
57
|
+
|
58
|
+
constructor() {
|
59
|
+
super();
|
60
|
+
this.labelHidden = false;
|
61
|
+
this.required = false;
|
62
|
+
this.setFormValue('');
|
63
|
+
}
|
64
|
+
|
65
|
+
render() {
|
66
|
+
return html`
|
67
|
+
<span class="d2l-input-label" ?hidden="${this.labelHidden}" id="${this.#labelId}"><span class="d2l-skeletize">${this.label}</span></span>
|
68
|
+
<div
|
69
|
+
aria-invalid="${ifDefined(this.invalid ? 'true' : undefined)}"
|
70
|
+
aria-labelledby="${this.#labelId}"
|
71
|
+
aria-required="${ifDefined(this.required ? 'true' : undefined)}"
|
72
|
+
@click="${this.#handleClick}"
|
73
|
+
@d2l-input-radio-checked="${this.#handleRadioChecked}"
|
74
|
+
@keydown="${this.#handleKeyDown}"
|
75
|
+
role="radiogroup">
|
76
|
+
<slot @slotchange="${this.#handleSlotChange}"></slot>
|
77
|
+
</div>
|
78
|
+
`;
|
79
|
+
}
|
80
|
+
|
81
|
+
willUpdate(changedProperties) {
|
82
|
+
super.willUpdate(changedProperties);
|
83
|
+
if (changedProperties.has('invalid')) {
|
84
|
+
const radios = this.#getRadios();
|
85
|
+
radios.forEach(el => el._invalid = this.invalid);
|
86
|
+
}
|
87
|
+
if (changedProperties.has('required')) {
|
88
|
+
this.#recalculateState(true);
|
89
|
+
}
|
90
|
+
}
|
91
|
+
|
92
|
+
focus() {
|
93
|
+
const radios = this.#getRadios();
|
94
|
+
if (radios.length === 0) return;
|
95
|
+
let firstFocusable = null;
|
96
|
+
let firstChecked = null;
|
97
|
+
radios.forEach(el => {
|
98
|
+
if (firstFocusable === null && !el.disabled) firstFocusable = el;
|
99
|
+
if (firstChecked === null && el._checked) firstChecked = el;
|
100
|
+
});
|
101
|
+
const focusElem = firstChecked || firstFocusable;
|
102
|
+
focusElem.focus();
|
103
|
+
setTimeout(() => focusElem.focus()); // timeout required when following link from form validation
|
104
|
+
}
|
105
|
+
|
106
|
+
#labelId = getUniqueId();
|
107
|
+
|
108
|
+
async #doUpdateChecked(newChecked, doFocus, doDispatchEvent) {
|
109
|
+
const radios = this.#getRadios();
|
110
|
+
let prevChecked = null;
|
111
|
+
radios.forEach(el => {
|
112
|
+
if (el._checked) prevChecked = el;
|
113
|
+
});
|
114
|
+
if (prevChecked === newChecked) return;
|
115
|
+
|
116
|
+
newChecked._checked = true;
|
117
|
+
if (prevChecked !== null) {
|
118
|
+
prevChecked._checked = false;
|
119
|
+
}
|
120
|
+
this.#recalculateState(true);
|
121
|
+
|
122
|
+
if (doDispatchEvent) {
|
123
|
+
this.dispatchEvent(new CustomEvent('change', {
|
124
|
+
bubbles: true,
|
125
|
+
composed: true,
|
126
|
+
detail: {
|
127
|
+
value: newChecked.value,
|
128
|
+
oldValue: prevChecked?.value
|
129
|
+
}
|
130
|
+
}));
|
131
|
+
}
|
132
|
+
|
133
|
+
if (doFocus) {
|
134
|
+
await newChecked.updateComplete; // wait for tabindex to be updated
|
135
|
+
newChecked.focus();
|
136
|
+
}
|
137
|
+
}
|
138
|
+
|
139
|
+
#getRadios() {
|
140
|
+
const elems = this.shadowRoot?.querySelector('slot')?.assignedElements();
|
141
|
+
if (!elems) return [];
|
142
|
+
return elems.filter(el => el.tagName === 'D2L-INPUT-RADIO');
|
143
|
+
}
|
144
|
+
|
145
|
+
#handleClick(e) {
|
146
|
+
if (e.target.tagName !== 'D2L-INPUT-RADIO') return;
|
147
|
+
if (e.target.disabled) return;
|
148
|
+
this.#doUpdateChecked(e.target, true, true);
|
149
|
+
e.preventDefault();
|
150
|
+
}
|
151
|
+
|
152
|
+
#handleKeyDown(e) {
|
153
|
+
if (e.target.tagName !== 'D2L-INPUT-RADIO') return;
|
154
|
+
|
155
|
+
const isRtl = (getComputedStyle(this).direction === 'rtl');
|
156
|
+
let newOffset = null;
|
157
|
+
if (e.key === ' ') {
|
158
|
+
newOffset = 0;
|
159
|
+
} else if (e.key === 'ArrowUp' || (!isRtl && e.key === 'ArrowLeft') || (isRtl && e.key === 'ArrowRight')) {
|
160
|
+
newOffset = -1;
|
161
|
+
} else if (e.key === 'ArrowDown' || (!isRtl && e.key === 'ArrowRight') || (isRtl && e.key === 'ArrowLeft')) {
|
162
|
+
newOffset = 1;
|
163
|
+
}
|
164
|
+
|
165
|
+
if (newOffset === null) return;
|
166
|
+
|
167
|
+
const radios = this.#getRadios().filter(el => !el.disabled || el._checked);
|
168
|
+
let checkedIndex = -1;
|
169
|
+
let firstFocusableIndex = -1;
|
170
|
+
radios.forEach((el, i) => {
|
171
|
+
if (el._checked) checkedIndex = i;
|
172
|
+
if (firstFocusableIndex < 0 && !el.disabled) firstFocusableIndex = i;
|
173
|
+
});
|
174
|
+
if (checkedIndex === -1) {
|
175
|
+
if (firstFocusableIndex === -1) return;
|
176
|
+
checkedIndex = firstFocusableIndex;
|
177
|
+
}
|
178
|
+
|
179
|
+
const newIndex = (checkedIndex + newOffset + radios.length) % radios.length;
|
180
|
+
this.#doUpdateChecked(radios[newIndex], true, true);
|
181
|
+
|
182
|
+
e.preventDefault();
|
183
|
+
}
|
184
|
+
|
185
|
+
#handleRadioChecked(e) {
|
186
|
+
if (e.detail.checked) {
|
187
|
+
this.#doUpdateChecked(e.target, false, false);
|
188
|
+
} else {
|
189
|
+
e.target._checked = false;
|
190
|
+
this.#recalculateState(true);
|
191
|
+
}
|
192
|
+
}
|
193
|
+
|
194
|
+
#handleSlotChange() {
|
195
|
+
this.#recalculateState(false);
|
196
|
+
}
|
197
|
+
|
198
|
+
#recalculateState(doValidate) {
|
199
|
+
const radios = this.#getRadios();
|
200
|
+
if (radios.length === 0) return;
|
201
|
+
|
202
|
+
let firstFocusable = null;
|
203
|
+
const checkedRadios = [];
|
204
|
+
radios.forEach(el => {
|
205
|
+
if (firstFocusable === null && !el.disabled) firstFocusable = el;
|
206
|
+
if (el._checked) checkedRadios.push(el);
|
207
|
+
el._isInitFromGroup = true;
|
208
|
+
el._firstFocusable = false;
|
209
|
+
});
|
210
|
+
|
211
|
+
// let the first non-disabled radio know it's first so it can be focusable
|
212
|
+
if (checkedRadios.length === 0 && firstFocusable !== null) {
|
213
|
+
firstFocusable._firstFocusable = true;
|
214
|
+
}
|
215
|
+
|
216
|
+
// only the last checked radio is actually checked
|
217
|
+
for (let i = 0; i < checkedRadios.length - 1; i++) {
|
218
|
+
checkedRadios[i]._checked = false;
|
219
|
+
}
|
220
|
+
if (checkedRadios.length > 0) {
|
221
|
+
const lastCheckedRadio = checkedRadios[checkedRadios.length - 1];
|
222
|
+
lastCheckedRadio._checked = true;
|
223
|
+
this.setFormValue(lastCheckedRadio.value);
|
224
|
+
if (this.required) {
|
225
|
+
this.setValidity({ valueMissing: false });
|
226
|
+
}
|
227
|
+
} else {
|
228
|
+
this.setFormValue('');
|
229
|
+
if (this.required) {
|
230
|
+
this.setValidity({ valueMissing: true });
|
231
|
+
}
|
232
|
+
}
|
233
|
+
if (doValidate && this.required) {
|
234
|
+
this.requestValidate(true);
|
235
|
+
}
|
236
|
+
}
|
237
|
+
|
238
|
+
}
|
239
|
+
customElements.define('d2l-input-radio-group', InputRadioGroup);
|
@@ -0,0 +1,177 @@
|
|
1
|
+
import { css, html, LitElement, nothing } from 'lit';
|
2
|
+
import { classMap } from 'lit/directives/class-map.js';
|
3
|
+
import { FocusMixin } from '../../mixins/focus/focus-mixin.js';
|
4
|
+
import { getUniqueId } from '../../helpers/uniqueId.js';
|
5
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
6
|
+
import { InputInlineHelpMixin } from './input-inline-help.js';
|
7
|
+
import { PropertyRequiredMixin } from '../../mixins/property-required/property-required-mixin.js';
|
8
|
+
import { radioStyles } from './input-radio-styles.js';
|
9
|
+
import { SkeletonMixin } from '../skeleton/skeleton-mixin.js';
|
10
|
+
|
11
|
+
/**
|
12
|
+
* A radio input within a <d2l-input-radio-group>.
|
13
|
+
* @slot inline-help - Help text that will appear below the input. Use this only when other helpful cues are not sufficient, such as a carefully-worded label.
|
14
|
+
* @slot supporting - Supporting information which will appear below and be aligned with the input.
|
15
|
+
*/
|
16
|
+
class InputRadio extends InputInlineHelpMixin(SkeletonMixin(FocusMixin(PropertyRequiredMixin(LitElement)))) {
|
17
|
+
|
18
|
+
static get properties() {
|
19
|
+
return {
|
20
|
+
/**
|
21
|
+
* Checked state
|
22
|
+
* @type {boolean}
|
23
|
+
*/
|
24
|
+
checked: { type: Boolean, reflect: true },
|
25
|
+
/**
|
26
|
+
* ACCESSIBILITY: Additional information communicated to screenreader users when focusing on the input
|
27
|
+
* @type {string}
|
28
|
+
*/
|
29
|
+
description: { type: String },
|
30
|
+
/**
|
31
|
+
* Disables the input
|
32
|
+
* @type {boolean}
|
33
|
+
*/
|
34
|
+
disabled: { type: Boolean, reflect: true },
|
35
|
+
/**
|
36
|
+
* REQUIRED: Label for the input
|
37
|
+
* @type {string}
|
38
|
+
*/
|
39
|
+
label: { required: true, type: String },
|
40
|
+
/**
|
41
|
+
* Hides the supporting slot when unchecked
|
42
|
+
* @type {boolean}
|
43
|
+
*/
|
44
|
+
supportingHiddenWhenUnchecked: { type: Boolean, attribute: 'supporting-hidden-when-unchecked', reflect: true },
|
45
|
+
/**
|
46
|
+
* Value of the input
|
47
|
+
* @type {string}
|
48
|
+
*/
|
49
|
+
value: { type: String },
|
50
|
+
_checked: { state: true },
|
51
|
+
_firstFocusable: { state: true },
|
52
|
+
_hasSupporting: { state: true },
|
53
|
+
_isHovered: { state: true },
|
54
|
+
_invalid: { state: true }
|
55
|
+
};
|
56
|
+
}
|
57
|
+
|
58
|
+
static get styles() {
|
59
|
+
return [super.styles, radioStyles, css`
|
60
|
+
:host {
|
61
|
+
display: block;
|
62
|
+
}
|
63
|
+
:host([hidden]) {
|
64
|
+
display: none;
|
65
|
+
}
|
66
|
+
.d2l-input-radio-label {
|
67
|
+
cursor: default;
|
68
|
+
margin-block-end: 0;
|
69
|
+
}
|
70
|
+
.d2l-input-inline-help,
|
71
|
+
.d2l-input-radio-supporting {
|
72
|
+
margin-inline-start: 1.7rem;
|
73
|
+
}
|
74
|
+
.d2l-input-radio-supporting {
|
75
|
+
display: none;
|
76
|
+
margin-block-start: 0.6rem;
|
77
|
+
}
|
78
|
+
.d2l-input-radio-supporting-visible {
|
79
|
+
display: block;
|
80
|
+
}
|
81
|
+
`];
|
82
|
+
}
|
83
|
+
|
84
|
+
constructor() {
|
85
|
+
super();
|
86
|
+
this.disabled = false;
|
87
|
+
this.supportingHiddenWhenUnchecked = false;
|
88
|
+
this.value = 'on';
|
89
|
+
this._checked = false;
|
90
|
+
this._firstFocusable = false;
|
91
|
+
this._hasSupporting = false;
|
92
|
+
this._isHovered = false;
|
93
|
+
this._isInitFromGroup = false;
|
94
|
+
this._invalid = false;
|
95
|
+
}
|
96
|
+
|
97
|
+
get checked() { return this._checked; }
|
98
|
+
set checked(value) {
|
99
|
+
if (value === this._checked) return;
|
100
|
+
if (!this._isInitFromGroup) {
|
101
|
+
this._checked = value;
|
102
|
+
} else {
|
103
|
+
/** @ignore */
|
104
|
+
this.dispatchEvent(
|
105
|
+
new CustomEvent(
|
106
|
+
'd2l-input-radio-checked',
|
107
|
+
{ bubbles: true, composed: false, detail: { checked: value } }
|
108
|
+
)
|
109
|
+
);
|
110
|
+
}
|
111
|
+
}
|
112
|
+
|
113
|
+
static get focusElementSelector() {
|
114
|
+
return '.d2l-input-radio';
|
115
|
+
}
|
116
|
+
|
117
|
+
render() {
|
118
|
+
const disabled = this.disabled || this.skeleton;
|
119
|
+
const labelClasses = {
|
120
|
+
'd2l-input-radio-label': true,
|
121
|
+
'd2l-input-radio-label-disabled': this.disabled && !this.skeleton,
|
122
|
+
};
|
123
|
+
const radioClasses = {
|
124
|
+
'd2l-input-radio': true,
|
125
|
+
'd2l-disabled': this.disabled && !this.skeleton,
|
126
|
+
'd2l-hovering': this._isHovered && !disabled,
|
127
|
+
'd2l-skeletize': true
|
128
|
+
};
|
129
|
+
const supportingClasses = {
|
130
|
+
'd2l-input-radio-supporting': true,
|
131
|
+
'd2l-input-radio-supporting-visible': this._hasSupporting && (!this.supportingHiddenWhenUnchecked || this._checked),
|
132
|
+
};
|
133
|
+
const description = this.description ? html`<div id="${this.#descriptionId}" hidden>${this.description}</div>` : nothing;
|
134
|
+
const ariaDescribedByIds = `${this.description ? this.#descriptionId : ''} ${this._hasInlineHelp ? this.#inlineHelpId : ''}`.trim();
|
135
|
+
const tabindex = (!disabled && (this._checked || this._firstFocusable)) ? '0' : undefined;
|
136
|
+
return html`
|
137
|
+
<div class="${classMap(labelClasses)}" @mouseover="${this.#handleMouseOver}" @mouseout="${this.#handleMouseOut}">
|
138
|
+
<div
|
139
|
+
aria-checked="${this._checked}"
|
140
|
+
aria-describedby="${ifDefined(ariaDescribedByIds.length > 0 ? ariaDescribedByIds : undefined)}"
|
141
|
+
aria-disabled="${ifDefined(disabled ? 'true' : undefined)}"
|
142
|
+
aria-invalid="${ifDefined(this._invalid ? 'true' : undefined)}"
|
143
|
+
aria-labelledby="${this.#labelId}"
|
144
|
+
class="${classMap(radioClasses)}"
|
145
|
+
role="radio"
|
146
|
+
tabindex="${ifDefined(tabindex)}"></div>
|
147
|
+
<div id="${this.#labelId}" class="d2l-skeletize">${this.label}</div>
|
148
|
+
</div>
|
149
|
+
${this._renderInlineHelp(this.#inlineHelpId)}
|
150
|
+
${description}
|
151
|
+
<div class="${classMap(supportingClasses)}" @change="${this.#handleSupportingChange}"><slot name="supporting" @slotchange="${this.#handleSupportingSlotChange}"></slot></div>
|
152
|
+
`;
|
153
|
+
}
|
154
|
+
|
155
|
+
#descriptionId = getUniqueId();
|
156
|
+
#inlineHelpId = getUniqueId();
|
157
|
+
#labelId = getUniqueId();
|
158
|
+
|
159
|
+
#handleMouseOut() {
|
160
|
+
this._isHovered = false;
|
161
|
+
}
|
162
|
+
|
163
|
+
#handleMouseOver() {
|
164
|
+
this._isHovered = true;
|
165
|
+
}
|
166
|
+
|
167
|
+
#handleSupportingChange(e) {
|
168
|
+
e.stopPropagation();
|
169
|
+
}
|
170
|
+
|
171
|
+
#handleSupportingSlotChange(e) {
|
172
|
+
const content = e.target.assignedNodes({ flatten: true });
|
173
|
+
this._hasSupporting = content?.length > 0;
|
174
|
+
}
|
175
|
+
|
176
|
+
}
|
177
|
+
customElements.define('d2l-input-radio', InputRadio);
|
@@ -4,10 +4,10 @@
|
|
4
4
|
The `d2l-validation-custom` component is used to add custom validation logic to native form elements like `input`, `select` and `textarea` or custom form elements created with the [`FormElementMixin`](../form/docs/form-element-mixin.md).
|
5
5
|
|
6
6
|
**Native Form Elements:**
|
7
|
-
- When attached to native form elements like `input`, `select` and `textarea`, both the `d2l-validation-custom` and native form element **must** be within a [`d2l-form`](../form/docs/form.md)
|
7
|
+
- When attached to native form elements like `input`, `select` and `textarea`, both the `d2l-validation-custom` and native form element **must** be within a [`d2l-form`](../form/docs/form.md) for the validation custom to function.
|
8
8
|
|
9
9
|
**Custom Form Elements:**
|
10
|
-
- When attached to custom form elements created with the [`FormElementMixin`](../form/docs/form-element-mixin.md), the `d2l-validation-custom` will function even if no [`d2l-form`](../form/docs/form.md)
|
10
|
+
- When attached to custom form elements created with the [`FormElementMixin`](../form/docs/form-element-mixin.md), the `d2l-validation-custom` will function even if no [`d2l-form`](../form/docs/form.md) is present.
|
11
11
|
|
12
12
|
**Usage:**
|
13
13
|
```html
|