@brightspace-ui/core 2.162.0 → 2.163.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.
@@ -11,6 +11,7 @@ import { getValidHexColor } from '../../helpers/color.js';
11
11
  import { ifDefined } from 'lit/directives/if-defined.js';
12
12
  import { inputLabelStyles } from './input-label-styles.js';
13
13
  import { LocalizeCoreElement } from '../../helpers/localize-core-element.js';
14
+ import { PropertyRequiredMixin } from '../../mixins/property-required/property-required-mixin.js';
14
15
  import { styleMap } from 'lit/directives/style-map.js';
15
16
 
16
17
  const DEFAULT_VALUE = '#000000';
@@ -80,7 +81,7 @@ const SWATCH_TRANSPARENT = `<svg xmlns="http://www.w3.org/2000/svg" width="24" h
80
81
  * This component allows for inputting a HEX color value.
81
82
  * @fires change - Dispatched when an alteration to the value is committed by the user.
82
83
  */
83
- class InputColor extends FocusMixin(FormElementMixin(LocalizeCoreElement(LitElement))) {
84
+ class InputColor extends PropertyRequiredMixin(FocusMixin(FormElementMixin(LocalizeCoreElement(LitElement)))) {
84
85
 
85
86
  static get properties() {
86
87
  return {
@@ -103,7 +104,13 @@ class InputColor extends FocusMixin(FormElementMixin(LocalizeCoreElement(LitElem
103
104
  * REQUIRED: Label for the input, comes with a default value for background & foreground types.
104
105
  * @type {string}
105
106
  */
106
- label: { type: String },
107
+ label: {
108
+ type: String,
109
+ required: {
110
+ dependentProps: ['type'],
111
+ validator: (_value, elem, hasValue) => elem.type !== 'custom' || hasValue
112
+ }
113
+ },
107
114
  /**
108
115
  * Hides the label visually
109
116
  * @type {boolean}
@@ -231,7 +238,6 @@ class InputColor extends FocusMixin(FormElementMixin(LocalizeCoreElement(LitElem
231
238
  this._associatedValue = undefined;
232
239
  this._missingLabelErrorHasBeenThrown = false;
233
240
  this._opened = false;
234
- this._validatingLabelTimeout = null;
235
241
  this._value = undefined;
236
242
  }
237
243
 
@@ -258,11 +264,6 @@ class InputColor extends FocusMixin(FormElementMixin(LocalizeCoreElement(LitElem
258
264
  return '#opener';
259
265
  }
260
266
 
261
- firstUpdated(changedProperties) {
262
- super.firstUpdated(changedProperties);
263
- this._validateLabel();
264
- }
265
-
266
267
  render() {
267
268
 
268
269
  const label = !this.labelHidden ? html`<div class="d2l-input-label">${this._getLabel()}</div>` : nothing;
@@ -277,8 +278,6 @@ class InputColor extends FocusMixin(FormElementMixin(LocalizeCoreElement(LitElem
277
278
 
278
279
  super.updated(changedProperties);
279
280
 
280
- if (changedProperties.has('label') || changedProperties.has('type')) this._validateLabel();
281
-
282
281
  if (changedProperties.has('value') || changedProperties.has('type') || changedProperties.has('disallowNone')) {
283
282
  this.setFormValue(this.value);
284
283
  }
@@ -397,17 +396,5 @@ class InputColor extends FocusMixin(FormElementMixin(LocalizeCoreElement(LitElem
397
396
  ));
398
397
  }
399
398
 
400
- _validateLabel() {
401
- clearTimeout(this._validatingLabelTimeout);
402
- // don't error immediately in case it doesn't get set immediately
403
- this._validatingLabelTimeout = setTimeout(() => {
404
- this._validatingLabelTimeout = null;
405
- const hasLabel = (typeof this.label === 'string') && this.label.length > 0;
406
- if (!hasLabel && this.type === 'custom') {
407
- throw new Error('<d2l-input-color>: "label" attribute is required when "type" is "custom"');
408
- }
409
- }, 3000);
410
- }
411
-
412
399
  }
413
400
  customElements.define('d2l-input-color', InputColor);
@@ -9,6 +9,7 @@ import { classMap } from 'lit/directives/class-map.js';
9
9
  import { getUniqueId } from '../../helpers/uniqueId.js';
10
10
  import { ifDefined } from 'lit/directives/if-defined.js';
11
11
  import { LocalizeCoreElement } from '../../helpers/localize-core-element.js';
12
+ import { PropertyRequiredMixin } from '../../mixins/property-required/property-required-mixin.js';
12
13
  import { RtlMixin } from '../../mixins/rtl/rtl-mixin.js';
13
14
 
14
15
  const keyCodes = {
@@ -18,7 +19,7 @@ const keyCodes = {
18
19
  SPACE: 32
19
20
  };
20
21
 
21
- export const TagListItemMixin = superclass => class extends LocalizeCoreElement(RtlMixin(superclass)) {
22
+ export const TagListItemMixin = superclass => class extends LocalizeCoreElement(PropertyRequiredMixin(RtlMixin(superclass))) {
22
23
 
23
24
  static get properties() {
24
25
  return {
@@ -39,7 +40,16 @@ export const TagListItemMixin = superclass => class extends LocalizeCoreElement(
39
40
  /**
40
41
  * @ignore
41
42
  */
42
- keyboardTooltipShown: { type: Boolean, attribute: 'keyboard-tooltip-shown' }
43
+ keyboardTooltipShown: { type: Boolean, attribute: 'keyboard-tooltip-shown' },
44
+ /**
45
+ * @ignore
46
+ */
47
+ _plainText: {
48
+ state: true,
49
+ required: {
50
+ message: (_value, elem) => `TagListItemMixin: "${elem.tagName.toLowerCase()}" called "_renderTag()" with empty "plainText" option`
51
+ }
52
+ }
43
53
  };
44
54
  }
45
55
 
@@ -136,7 +146,6 @@ export const TagListItemMixin = superclass => class extends LocalizeCoreElement(
136
146
  this.keyboardTooltipShown = false;
137
147
  this._id = getUniqueId();
138
148
  this._plainText = '';
139
- this._validatingPlainTextTimeout = null;
140
149
  }
141
150
 
142
151
  firstUpdated(changedProperties) {
@@ -217,7 +226,6 @@ export const TagListItemMixin = superclass => class extends LocalizeCoreElement(
217
226
  }
218
227
 
219
228
  _renderTag(tagContent, options = {}) {
220
- this._validatePlainText();
221
229
  this._plainText = options.plainText || '';
222
230
 
223
231
  const buttonText = this.localize('components.tag-list.clear', { value: this._plainText });
@@ -277,17 +285,4 @@ export const TagListItemMixin = superclass => class extends LocalizeCoreElement(
277
285
  `;
