@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.
@@ -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) or [`d2l-form-native`](../form/docs/form-native.md) for the validation custom to function.
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) or [`d2l-form-native`](../form/docs/form-native.md) is present.
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