Skip to content

Commit 37e473b

Browse files
authored
feat: FormControlMixin refactor for types safety and default values (#745)
* fix types * getDefaultValue * make js private to avoid collisions * Prefix internals * BREAKING CHANGE: rename to UUIFormControlMixin * ValueType type * append the ValueType type on getDefaultValue method * refactor for typings and default value * declare _runValidators method * undefined Default Value Type * corrections
1 parent 181d385 commit 37e473b

File tree

46 files changed

+339
-227
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+339
-227
lines changed

packages/uui-avatar-group/lib/uui-avatar-group.story.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import '.';
22

3-
import { Story } from '@storybook/web-components';
3+
import { StoryFn } from '@storybook/web-components';
44
import { html } from 'lit';
55
import readme from '../README.md?raw';
66

@@ -18,7 +18,7 @@ export default {
1818
},
1919
};
2020

21-
export const AAAOverview: Story = props => html`
21+
export const AAAOverview: StoryFn = props => html`
2222
<uui-avatar-group
2323
style="font-size: ${props.fontSize}em; --uui-avatar-border-color: ${props[
2424
'--uui-avatar-border-color'
@@ -39,7 +39,7 @@ AAAOverview.parameters = {};
3939

4040
AAAOverview.storyName = 'Overview';
4141

42-
export const Limit: Story = ({ limit }) => html`
42+
export const Limit: StoryFn = ({ limit }) => html`
4343
<uui-avatar-group
4444
style="font-size: 2rem; --uui-avatar-border-color: white;"
4545
.limit=${limit}>

packages/uui-base/lib/events/UUIFormControlEvent.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { FormControlMixinInterface } from '../mixins';
1+
import { UUIFormControlMixinInterface } from '../mixins';
22
import { UUIEvent } from './UUIEvent';
33