278
286
  }
279
287
 
280
- _validatePlainText() {
281
- clearTimeout(this._validatingPlainTextTimeout);
282
- // don't error immediately in case it doesn't get set immediately
283
- this._validatingPlainTextTimeout = setTimeout(() => {
284
- this._validatingPlainTextTimeout = null;
285
- if (!this.isConnected) return;
286
- const hasPlainText = (this._plainText?.constructor === String) && this._plainText?.length > 0;
287
- if (!hasPlainText) {
288
- throw new Error(`TagListItemMixin: "${this.tagName.toLowerCase()}" called "_render()" with empty "plainText" option`);
289
- }
290
- }, 3000);
291
- }
292
-
293
288
  };
@@ -0,0 +1,27 @@
1
+ import { getComposedParent } from './dom.js';
2
+
3
+ function getComposedPath(elem, opts) {
4
+
5
+ if (!opts?.composedPath) return '';
6
+
7
+ const composedParents = [];
8
+ let parent = elem;
9
+ while (parent !== null && parent?.tagName?.toLowerCase() !== 'body') {
10
+ if (parent?.tagName) {
11
+ composedParents.push(parent.tagName.toLowerCase());
12
+ }
13
+ parent = getComposedParent(parent);
14
+ }
15
+
16
+ if (composedParents.length === 0) return '';
17
+
18
+ composedParents.reverse();
19
+ const path = ` Path: "${composedParents.join(' > ')}".`;
20
+ return path;
21
+
22
+ }
23
+
24
+ export function createElementErrorMessage(type, elem, message, opts) {
25
+ const path = getComposedPath(elem, opts);
26
+ return `<${elem.tagName.toLowerCase()}>: ${message}.${path} (@brightspace-ui/core:${type})`;
27
+ }
@@ -1,5 +1,6 @@
1
1
 
