Skip to content

Commit

Permalink
feat(checkbox): add full form association support
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 532621912
  • Loading branch information
asyncLiz authored and copybara-github committed May 17, 2023
1 parent 57f7ae2 commit a61f79c
Show file tree
Hide file tree
Showing 3 changed files with 497 additions and 15 deletions.
137 changes: 137 additions & 0 deletions checkbox/checkbox_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

// import 'jasmine'; (google3-only)

import {html} from 'lit';

import {createFormTests} from '../testing/forms.js';
import {createTokenTests} from '../testing/tokens.js';

import {MdCheckbox} from './checkbox.js';
Expand All @@ -14,4 +17,138 @@ describe('<md-checkbox>', () => {
describe('.styles', () => {
createTokenTests(MdCheckbox.styles);
});

describe('forms', () => {
createFormTests({
queryControl: root => root.querySelector('md-checkbox'),
valueTests: [
{
name: 'unnamed',
render: () => html`<md-checkbox checked></md-checkbox>`,
assertValue(formData) {
expect(formData)
.withContext('should not add anything to form without a name')
.toHaveSize(0);
}
},
{
name: 'unchecked',
render: () => html`<md-checkbox name="checkbox"></md-checkbox>`,
assertValue(formData) {
expect(formData)
.withContext('should not add anything to form when unchecked')
.toHaveSize(0);
}
},
{
name: 'checked default value',
render: () =>
html`<md-checkbox name="checkbox" checked></md-checkbox>`,
assertValue(formData) {
expect(formData.get('checkbox')).toBe('on');
}
},
{
name: 'checked custom value',
render: () =>
html`<md-checkbox name="checkbox" checked value="Custom value"></md-checkbox>`,
assertValue(formData) {
expect(formData.get('checkbox')).toBe('Custom value');
}
},
{
name: 'indeterminate',
render: () =>
html`<md-checkbox name="checkbox" checked indeterminate></md-checkbox>`,
assertValue(formData) {
expect(formData)
.withContext(
'should not add anything to form when indeterminate')
.toHaveSize(0);
}
},
{
name: 'disabled',
render: () =>
html`<md-checkbox name="checkbox" checked disabled></md-checkbox>`,
assertValue(formData) {
expect(formData)
.withContext('should not add anything to form when disabled')
.toHaveSize(0);
}
}
],
resetTests: [
{
name: 'reset to unchecked',
render: () => html`<md-checkbox name="checkbox"></md-checkbox>`,
change(checkbox) {
checkbox.checked = true;
},
assertReset(checkbox) {
expect(checkbox.checked)
.withContext('checkbox.checked after reset')
.toBeFalse();
}
},
{
name: 'reset to checked',
render: () =>
html`<md-checkbox name="checkbox" checked></md-checkbox>`,
change(checkbox) {
checkbox.checked = false;
},
assertReset(checkbox) {
expect(checkbox.checked)
.withContext('checkbox.checked after reset')
.toBeTrue();
}
},
{
name: 'reset to indeterminate',
render: () =>
html`<md-checkbox name="checkbox" indeterminate></md-checkbox>`,
change(checkbox) {
checkbox.indeterminate = false;
},
assertReset(checkbox) {
expect(checkbox.indeterminate)
.withContext('checkbox.indeterminate should not be reset')
.toBeFalse();
}
}
],
restoreTests: [
{
name: 'restore unchecked',
render: () => html`<md-checkbox name="checkbox"></md-checkbox>`,
assertRestored(checkbox) {
expect(checkbox.checked)
.withContext('checkbox.checked after restore')
.toBeFalse();
}
},
{
name: 'restore checked',
render: () =>
html`<md-checkbox name="checkbox" checked></md-checkbox>`,
assertRestored(checkbox) {
expect(checkbox.checked)
.withContext('checkbox.checked after restore')
.toBeTrue();
}
},
{
name: 'restore indeterminate',
render: () =>
html`<md-checkbox name="checkbox" indeterminate></md-checkbox>`,
assertRestored(checkbox) {
expect(checkbox.indeterminate)
.withContext('checkbox.indeterminate should not be restored')
.toBeFalse();
}
}
]
});
});
});
50 changes: 35 additions & 15 deletions checkbox/lib/checkbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ import {when} from 'lit/directives/when.js';
import {ARIAMixinStrict} from '../../aria/aria.js';
import {requestUpdateOnAriaChange} from '../../aria/delegate.js';
import {dispatchActivationClick, isActivationClick, redispatchEvent} from '../../controller/events.js';
import {FormController, getFormValue} from '../../controller/form-controller.js';
import {stringConverter} from '../../controller/string-converter.js';
import {ripple} from '../../ripple/directive.js';
import {MdRipple} from '../../ripple/ripple.js';

