From af4d25ee374585bcf8da223bf631082f2a954438 Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Thu, 24 Mar 2022 08:11:49 -0400 Subject: [PATCH] restore desired commits --- docs/components/checkbox.md | 60 ++++++ docs/components/radio-button.md | 73 +++++++ docs/components/radio.md | 73 +++++++ docs/getting-started/form-controls.md | 4 +- docs/resources/changelog.md | 9 + src/components/button/button.styles.ts | 5 +- src/components/checkbox/checkbox.test.ts | 48 ++++- .../radio-button/radio-button.styles.ts | 23 +++ .../radio-button/radio-button.test.ts | 28 ++- src/components/radio-button/radio-button.ts | 193 +++++++++++++----- src/components/radio/radio.test.ts | 28 ++- src/components/radio/radio.ts | 103 +++++++++- src/internal/radio.ts | 108 ---------- 13 files changed, 585 insertions(+), 170 deletions(-) create mode 100644 src/components/radio-button/radio-button.styles.ts delete mode 100644 src/internal/radio.ts diff --git a/docs/components/checkbox.md b/docs/components/checkbox.md index dbb67e377..34ed1703c 100644 --- a/docs/components/checkbox.md +++ b/docs/components/checkbox.md @@ -60,4 +60,64 @@ import { SlCheckbox } from '@shoelace-style/shoelace/dist/react'; const App = () => Disabled; ``` +### Custom Validity + +Use the `setCustomValidity()` method to set a custom validation message. This will prevent the form from submitting and make the browser display the error message you provide. To clear the error, call this function with an empty string. + +```html preview +
+ Check me +
+ Submit +
+ +``` + +```jsx react +import { useEffect, useRef } from 'react'; +import { SlButton, SlCheckbox } from '@shoelace-style/shoelace/dist/react'; +const App = () => { + const checkbox = useRef(null); + const errorMessage = `Don't forget to check me!`; + function handleChange() { + checkbox.current.setCustomValidity(checkbox.current.checked ? '' : errorMessage); + } + function handleSubmit(event) { + event.preventDefault(); + alert('All fields are valid!'); + } + useEffect(() => { + checkbox.current.setCustomValidity(errorMessage); + }, []); + return ( +
+ + Check me + +
+ + Submit + +
+ ); +}; +``` + [component-metadata:sl-checkbox] diff --git a/docs/components/radio-button.md b/docs/components/radio-button.md index a21d138f5..7dcf1b2f6 100644 --- a/docs/components/radio-button.md +++ b/docs/components/radio-button.md @@ -414,4 +414,77 @@ const App = () => ( ); ``` +### Custom Validity + +Use the `setCustomValidity()` method to set a custom validation message. This will prevent the form from submitting and make the browser display the error message you provide. To clear the error, call this function with an empty string. + +```html preview +
+ + Not me + Me neither + Choose me + +
+ Submit +
+ +``` + +```jsx react +import { useEffect, useRef } from 'react'; +import { SlButton, SlIcon, SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react'; +const App = () => { + const radio = useRef(null); + const errorMessage = 'You must choose this option'; + function handleChange(event) { + radio.current.setCustomValidity(radio.current.checked ? '' : errorMessage); + } + function handleSubmit(event) { + event.preventDefault(); + alert('All fields are valid!'); + } + useEffect(() => { + radio.current.setCustomValidity(errorMessage); + }, []); + return ( +
+ + + Not me + + + Me neither + + + Choose me + + +
+ + Submit + +
+ ); +}; +``` + [component-metadata:sl-radio-button] diff --git a/docs/components/radio.md b/docs/components/radio.md index 4c271e972..25b481ed6 100644 --- a/docs/components/radio.md +++ b/docs/components/radio.md @@ -96,4 +96,77 @@ const App = () => ( ); ``` +### Custom Validity + +Use the `setCustomValidity()` method to set a custom validation message. This will prevent the form from submitting and make the browser display the error message you provide. To clear the error, call this function with an empty string. + +```html preview +
+ + Not me + Me neither + Choose me + +
+ Submit +
+ +``` + +```jsx react +import { useEffect, useRef } from 'react'; +import { SlButton, SlIcon, SlRadio, SlRadioGroup } from '@shoelace-style/shoelace/dist/react'; +const App = () => { + const radio = useRef(null); + const errorMessage = 'You must choose this option'; + function handleChange(event) { + radio.current.setCustomValidity(radio.current.checked ? '' : errorMessage); + } + function handleSubmit(event) { + event.preventDefault(); + alert('All fields are valid!'); + } + useEffect(() => { + radio.current.setCustomValidity(errorMessage); + }, []); + return ( +
+ + + Not me + + + Me neither + + + Choose me + + +
+ + Submit + +
+ ); +}; +``` + [component-metadata:sl-radio] diff --git a/docs/getting-started/form-controls.md b/docs/getting-started/form-controls.md index 472be6962..b4c1584ad 100644 --- a/docs/getting-started/form-controls.md +++ b/docs/getting-started/form-controls.md @@ -194,7 +194,7 @@ const App = () => { ### Custom Validation -To create a custom validation error, use the `setCustomValidity` method. The form will not be submitted when this method is called with anything other than an empty string, and its message will be shown by the browser as the validation error. To make the input valid again, call the method a second time with an empty string as the argument. +To create a custom validation error, pass a non-empty string to the `setCustomValidity()` method. This will override any existing validation constraints. The form will not be submitted when a custom validity is set and the browser will show a validation error when the containing form is submitted. To make the input valid again, call `setCustomValidity()` again with an empty string. ```html preview
@@ -257,6 +257,8 @@ const App = () => { }; ``` +?> Custom validation can be applied to any form control that supports the `setCustomValidity()` method. It is not limited to inputs and textareas. + ### Custom Validation Styles The `invalid` attribute reflects the form control's validity, so you can style invalid fields using the `[invalid]` selector. The example below demonstrates how you can give erroneous fields a different appearance. Type something other than "shoelace" to demonstrate this. diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index 495686221..083f904ff 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -6,6 +6,15 @@ Components with the Experimental bad _During the beta period, these restrictions may be relaxed in the event of a mission-critical bug._ 🐛 +## Next + +- Added `button` part to `` +- Added custom validity examples and tests to ``, ``, and `` +- Fixed a bug that prevented `setCustomValidity()` from working with `` +- Fixed a bug where the right border of a checked `` was the wrong color +- Fixed a bug that prevented a number of properties, methods, etc. from being documented in `` and `` +- Once again removed path aliasing because it doesn't work with Web Test Runner's esbuild plugin + ## 2.0.0-beta.72 - 🚨 BREAKING: refactored parts in ``, ``, ``, and `` to allow you to customize the label and help text position diff --git a/src/components/button/button.styles.ts b/src/components/button/button.styles.ts index 289fa63b4..181fa2b82 100644 --- a/src/components/button/button.styles.ts +++ b/src/components/button/button.styles.ts @@ -632,12 +632,13 @@ export default css` mix-blend-mode: multiply; } - /* Bump focused buttons up so their focus ring isn't clipped */ + /* Bump hovered, focused, and checked buttons up so their focus ring isn't clipped */ :host(.sl-button-group__button--hover) { z-index: 1; } - :host(.sl-button-group__button--focus) { + :host(.sl-button-group__button--focus), + :host(.sl-button-group__button[checked]) { z-index: 2; } `; diff --git a/src/components/checkbox/checkbox.test.ts b/src/components/checkbox/checkbox.test.ts index 12c5454b6..73c8ee8a8 100644 --- a/src/components/checkbox/checkbox.test.ts +++ b/src/components/checkbox/checkbox.test.ts @@ -1,5 +1,6 @@ -import { expect, fixture, html, oneEvent } from '@open-wc/testing'; +import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; import { sendKeys } from '@web/test-runner-commands'; +import sinon from 'sinon'; import type SlCheckbox from './checkbox'; describe('', () => { @@ -42,4 +43,49 @@ describe('', () => { el.checked = false; await el.updateComplete; }); + + describe('when submitting a form', () => { + it('should submit the correct value', async () => { + const form = await fixture(html` + + + Submit + + `); + const button = form.querySelector('sl-button')!; + const submitHandler = sinon.spy((event: SubmitEvent) => { + formData = new FormData(form); + event.preventDefault(); + }); + let formData: FormData; + + form.addEventListener('submit', submitHandler); + button.click(); + + await waitUntil(() => submitHandler.calledOnce); + + expect(formData!.get('a')).to.equal('1'); + }); + + it('should show a constraint validation error when setCustomValidity() is called', async () => { + const form = await fixture(html` +
+ + Submit +
+ `); + const button = form.querySelector('sl-button')!; + const checkbox = form.querySelector('sl-checkbox')!; + const submitHandler = sinon.spy((event: SubmitEvent) => event.preventDefault()); + + // Submitting the form after setting custom validity should not trigger the handler + checkbox.setCustomValidity('Invalid selection'); + form.addEventListener('submit', submitHandler); + button.click(); + + await aTimeout(100); + + expect(submitHandler).to.not.have.been.called; + }); + }); }); diff --git a/src/components/radio-button/radio-button.styles.ts b/src/components/radio-button/radio-button.styles.ts new file mode 100644 index 000000000..2e2d2ceee --- /dev/null +++ b/src/components/radio-button/radio-button.styles.ts @@ -0,0 +1,23 @@ +import { css } from 'lit'; +import buttonStyles from '../button/button.styles'; + +export default css` + ${buttonStyles} + label { + display: inline-block; + position: relative; + } + /* We use a hidden input so constraint validation errors work, since they don't appear to show when used with buttons. + We can't actually hide it, though, otherwise the messages will be suppressed by the browser. */ + .hidden-input { + all: unset; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + outline: dotted 1px red; + opacity: 0; + z-index: -1; + } +`; diff --git a/src/components/radio-button/radio-button.test.ts b/src/components/radio-button/radio-button.test.ts index 5062d83cd..8b865e33c 100644 --- a/src/components/radio-button/radio-button.test.ts +++ b/src/components/radio-button/radio-button.test.ts @@ -1,4 +1,4 @@ -import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; +import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; import { sendKeys } from '@web/test-runner-commands'; import sinon from 'sinon'; import type SlRadioGroup from '../../components/radio-group/radio-group'; @@ -74,7 +74,7 @@ describe('', () => { - + Submit @@ -96,4 +96,28 @@ describe('', () => { expect(formData!.get('a')).to.equal('2'); }); }); + + it('should show a constraint validation error when setCustomValidity() is called', async () => { + const form = await fixture(html` +
+ + + + + Submit +
+ `); + const button = form.querySelector('sl-button')!; + const radio = form.querySelectorAll('sl-radio-button')[1]!; + const submitHandler = sinon.spy((event: SubmitEvent) => event.preventDefault()); + + // Submitting the form after setting custom validity should not trigger the handler + radio.setCustomValidity('Invalid selection'); + form.addEventListener('submit', submitHandler); + button.click(); + + await aTimeout(100); + + expect(submitHandler).to.not.have.been.called; + }); }); diff --git a/src/components/radio-button/radio-button.ts b/src/components/radio-button/radio-button.ts index fbccddbae..3acd6bf59 100644 --- a/src/components/radio-button/radio-button.ts +++ b/src/components/radio-button/radio-button.ts @@ -1,10 +1,13 @@ -import { customElement, property } from 'lit/decorators.js'; +import { LitElement } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { html } from 'lit/static-html.js'; -import styles from '../../components/button/button.styles'; -import RadioBase from '../../internal/radio'; +import { emit } from '../../internal/event'; +import { FormSubmitController } from '../../internal/form'; import { HasSlotController } from '../../internal/slot'; +import { watch } from '../../internal/watch'; +import styles from './radio-button.styles'; /** * @since 2.0 @@ -21,16 +24,109 @@ import { HasSlotController } from '../../internal/slot'; * @slot suffix - Used to append an icon or similar element to the button. * * @csspart base - The component's internal wrapper. + * @csspart button - The internal button element. * @csspart prefix - The prefix slot's container. * @csspart label - The button's label. * @csspart suffix - The suffix slot's container. */ @customElement('sl-radio-button') -export default class SlRadioButton extends RadioBase { +export default class SlRadioButton extends LitElement { static styles = styles; + @query('.button') input: HTMLInputElement; + @query('.hidden-input') hiddenInput: HTMLInputElement; + + protected readonly formSubmitController = new FormSubmitController(this, { + value: (control: SlRadioButton) => (control.checked ? control.value : undefined) + }); private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix'); + @state() protected hasFocus = false; + + /** The radio's name attribute. */ + @property() name: string; + + /** The radio's value attribute. */ + @property() value: string; + + /** Disables the radio. */ + @property({ type: Boolean, reflect: true }) disabled = false; + + /** Draws the radio in a checked state. */ + @property({ type: Boolean, reflect: true }) checked = false; + + /** + * This will be true when the control is in an invalid state. Validity in radios is determined by the message provided + * by the `setCustomValidity` method. + */ + @property({ type: Boolean, reflect: true }) invalid = false; + + connectedCallback(): void { + super.connectedCallback(); + this.setAttribute('role', 'radio'); + } + + /** Simulates a click on the radio. */ + click() { + this.input.click(); + } + + /** Sets focus on the radio. */ + focus(options?: FocusOptions) { + this.input.focus(options); + } + + /** Removes focus from the radio. */ + blur() { + this.input.blur(); + } + + /** Checks for validity and shows the browser's validation message if the control is invalid. */ + reportValidity() { + return this.hiddenInput.reportValidity(); + } + + /** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */ + setCustomValidity(message: string) { + this.hiddenInput.setCustomValidity(message); + } + + handleBlur() { + this.hasFocus = false; + emit(this, 'sl-blur'); + } + + handleClick() { + if (!this.disabled) { + this.checked = true; + } + } + + handleFocus() { + this.hasFocus = true; + emit(this, 'sl-focus'); + } + + @watch('checked') + handleCheckedChange() { + this.setAttribute('aria-checked', this.checked ? 'true' : 'false'); + + if (this.hasUpdated) { + emit(this, 'sl-change'); + } + } + + @watch('disabled', { waitUntilFirstUpdate: true }) + handleDisabledChange() { + this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false'); + + // Disabled form controls are always valid, so we need to recheck validity when the state changes + if (this.hasUpdated) { + this.input.disabled = this.disabled; + this.invalid = !this.input.checkValidity(); + } + } + /** The button's variant. */ @property({ reflect: true }) variant: 'default' | 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'default'; @@ -38,57 +134,54 @@ export default class SlRadioButton extends RadioBase { /** The button's size. */ @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; - /** - * This will be true when the control is in an invalid state. Validity in radio buttons is determined by the message - * provided by the `setCustomValidity` method. - */ - @property({ type: Boolean, reflect: true }) invalid = false; - /** Draws a pill-style button with rounded edges. */ @property({ type: Boolean, reflect: true }) pill = false; render() { return html` - +
+ + +
`; } } diff --git a/src/components/radio/radio.test.ts b/src/components/radio/radio.test.ts index a5189de74..51f9cbbb3 100644 --- a/src/components/radio/radio.test.ts +++ b/src/components/radio/radio.test.ts @@ -1,4 +1,4 @@ -import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; +import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; import { sendKeys } from '@web/test-runner-commands'; import sinon from 'sinon'; import type SlRadioGroup from '../../components/radio-group/radio-group'; @@ -75,7 +75,7 @@ describe('', () => { - + Submit @@ -97,4 +97,28 @@ describe('', () => { expect(formData!.get('a')).to.equal('2'); }); }); + + it('should show a constraint validation error when setCustomValidity() is called', async () => { + const form = await fixture(html` +
+ + + + + Submit +
+ `); + const button = form.querySelector('sl-button')!; + const radio = form.querySelectorAll('sl-radio')[1]!; + const submitHandler = sinon.spy((event: SubmitEvent) => event.preventDefault()); + + // Submitting the form after setting custom validity should not trigger the handler + radio.setCustomValidity('Invalid selection'); + form.addEventListener('submit', submitHandler); + button.click(); + + await aTimeout(100); + + expect(submitHandler).to.not.have.been.called; + }); }); diff --git a/src/components/radio/radio.ts b/src/components/radio/radio.ts index c5289f84a..26eecd38e 100644 --- a/src/components/radio/radio.ts +++ b/src/components/radio/radio.ts @@ -1,9 +1,11 @@ -import { html } from 'lit'; -import { customElement } from 'lit/decorators.js'; +import { html, LitElement } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; -import RadioBase from '../../internal/radio'; +import { emit } from '../../internal/event'; +import { FormSubmitController } from '../../internal/form'; +import { watch } from '../../internal/watch'; import styles from './radio.styles'; /** @@ -22,9 +24,102 @@ import styles from './radio.styles'; * @csspart label - The radio label. */ @customElement('sl-radio') -export default class SlRadio extends RadioBase { +export default class SlRadio extends LitElement { static styles = styles; + @query('.radio__input') input: HTMLInputElement; + + protected readonly formSubmitController = new FormSubmitController(this, { + value: (control: HTMLInputElement) => (control.checked ? control.value : undefined) + }); + + @state() protected hasFocus = false; + + /** The radio's name attribute. */ + @property() name: string; + + /** The radio's value attribute. */ + @property() value: string; + + /** Disables the radio. */ + @property({ type: Boolean, reflect: true }) disabled = false; + + /** Draws the radio in a checked state. */ + @property({ type: Boolean, reflect: true }) checked = false; + + /** + * This will be true when the control is in an invalid state. Validity in radios is determined by the message provided + * by the `setCustomValidity` method. + */ + @property({ type: Boolean, reflect: true }) invalid = false; + + connectedCallback(): void { + super.connectedCallback(); + this.setAttribute('role', 'radio'); + } + + /** Simulates a click on the radio. */ + click() { + this.input.click(); + } + + /** Sets focus on the radio. */ + focus(options?: FocusOptions) { + this.input.focus(options); + } + + /** Removes focus from the radio. */ + blur() { + this.input.blur(); + } + + /** 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. If `message` is not empty, the field will be considered invalid. */ + setCustomValidity(message: string) { + this.input.setCustomValidity(message); + this.invalid = !this.input.checkValidity(); + } + + handleBlur() { + this.hasFocus = false; + emit(this, 'sl-blur'); + } + + handleClick() { + if (!this.disabled) { + this.checked = true; + } + } + + handleFocus() { + this.hasFocus = true; + emit(this, 'sl-focus'); + } + + @watch('checked') + handleCheckedChange() { + this.setAttribute('aria-checked', this.checked ? 'true' : 'false'); + + if (this.hasUpdated) { + emit(this, 'sl-change'); + } + } + + @watch('disabled', { waitUntilFirstUpdate: true }) + handleDisabledChange() { + this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false'); + + // Disabled form controls are always valid, so we need to recheck validity when the state changes + if (this.hasUpdated) { + this.input.disabled = this.disabled; + this.invalid = !this.input.checkValidity(); + } + } + render() { return html`