2
2
  import { cssEscape } from '../../helpers/dom.js';
3
+ import { PropertyRequiredMixin } from '../property-required/property-required-mixin.js';
3
4
 
4
5
  const getCommonAncestor = (elem1, elem2) => {
5
6
 
@@ -74,7 +75,7 @@ export const LabelMixin = superclass => class extends superclass {
74
75
 
75
76
  };
76
77
 
77
- export const LabelledMixin = superclass => class extends superclass {
78
+ export const LabelledMixin = superclass => class extends PropertyRequiredMixin(superclass) {
78
79
 
79
80
  static get properties() {
80
81
  return {
@@ -87,7 +88,20 @@ export const LabelledMixin = superclass => class extends superclass {
87
88
  * REQUIRED: Explicitly defined label for the element
88
89
  * @type {string}
89
90
  */
90
- label: { type: String }
91
+ label: {
92
+ type: String,
93
+ required: {
94
+ message: (_value, elem, defaultMessage) => {
95
+ if (!elem.labelledBy) return defaultMessage;
96
+ return `LabelledMixin: "${elem.tagName.toLowerCase()}" is labelled-by="${elem.labelledBy}", but its label is empty`;
97
+ },
98
+ validator: (_value, elem, hasValue) => {
99
+ if (!elem.labelRequired || hasValue) return true;
100
+ if (!elem.labelledBy) return false;
101
+ return elem._labelElem !== null;
102
+ }
103
+ }
104
+ }
91
105
  };
92
106
  }
93
107
 
@@ -96,20 +110,12 @@ export const LabelledMixin = superclass => class extends superclass {
96
110
  this.labelRequired = true;
97
111
  this._labelElem = null;
98
112
  this._missingLabelErrorHasBeenThrown = false;
99
- this._validatingLabelTimeout = null;
100
- }
101
-
102
- firstUpdated(changedProperties) {
103
- super.firstUpdated(changedProperties);
104
- this._validateLabel(); // need to check this even if "label" isn't updated in case it's never set
105
113
  }
106
114
 
107
115
  async updated(changedProperties) {
108
116
 
109
117
  super.updated(changedProperties);
110
118
 
111
- if (changedProperties.has('label')) this._validateLabel();
112
-
113
119
  if (!changedProperties.has('labelledBy')) return;
114
120
 
115
121
  if (!this.labelledBy) {
@@ -201,26 +207,4 @@ export const LabelledMixin = superclass => class extends superclass {
201
207
 
202
208
  }
203
209
 
204
- _validateLabel() {
205
- clearTimeout(this._validatingLabelTimeout);
206
- // don't error immediately in case it doesn't get set immediately
207
- this._validatingLabelTimeout = setTimeout(() => {
208
- this._validatingLabelTimeout = null;
209
- const hasLabel = (typeof this.label === 'string') && this.label.length > 0;
210
- if (this.isConnected && !hasLabel) {
211
- if (this.labelledBy) {
212
- if (this._labelElem) {
213
- this._throwError(
214
- new Error(`LabelledMixin: "${this.tagName.toLowerCase()}" is labelled-by="${this.labelledBy}", but its label is empty`)
215
- );
216
- }
217
- } else {
218
- this._throwError(
219
- new Error(`LabelledMixin: "${this.tagName.toLowerCase()}" is missing a required "label" attribute`)
220
- );
221
- }
222
- }
223
- }, 3000);
224
- }
225
-
226
210
  };
@@ -0,0 +1,105 @@
1
+ # PropertyRequiredMixin
2
+
3
+ This mixin will make a component's property "required". If a value is not provided for a required property, an exception will be thrown asynchronously.
4
+
5
+ Required properties are useful in situations where a missing value would put the component into an invalid state, such as an accessibility violation.
6
+
7
+ Only `String` properties can be required.
8
+
9
+ ## Making a Property Required
10
+
11
+ To require a property, simply set `required` to `true` in the reactive Lit property definition:
12
+
13
+ ```javascript
14
+ import { PropertyRequiredMixin } from '@brightspace-ui/core/mixins/property-required/property-required-mixin.js';
15
+
16
+ class MyElem extends PropertyRequiredMixin(LitElement) {
17
+ static properties = {
18
+ prop: { type: String, required: true }
19
+ };
20
+ }
21
+ ```
22
+
23
+ If a non-empty `String` value for `prop` is not provided, an exception will be thrown after a few seconds.
24
+
25
+ ## Custom Validation
26
+
27
+ By default, validating a required property involves ensuring that it's a non-empty `String`. To customize this logic, set `required` to an object and provide a `validator` delegate.
28
+
29
+ ```javascript
30
+ static properties = {
31
+ mustBeFoo: {
32
+ required: {
33
+ validator: (value, elem, hasValue) => {
34
+ return value === 'foo';
35
+ }
36
+ },
37
+ type: String
38
+ }
39
+ };
40
+ ```
41
+
42
+ The `validator` will be passed the current property `value`, a reference to the element and the result of the default validation.
43
+
44
+ ## Custom Messages
45
+
46
+ To customize the exception message that gets thrown when validation fails, set `required` to an object and provide a `message` delegate.
47
+
48
+ ```javascript
49
+ static properties = {
50
+ prop: {
51
+ required: {
52
+ message: (value, elem, defaultMessage) => {
53
+ return `"prop" on "${elem.tagName}" is required.`;
54
+ }
55
+ },
56
+ type: String
57
+ }
58
+ };
59
+ ```
60
+
61
+ The `message` delegate will be passed the current property `value`, a reference to the element and the default message.
62
+
63
+ ## Dependent Properties
64
+
65
+ The mixin will automatically listen for updates to a required property and re-run the `validator` when the value changes. If custom validation logic relies on multiple properties, list them in `dependentProps`.
66
+
67
+ ```javascript
68
+ static properties = {
69
+ prop: {
70
+ required: {
71
+ dependentProps: ['isReallyRequired']
72
+ validator: (value, elem, hasValue) => {
73
+ return elem.isReallyRequired && hasValue;
74
+ }
75
+ },
76
+ type: String
77
+ },
78
+ isReallyRequired: { type: Boolean }
79
+ };
80
+ ```
81
+
82
+ ## Unit Testing
83
+
84
+ If no custom `validator` is used, tests that assert the correct required property errors are thrown are unnecessary -- `@brightspace-ui/core` already covers those tests.
85
+
86
+ Required property exceptions are thrown asynchronously multiple seconds after the component has rendered. For unit tests, this makes catching them challenging.
87
+
88
+ To help, use the `flushRequiredPropertyErrors()` method.
89
+
90
+ ```javascript
91
+ const tag = defineCE(
92
+ class extends PropertyRequiredMixin(LitElement) {
93
+ static properties = {
94
+ prop: {
95
+ type: String,
96
+ required: {
97
+ validator: (value) => value === 'valid'
98
+ }
99
+ }
100
+ };
101
+ }
102
+ );
103
+ const elem = await fixture(`<${tag} prop="invalid"></${tag}>`);
104
+ expect(() => elem.flushRequiredPropertyErrors()).to.throw();
105
+ ```
@@ -0,0 +1,118 @@
1
+ import { createElementErrorMessage } from '../../helpers/error.js';
2
+ import { dedupeMixin } from '@open-wc/dedupe-mixin';
3
+
4
+ const TIMEOUT_DURATION = 3000;
5
+
6
+ const defaultMessage = (propertyName) => `"${propertyName}" attribute is required`;
7
+
8
+ export function createMessage(elem, propertyName, message = defaultMessage(propertyName)) {
9
+ return createElementErrorMessage(
10
+ 'PropertyRequiredMixin',
11
+ elem,
12
+ message,
13
+ { composedPath: true }
14
+ );
15
+ }
16
+
17
+ export function createInvalidPropertyTypeMessage(elem, propertyName) {
18
+ return createMessage(
19
+ elem,
20
+ propertyName,
21
+ `only String properties can be required (property: "${propertyName}")`
22
+ );
23
+ }
24
+
25
+ export const PropertyRequiredMixin = dedupeMixin(superclass => class extends superclass {
26
+
27
+ constructor() {
28
+ super();
29
+ this._requiredProperties = new Map();
30
+ this._initProperties(Object.getPrototypeOf(this));
31
+ }
32
+
33
+ firstUpdated(changedProperties) {
34
+ super.firstUpdated(changedProperties);
35
+ for (const name of this._requiredProperties.keys()) {
36
+ this._validateRequiredProperty(name);
37
+ }
38
+ }
39
+
40
+ updated(changedProperties) {
41
+ super.updated(changedProperties);
42
+ this._requiredProperties.forEach((value, name) => {
43
+ const doValidate = changedProperties.has(name) || value.dependentProps.includes(name);
44
+ if (doValidate) this._validateRequiredProperty(name);
45
+ });
46
+ }
47
+
48
+ flushRequiredPropertyErrors() {
49
+ for (const name of this._requiredProperties.keys()) {
50
+ this._flushRequiredPropertyError(name);
51
+ }
52
+ }
53
+
54
+ _addRequiredProperty(name, prop) {
55
+
56
+ const opts = {
57
+ ...{
58
+ dependentProps: [],
59
+ message: (_value, _elem, defaultMessage) => defaultMessage,
60
+ validator: (_value, _elem, hasValue) => hasValue
61
+ },
62
+ ...prop.required
63
+ };
64
+
65
+ this._requiredProperties.set(name, {
66
+ attrName: prop.attribute || name,
67
+ dependentProps: opts.dependentProps,
68
+ message: opts.message,
69
+ thrown: false,
70
+ timeout: null,
71
+ type: prop.type,
72
+ validator: opts.validator
73
+ });
74
+
75
+ }
76
+
77
+ _flushRequiredPropertyError(name) {
78
+
79
+ if (!this._requiredProperties.has(name) || !this.isConnected) return;
80
+
81
+ const info = this._requiredProperties.get(name);
82
+ clearTimeout(info.timeout);
83
+ info.timeout = null;
84
+
85
+ if (info.type !== undefined && info.type !== String) {
86
+ throw new Error(createInvalidPropertyTypeMessage(this, name));
87
+ }
88
+
89
+ const value = this[name];
90
+ const hasValue = value?.constructor === String && value?.length > 0;
91
+ const success = info.validator(value, this, hasValue);
92
+ if (!success) {
93
+ if (info.thrown) return;
94
+ info.thrown = true;
95
+ const message = createMessage(this, info.attrName, info.message(value, this, defaultMessage(info.attrName)));
96
+ throw new TypeError(message);
97
+ }
98
+
99
+ }
100
+
101
+ _initProperties(base) {
102
+ if (base === null) return;
103
+ this._initProperties(Object.getPrototypeOf(base));
104
+ for (const name in base.constructor.properties) {
105
+ const prop = base.constructor.properties[name];
106
+ if (prop.required) {
107
+ this._addRequiredProperty(name, prop);
108
+ }
109
+ }
110
+ }
111
+
112
+ _validateRequiredProperty(name) {
113
+ const info = this._requiredProperties.get(name);
114
+ clearTimeout(info.timeout);
115
+ info.timeout = setTimeout(() => this._flushRequiredPropertyError(name), TIMEOUT_DURATION);
116
+ }
117
+
118
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brightspace-ui/core",
3
- "version": "2.162.0",
3
+ "version": "2.163.0",
4
4
  "description": "A collection of accessible, free, open-source web components for building Brightspace applications",
5
5
  "type": "module",
6
6
  "repository": "https://github.com/BrightspaceUI/core.git",