first pass at form association

This commit is contained in:
konnorrogers
2024-02-29 14:05:14 -05:00
parent b53c1d940a
commit 867184bdfe
6 changed files with 110 additions and 63 deletions

6
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"@shoelace-style/animations": "^1.1.0",
"@shoelace-style/localize": "^3.1.2",
"composed-offset-position": "^0.0.4",
"form-associated-helpers": "^0.0.5",
"lit": "^3.0.0",
"qr-creator": "^1.0.0"
},
@@ -10835,6 +10836,11 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/form-associated-helpers": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/form-associated-helpers/-/form-associated-helpers-0.0.5.tgz",
"integrity": "sha512-OTcCYvcDy9EJYh0p19z7ttwikBWrMURaEX5HFKvxIlmN/mIOj/YiqVTzifvgeaKPqlsoleyfzzc5qwQx1dm7fg=="
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",

View File

@@ -70,6 +70,7 @@
"@shoelace-style/animations": "^1.1.0",
"@shoelace-style/localize": "^3.1.2",
"composed-offset-position": "^0.0.4",
"form-associated-helpers": "^0.0.5",
"lit": "^3.0.0",
"qr-creator": "^1.0.0"
},
@@ -100,8 +101,8 @@
"cspell": "^6.18.1",
"custom-element-jet-brains-integration": "^1.4.0",
"custom-element-vs-code-integration": "^1.2.1",
"dedent": "^1.5.1",
"custom-element-vuejs-integration": "^1.0.0",
"dedent": "^1.5.1",
"del": "^7.1.0",
"download": "^8.0.0",
"esbuild": "^0.19.4",

View File