44
export class UUIFormControlEvent extends UUIEvent<
55
{},
6-
FormControlMixinInterface
6+
UUIFormControlMixinInterface<unknown, unknown>
77
> {
88
constructor(evName: string, eventInit: any | null = {}) {
99
super(evName, {

packages/uui-base/lib/mixins/FormControlMixin.ts

Lines changed: 73 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,27 @@ type Constructor<T = {}> = new (...args: any[]) => T;
88
type NativeFormControlElement = HTMLInputElement; // Eventually use a specific interface or list multiple options like appending these types: ... | HTMLTextAreaElement | HTMLSelectElement
99

1010
// TODO: make it possible to define FormDataEntryValue type.
11-
export declare abstract class FormControlMixinInterface extends LitElement {
11+
// TODO: Prefix with UUI
12+
export declare abstract class UUIFormControlMixinInterface<
13+
ValueType,
14+
DefaultValueType,
15+
> extends LitElement {
1216
formAssociated: boolean;
13-
get value(): FormDataEntryValue | FormData;
14-
set value(newValue: FormDataEntryValue | FormData);
17+
protected _internals: ElementInternals;
18+
protected _runValidators(): void;
19+
get value(): ValueType | DefaultValueType;
20+
set value(newValue: ValueType | DefaultValueType);
1521
name: string;
1622
formResetCallback(): void;
1723
checkValidity(): boolean;
1824
get validationMessage(): string;
1925
get validity(): ValidityState;
2026
public setCustomValidity(error: string): void;
2127
public submit(): void;
22-
protected _value: FormDataEntryValue | FormData;
23-
protected _internals: any;
2428
protected abstract getFormElement(): HTMLElement | undefined;
2529
protected addValidator: (
2630
flagKey: FlagTypes,
27-
getMessageMethod: () => String,
31+
getMessageMethod: () => string,
2832
checkMethod: () => boolean,
2933
) => void;
3034
protected addFormControlElement(element: NativeFormControlElement): void;
@@ -55,7 +59,7 @@ type FlagTypes =
5559
// Acceptable as an internal interface/type, BUT if exposed externally this should be turned into a public class in a separate file.
5660
interface Validator {
5761
flagKey: FlagTypes;
58-
getMessageMethod: () => String;
62+
getMessageMethod: () => string;
5963
checkMethod: () => boolean;
6064
}
6165

@@ -65,10 +69,15 @@ interface Validator {
6569
* @param {Object} superClass - superclass to be extended.
6670
* @mixin
6771
*/
68-
export const FormControlMixin = <T extends Constructor<LitElement>>(
72+
export const UUIFormControlMixin = <
73+
ValueType = FormDataEntryValue | FormData,
74+
T extends Constructor<LitElement> = typeof LitElement,
75+
DefaultValueType = unknown,
76+
>(
6977
superClass: T,
78+
defaultValue: DefaultValueType,
7079
) => {
71-
abstract class FormControlMixinClass extends superClass {
80+
abstract class UUIFormControlMixinClass extends superClass {
7281
/**
7382
* This is a static class field indicating that the element is can be used inside a native form and participate in its events.
7483
* It may require a polyfill, check support here https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/attachInternals.
@@ -88,23 +97,24 @@ export const FormControlMixin = <T extends Constructor<LitElement>>(
8897

8998
/**
9099
* Value of this form control.
100+
* If you dont want the setFormValue to be called on the ElementInternals, then prevent calling this method, by not calling super.value = newValue in your implementation of the value setter method.
91101
* @type {string}
92-
* @attr
102+
* @attr value
93103
* @default ''
94104
*/
95105
@property() // Do not 'reflect' as the attribute is used as fallback.
96-
get value() {
97-
return this._value;
106+
get value(): ValueType | DefaultValueType {
107+
return this.#value;
98108
}
99-
set value(newValue) {
100-
const oldValue = this._value;
101-
this._value = newValue;
109+
set value(newValue: ValueType | DefaultValueType) {
110+
const oldValue = this.#value;
111+
this.#value = newValue;
102112
if (
103113
'ElementInternals' in window &&
104114
//@ts-ignore
105115
'setFormValue' in window.ElementInternals.prototype
106116
) {
107-
this._internals.setFormValue(this._value);
117+
this._internals.setFormValue((this.#value as any) ?? null);
108118
}
109119
this.requestUpdate('value', oldValue);
110120
}
@@ -150,15 +160,15 @@ export const FormControlMixin = <T extends Constructor<LitElement>>(
150160
@property({ type: String, attribute: 'error-message' })
151161
errorMessage = 'This field is invalid';
152162

153-
private _value: FormDataEntryValue | FormData = '';
154-
private _internals: any;
155-
private _form: HTMLFormElement | null = null;
156-
private _validators: Validator[] = [];
157-
private _formCtrlElements: NativeFormControlElement[] = [];
163+
#value: ValueType | DefaultValueType = defaultValue;
164+
_internals: ElementInternals;
165+
#form: HTMLFormElement | null = null;
166+
#validators: Validator[] = [];
167+
#formCtrlElements: NativeFormControlElement[] = [];
158168

159169
constructor(...args: any[]) {
160170
super(...args);
161-
this._internals = (this as any).attachInternals();
171+
this._internals = this.attachInternals();
162172

163173
this.addValidator(
164174
'valueMissing',
@@ -177,12 +187,12 @@ export const FormControlMixin = <T extends Constructor<LitElement>>(
177187
}
178188

179189
/**
180-
* Determn wether this FormControl has a value.
190+
* Determine wether this FormControl has a value.
181191
* @method hasValue
182192
* @returns {boolean}
183193
*/
184194
public hasValue(): boolean {
185-
return this.value !== '';
195+
return this.value !== this.getDefaultValue();
186196
}
187197

188198
/**
@@ -196,11 +206,11 @@ export const FormControlMixin = <T extends Constructor<LitElement>>(
196206

197207
disconnectedCallback(): void {
198208
super.disconnectedCallback();
199-
this._removeFormListeners();
209+
this.#removeFormListeners();
200210
}
201-
private _removeFormListeners() {
202-
if (this._form) {
203-
this._form.removeEventListener('submit', this._onFormSubmit);
211+
#removeFormListeners() {
212+
if (this.#form) {
213+
this.#form.removeEventListener('submit', this.#onFormSubmit);
204214
}
205215
}
206216

@@ -221,22 +231,22 @@ export const FormControlMixin = <T extends Constructor<LitElement>>(
221231
*/
222232
protected addValidator(
223233
flagKey: FlagTypes,
224-
getMessageMethod: () => String,
234+
getMessageMethod: () => string,
225235
checkMethod: () => boolean,
226236
): Validator {
227237
const obj = {
228238
flagKey: flagKey,
229239
getMessageMethod: getMessageMethod,
230240
checkMethod: checkMethod,
231241
};
232-
this._validators.push(obj);
242+
this.#validators.push(obj);
233243
return obj;
234244
}
235245

236246
protected removeValidator(validator: Validator) {
237-
const index = this._validators.indexOf(validator);
247+
const index = this.#validators.indexOf(validator);
238248
if (index !== -1) {
239-
this._validators.splice(index, 1);
249+
this.#validators.splice(index, 1);
240250
}
241251
}
242252

@@ -246,7 +256,7 @@ export const FormControlMixin = <T extends Constructor<LitElement>>(
246256
* @param element {NativeFormControlElement} - element to validate and include as part of this form association.
247257
*/
248258
protected addFormControlElement(element: NativeFormControlElement) {
249-
this._formCtrlElements.push(element);
259+
this.#formCtrlElements.push(element);
250260
}
251261

252262
private _customValidityObject?: Validator;
@@ -273,11 +283,18 @@ export const FormControlMixin = <T extends Constructor<LitElement>>(
273283
this._runValidators();
274284
}
275285

276-
private _runValidators() {
286+
/**
287+
* @method _runValidators
288+
* @description Run all validators and set the validityState of this form control.
289+
* Run this method when you want to re-run all validators.
290+
* This can be relevant if you have a validators that is using values that is not triggering the Lit Updated Callback.
291+
* Such are mainly properties that are not declared as a Lit state and or Lit property.
292+
*/
293+
protected _runValidators() {
277294
this._validityState = {};
278295

279296
// Loop through inner native form controls to adapt their validityState.
280-
this._formCtrlElements.forEach(formCtrlEl => {
297+
this.#formCtrlElements.forEach(formCtrlEl => {
281298
for (const key in formCtrlEl.validity) {
282299
if (key !== 'valid' && (formCtrlEl.validity as any)[key]) {
283300
(this as any)._validityState[key] = true;
@@ -291,7 +308,7 @@ export const FormControlMixin = <T extends Constructor<LitElement>>(
291308
});
292309

293310
// Loop through custom validators, currently its intentional to have them overwritten native validity. but might need to be reconsidered (This current way enables to overwrite with custom messages)
294-
this._validators.forEach(validator => {
311+
this.#validators.forEach(validator => {
295312
if (validator.checkMethod()) {
296313
this._validityState[validator.flagKey] = true;
297314
this._internals.setValidity(
@@ -322,33 +339,40 @@ export const FormControlMixin = <T extends Constructor<LitElement>>(
322339
this._runValidators();
323340
}
324341

325-
private _onFormSubmit = () => {
342+
#onFormSubmit = () => {
326343
this.pristine = false;
327344
};
328345

329346
public submit() {
330-
this._form?.requestSubmit();
347+
this.#form?.requestSubmit();
331348
}
332349

333350
public formAssociatedCallback() {
334-
this._removeFormListeners();
335-
this._form = this._internals.form;
336-
if (this._form) {
351+
this.#removeFormListeners();
352+
this.#form = this._internals.form;
353+
if (this.#form) {
337354
// This relies on the form begin a 'uui-form':
338-
if (this._form.hasAttribute('submit-invalid')) {
355+
if (this.#form.hasAttribute('submit-invalid')) {
339356
this.pristine = false;
340357
}
341-
this._form.addEventListener('submit', this._onFormSubmit);
358+
this.#form.addEventListener('submit', this.#onFormSubmit);
342359
}
343360
}
344361
public formResetCallback() {
345362
this.pristine = true;
346-
this.value = this.getAttribute('value') || '';
363+
this.value = this.getInitialValue() ?? this.getDefaultValue();
364+
}
365+
366+
protected getDefaultValue(): DefaultValueType {
367+
return defaultValue;
368+
}
369+
protected getInitialValue(): ValueType | DefaultValueType {
370+
return this.getAttribute('value') as ValueType | DefaultValueType;
347371
}
348372

349373
public checkValidity() {
350-
for (const key in this._formCtrlElements) {
351-
if (this._formCtrlElements[key].checkValidity() === false) {
374+
for (const key in this.#formCtrlElements) {
375+
if (this.#formCtrlElements[key].checkValidity() === false) {
352376
return false;
353377
}
354378
}
@@ -365,6 +389,8 @@ export const FormControlMixin = <T extends Constructor<LitElement>>(
365389
return this._internals?.validationMessage;
366390
}
367391
}
368-
return FormControlMixinClass as unknown as Constructor<FormControlMixinInterface> &
392+
return UUIFormControlMixinClass as unknown as Constructor<
393+
UUIFormControlMixinInterface<ValueType, DefaultValueType>
394+
> &
369395
T;
370396
};

packages/uui-boolean-input/lib/uui-boolean-input.element.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { FormControlMixin, LabelMixin } from '@umbraco-ui/uui-base/lib/mixins';
1+
import {
2+
UUIFormControlMixin,
3+
LabelMixin,
4+
} from '@umbraco-ui/uui-base/lib/mixins';
25
import { css, html, LitElement, TemplateResult } from 'lit';
36
import { property, query } from 'lit/decorators.js';
47

@@ -12,15 +15,18 @@ type LabelPosition = 'left' | 'right' | 'top' | 'bottom';
1215
* @fires UUIBooleanInputEvent#change on change
1316
* @abstract
1417
*/
15-
export abstract class UUIBooleanInputElement extends FormControlMixin(
18+
export abstract class UUIBooleanInputElement extends UUIFormControlMixin(
1619
LabelMixin('', LitElement),
20+
'',
1721
) {
22+
private _value = '';
23+
1824
/** intentional overwrite of FormControlMixins value getter and setter method. */
1925
get value() {
20-
return this._value as string;
26+
return this._value;
2127
}
2228
set value(newVal: string) {
23-
const oldValue = this._value;
29+
const oldValue = super.value;
2430
this._value = newVal;
2531
if (
2632
'ElementInternals' in window &&

packages/uui-boolean-input/lib/uui-boolean-input.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ describe('UUIBooleanInputElement', () => {
7575
expect(input?.checked).to.equal(true);
7676
});
7777
it('emits an change event when the input changes', async () => {
78-
const listener = oneEvent(element, UUIBooleanInputEvent.CHANGE);
78+
const listener = oneEvent(element, UUIBooleanInputEvent.CHANGE, false);
7979
label.click();
8080

8181
const event = await listener;
@@ -128,7 +128,7 @@ describe('BooleanInputBaseElement in a Form', () => {
128128

129129
describe('submit', () => {
130130
it('should submit when pressing enter', async () => {
131-
const listener = oneEvent(formElement, 'submit');
131+
const listener = oneEvent(formElement, 'submit', false);
132132
element.dispatchEvent(new KeyboardEvent('keypress', { key: 'Enter' }));
133133

134134
const event = await listener;

packages/uui-button/lib/uui-button.element.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
} from '@umbraco-ui/uui-base/lib/animations';
55
import { demandCustomElement } from '@umbraco-ui/uui-base/lib/utils';
66
import {
7-
FormControlMixin,
7+
UUIFormControlMixin,
88
LabelMixin,
99
PopoverTargetMixin,
1010
} from '@umbraco-ui/uui-base/lib/mixins';
@@ -49,8 +49,9 @@ export type UUIButtonType = 'submit' | 'button' | 'reset';
4949
* @cssprop --uui-button-content-align - Overwrite justify-content alignment. Possible values: 'left', 'right', 'center'.
5050
*/
5151
@defineElement('uui-button')
52-
export class UUIButtonElement extends FormControlMixin(
52+
export class UUIButtonElement extends UUIFormControlMixin(
5353
LabelMixin('', PopoverTargetMixin(LitElement)),
54+
undefined,
5455
) {
5556
/**
5657
* Specifies the type of button

packages/uui-button/lib/uui-button.story.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ export const MultiLine: Story = props => {
435435

436436
MultiLine.args = { look: 'primary' };
437437

438-
export const ContentAlignment = props => html`
438+
export const ContentAlignment: Story = props => html`
439439
<uui-button
440440
style="max-width: 400px; width: 100%;
441441
--uui-button-content-align: ${props['--uui-button-content-align']}"

packages/uui-button/lib/uui-button.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ describe('UuiButton', () => {
120120
describe('events', () => {
121121
describe('click', async () => {
122122
it('emits a click event when native button fires one', async () => {
123-
const listener = oneEvent(element, 'click');
123+
const listener = oneEvent(element, 'click', false);
124124

125125
button.click();
126126

0 commit comments

Comments
 (0)