From 9d4729e4cced959a5eea3462498a14d2c43b0044 Mon Sep 17 00:00:00 2001
From: Jeremias Peier
Date: Tue, 22 Oct 2024 10:13:43 +0200
Subject: [PATCH 01/20] feat: button form support
---
src/elements/button/button/button.stories.ts | 2 +
src/elements/button/common/common-stories.ts | 46 ++++++-
.../core/base-elements/action-base-element.ts | 2 +-
.../base-elements/button-base-element.spec.ts | 130 ++++++++++++++++--
.../core/base-elements/button-base-element.ts | 80 +++++------
.../form-field/form-field/form-field.ts | 1 +
src/elements/overlay/overlay-base-element.ts | 1 +
7 files changed, 211 insertions(+), 51 deletions(-)
diff --git a/src/elements/button/button/button.stories.ts b/src/elements/button/button/button.stories.ts
index 979d1ce631..94d766a5c0 100644
--- a/src/elements/button/button/button.stories.ts
+++ b/src/elements/button/button/button.stories.ts
@@ -19,6 +19,7 @@ import {
primaryNegativeDisabled,
sizeM,
sizeS,
+ withForm,
withHiddenSlottedIcon,
withSlottedIcon,
} from '../common/common-stories.js';
@@ -49,6 +50,7 @@ export const WithSlottedIcon: StoryObj = withSlottedIcon;
export const LoadingIndicator: StoryObj = loadingIndicator;
export const RequestSubmit: StoryObj = requestSubmit;
export const WithHiddenSlottedIcon: StoryObj = withHiddenSlottedIcon;
+export const WithForm: StoryObj = withForm;
const meta: Meta = {
args: defaultArgs,
diff --git a/src/elements/button/common/common-stories.ts b/src/elements/button/common/common-stories.ts
index 82179405e4..93da911280 100644
--- a/src/elements/button/common/common-stories.ts
+++ b/src/elements/button/common/common-stories.ts
@@ -8,13 +8,15 @@ import type {
StoryObj,
WebComponentsRenderer,
} from '@storybook/web-components';
-import type { TemplateResult } from 'lit';
+import { nothing, type TemplateResult } from 'lit';
import { html, unsafeStatic } from 'lit/static-html.js';
import { sbbSpread } from '../../../storybook/helpers/spread.js';
+import '../../action-group.js';
import '../../icon.js';
import '../../loading-indicator.js';
+import '../../form-field.js';
/* eslint-disable lit/binding-positions, @typescript-eslint/naming-convention */
const Template = ({ tag, text, ...args }: Args): TemplateResult => html`
@@ -60,6 +62,39 @@ const FixedWidthTemplate = ({ tag, text, ...args }: Args): TemplateResult => htm
`;
+
+const FormTemplate = ({
+ tag,
+ name,
+ value,
+ type: _type,
+ reset: _reset,
+ ...args
+}: Args): TemplateResult => html`
+`;
/* eslint-enable lit/binding-positions, @typescript-eslint/naming-convention */
const text: InputType = {
@@ -207,6 +242,15 @@ export const withHiddenSlottedIcon: StoryObj = {
},
};
+export const withForm: StoryObj = {
+ render: FormTemplate,
+ args: {
+ type: undefined,
+ text: undefined,
+ value: 'submit value',
+ },
+};
+
export const commonDecorators = [
(story: () => WebComponentsRenderer['storyResult'], context: StoryContext) =>
context.args.negative
diff --git a/src/elements/core/base-elements/action-base-element.ts b/src/elements/core/base-elements/action-base-element.ts
index 2136910fb3..36ba63fd84 100644
--- a/src/elements/core/base-elements/action-base-element.ts
+++ b/src/elements/core/base-elements/action-base-element.ts
@@ -64,6 +64,6 @@ abstract class SbbActionBaseElement extends LitElement {
/** Default render method for button-like components. */
protected override render(): TemplateResult {
- return html` ${this.renderTemplate()} `;
+ return html`${this.renderTemplate()}`;
}
}
diff --git a/src/elements/core/base-elements/button-base-element.spec.ts b/src/elements/core/base-elements/button-base-element.spec.ts
index 5f5954eeee..23d3316239 100644
--- a/src/elements/core/base-elements/button-base-element.spec.ts
+++ b/src/elements/core/base-elements/button-base-element.spec.ts
@@ -1,22 +1,15 @@
import { assert, expect } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
import { html, type TemplateResult } from 'lit';
-import { property } from 'lit/decorators.js';
+import { spy } from 'sinon';
-import { forceType } from '../decorators.js';
+import { SbbDisabledInteractiveMixin, SbbDisabledMixin } from '../mixins.js';
import { fixture } from '../testing/private.js';
import { EventSpy, waitForLitRender } from '../testing.js';
import { SbbButtonBaseElement } from './button-base-element.js';
-class GenericButton extends SbbButtonBaseElement {
- @forceType()
- @property({ type: Boolean })
- public accessor disabled: boolean = false;
- @forceType()
- @property({ type: Boolean })
- public accessor disabledInteractive: boolean = false;
-
+class GenericButton extends SbbDisabledInteractiveMixin(SbbDisabledMixin(SbbButtonBaseElement)) {
protected override renderTemplate(): TemplateResult {
return html`Button`;
}
@@ -110,6 +103,123 @@ describe(`SbbButtonBaseElement`, () => {
expect(clickSpy.count).to.be.equal(1);
});
});
+
+ describe('form association', () => {
+ let form: HTMLFormElement;
+
+ let submitButton: GenericButton | HTMLButtonElement;
+ let resetButton: GenericButton | HTMLButtonElement;
+ let fieldSet: HTMLFieldSetElement;
+ const submitEventSpy = spy();
+ const resetEventSpy = spy();
+
+ for (const entry of [
+ {
+ selector: 'generic-button',
+ resetButton: html`
+ Reset
+ `,
+ button: html`
+ Submit
+ `,
+ },
+ {
+ selector: 'button',
+ resetButton: html``,
+ button: html``,
+ },
+ ]) {
+ describe(entry.selector, () => {
+ beforeEach(async () => {
+ form = await fixture(html`
+
+ `);
+ submitButton = form.querySelector(`[type="submit"]`)!;
+ resetButton = form.querySelector(`[type="reset"]`)!;
+ fieldSet = form.querySelector('fieldset')!;
+ });
+
+ it('should set default value', async () => {
+ expect(submitButton.value).to.be.equal('submit');
+ });
+
+ it('should result :disabled', async () => {
+ submitButton.disabled = true;
+ await waitForLitRender(form);
+ expect(submitButton).to.match(':disabled');
+
+ submitButton.disabled = false;
+ await waitForLitRender(form);
+ expect(submitButton).not.to.match(':disabled');
+ });
+
+ it('should result :disabled if a fieldSet is', async () => {
+ fieldSet.disabled = true;
+
+ await waitForLitRender(form);
+
+ expect(submitButton).to.match(':disabled');
+
+ fieldSet.disabled = false;
+ await waitForLitRender(form);
+
+ expect(submitButton).not.to.match(':disabled');
+ });
+
+ it('should reset on form reset', async () => {
+ submitButton.value = 'changed-value';
+
+ form.reset();
+
+ await waitForLitRender(form);
+
+ expect(submitButton.value).to.be.equal('changed-value');
+ });
+
+ it('should reset on button reset', async () => {
+ submitButton.value = 'changed-value';
+
+ resetButton.click();
+ await waitForLitRender(form);
+
+ expect(submitButton.value).to.be.equal('changed-value');
+ });
+ });
+ }
+ });
+
+ /*
+ // TODO: implement
+ it('should restore form state on formStateRestoreCallback()', async () => {
+ // Mimic tab restoration. Does not test the full cycle as we can not set the browser in the required state.
+ submitButton.formStateRestoreCallback('3', 'restore');
+ await waitForLitRender(element);
+
+ expect(element.value).to.be.equal('3');
+
+ element.multiple = true;
+ await waitForLitRender(element);
+
+ const formData = new FormData();
+ formData.append(element.name, '1');
+ formData.append(element.name, '2');
+
+ element.formStateRestoreCallback(Array.from(formData.entries()), 'restore');
+ await waitForLitRender(element);
+
+ expect(element.value).to.be.eql(['1', '2']);
+ });
+
+ */
});
declare global {
diff --git a/src/elements/core/base-elements/button-base-element.ts b/src/elements/core/base-elements/button-base-element.ts
index a4e4386e6a..557c33870b 100644
--- a/src/elements/core/base-elements/button-base-element.ts
+++ b/src/elements/core/base-elements/button-base-element.ts
@@ -1,8 +1,13 @@
import { isServer } from 'lit';
import { property } from 'lit/decorators.js';
-import { forceType, hostAttributes } from '../decorators.js';
+import { hostAttributes } from '../decorators.js';
import { isEventPrevented } from '../eventing.js';
+import {
+ type FormRestoreReason,
+ type FormRestoreState,
+ SbbFormAssociatedMixin,
+} from '../mixins.js';
import { SbbActionBaseElement } from './action-base-element.js';
@@ -12,49 +17,32 @@ export type SbbButtonType = 'button' | 'reset' | 'submit';
/** Button base class. */
export
@hostAttributes({
- role: 'button',
tabindex: '0',
'data-button': '',
})
-abstract class SbbButtonBaseElement extends SbbActionBaseElement {
+abstract class SbbButtonBaseElement extends SbbFormAssociatedMixin(SbbActionBaseElement) {
/** The type attribute to use for the button. */
- @property() public accessor type: SbbButtonType = 'button';
+ @property() public override accessor type: SbbButtonType = 'button';
- /**
- * The name of the button element.
- *
- * @description Developer note: In this case updating the attribute must be synchronous.
- * Due to this it is implemented as a getter/setter and the attributeChangedCallback() handles the diff check.
- */
- @property()
- public set name(name: string) {
- this.setAttribute('name', `${name}`);
- }
- public get name(): string {
- return this.getAttribute('name') ?? '';
- }
-
- /**
- * The value of the button element.
- *
- * @description Developer note: In this case updating the attribute must be synchronous.
- * Due to this it is implemented as a getter/setter and the attributeChangedCallback() handles the diff check.
- */
+ /** The