Expand All @@ -28,15 +26,13 @@ export class Checkbox extends LitElement {
requestUpdateOnAriaChange(this);
}

/**
* @nocollapse
*/
/** @nocollapse */
static formAssociated = true;

/**
* Whether or not the checkbox is selected.
*/
@property({type: Boolean, reflect: true}) checked = false;
@property({type: Boolean}) checked = false;

/**
* Whether or not the checkbox is disabled.
Expand All @@ -46,14 +42,14 @@ export class Checkbox extends LitElement {
/**
* Whether or not the checkbox is invalid.
*/
@property({type: Boolean, reflect: true}) error = false;
@property({type: Boolean}) error = false;

/**
* Whether or not the checkbox is indeterminate.
*
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#indeterminate_state_checkboxes
*/
@property({type: Boolean, reflect: true}) indeterminate = false;
@property({type: Boolean}) indeterminate = false;

/**
* The value of the checkbox that is submitted with a form when selected.
Expand All @@ -65,13 +61,25 @@ export class Checkbox extends LitElement {
/**
* The HTML name to use in form submission.
*/
@property({reflect: true, converter: stringConverter}) name = '';
get name() {
return this.getAttribute('name') ?? '';
}
set name(name: string) {
this.setAttribute('name', name);
}

/**
* The associated form element with which this element's value will submit.
*/
get form() {
return this.closest('form');
return this.internals.form;
}

/**
* The labels this element is associated with.
*/
get labels() {
return this.internals.labels;
}

@state() private prevChecked = false;
Expand All @@ -80,10 +88,11 @@ export class Checkbox extends LitElement {
@queryAsync('md-ripple') private readonly ripple!: Promise<MdRipple|null>;
@query('input') private readonly input!: HTMLInputElement|null;
@state() private showRipple = false;
private readonly internals =
(this as HTMLElement /* needed for closure */).attachInternals();

constructor() {
super();
this.addController(new FormController(this));
if (!isServer) {
this.addEventListener('click', (event: MouseEvent) => {
if (!isActivationClick(event)) {
Expand All @@ -99,10 +108,6 @@ export class Checkbox extends LitElement {
this.input?.focus();
}

[getFormValue]() {
return this.checked ? this.value : null;
}

protected override update(changed: PropertyValues<Checkbox>) {
if (changed.has('checked') || changed.has('disabled') ||
changed.has('indeterminate')) {
Expand All @@ -112,6 +117,9 @@ export class Checkbox extends LitElement {
changed.get('indeterminate') ?? this.indeterminate;
}

const shouldAddFormValue = this.checked && !this.indeterminate;
const state = String(this.checked);
this.internals.setFormValue(shouldAddFormValue ? this.value : null, state);
super.update(changed);
}

Expand Down Expand Up @@ -176,4 +184,16 @@ export class Checkbox extends LitElement {
private readonly renderRipple = () => { // bind to this
return html`<md-ripple ?disabled=${this.disabled} unbounded></md-ripple>`;
};

/** @private */
formResetCallback() {
// The checked property does not reflect, so the original attribute set by
// the user is used to determine the default value.
this.checked = this.hasAttribute('checked');
}

/** @private */
formStateRestoreCallback(state: string) {
this.checked = state === 'true';
}
}
Loading

0 comments on commit a61f79c

Please sign in to comment.