@@ -1,8 +1,8 @@
import { classMap } from 'lit/directives/class-map.js';
import { defaultValue } from '../../internal/default-value.js';
import { FormControlController } from '../../internal/form.js';
// import { FormControlController } from '../../internal/form.js';
import { HasSlotController } from '../../internal/slot.js';
import { html } from 'lit';
import { html, LitElement } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { property, query, state } from 'lit/decorators.js';
@@ -12,6 +12,10 @@ import WebAwesomeElement from '../../internal/webawesome-element.js';
import type { CSSResultGroup } from 'lit';
import type { WebAwesomeFormControl } from '../../internal/webawesome-element.js';
import { LitTextareaMixin } from 'form-associated-helpers/exports/mixins/lit-textarea-mixin.js';
import { ref } from 'lit/directives/ref.js';
import { MirrorValidator } from 'form-associated-helpers/exports/validators/mirror-validator.js';
/**
* @summary Textareas collect data from the user and allow multiple lines of text.
* @documentation https://shoelace.style/components/textarea
@@ -41,17 +45,30 @@ import type { WebAwesomeFormControl } from '../../internal/webawesome-element.js
* @cssproperty --border-width - The width of the textarea's borders.
* @cssproperty --box-shadow - The shadow effects around the edges of the textarea.
*/
export default class WaTextarea extends WebAwesomeElement implements WebAwesomeFormControl {
export default class WaTextarea extends LitTextareaMixin(WebAwesomeElement) implements WebAwesomeFormControl {
static styles: CSSResultGroup = styles;
private readonly formControlController = new FormControlController(this, {
assumeInteractionOn: ['wa-blur', 'wa-input']
});
static shadowRootOptions = {...LitElement.shadowRootOptions, delegatesFocus: true};
// static get validators () { return [MirrorValidator] }
static get properties () {
return {
...super.properties,
...LitTextareaMixin.formProperties,
}
}
// private readonly formControlController = new FormControlController(this, {
// assumeInteractionOn: ['wa-blur', 'wa-input']
// });
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private resizeObserver: ResizeObserver;
@query('.textarea__control') input: HTMLTextAreaElement;
// Required to be public.
@state() formControl: HTMLTextAreaElement | null;
@state() private hasFocus = false;
@property() title = ''; // make reactive to pass through
@@ -82,7 +99,7 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
/** Controls how the textarea can be resized. */
@property() resize: 'none' | 'vertical' | 'auto' = 'vertical';
/** Disables the textarea. */
/** Disables the textarea. We don't want to reflect here. It breaks callbacks. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** Makes the textarea readonly. */
@@ -93,7 +110,7 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = '';
// @property({ reflect: true }) form = '';
/** Makes the textarea a required field. */
@property({ type: Boolean, reflect: true }) required = false;
@@ -114,7 +131,9 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
* Specifies what permission the browser has to provide assistance in filling out form field values. Refer to
* [this page on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) for available values.
*/
@property() autocomplete: string;
// @ts-expect-error
// The base LitTextareaMixin expects this as a "Autofill" type.
@property() autocomplete: string = undefined;
/** Indicates that the input should receive focus on page load. */
@property({ type: Boolean }) autofocus: boolean;
@@ -142,14 +161,14 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue() defaultValue = '';
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
constructor () {
super()
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
// @ts-expect-error
this._boundEmitInvalidEvent = this.emitInvalidEvent.bind(this)
// @ts-expect-error
this.addEventListener("invalid", this._boundEmitInvalidEvent)
}
connectedCallback() {
@@ -162,9 +181,9 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
});
}
firstUpdated() {
this.formControlController.updateValidity();
}
// firstUpdated() {
// this.formControlController.updateValidity();
// }
disconnectedCallback() {
super.disconnectedCallback();
@@ -192,10 +211,36 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
this.emit('wa-input');
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
/**
* Dispatches a non-bubbling, cancelable custom event of type `wa-invalid`.
* If the `wa-invalid` event will be cancelled then the original `invalid`
* event (which may have been passed as argument) will also be cancelled.
* If no original `invalid` event has been passed then the `wa-invalid`
* event will be cancelled before being dispatched.
*/
emitInvalidEvent(originalInvalidEvent?: Event) {
const slInvalidEvent = new CustomEvent<Record<PropertyKey, never>>('wa-invalid', {
bubbles: false,
composed: false,
cancelable: true,
detail: {}
});
if (!originalInvalidEvent) {
slInvalidEvent.preventDefault();
}
if (!this.dispatchEvent(slInvalidEvent)) {
originalInvalidEvent?.preventDefault();
}
}
// `form-associated-helpers` also has a `handleInvalid` event.
// handleInvalid = (event: Event) => {
// super.handleInvalid(event)
// this.emitInvalidEvent(event)
// }
private setTextareaHeight() {
if (this.resize === 'auto') {
@@ -209,7 +254,9 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid
this.formControlController.setValidity(this.disabled);
// this.formControlController.setValidity(this.disabled);
// this.formControl?.setCustomValidity('')
// this.setValidity()
}
@watch('rows', { waitUntilFirstUpdate: true })
@@ -220,7 +267,7 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
@watch('value', { waitUntilFirstUpdate: true })
async handleValueChange() {
await this.updateComplete;
this.formControlController.updateValidity();
// this.formControlController.updateValidity();
this.setTextareaHeight();
}
@@ -280,25 +327,8 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
}
}
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
return this.input.checkValidity();
}
/** Gets the associated form, if one exists. */
getForm(): HTMLFormElement | null {
return this.formControlController.getForm();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.input.reportValidity();
}
/** Sets a custom validation message. Pass an empty string to restore validity. */
setCustomValidity(message: string) {
this.input.setCustomValidity(message);
this.formControlController.updateValidity();
private formControlChanged (el?: HTMLTextAreaElement) {
this.formControl = el || null
}
render() {
@@ -372,6 +402,7 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
@invalid=${this.handleInvalid}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
${ref(this.formControlChanged)}
></textarea>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import '../../../dist/webawesome.js';
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests.js';
import { sendKeys } from '@web/test-runner-commands';
import { serialize } from '../../utilities/form.js';
@@ -122,16 +122,20 @@ describe('<wa-textarea>', () => {
it('should be invalid when required and after removing disabled ', async () => {
const el = await fixture<WaTextarea>(html` <wa-textarea disabled required></wa-textarea> `);
el.disabled = false;
// el.disabled = false;
el.removeAttribute("disabled")
await el.updateComplete;
// await aTimeout(0)
expect(el.checkValidity()).to.be.false;
});
it('should be invalid when required and disabled is removed', async () => {
const el = await fixture<WaTextarea>(html` <wa-textarea disabled required></wa-textarea> `);
el.disabled = false;
// el.disabled = false;
el.removeAttribute("disabled")
await el.updateComplete;
// await aTimeout(0)
expect(el.checkValidity()).to.be.false;
});
@@ -139,8 +143,8 @@ describe('<wa-textarea>', () => {
const el = await fixture<WaTextarea>(html` <wa-textarea required value="a"></wa-textarea> `);
expect(el.checkValidity()).to.be.true;
expect(el.hasAttribute('data-required')).to.be.true;
expect(el.hasAttribute('data-optional')).to.be.false;
// expect(el.hasAttribute('data-required')).to.be.true;
// expect(el.hasAttribute('data-optional')).to.be.false;
expect(el.hasAttribute('data-invalid')).to.be.false;
expect(el.hasAttribute('data-valid')).to.be.true;
expect(el.hasAttribute('data-user-invalid')).to.be.false;
@@ -160,8 +164,8 @@ describe('<wa-textarea>', () => {
it('should receive the correct validation attributes ("states") when invalid', async () => {
const el = await fixture<WaTextarea>(html` <wa-textarea required></wa-textarea> `);
expect(el.hasAttribute('data-required')).to.be.true;
expect(el.hasAttribute('data-optional')).to.be.false;
// expect(el.hasAttribute('data-required')).to.be.true;
// expect(el.hasAttribute('data-optional')).to.be.false;
expect(el.hasAttribute('data-invalid')).to.be.true;
expect(el.hasAttribute('data-valid')).to.be.false;
expect(el.hasAttribute('data-user-invalid')).to.be.false;
@@ -182,8 +186,8 @@ describe('<wa-textarea>', () => {
const el = await fixture<HTMLFormElement>(html` <form novalidate><wa-textarea required></wa-textarea></form> `);
const textarea = el.querySelector<WaTextarea>('wa-textarea')!;
expect(textarea.hasAttribute('data-required')).to.be.true;
expect(textarea.hasAttribute('data-optional')).to.be.false;
// expect(textarea.hasAttribute('data-required')).to.be.true;
// expect(textarea.hasAttribute('data-optional')).to.be.false;
expect(textarea.hasAttribute('data-invalid')).to.be.true;
expect(textarea.hasAttribute('data-valid')).to.be.false;
expect(textarea.hasAttribute('data-user-invalid')).to.be.false;
@@ -205,7 +209,8 @@ describe('<wa-textarea>', () => {
});
it('should be invalid when setCustomValidity() is called with a non-empty value', async () => {
const textarea = await fixture<HTMLFormElement>(html` <wa-textarea></wa-textarea> `);
const form = await fixture<HTMLFormElement>(html` <form><wa-textarea></wa-textarea></form> `);
const textarea = form.querySelector("wa-textarea") as WaTextarea
textarea.setCustomValidity('Invalid selection');
await textarea.updateComplete;

View File

@@ -44,7 +44,7 @@ export function runFormControlBaseTests<T extends WebAwesomeFormControl = WebAwe
// - `.checkValidity()`
// - `.reportValidity()`
// - `.setCustomValidity(msg)`
// - `.getForm()`
// - `.form`
//
function runAllValidityTests(
tagName: string, //
@@ -130,21 +130,21 @@ function runAllValidityTests(
const formId = 'test-form';
const form = await fixture(`<form id='${formId}'></form>`);
const control = await createControl();
expect(control.getForm()).to.equal(null);
control.form = 'test-form';
expect(control.form).to.equal(null);
control.setAttribute("form", 'test-form');
await control.updateComplete;
expect(control.getForm()).to.equal(form);
expect(control.form).to.equal(form);
});
it('Should find the correct form when given a form attribute', async () => {
const formId = 'test-form';
const form = await fixture(`<form id='${formId}'></form>`);
const control = await createControl();
expect(control.getForm()).to.equal(null);
expect(control.form).to.equal(null);
control.setAttribute('form', 'test-form');
await control.updateComplete;
expect(control.getForm()).to.equal(form);
expect(control.form).to.equal(form);
});
}
@@ -244,6 +244,8 @@ function runSpecialTests_standard(createControl: CreateControlFn) {
it('should make sure that `.validity.valid` is `false` in custom error case', async () => {
const control = await createControl();
control.setCustomValidity('error');
await control.updateComplete
expect(control.validity.valid).to.equal(false);
});
@@ -273,8 +275,10 @@ function runSpecialTests_standard(createControl: CreateControlFn) {
control.setCustomValidity('error');
control.disabled = false;
await control.updateComplete;
const emittedEvents = checkEventEmissions(control, 'wa-invalid', () => control.reportValidity());
const emittedEvents = checkEventEmissions(control, 'invalid', () => control.reportValidity());
const waEmittedEvents = checkEventEmissions(control, 'wa-invalid', () => control.reportValidity());
expect(emittedEvents.length).to.equal(1);
expect(waEmittedEvents.length).to.equal(1)
});
}

View File

@@ -164,7 +164,7 @@ export interface WebAwesomeFormControl extends WebAwesomeElement {
// Form validation methods
checkValidity: () => boolean;
getForm: () => HTMLFormElement | null;
getForm?: () => HTMLFormElement | null;
reportValidity: () => boolean;
setCustomValidity: (message: string) => void;
}