diff --git a/dev/playground/toggle-switch.html b/dev/playground/toggle-switch.html new file mode 100644 index 00000000000..3aa69ec684f --- /dev/null +++ b/dev/playground/toggle-switch.html @@ -0,0 +1,80 @@ + + + + + + + Toggle Switch + + + + + + +
+

Label & State

+ + +
+ +
+

Disabled

+ + +
+ +
+

Read-only

+ + +
+ +
+

Required

+ +
+ +
+

Invalid with error message

+ +
+ +
+

Helper text

+ +
+ +
+

With tooltip

+ + + +
+ +
+

Label with link

+ + + Send anonymous usage data — read our privacy policy + + +
+ + + diff --git a/dev/toggle-switch.html b/dev/toggle-switch.html new file mode 100644 index 00000000000..20fb0c5ec57 --- /dev/null +++ b/dev/toggle-switch.html @@ -0,0 +1,22 @@ + + + + + + + Toggle Switch + + + + + + + + diff --git a/packages/aura/aura.css b/packages/aura/aura.css index 5c3dcf07a55..513798f0bba 100644 --- a/packages/aura/aura.css +++ b/packages/aura/aura.css @@ -39,6 +39,7 @@ @import './src/components/side-nav.css'; @import './src/components/slider.css'; @import './src/components/tabs.css'; +@import './src/components/toggle-switch.css'; @import './src/components/tooltip.css'; @import './src/components/upload.css'; diff --git a/packages/aura/src/components/toggle-switch.css b/packages/aura/src/components/toggle-switch.css new file mode 100644 index 00000000000..1f0695fb370 --- /dev/null +++ b/packages/aura/src/components/toggle-switch.css @@ -0,0 +1,71 @@ +:where(:root), +:where(:host) { + --vaadin-toggle-switch-size: round(1lh - 2px, 2px); +} + +vaadin-toggle-switch::part(switch) { + transition: + background-color 100ms, + box-shadow 100ms; +} + +vaadin-toggle-switch:not([disabled], [readonly])::part(switch) { + --aura-surface-level: 4; + background: var(--vaadin-toggle-switch-background, var(--aura-surface-color)); + box-shadow: var(--aura-shadow-xs); + --_shade: color-mix(in srgb, var(--vaadin-border-color-secondary) 50%, transparent); + background-image: linear-gradient( + light-dark(transparent, var(--_shade)), + transparent 33%, + transparent 66%, + light-dark(var(--_shade), transparent) + ); +} + +vaadin-toggle-switch:not([checked])::part(switch) { + background-clip: padding-box; +} + +vaadin-toggle-switch[checked]:not([readonly], [disabled])::part(switch) { + --vaadin-toggle-switch-background: var(--aura-accent-color); + --vaadin-toggle-switch-border-color: var(--vaadin-border-color-secondary); + --vaadin-toggle-switch-thumb-checked-color: var(--aura-accent-contrast-color); + background-image: none; + box-shadow: var(--aura-shadow-s); +} + +vaadin-toggle-switch[invalid]:not([disabled], [readonly])::part(switch) { + --vaadin-toggle-switch-background: color-mix(in srgb, var(--aura-red) 10%, transparent); + --vaadin-toggle-switch-border-color: var(--aura-red-text); + background-image: none; +} + +vaadin-toggle-switch[invalid][checked]:not([disabled], [readonly])::part(switch) { + --vaadin-toggle-switch-background: var(--aura-red); + --vaadin-toggle-switch-border-color: var(--vaadin-border-color-secondary); + --vaadin-toggle-switch-thumb-checked-color: var(--aura-accent-contrast-color); +} + +vaadin-toggle-switch:not([disabled], [readonly])::part(switch)::before { + content: ''; + position: absolute; + inset: calc(var(--vaadin-toggle-switch-border-width, var(--vaadin-input-field-border-width, 1px)) * -1); + border-radius: inherit; + background-color: currentColor; + opacity: 0; + transition: + opacity 100ms, + background-color 100ms; + pointer-events: none; +} + +@media (any-hover: hover) { + vaadin-toggle-switch:hover:not([readonly], [disabled], [active])::part(switch)::before { + opacity: 0.04; + } +} + +vaadin-toggle-switch[active]:not([readonly], [disabled])::part(switch)::before { + opacity: 0.1; + background: #000; +} diff --git a/packages/checkbox/src/vaadin-checkbox-mixin.d.ts b/packages/checkbox/src/vaadin-checkbox-mixin.d.ts index 1e89f70de05..e8c8b5f0acb 100644 --- a/packages/checkbox/src/vaadin-checkbox-mixin.d.ts +++ b/packages/checkbox/src/vaadin-checkbox-mixin.d.ts @@ -38,15 +38,6 @@ export declare function CheckboxMixin>( T; export declare class CheckboxMixinClass { - /** - * True if the checkbox is in the indeterminate state which means - * it is not possible to say whether it is checked or unchecked. - * The state is reset once the user switches the checkbox by hand. - * - * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#Indeterminate_state_checkboxes - */ - indeterminate: boolean; - /** * The name of the checkbox. */ diff --git a/packages/checkbox/src/vaadin-checkbox-mixin.js b/packages/checkbox/src/vaadin-checkbox-mixin.js index b3ff5ef4d83..9501258361e 100644 --- a/packages/checkbox/src/vaadin-checkbox-mixin.js +++ b/packages/checkbox/src/vaadin-checkbox-mixin.js @@ -20,20 +20,6 @@ export const CheckboxMixin = (superclass) => ) { static get properties() { return { - /** - * True if the checkbox is in the indeterminate state which means - * it is not possible to say whether it is checked or unchecked. - * The state is reset once the user switches the checkbox by hand. - * - * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#Indeterminate_state_checkboxes - */ - indeterminate: { - type: Boolean, - notify: true, - value: false, - reflectToAttribute: true, - }, - /** * The name of the checkbox. */ @@ -60,11 +46,6 @@ export const CheckboxMixin = (superclass) => return ['__readonlyChanged(readonly, inputElement)']; } - /** @override */ - static get delegateProps() { - return [...super.delegateProps, 'indeterminate']; - } - /** @override */ static get delegateAttrs() { return [...super.delegateAttrs, 'name', 'invalid', 'required']; @@ -187,22 +168,6 @@ export const CheckboxMixin = (superclass) => } } - /** - * Override method inherited from `CheckedMixin` to reset - * `indeterminate` state checkbox is toggled by the user. - * - * @param {boolean} checked - * @protected - * @override - */ - _toggleChecked(checked) { - if (this.indeterminate) { - this.indeterminate = false; - } - - super._toggleChecked(checked); - } - /** * @override * @return {boolean} diff --git a/packages/checkbox/src/vaadin-checkbox.d.ts b/packages/checkbox/src/vaadin-checkbox.d.ts index 1580f095660..c4a5862e441 100644 --- a/packages/checkbox/src/vaadin-checkbox.d.ts +++ b/packages/checkbox/src/vaadin-checkbox.d.ts @@ -120,6 +120,15 @@ export interface CheckboxEventMap extends HTMLElementEventMap, CheckboxCustomEve * @fires {CustomEvent} validated - Fired whenever the field is validated. */ declare class Checkbox extends CheckboxMixin(ElementMixin(ThemableMixin(HTMLElement))) { + /** + * True if the checkbox is in the indeterminate state which means + * it is not possible to say whether it is checked or unchecked. + * The state is reset once the user switches the checkbox by hand. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#Indeterminate_state_checkboxes + */ + indeterminate: boolean; + addEventListener( type: K, listener: (this: Checkbox, ev: CheckboxEventMap[K]) => void, diff --git a/packages/checkbox/src/vaadin-checkbox.js b/packages/checkbox/src/vaadin-checkbox.js index 31dcc847705..c7b354a2169 100644 --- a/packages/checkbox/src/vaadin-checkbox.js +++ b/packages/checkbox/src/vaadin-checkbox.js @@ -96,6 +96,30 @@ export class Checkbox extends CheckboxMixin(ElementMixin(ThemableMixin(PolylitMi return checkboxStyles; } + static get properties() { + return { + /** + * True if the checkbox is in the indeterminate state which means + * it is not possible to say whether it is checked or unchecked. + * The state is reset once the user switches the checkbox by hand. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#Indeterminate_state_checkboxes + * + */ + indeterminate: { + type: Boolean, + notify: true, + value: false, + reflectToAttribute: true, + }, + }; + } + + /** @override */ + static get delegateProps() { + return [...super.delegateProps, 'indeterminate']; + } + /** @protected */ render() { return html` @@ -125,6 +149,22 @@ export class Checkbox extends CheckboxMixin(ElementMixin(ThemableMixin(PolylitMi this._tooltipController.setAriaTarget(this.inputElement); this.addController(this._tooltipController); } + + /** + * Override method inherited from `CheckedMixin` to reset + * `indeterminate` when the checkbox is toggled by the user. + * + * @param {boolean} checked + * @protected + * @override + */ + _toggleChecked(checked) { + if (this.indeterminate) { + this.indeterminate = false; + } + + super._toggleChecked(checked); + } } defineCustomElement(Checkbox); diff --git a/packages/toggle-switch/LICENSE b/packages/toggle-switch/LICENSE new file mode 100644 index 00000000000..c8403c8530f --- /dev/null +++ b/packages/toggle-switch/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025-2026 Vaadin Ltd. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/toggle-switch/README.md b/packages/toggle-switch/README.md new file mode 100644 index 00000000000..fe165082158 --- /dev/null +++ b/packages/toggle-switch/README.md @@ -0,0 +1,36 @@ +# @vaadin/toggle-switch + +A web component that displays a binary on/off switch control. + +> ⚠️ This component is experimental and the API may change. In order to use it, enable the feature flag by setting `window.Vaadin.featureFlags.toggleSwitchComponent = true`. + +```html + +``` + +[Documentation + Live Demo ↗](https://vaadin.com/docs/latest/components/toggle-switch) + +## Installation + +Install the component: + +```sh +npm i @vaadin/toggle-switch +``` + +Once installed, import the component in your application: + +```js +import '@vaadin/toggle-switch'; +``` + +## Contributing + +Read the [contributing guide](https://vaadin.com/docs/latest/contributing) to learn about our development process, how to propose bugfixes and improvements, and how to test your changes to Vaadin components. + +## License + +Apache License 2.0 + +Vaadin collects usage statistics at development time to improve this product. +For details and to opt-out, see https://github.com/vaadin/vaadin-usage-statistics. diff --git a/packages/toggle-switch/package.json b/packages/toggle-switch/package.json new file mode 100644 index 00000000000..561a6866129 --- /dev/null +++ b/packages/toggle-switch/package.json @@ -0,0 +1,58 @@ +{ + "name": "@vaadin/toggle-switch", + "version": "25.2.0-alpha12", + "publishConfig": { + "access": "public" + }, + "description": "Web component that displays a binary on/off switch control.", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/vaadin/web-components.git", + "directory": "packages/toggle-switch" + }, + "author": "Vaadin Ltd", + "homepage": "https://vaadin.com/components", + "bugs": { + "url": "https://github.com/vaadin/web-components/issues" + }, + "main": "vaadin-toggle-switch.js", + "module": "vaadin-toggle-switch.js", + "type": "module", + "files": [ + "src", + "vaadin-*.d.ts", + "vaadin-*.js", + "custom-elements.json", + "web-types.json", + "web-types.lit.json" + ], + "keywords": [ + "Vaadin", + "toggle-switch", + "switch", + "web-components", + "web-component" + ], + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@vaadin/checkbox": "25.2.0-alpha12", + "@vaadin/component-base": "25.2.0-alpha12", + "@vaadin/field-base": "25.2.0-alpha12", + "@vaadin/vaadin-themable-mixin": "25.2.0-alpha12", + "lit": "^3.0.0" + }, + "devDependencies": { + "@vaadin/aura": "25.2.0-alpha12", + "@vaadin/chai-plugins": "25.2.0-alpha12", + "@vaadin/test-runner-commands": "25.2.0-alpha12", + "@vaadin/testing-helpers": "^2.0.0", + "@vaadin/vaadin-lumo-styles": "25.2.0-alpha12", + "sinon": "^21.0.2" + }, + "customElements": "custom-elements.json", + "web-types": [ + "web-types.json", + "web-types.lit.json" + ] +} diff --git a/packages/toggle-switch/spec/flow-api.md b/packages/toggle-switch/spec/flow-api.md new file mode 100644 index 00000000000..1e7256acf35 --- /dev/null +++ b/packages/toggle-switch/spec/flow-api.md @@ -0,0 +1,324 @@ +# Toggle Switch Flow Developer API + +Java wrapper for ``. Class: `ToggleSwitch` in package `com.vaadin.flow.component.toggleswitch`. + +The Flow class follows the `Checkbox` pattern from `vaadin-checkbox-flow` line-for-line: same base class, same shared mixins (`InputField`, `HasValidationProperties`, `HasAriaLabel`, `HasValidator`), same `ClickNotifier` / `Focusable`, same nested `ToggleSwitchI18n` for the required-error message. The intentional differences are (1) no `indeterminate`-related API — toggle switches do not have an indeterminate state per the problem statement — and (2) no `HasThemeVariant` / typed `ToggleSwitchVariant` enum, see Discussion. + +--- + +## 1. Basic instantiation, default-off state, on/off value, ARIA switch role + +Covers requirement(s): 1, 4 + +```java +// Default: starts off +ToggleSwitch notifications = new ToggleSwitch("Notifications"); +add(notifications); + +// Initial state: on +ToggleSwitch darkMode = new ToggleSwitch("Dark mode", true); +add(darkMode); + +// Read / write the current state programmatically +boolean isOn = notifications.getValue(); +notifications.setValue(true); + +// No-arg constructor for late-set label +ToggleSwitch s = new ToggleSwitch(); +s.setLabel("Auto-save"); +add(s); +``` + +**Why this shape:** Mirrors `Checkbox` from `vaadin-checkbox-flow`. The component extends `AbstractSinglePropertyField` so the on/off state is the field's `Boolean` value — making it a drop-in `HasValue` for `Binder` and other data-binding code. The `(String label)`, `(boolean initialValue)`, and `(String, boolean)` convenience constructors match the Checkbox set; no separate constructor is needed for "switch role" because the role is exposed automatically by the underlying web component, with no Flow-level switch. + +--- + +## 2. Value change listener + +Covers requirement(s): 2 + +```java +ToggleSwitch dailyDigest = new ToggleSwitch("Daily digest"); + +dailyDigest.addValueChangeListener(event -> { + // event.isFromClient() distinguishes user vs. programmatic updates + if (event.isFromClient()) { + userPreferences.setDailyDigest(event.getValue()); + } +}); + +// Convenience constructor: label + listener in one go +ToggleSwitch compare = new ToggleSwitch("Compare with previous period", + event -> dashboard.toggleOverlay(event.getValue())); +add(compare); +``` + +**Why this shape:** The web component's `change` event (user-initiated) and `checked-changed` event (any change) collapse into a single Flow `ValueChangeListener` whose event carries `isFromClient()` for the user-vs-programmatic distinction — the standard Vaadin Flow way of handling this split. Matches `Checkbox.addValueChangeListener` exactly. The two-arg `(String, ValueChangeListener)` constructor mirrors the corresponding Checkbox constructor for the most common call site. + +--- + +## 3. Label, label component, accessible name + +Covers requirement(s): 3 + +```java +// Plain-text label (most common) +ToggleSwitch s = new ToggleSwitch("Email me when I'm @mentioned"); + +// Replace the label with a custom component when HTML / inline children are needed +ToggleSwitch consent = new ToggleSwitch(); +HorizontalLayout label = new HorizontalLayout( + new Span("Send anonymous usage data — "), + new Anchor("/privacy", "read our privacy policy")); +label.setSpacing(false); +consent.setLabelComponent(label); +add(consent); + +// Label-less switch (e.g. inside a Grid column where the column header is the name) +ToggleSwitch active = new ToggleSwitch(); +active.setAriaLabel("Active"); +// Or reference an external label by id +active.setAriaLabelledBy("row-3-active-label"); +``` + +**Why this shape:** `setLabel(String)` comes from `HasLabel` (transitive via `InputField`), `setLabelComponent(Component)` matches the Checkbox additional method for HTML-in-label cases, and `setAriaLabel(String)` / `setAriaLabelledBy(String)` come from `HasAriaLabel` (Flow core). All three mirror the public surface of `Checkbox`. The component refuses to flip when the user clicks an interactive child of a label component; this is web-component behavior, no Flow API surface. + +--- + +## 4. Disabled + +Covers requirement(s): 5 + +```java +ToggleSwitch dailyDigest = new ToggleSwitch("Daily digest"); +dailyDigest.setEnabled(false); // not focusable, not Tab-reachable, no interaction + +// Programmatic flips are still allowed and silent (no value-change event from user) +ToggleSwitch parent = new ToggleSwitch("Email me on activity"); +ToggleSwitch child = new ToggleSwitch("Daily digest"); +parent.addValueChangeListener(e -> { + child.setEnabled(e.getValue()); + if (!e.getValue()) { + child.setValue(false); // programmatic update — listener will see isFromClient=false + } +}); +``` + +**Why this shape:** `setEnabled(boolean)` is the Vaadin Flow convention (`HasEnabled` via `InputField`); a separate `setDisabled` would be an idiosyncratic deviation. Matches Checkbox. + +--- + +## 5. Read-only + +Covers requirement(s): 6 + +```java +// Read-only switch reflecting a plan-locked setting +ToggleSwitch retention = new ToggleSwitch("Audit log retention (90 days)", true); +retention.setHelperText("Included on the Business plan."); +retention.setReadOnly(true); +add(retention); + +// Read-only + required: validation rule "valid if on, invalid if off" still applies +ToggleSwitch verified = new ToggleSwitch("Account verified", true); +verified.setReadOnly(true); +verified.setRequiredIndicatorVisible(true); +``` + +**Why this shape:** `setReadOnly(boolean)` is part of `HasValue` (transitive via `InputField`), so applications already know it from every other Vaadin field. The web component's `aria-readonly` qualifier and the read-only-still-submits-with-form distinction are handled internally by the web component; no Flow-level API. + +--- + +## 6. Required validation, error message, manual validation, i18n + +Covers requirement(s): 7, 9 + +```java +// Built-in required validation: turning the switch on satisfies it; off is invalid +ToggleSwitch terms = new ToggleSwitch("I confirm the trip details are correct"); +terms.setRequiredIndicatorVisible(true); +terms.setErrorMessage("You must accept the trip details to continue"); + +// i18n-supplied default required message (e.g. localized at app level) +ToggleSwitch terms2 = new ToggleSwitch("I accept the booking terms"); +terms2.setRequiredIndicatorVisible(true); +terms2.setI18n(new ToggleSwitch.ToggleSwitchI18n() + .setRequiredErrorMessage(getTranslation("toggleSwitch.required"))); + +// Manual validation: app drives `invalid` and `errorMessage` itself +// (e.g. for server-returned business-rule violations) +ToggleSwitch twoFactor = new ToggleSwitch("Two-factor authentication required"); +twoFactor.setManualValidation(true); + +binder.forField(twoFactor) + .withValidator((value, context) -> { + if (account.isCompliancePlan() && !value) { + return ValidationResult.error( + "Two-factor authentication can't be disabled on the Compliance plan"); + } + return ValidationResult.ok(); + }) + .bind(User::isTwoFactorRequired, User::setTwoFactorRequired); + +// Application-level revalidation runs through Binder; the protected +// validate() method on the component itself is invoked by the framework. +binder.validate(); +``` + +**Why this shape:** Required-handling via `setRequiredIndicatorVisible` plus a default validator that fails on the empty value (`Boolean.FALSE`) is exactly how `Checkbox` does it — consistent across all binary Vaadin fields. `HasValidationProperties` provides `setErrorMessage` / `setInvalid`. `HasValidator` provides `setManualValidation`; `validate()` itself is `protected` (as on `Checkbox`) — applications that need to drive validation imperatively go through `Binder.validate()`. The nested `ToggleSwitchI18n` class mirrors `CheckboxI18n` exactly: one fluent setter for the default required-error message, `Serializable`, retrievable via `getI18n()`. Custom `setErrorMessage(String)` takes priority over the i18n message (matches Checkbox semantics). The empty value of the field is `Boolean.FALSE`, also matching Checkbox — so `Binder` `asRequired()` and the toggle switch's own required validation agree on which state counts as "empty". + +--- + +## 7. Helper text + +Covers requirement(s): 8 + +```java +ToggleSwitch autosave = new ToggleSwitch("Auto-save"); +autosave.setHelperText("Save changes automatically every 30 seconds"); +add(autosave); + +// HTML helper content (links, formatting) +ToggleSwitch beta = new ToggleSwitch("Beta features"); +beta.setHelperComponent(new Anchor("/beta", "See what's enabled in the beta program")); +``` + +**Why this shape:** `setHelperText(String)` and `setHelperComponent(Component)` come from `HasHelper` (transitive via `InputField`) — same surface every Vaadin field already exposes. + +--- + +## 8. Tooltip + +Covers requirement(s): 10 + +```java +ToggleSwitch active = new ToggleSwitch("Active"); +active.setTooltipText("Last delivery: 2 minutes ago"); + +// Markdown-formatted tooltip +active.setTooltipMarkdown("Last delivery: **2 minutes ago**"); + +// Direct access to the tooltip handle for advanced configuration (position, etc.) +active.getTooltip().setPosition(Tooltip.TooltipPosition.TOP); +``` + +**Why this shape:** `setTooltipText`, `setTooltipMarkdown`, and `getTooltip()` come from `HasTooltip` (transitive via `InputField`) — same surface every Vaadin field component already exposes. No component-specific tooltip method. + +--- + +## 9. Form integration via Binder + +Covers requirement(s): 11 + +```java +public class TwoFactorForm extends FormLayout { + private final ToggleSwitch twoFactor = new ToggleSwitch("Two-factor authentication required"); + private final TextField name = new TextField("Name"); + private final Binder binder = new Binder<>(User.class); + + public TwoFactorForm() { + add(name, twoFactor); + + binder.forField(twoFactor) + .bind(User::isTwoFactorRequired, User::setTwoFactorRequired); + binder.forField(name) + .bind(User::getName, User::setName); + } + + public void edit(User user) { + binder.readBean(user); // hydrate the form — value-change events fire with isFromClient=false + } + + public void save(User user) { + if (binder.writeBeanIfValid(user)) { + userService.save(user); + } + } + + public void cancel(User original) { + binder.readBean(original); // revert: switch flips back, no isFromClient=true events + } +} +``` + +**Why this shape:** Flow does not use native HTML form submission; the Vaadin equivalent of "name + value submitted on `
` submit" is `Binder.bind` for hydration and `writeBean` / `writeBeanIfValid` for save. The cancel/revert path described in Req 11 maps to `Binder.readBean(original)`, which restores values without the `isFromClient` flag set (so the application's user-change handlers don't ricochet, matching Req 2). The field's empty value of `Boolean.FALSE` (see §6) keeps the toggle switch's required-validation aligned with `asRequired()` if the application uses it. + +--- + +## 10. Click notifier and focusable + +Covers requirement(s): — (reachability mapping for click events and programmatic focus exposed by the web component) + +```java +ToggleSwitch s = new ToggleSwitch("Notifications"); + +// ClickNotifier — for the rare case the app wants to react to a click regardless +// of whether the value actually flipped (e.g. analytics) +s.addClickListener(event -> analytics.event("toggleSwitch.clicked")); + +// Focusable — programmatic focus, e.g. when opening a settings dialog +s.focus(); +``` + +**Why this shape:** `ClickNotifier` and `Focusable` are implemented for parity with Checkbox; experienced Flow developers expect both on every field. They are reachability mappings, not direct requirement coverage — DOM clicks and programmatic focus are real surface the developer might need. + +--- + +## 11. Styling + +Covers requirement(s): — (reachability mapping for CSS custom properties exposed by the web component) + +```java +ToggleSwitch s = new ToggleSwitch("Compact"); + +// CSS custom properties go through the standard HasStyle surface (transitive via InputField) +s.getStyle().set("--vaadin-toggle-switch-track-width", "32px"); +s.getStyle().set("--vaadin-toggle-switch-thumb-color", "var(--lumo-base-color)"); +``` + +**Why this shape:** CSS custom properties are not surfaced as typed Java setters; `getStyle()` from `HasStyle` is the canonical Flow path. The component does not implement `HasThemeVariant<…>` — see Discussion. + +--- + +## Web API coverage check + +| Web API surface (from `web-component-api.md`) | Flow API | Notes | +|---|---|---| +| `` element | `new ToggleSwitch()` (+ convenience overloads) | constructor | +| `checked` boolean attr/prop | `setValue(Boolean)` / `getValue()` via `HasValue` | maps to the field's value, like `Checkbox` | +| `change` event | `addValueChangeListener` with `event.isFromClient() == true` | standard Flow mapping; matches Checkbox | +| `checked-changed` event | covered transparently by Flow's two-way value binding | no public Flow API; framework wiring | +| `label` attribute | `setLabel(String)` via `HasLabel` (transitive via `InputField`) | — | +| `slot="label"` | `setLabelComponent(Component)` | matches Checkbox | +| `accessible-name` | `setAriaLabel(String)` via `HasAriaLabel` | — | +| `accessible-name-ref` | `setAriaLabelledBy(String)` via `HasAriaLabel` | — | +| `disabled` boolean | `setEnabled(boolean)` (inverted) via `HasEnabled` | standard Flow inversion | +| `readonly` boolean | `setReadOnly(boolean)` via `HasValue` | — | +| `required` boolean | `setRequiredIndicatorVisible(boolean)` via `HasValueAndElement` | matches Checkbox | +| `error-message` attr | `setErrorMessage(String)` via `HasValidationProperties` | — | +| `invalid` boolean | `setInvalid(boolean)` / `isInvalid()` via `HasValidationProperties` | — | +| `manualValidation` flag | `setManualValidation(boolean)` via `HasValidator` | — | +| `validate()` method | `validate()` (protected) | matches Checkbox | +| `validated` event | not directly exposed | applications use `addValueChangeListener` + the `invalid` property; matches Checkbox | +| `helper-text` attr | `setHelperText(String)` via `HasHelper` (transitive via `InputField`) | — | +| `slot="helper"` | `setHelperComponent(Component)` via `HasHelper` | — | +| `slot="tooltip"` | `setTooltipText` / `setTooltipMarkdown` / `getTooltip` via `HasTooltip` | transitive via `InputField` | +| `name` attribute | superseded by `Binder.forField(toggleSwitch).bind(...)` (§9) | Flow uses `Binder` + `HasValue`, not native HTML form submission; matches Checkbox (no `setName`) | +| `value` attribute (form-submission default `"on"`) | superseded by the Boolean field value (§1) and Binder (§9) | the field's typed value is the `Boolean` value, not a string submission token | +| `.reset()` interaction | superseded by `Binder.readBean(original)` (§9) | native-form lifecycle is irrelevant to Flow; Binder handles cancel/revert via its own pristine state | +| CSS custom properties (`--vaadin-toggle-switch-*`) | `getStyle().set(...)` via `HasStyle` | no typed setters; standard Vaadin convention | + +Every web-component API surface is reachable from Flow except the four marked "not exposed in Flow", each of which is intentionally elided and rationale-tagged: native HTML form attributes (`name`, `value`, `.reset()`) are superseded by Vaadin's `Binder` data-binding model, and the `validated` event has no widely used Flow analog (Checkbox has no `addValidatedListener` either). + +## Discussion + +No questions were posed to the user during the production of this document. Every API choice tracks the existing `Checkbox` Flow class: + +- **Class hierarchy** — `extends AbstractSinglePropertyField` plus the same `implements` list as `Checkbox` (minus indeterminate-related concerns and minus `HasThemeVariant`). +- **Constructors** — same set as `Checkbox`: no-arg, `(String label)`, `(boolean initialValue)`, `(String, boolean)`, `(String, ValueChangeListener)`, `(boolean, ValueChangeListener)`, `(String, boolean, ValueChangeListener)`. +- **Validation** — same `HasValidationProperties` / `HasValidator` / `setManualValidation` / `validate()` / `ToggleSwitchI18n.setRequiredErrorMessage` machinery. +- **Styling** — CSS custom properties via `HasStyle.getStyle()`; no typed theme-variant enum (see below). + +**Q: Why is `HasThemeVariant` not implemented?** + +The only candidate variant for a Checkbox-shaped field is `helper-above-field`. That theme attribute is documented but [not actually supported by the web component](https://github.com/vaadin/web-components/issues/11750), so a `ToggleSwitchVariant` enum with `HELPER_ABOVE` as its only constant would expose a no-op API. The class therefore omits `HasThemeVariant` and ships without a `ToggleSwitchVariant` enum. If the underlying variant is implemented for the web component in the future, the typed Java enum and the `HasThemeVariant<…>` interface can be added without affecting other public surface. diff --git a/packages/toggle-switch/spec/flow-spec.md b/packages/toggle-switch/spec/flow-spec.md new file mode 100644 index 00000000000..37994a95cb6 --- /dev/null +++ b/packages/toggle-switch/spec/flow-spec.md @@ -0,0 +1,282 @@ +# ToggleSwitch Flow Component Specification + +> Wraps the experimental web component ``. Flow users enable it via the same feature flag the web component surfaces (`window.Vaadin.featureFlags.toggleSwitchComponent = true`); see `web-component-spec.md`. + +## Key Design Decisions + +1. **Class hierarchy mirrors `Checkbox` line-for-line.** `ToggleSwitch` extends `AbstractSinglePropertyField` with the synchronisable client property `"checked"` and empty value `false`. Same shape as `Checkbox(super("checked", false, false))`. `web-component-api.md` §1's "default-off, on/off boolean state" maps cleanly onto a `HasValue` field. + +2. **Implemented mixin interfaces — the Checkbox set, minus indeterminate-related concerns and minus `HasThemeVariant`.** `ClickNotifier`, `Focusable`, `HasAriaLabel`, `HasValidationProperties`, `HasValidator`, `InputField, Boolean>`. `InputField` transitively pulls in `HasEnabled`, `HasHelper`, `HasLabel`, `HasSize`, `HasStyle`, `HasTooltip`, `HasValue`. No `setIndeterminate` / `bindIndeterminate` / `isIndeterminate` and no `indeterminate-changed` synchronisation. No `ToggleSwitchVariant` enum — see Discussion. + +3. **Package: `com.vaadin.flow.component.toggleswitch`.** No reserved-keyword collision — `toggleswitch` is a valid Java identifier. The kebab-name segment is concatenated without dashes, matching `radiobutton`, `combobox`, `multiselectcombobox`, `datepicker`, etc. + +4. **Constructors mirror `Checkbox`.** Seven overloads: `()`, `(String label)`, `(boolean initialValue)`, `(String, boolean)`, `(String, ValueChangeListener)`, `(boolean, ValueChangeListener)`, `(String, boolean, ValueChangeListener)`. No Signal-based overloads — Checkbox does not provide any (its only Signal API is `bindIndeterminate(Signal, …)`, which has no toggle-switch analogue since indeterminate is out of scope). + +5. **Validation: required ⇒ on.** Default validator returns `validateRequiredConstraint` against `Boolean.FALSE` as the empty value. `ValidationController` is the same field reused by Checkbox. `validate()` is `protected`. `setManualValidation(boolean)` opts into application-driven invalid/error state. `addValueChangeListener(e -> validate())` is wired in the constructor so user toggles drive auto-validation. Identical to `Checkbox` (`flow-api.md` §6). + +6. **The host element's `manualValidation` client property is pinned to `true` for the entire lifetime of the component.** This is set in the Flow constructor and is not influenced by the application-facing `setManualValidation(boolean)` API. The application setter only toggles whether the server-side `ValidationController` runs auto-validation; the web component itself is always in manual-validation mode so client and server cannot race over `invalid`. Direct copy of Checkbox's setup. Document this distinction on `setManualValidation`'s javadoc at implementation time so applications do not expect the host element to flip its own client property. + +7. **i18n: nested `ToggleSwitchI18n` class with one field — `requiredErrorMessage`.** `Serializable`, fluent setter, no JSON annotations. Stored on the component via `setI18n` / `getI18n`. No client-side property push (the web component has no `i18n` property of its own); the server-side i18n message is read by the default validator. The instance returned by `getI18n()` is the live stored object, but mutations to it after `setI18n(...)` will not re-trigger any validation — applications must call `setI18n(...)` again to apply changes (matches `CheckboxI18n.getI18n` javadoc). Identical to `CheckboxI18n` (`flow-api.md` §6). + +8. **Label slot fallback: `setLabelComponent(Component)` for HTML-in-label.** Same `NativeLabel labelElement` field with `slot="label"` as Checkbox. `setLabel(String)` removes the slotted component and sets the host's `label` property (`null` is normalised to `""`, matching Checkbox); `setLabelComponent(Component)` clears the property and slots the component into the label. Passing a non-null component is required; passing `null` is undefined behavior (Checkbox NPEs on the underlying `add` call — toggle-switch inherits this and does not validate the argument explicitly). (`flow-api.md` §3.) + +9. **`HasAriaLabel` is implemented by writing `accessibleName` and `accessibleNameRef` properties on the host element**, not by writing `aria-label` directly — same as Checkbox. The web component's `FieldMixin` is the side that translates these into the inner input's ARIA attributes. + +10. **`autofocus` is exposed on the Flow class.** Checkbox has `setAutofocus` / `isAutofocus` — the web component supports `autofocus` natively because the inner input is a real ``. Match Checkbox here even though the web-component API spec did not call it out as a separate section; this is a reachability mapping rather than a new feature. + +11. **Connector needed: no.** The web component is a single element with all state expressible as Element attributes/properties (`checked`, `disabled`, `readonly`, `required`, `invalid`, `errorMessage`, `helperText`, `label`, `accessibleName`, `accessibleNameRef`, `name`, `value`, `manualValidation`, `i18n` is server-side only). No data-driven items array, no DOM mutations needing client-side recomputation, no drag-and-drop wiring. Pattern-matches Checkbox, which has no connector either. + +12. **Serialisation.** Every field on the class (`i18n`, `defaultValidator`, `validationController`, `labelElement`) is a `Serializable`-friendly type: the `CheckboxI18n` precedent shows the same `ValidationController` and lambda `Validator` setup is safe; `NativeLabel` is a Flow `Component`. No `transient` fields, no custom `readObject` / `writeObject`. A `ToggleSwitchSerializableTest` extends `ClassesSerializableTest` to enforce this — same one-liner pattern as `CheckboxSerializableTest`. + +13. **Router-agnostic.** No path/URL setters; the component never calls `RouteConfiguration` or `UI.navigate`. Form integration goes through `Binder` — `flow-api.md` §9 — which is application-driven. + +--- + +## Module / Package Layout + +``` +flow-components/ +└── vaadin-toggle-switch-flow-parent/ + ├── pom.xml + ├── vaadin-toggle-switch-flow/ + │ ├── pom.xml + │ └── src/ + │ ├── main/java/com/vaadin/flow/component/toggleswitch/ + │ │ └── ToggleSwitch.java + │ └── test/java/com/vaadin/flow/component/toggleswitch/tests/ + │ ├── ToggleSwitchUnitTest.java + │ ├── ToggleSwitchSerializableTest.java + │ └── validation/ + │ ├── ToggleSwitchBasicValidationTest.java + │ └── ToggleSwitchBinderValidationTest.java + ├── vaadin-toggle-switch-flow-integration-tests/ + │ ├── pom.xml + │ └── src/ + │ ├── main/java/com/vaadin/flow/component/ + │ │ ├── app/ + │ │ │ └── TestAppShell.java + │ │ └── toggleswitch/tests/ + │ │ └── ToggleSwitchPage.java # @Route("vaadin-toggle-switch/toggle-switch-test") + │ └── test/java/com/vaadin/flow/component/toggleswitch/tests/ + │ └── ToggleSwitchIT.java # extends AbstractComponentIT, @TestPath("vaadin-toggle-switch/toggle-switch-test") + └── vaadin-toggle-switch-testbench/ + ├── pom.xml + └── src/main/java/com/vaadin/flow/component/toggleswitch/testbench/ + └── ToggleSwitchElement.java # @Element("vaadin-toggle-switch") +``` + +Package name: `com.vaadin.flow.component.toggleswitch`. The class lives at the top level — there are no item / sub-element classes (the toggle switch is a single element). `ToggleSwitchI18n` is a public **nested** class on `ToggleSwitch` (not a separate file), matching `CheckboxI18n`. + +The IT module includes a minimal `TestAppShell.java` under `com.vaadin.flow.component.app`, matching `vaadin-checkbox-flow-integration-tests/.../app/TestAppShell.java`. The IT module starts with a single combined `ToggleSwitchPage` + `ToggleSwitchIT` pair (per-concern fan-out — `HelperPage`, `DetachReattachPage`, etc., as Checkbox accumulated over years — is added later only when a specific concern needs its own isolated route). + +--- + +## Component Classes + +### `ToggleSwitch` — main component + +```java +@Tag("vaadin-toggle-switch") +@NpmPackage(value = "@vaadin/toggle-switch", version = "{WEB_COMPONENT_VERSION}") +@JsModule("@vaadin/toggle-switch/src/vaadin-toggle-switch.js") +public class ToggleSwitch extends AbstractSinglePropertyField + implements ClickNotifier, Focusable, HasAriaLabel, + HasValidationProperties, HasValidator, + InputField, Boolean> { + + private final NativeLabel labelElement; + private ToggleSwitchI18n i18n; + private final Validator defaultValidator = (value, context) -> { + boolean fromComponent = context == null; + boolean isRequired = fromComponent && isRequiredIndicatorVisible(); + return ValidationUtil.validateRequiredConstraint( + getI18nErrorMessage(ToggleSwitchI18n::getRequiredErrorMessage), + isRequired, getValue(), getEmptyValue()); + }; + private final ValidationController validationController = + new ValidationController<>(this); + + // Constructors — see Decision 4 + public ToggleSwitch(); + public ToggleSwitch(String labelText); + public ToggleSwitch(boolean initialValue); + public ToggleSwitch(String labelText, boolean initialValue); + public ToggleSwitch(String labelText, ValueChangeListener> listener); + public ToggleSwitch(boolean initialValue, ValueChangeListener> listener); + public ToggleSwitch(String labelText, boolean initialValue, + ValueChangeListener> listener); + + // Required (HasValueAndElement override for documentation) + @Override + public void setRequiredIndicatorVisible(boolean required); + @Override + public boolean isRequiredIndicatorVisible(); + + // Label + @Override + public String getLabel(); + @Override + public void setLabel(String label); + public void setLabelComponent(Component component); + + // ARIA labelling — HasAriaLabel + @Override + public void setAriaLabel(String ariaLabel); + @Override + public Optional getAriaLabel(); + @Override + public void setAriaLabelledBy(String ariaLabelledBy); + @Override + public Optional getAriaLabelledBy(); + + // Autofocus + public void setAutofocus(boolean autofocus); + public boolean isAutofocus(); + + // Validation + @Override + public void setManualValidation(boolean enabled); + @Override + public Validator getDefaultValidator(); + protected void validate(); + + // i18n + public ToggleSwitchI18n getI18n(); + public void setI18n(ToggleSwitchI18n i18n); + + // Internal: read-only switch toggling at the framework layer (matches Checkbox's package-private access) + void setDisabled(boolean disabled); // package-private + boolean isDisabledBoolean(); // package-private +} +``` + +**Implemented mixin interfaces (with one-line justification):** + +| Interface | Source | Justification | +|---|---|---| +| `ClickNotifier` | Flow core | Reachability for the web-component DOM `click` event (flow-api.md §10). Matches Checkbox. | +| `Focusable` | Flow core | Programmatic `focus()` / `blur()`. Matches Checkbox. | +| `HasAriaLabel` | Flow core | `setAriaLabel` / `setAriaLabelledBy` mapped to `accessibleName` / `accessibleNameRef` (flow-api.md §3). | +| `HasValidationProperties` | `vaadin-flow-components-base` | `setErrorMessage` / `setInvalid` (flow-api.md §6). | +| `HasValidator` | Flow core | `setManualValidation`, default-validator integration with `Binder` (flow-api.md §6). | +| `InputField<…, Boolean>` | `vaadin-flow-components-base` | Transitively brings `HasEnabled`, `HasHelper`, `HasLabel`, `HasSize`, `HasStyle`, `HasTooltip`, `HasValue` (flow-api.md §§3, 5, 7, 8, 9). | + +Inherited from `AbstractSinglePropertyField`: `getValue` / `setValue` / `addValueChangeListener` / `setReadOnly` / `getEmptyValue` (returns `Boolean.FALSE`). Inherited from `Component`: tag identity, lifecycle, `getElement()`. The `enabled` / `disabled` distinction follows `HasEnabled` (Flow's standard inverted accessor); the package-private `setDisabled(boolean)` mirrors Checkbox's internal helper for tests. + +**`@Synchronize`'d properties:** None on the main class. `AbstractSinglePropertyField("checked", false, false)` already wires the `checked-changed` event subscription that synchronises the Boolean value. `manualValidation = true` is set in the constructor so the web component's own validation does not compete with the server-side controller. + +**Events:** No custom `@DomEvent` classes — same as Checkbox. Value changes are delivered via the standard `addValueChangeListener` returning `ComponentValueChangeEvent`. `event.isFromClient()` distinguishes user toggles from programmatic updates (flow-api.md §2). Programmatic `setValue(...)` on a disabled or read-only switch fires a value-change event with `isFromClient=false`, satisfying requirement 5's "programmatic flips are silent" clause without dedicated API. The web component's `validated` event is not surfaced as a Flow listener — Checkbox set the precedent of leaving it unmapped (flow-api.md Web API coverage check). `HasValidator` provides `addValidationStatusChangeListener(...)` for applications that want to observe Binder-level validation outcomes. + +--- + +## i18n + +```java +public static class ToggleSwitchI18n implements Serializable { + private String requiredErrorMessage; + + public String getRequiredErrorMessage(); + public ToggleSwitchI18n setRequiredErrorMessage(String errorMessage); +} +``` + +| Field | Type | Default | Notes | +|---|---|---|---| +| `requiredErrorMessage` | `String` | `null` (when null, `getI18nErrorMessage` returns `""`) | Used by the default required-validator. Custom error set via `setErrorMessage(String)` takes priority. | + +The class is server-side only — there is no client-side `i18n` property to push. `setI18n` stores the instance; the default validator reads `requiredErrorMessage` from it during validation. `setI18n(null)` is rejected with `NullPointerException` (matches `Checkbox.setI18n`). + +--- + +## Connector + +**No connector needed.** All state is set via Element properties / attributes: +- `checked`, `disabled`, `readonly`, `required`, `invalid`, `errorMessage`, `helperText`, `label`, `name`, `value`, `manualValidation`, `accessibleName`, `accessibleNameRef`, `autofocus` — direct property writes through Flow's Element API. +- Tooltip text — handled by `HasTooltip` (uses Vaadin's existing tooltip plumbing). +- i18n's only field (`requiredErrorMessage`) is consumed server-side by the default validator and never pushed to the client. + +The component matches Checkbox's connector-less profile because both are single-element fields without per-item client state, drag-and-drop, or DOM mutations requiring client-side recomputation. + +--- + +## Server/Client Sync Concerns + +- **Serialisation.** Every field on `ToggleSwitch` and `ToggleSwitchI18n` is `Serializable`. `ValidationController` and `Validator` (the lambda) are `Serializable` — confirmed by Checkbox's identical setup, which has been the subject of `CheckboxSerializableTest extends ClassesSerializableTest` for years. `NativeLabel labelElement` is a Flow component and inherits `Serializable`. No `transient` fields, no custom `readObject`/`writeObject`. +- **Signal support.** Checkbox provides `bindIndeterminate(Signal, SerializableConsumer)` for its indeterminate property. The toggle switch has no indeterminate state, so no `bind*(Signal, ...)` helper is added. Signal-based binding for the value is inherited from `AbstractSinglePropertyField` (whatever shared support that base class offers — typically applications wire signals through `Binder` or via the inherited `getElement().bindProperty(...)` pattern). No Signal-based constructor overloads are added — Checkbox does not provide them either. +- **Routing.** N/A — the component does not expose URL/path setters and never calls `RouteConfiguration`. Form-submission integration is via `Binder` (flow-api.md §9), which is application-driven. +- **DisabledUpdateMode.** Default — no override. The toggle switch should not accept user input while disabled (`HasEnabled`'s default). Programmatic state changes still propagate. +- **Disable-on-click.** Not applicable. Toggling a switch is its primary interaction; there is no "submit-style" action that would warrant `DisableOnClickController`. + +--- + +## TestBench Elements + +### `ToggleSwitchElement` + +```java +@Element("vaadin-toggle-switch") +public class ToggleSwitchElement extends TestBenchElement + implements HasLabel, HasHelper, HasValidation { + + /** + * Returns whether the toggle switch is checked. + */ + public boolean isChecked(); + + /** + * Sets the checked state and dispatches a bubbling `change` event so + * Flow's value-change listeners see the user-initiated update. + */ + public void setChecked(boolean checked); + + @Override + public String getLabel(); +} +``` + +Mirrors `CheckboxElement` (which also implements `HasLabel`, `HasHelper`, `HasValidation` and dispatches `change` after writing `checked`). No item-level testbench element — the toggle switch is a single element. The implementation of `setChecked` follows Checkbox's pattern: write the `checked` property and dispatch a bubbling `change` event so server-side `ValueChangeListener`s with `isFromClient=true` are reached. + +--- + +## Reuse and Proposed Adjustments to Existing Modules + +All shared modules are reused as-is — no adjustments are required. + +- **`com.vaadin.flow.component.shared.HasValidationProperties`** — provides `setErrorMessage` / `setInvalid`. Used by every field. As-is. +- **`com.vaadin.flow.component.shared.HasTooltip`** (transitive via `InputField`) — the `setTooltipText` / `setTooltipMarkdown` / `getTooltip()` trio. As-is. +- **`com.vaadin.flow.component.shared.InputField`** — transitive bundle of field-mixin interfaces. As-is. +- **`com.vaadin.flow.component.shared.ValidationUtil#validateRequiredConstraint`** — validation helper for the `required ⇒ on` rule. As-is. +- **`com.vaadin.flow.component.shared.internal.ValidationController`** — orchestrates auto vs. manual validation. As-is. +- **`com.vaadin.flow.component.HasAriaLabel`** (Flow core) — `setAriaLabel` / `setAriaLabelledBy`. As-is. +- **`com.vaadin.flow.testutil.ClassesSerializableTest`** (test-util) — base class for `ToggleSwitchSerializableTest`. As-is. +- **`com.vaadin.tests.AbstractComponentIT`** (test-util) — base class for `ToggleSwitchIT`. As-is. + +The `HasValueAndElement.setRequiredIndicatorVisible(boolean)` semantics (a required indicator that does NOT validate the input until the user has interacted) is the established Flow contract — Checkbox already documents this contract on `setRequiredIndicatorVisible` and the toggle switch inherits it verbatim. + +--- + +## Coverage + +| Requirement | Addressed in spec section(s) | +|---|---| +| 1. Flip via pointer or keyboard, default off, ARIA switch role | Component Classes → `ToggleSwitch` (constructors, `AbstractSinglePropertyField` defaulting to `false`); web component handles activation and role | +| 2. Notify only on user-initiated changes | Component Classes → `ToggleSwitch` (inherited `addValueChangeListener` + `event.isFromClient()`); Decision 5 | +| 3. Clickable label / label component / accessible name | Component Classes → `ToggleSwitch` (`setLabel`, `setLabelComponent`, `setAriaLabel`, `setAriaLabelledBy`); Decisions 8–9 | +| 4. Announce as a switch with on/off state | Web component handles ARIA; Flow has no surface needed (`flow-api.md` Web API coverage: "covered transparently") | +| 5. Disabled refuses interaction | Component Classes → `ToggleSwitch` (`HasEnabled.setEnabled` via `InputField`); Decision 2 | +| 6. Read-only stays focusable and announced | Component Classes → `ToggleSwitch` (`HasValue.setReadOnly` inherited via `AbstractSinglePropertyField`); web component manages the ARIA / form-submission split | +| 7. Required validation | Component Classes → `ToggleSwitch` (`setRequiredIndicatorVisible`, `validate`, `defaultValidator`, `ValidationController`); i18n → `requiredErrorMessage`; Decisions 5–7 | +| 8. Helper text | Component Classes → `ToggleSwitch` (`HasHelper` via `InputField`) | +| 9. Error message when invalid (custom messages allowed) | Component Classes → `ToggleSwitch` (`HasValidationProperties.setErrorMessage`, `setInvalid`, `setManualValidation`); Decisions 5–7 | +| 10. Optional tooltip | Component Classes → `ToggleSwitch` (`HasTooltip` via `InputField`) | +| 11. Native form submission | Decision 11 (no connector); web component participates in `` natively. Flow form integration via `Binder` (`flow-api.md` §9) | + +All eleven requirements are addressed. `flow-api.md`'s 11 sections + Web API coverage check map onto the Component Classes / i18n / TestBench sections above; no API features are dropped. + +--- + +## Discussion + +**Q: Why is `HasThemeVariant` not implemented, and why is there no `ToggleSwitchVariant` enum?** + +The only candidate Checkbox-style variant is `helper-above-field`, and that theme attribute is [not actually supported by the web component](https://github.com/vaadin/web-components/issues/11750). A `ToggleSwitchVariant` enum with `HELPER_ABOVE` as its only constant would expose a no-op API, so the Flow class omits both the enum and the `HasThemeVariant<…>` interface. If the underlying variant is implemented for the web component in the future, the typed enum and the interface can be added without affecting other public surface — this is purely a deferral, not a permanent design decision. diff --git a/packages/toggle-switch/spec/implementation-notes.md b/packages/toggle-switch/spec/implementation-notes.md new file mode 100644 index 00000000000..8a18d225d72 --- /dev/null +++ b/packages/toggle-switch/spec/implementation-notes.md @@ -0,0 +1,162 @@ +# ToggleSwitch Implementation Notes + +## Task 1 — Package scaffolding and dev page + +- **Commit:** (this commit) +- **Date:** 2026-05-07 +- **Decisions:** + - Class chain ships without `CheckboxMixin` for Task 1: `ElementMixin(ThemableMixin(PolylitMixin(LumoInjectionMixin(LitElement))))`. This keeps the scaffolding pure — `render()` can be a literal `html\`\`` placeholder, no slot-controllers are wired, no shadow DOM template is forced. Task 2 swaps `CheckboxMixin` into the chain together with the shadow DOM template that supplies the slots its slot-controllers need. + - `static get experimental() { return true; }` — verified that `defineCustomElement` derives the flag name from the camel-cased tag suffix (`toggle-switch` → `toggleSwitchComponent`); the recent `guidelines/02-design.md` direction prefers this over the older string-return form used by `breadcrumbs`. + - `toggleSwitchStyles` exported as `[]` (empty array) — matches the `checkbox` / `radio-button` shape that Task 4 will populate as `[field, checkable('switch', 'toggle-switch'), toggleSwitch]`. Differs from the singular `CSSResult` used by `breadcrumbs`, but that's because `breadcrumbs` doesn't compose with the field/checkable styles. + - `@vaadin/checkbox` is declared as a runtime dependency in `package.json` from Task 1 even though the import lands in Task 2 — the manifest is established once, here. +- **Surprises:** — +- **Spec adjustments:** + - Task 1 in `web-component-tasks.md` rewritten to drop `CheckboxMixin` from the class chain (was adding it during scaffolding and forcing a non-empty `render()`); Task 2 now owns the mixin swap together with the full shadow DOM template. + +## Task 2 — Element class wiring — shadow DOM, `role="switch"`, tooltip + +- **Commit:** (this commit) +- **Date:** 2026-05-07 +- **Decisions:** + - `_inputElementChanged(input, oldInput)` is the canonical hook for setting `role="switch"` on the inner input — same pattern `vaadin-combo-box` and `vaadin-date-picker` use to set `role="combobox"`. Calls `super._inputElementChanged(input, oldInput)` first so `CheckboxMixin`'s inherited add/remove-listener wiring still runs, then guards on `if (input)` before calling `setAttribute`. + - `ready()` wires `TooltipController` with `setAriaTarget(this.inputElement)`, identical to `Checkbox.ready()`. The tooltip's id is added to the inner input's `aria-describedby`, so screen-reader users hear the tooltip text when the input is focused. + - Test surface uses snapshots + the shared tooltip integration test rather than per-feature unit tests: + - Shadow DOM parts (`switch`, `thumb`, `label`, `helper-text`, `error-message`, `required-indicator`, `tooltip` slot) and the inner input's `role="switch"` / `type="checkbox"` / `slot="input"` are locked in by `test/dom/toggle-switch.test.js` via `host` and `shadow` snapshots. Asserting these as individual unit tests would duplicate what the snapshot already pins. + - Tooltip behavior (`has-tooltip` attribute, tooltip target, `aria-describedby` composition on the inner input, hover/leave wiring) is exercised by `test/integration/component-tooltip.test.js` which already iterates over every Vaadin field and applies the same set of tooltip assertions to each. Adding `ToggleSwitch.is` with `ariaTargetSelector: 'input'` to that array is enough — no toggle-switch-local tooltip test needed. + - Inner-input handling (`InputController` slotting the ``, `LabelledInputController` wiring `aria-labelledby`, the slotted-input `opacity: 0` Tailwind workaround) is already tested in `field-base` / `checkbox` packages where the behavior originates. The toggle switch inherits it through `CheckboxMixin` and does not re-test it here. +- **Surprises:** — +- **Spec adjustments:** — + +## Task 3 — Behavioral verification through CheckboxMixin + +- **Commit:** (this commit) +- **Date:** 2026-05-08 +- **Decisions:** + - **Rule: don't duplicate mixin / controller tests in components that consume them.** The mixin's own test suite already covers the behavior. Toggle-switch-specific surface (the `role="switch"` override, the shadow DOM template) is covered by the snapshot tests in `test/dom/`; everything else (toggle, change events, label clicks, disabled, readonly, validation, helper / error / label slots, native-form submission, focus delegation, active attribute) is delivered unchanged by `CheckboxMixin` and exercised by `packages/checkbox/test/checkbox.test.js` and `packages/checkbox/test/validation.test.js`. + - As a result, Task 3 produces **no test additions and no source changes**. The unit-test file stays at the Task 1 smoke tests (custom-element registration + `is` getter). The validation.test.js file the test agent had drafted is dropped without committing. + - The implementation agent's run surfaced seven concrete tests that proved the rule: five asserted behaviors that aren't actually provided by the inherited mixins (`aria-required` on the input — FieldAriaController omits it for native inputs; host `tabindex="-1"` on disabled — DelegateFocusMixin moves the tabindex to the inner input; `focus-ring` cleared on `focus({ focusVisible: false })` — leaks from earlier `sendKeys` keyboard state; `event.target === host` for `change` — native event bubbles without re-dispatch; error-slot population without `invalid: true` — ErrorController only writes when both are set), and two asserted `.reset()` synchronisation that doesn't exist upstream. +- **Surprises:** — +- **Spec adjustments:** + - `web-component-spec.md`: dropped "and `.reset()` synchronisation" from the "Inherited from `CheckboxMixin`" bullet, and dropped the corresponding "and `.reset()`" mention from the "ARIA switch role" bullet. Added a Discussion entry explaining the gap and pointing at `Binder` (Flow) / a manual `reset` listener as the workaround. `requirements.md` Req 11 still calls for the behavior; satisfying it would require listener wiring upstream in `CheckboxMixin` and is a candidate for a future task. + +## Post-Task 3 — Pick up new testing guideline + +- **Date:** 2026-05-08 +- **Trigger:** rebased onto `origin/main` to pick up `guidelines/12-testing.md` (the previous `12-checklist.md` moved to `13-checklist.md`). +- **Decisions:** + - The new "don't test mixin / controller internals in components that consume them" rule from `guidelines/12-testing.md` codifies what Task 3 already settled on. No additional spec changes needed for that rule. + - Updated `web-component-spec.md` Decision 9 (Guideline alignment) to reference `guidelines/12-testing.md` and added a Discussion entry explaining why Task 3's coverage is intentionally minimal. + - Updated `web-component-tasks.md` Task 7's "Spec sections" line to reference both `guidelines/12-testing.md` and the renamed `guidelines/13-checklist.md`. +- **Follow-up (not done in this session):** + - `guidelines/12-testing.md` recommends new tests be written in `.ts`. The committed test files (`packages/toggle-switch/test/toggle-switch.test.js`, `packages/toggle-switch/test/dom/toggle-switch.test.js`) were authored in `.js` before the recommendation landed. Converting them to `.ts` is a small, pure-renaming change worth doing alongside Task 7 (which adds the `test/typings/toggle-switch.types.ts` already in `.ts`) for consistency. + +## Task 4 — Base styles completion + +- **Commit:** (this commit) +- **Date:** 2026-05-12 +- **Decisions:** + - Styles composition stays at `[field, checkable('switch', 'toggle-switch'), toggleSwitch]` — every shared rule (track background / border / focus ring outline / disabled track / checked-state background swap / forced-colors checked border) comes from `checkable()` unmodified. The local `toggleSwitch` block only adds the toggle-specific deltas: track dimensions, the thumb element, the `[part='switch']::after` checkmark suppression (Decision 5), the read-only and disabled thumb visuals, the RTL mirror, and the forced-colors thumb/background rules. + - Thumb positioning factored through a shared `--_thumb-offset` custom property on `[part='switch']` so the on-state and RTL-on-state `translate` rules each reduce to one line; only the off-state `inset-inline-start` keeps its own calc because it expresses a different quantity (centering offset minus border width). + - **`--vaadin-toggle-switch-label-font-weight` is redeclared locally.** `checkable()` wires the label `font-weight` through `--vaadin-${propName}-font-weight` (no `-label-` segment) — a pre-existing naming inconsistency in the shared module. To honor the spec table's name without forking the shared file, the local block re-declares `font-weight` on `[part='label'], ::slotted(label)` using the spec-named property. All other label custom properties (`label-color`, `label-font-size`, `label-line-height`) already match the spec via `checkable()`'s template — no redeclaration needed. + - Read-only on-state uses `--vaadin-background-container-strong` for the track background to stay visibly distinct from both the off-state readonly and the active checked state. Required because `checkable()`'s `:host(:is([checked], [indeterminate]))` rule overrides the track background unconditionally; the `:host([readonly][checked])` rule has to point the property somewhere visible and also undo `checkable()`'s checked-state `border-color: transparent` so the dashed border survives. + - Visual coverage: `test/visual/base/toggle-switch.test.ts` mirrors the checkbox visual suite — `basic`, `checked`, `required`, `empty`, `disabled` × {basic, checked, required}, `readonly` × {basic, checked}, `focus` × {keyboard, checked, readonly}, plus `features` × {ltr, rtl} for `error-message` / `helper-text`, and a toggle-specific `rtl > checked` to lock in the thumb mirror. 17 baseline screenshots total. +- **Surprises:** + - `yarn update:base --group toggle-switch` invokes `scripts/run-docker-visual-tests.sh`, which uses `docker run -it`. Outside a TTY the `-t` flag fails. The implementation agent worked around it by invoking the same docker command with `-i` only (no `-t`); the produced baselines are byte-identical to what an interactive run would have generated. +- **Spec adjustments:** + - Initially the test agent authored `test/styles.test.ts` — a parameterised suite that overrode each `--vaadin-toggle-switch-*` custom property on the host and asserted the corresponding computed style on `host` / `[part='switch']` / `[part='thumb']` / `[part='label']`. Per user feedback after the commit, this kind of test was dropped: visual regression tests already cover the styling layer, and computed-style assertions for property pass-through test the CSS engine, not the component. The file was removed; `web-component-tasks.md` Task 4 dropped the "Computed style" test bullet; `web-component-spec.md` Discussion gained an entry explaining the rationale. + +## Post-Task 4 — Label baseline alignment fix + +- **Date:** 2026-05-12 +- **Trigger:** the dev page rendered the label visibly higher than `dev/checkbox.html` does. +- **Cause:** the initial `[part='switch']::after { content: none; }` removed the pseudo-element entirely. `checkable()` carries `content: '\\2003' / ''` (em-space) on that `::after` so its first baseline (centred by the flex parent) anchors `:host { align-items: baseline }` to the centre of the track. With the element gone, the flex container's baseline collapsed to its margin-box bottom and the label drifted up. +- **Decisions:** + - Replace `content: none` with `background: none; mask: none;` — keeps the inherited em-space content (and therefore the centred baseline), removes only the visible checkmark paint. The absolutely-positioned `[part='thumb']` already paints on top. + - Decision 5 in `web-component-spec.md` rewritten to describe the new state. Added a Discussion entry explaining the baseline-anchor constraint. + - 17 base visual baselines regenerated with the corrected alignment. + +## Task 5 — Lumo theme + +- **Commit:** (this commit) +- **Date:** 2026-05-13 +- **Decisions:** + - The Lumo file is **self-contained** — it ships its own `[part='switch']` and `[part='thumb']` sizing, positioning, border, grid layout, thumb translate / RTL mirror, and forced-colors rules rather than relying on the base styles to provide the structural layer. This is required because `LumoInjectionMixin` (`packages/vaadin-themable-mixin/lumo-injection-mixin.js`) defaults to `includeBaseStyles: false`, and `getEffectiveStyles()` in `vaadin-themable-mixin/src/css-utils.js` then drops the static base styles for any component injected through the Lumo path. Checkbox / radio-button hide this dependency because their Lumo files already redeclare every visible primitive; toggle-switch follows the same pattern. + - Token bindings per the task spec: `--vaadin-toggle-switch-background` → `--lumo-contrast-20pct` off / `--lumo-primary-color` on / `--lumo-contrast-10pct` disabled / `--lumo-contrast-60pct` readonly+checked / `--lumo-error-color-10pct` invalid; thumb color → `--lumo-body-text-color` off / `--lumo-primary-contrast-color` checked / `--lumo-contrast-30pct` disabled / `--lumo-contrast-50pct` readonly; size → `calc(var(--lumo-size-m) / 2)`; focus ring → `--lumo-primary-color-50pct`; font → `--lumo-font-family` / `--lumo-font-size-m` / `--lumo-line-height-s`; label color → `--lumo-body-text-color`; required indicator → `--lumo-primary-text-color`; error-message → `--lumo-error-text-color`. + - Invalid + checked also tints the track via `--lumo-error-color-10pct` (the `:host([invalid])` rule sets the background unconditionally), matching the checkbox precedent. + - `packages/vaadin-lumo-styles/components/index.css` updated to import `./toggle-switch.css` between `time-picker.css` and `tooltip.css` so the aggregator-level Lumo bundle picks it up alongside every other component. + - Test surface: `test/visual/lumo/toggle-switch.test.ts` mirrors the base visual suite 1:1 (18 `it` blocks). Screenshot names follow the checkbox-lumo convention (no `state-` prefix). 18 baseline PNGs generated under `test/visual/lumo/screenshots/toggle-switch/baseline/`. +- **Surprises:** + - The first round of baselines passed `yarn test:lumo --group toggle-switch` but the visual-verify agent caught that the rendered switch was invisible — only the "Toggle" label appeared. Root cause was the `includeBaseStyles: false` behaviour above: the Lumo file had only color overrides and no structural rules, so the track collapsed to zero height under the Lumo cascade. Baselines were regenerated after the Lumo file was made self-contained. + - `yarn test:lumo --group toggle-switch` matching itself doesn't prove the component renders correctly; it only proves the rendering is stable. Visual sanity-check against the base baselines is the actual regression gate. +- **Spec adjustments:** — + +## Post-Task 5 — Align Lumo styles with prototype + +- **Date:** 2026-05-13 +- **Trigger:** review against `proto/toggle-switch` flagged divergences in unchecked thumb appearance, missing active-press affordance, and a visible track border the prototype does not have. +- **Decisions:** + - **Thumb colour unified to `--lumo-primary-contrast-color` in both states** with an elevation `box-shadow: 0 1px 2px 0 rgba(0,0,0,0.1), 0 1px 1px 0 rgba(0,0,0,0.06)`. `:host([checked]) [part='thumb']` now only carries the translate override; `:host([disabled]) [part='thumb']` strips the shadow. + - **Active press affordance added.** `:host([active]) [part='switch'] { transform: scale(0.95); transition-duration: 0.05s; }` plus an activation halo via `[part='switch']::before` (transparent scale-1.4 → opacity-flash on off-state press). The track transition gains `transform 0.2s cubic-bezier(0.12, 0.32, 0.54, 2)` so the scale animates. + - **Track border removed for active states.** Dropped the explicit `border: var(--_input-border-width) solid var(--_input-border-color)` and the `--_input-border-width` / `--_input-border-color` locals from `[part='switch']`. Proto carries no border on the track — the contrast between the contrast-20pct track and the contrast-50pct page is the only edge cue. The focus-ring's inset shadow (which referenced the dropped `--_input-border-width`) is removed; the outer halo alone communicates focus. + - **Readonly affordance.** Proto's "transparent track + floating thumb" rendered as white-on-white on a light page, hiding the readonly state entirely. Readonly now inherits the off-state's `--lumo-contrast-20pct` fill (darker than the disabled state's `--lumo-contrast-10pct`) and is overlaid with `outline: var(--vaadin-input-field-readonly-border, 1px dashed var(--lumo-contrast-50pct)); outline-offset: -1px;`, matching the dashed-border cue the readonly checkbox uses. `outline` is used instead of `border` so the track's box-model dimensions don't shift between active and readonly states. + - **Hover hook**: `:host(:not([checked])…:hover) [part='switch']` reads `var(--vaadin-toggle-switch-background-hover, var(--lumo-contrast-30pct))` so consumers can override hover without re-declaring the off-state token. + - **Readonly + checked**: track background bumped to `--lumo-contrast-70pct` to match prototype. + - **Thumb inset-inline-start** simplified to `calc((var(--vaadin-toggle-switch-size) - var(--_thumb-size)) / 2)` — equivalent to proto's hardcoded `2px` for the default sizes — now that the border-width subtraction is no longer needed. + - **Label alignment via shared checkable-field mixin.** The grid container layout, the grid placement of `[part='switch']` / label / helper / error, and the `[part='switch']::before` em-space baseline anchor are delivered by `packages/vaadin-lumo-styles/src/mixins/checkable-field.css` — the shared mixin's selector lists were extended to include the toggle switch's part name and container class so the toggle switch reuses the same machinery checkbox and radio-button do. The toggle-switch-local `[part='switch']::before` rule layers a transparent `transform: scale(1.4)` halo on top of that inherited em-space content (the em-space stays invisible because the local rule sets `color: transparent`), serving both the baseline anchor and the activation-flash without an extra DOM node. `[part='label']` adopts proto's `display: flex; position: relative; max-width: max-content;` so the slotted label sits on the track's vertical centre and the absolutely-positioned required indicator anchors to the label box (not the host). Horizontal spacing between the switch and the label is provided by `margin: var(--lumo-space-xs)` on `[part='switch']`; the public `--vaadin-toggle-switch-gap` is left unset by Lumo so the design-system default applies elsewhere if the grid is reactivated. + - **Private `--_switch-size` indirection on `:host`.** `--_switch-size: var(--vaadin-toggle-switch-size, calc(var(--lumo-size-m) / 2))` resolves once: consumer override → Lumo default. The Lumo-internal calcs for `--_thumb-size`, `--_track-width`, `--_thumb-offset`, `[part='switch']` `height` / `border-radius`, and `[part='thumb']` `inset-inline-start` all read `var(--_switch-size)` directly, avoiding the per-call `var(--vaadin-toggle-switch-size, …)` fallback dance while keeping the public token override-able from outside. + - 18 Lumo baselines regenerated. +- **Surprises:** `yarn update:lumo` is gated by `TEST_ENV=update`, not a CLI flag (`--update-visual-baseline` is silently ignored and the suite runs in diff-mode, surfacing as "11 failed tests"). The orchestrator now wraps the docker invocation with `-e TEST_ENV=update` when regenerating. +- **Spec adjustments:** — + +## Post-Task 5 — Pick up shared Lumo field mixins + +- **Date:** 2026-05-14 +- **Trigger:** rebased onto `main` to pick up the Lumo CSS cleanup. `checkable-field.css` absorbed the per-component font / line-height / smoothing / focus-ring locals, the `[part='switch'] / [part='checkbox'] / [part='radio']` sizing / margin / cursor / transition primitives, the `::before` halo (with the em-space baseline anchor), and the `::slotted(input)` visually-hidden rule. Two new mixins were extracted from checkbox: `lumo_mixins_field-helper` (helper-text styling, hover lightening, disabled colour) and `lumo_mixins_field-error-message` (error-text styling, slide-down transition, RTL margin flip). +- **Decisions:** + - Public entry `packages/vaadin-lumo-styles/components/toggle-switch.css` imports the two new mixins (`field-helper.css`, `field-error-message.css`) and lists `lumo_mixins_field-helper` and `lumo_mixins_field-error-message` in the inject-modules order checkbox uses (checkable-field → field-helper → field-required → field-error-message → components). + - Source styles `packages/vaadin-lumo-styles/src/components/toggle-switch.css` trimmed to the toggle-switch-specific surface: the `:host` block now declares only `color`, `font-size`, `--_switch-size`, and `--_invalid-background` (the shared font / smoothing / focus-ring locals are inherited from `checkable-field`); `[part='switch']` keeps only the thumb-offset locals, sizing, border-radius, and background (mixin owns position / cursor / margin / transition); the local `[part='switch']::before` halo is dropped (mixin provides it with identical declarations); the local `[part='helper-text']` and `[part='error-message']` blocks are replaced by minimal overrides matching checkbox's `padding-inline-start: var(--lumo-space-xs)` + `margin: 0` on error-message + `display: none` on the mixin's leading spacers; the `:host(:hover:not([readonly])) [part='helper-text']` hover-colour rule and the disabled `[part='helper-text']` override are dropped (covered by `field-helper`). + - 4 Lumo baselines regenerated (`ltr-error-message`, `ltr-helper-text`, `rtl-error-message`, `rtl-helper-text`) — small spacing shifts from the mixin's `::before` / `::after` helper-text spacer; all other baselines stayed byte-identical. +- **Spec adjustments:** — + +## Task 6 — Aura theme + +- **Commit:** (this commit) +- **Date:** 2026-05-15 +- **Decisions:** + - **Standalone Aura stylesheet** at `packages/aura/src/components/toggle-switch.css` (rather than extending the shared `checkbox-radio.css`) — the toggle switch's pill-track + circular-thumb pattern is structurally different enough from checkbox/radio that a shared file would only carry the size-token line in common. Registered from `packages/aura/aura.css` via `@import './src/components/toggle-switch.css';` placed alphabetically between `tabs.css` and `tooltip.css`. + - **Layered, not injected**: Aura targets parts via `vaadin-toggle-switch::part(switch)` / `::part(thumb)` against the global selector — no Lumo-style `@media lumo_components_…` wrapping. The base styles (which Aura does NOT drop) remain in effect; Aura only overrides specific decoration via `--vaadin-toggle-switch-*` tokens. + - **Token bindings**: `--vaadin-toggle-switch-size: round(1lh - 2px, 2px)` (matches `--vaadin-checkbox-size`); `--vaadin-toggle-switch-background` → `--aura-surface-color` off-state, `--aura-accent-color` checked, `color-mix(--aura-red 10%, transparent)` invalid off, `--aura-red` invalid checked; `--vaadin-toggle-switch-border-color` → `--vaadin-border-color-secondary` on accent / `--aura-red-text` on invalid; `--vaadin-toggle-switch-thumb-checked-color` → `--aura-accent-contrast-color`. Off-state thumb color is left to the base default (`--vaadin-text-color`) and renders with adequate contrast against the pale surface in both light and dark mode. + - **Surface decoration** uses the same idioms as `checkbox-radio.css`: `--aura-surface-level: 4`, `box-shadow: var(--aura-shadow-xs)` off / `var(--aura-shadow-s)` checked, plus the `light-dark()` `--_shade` linear-gradient overlay for off-state depth. Checked / invalid rules set `background-image: none` to suppress the gradient cleanly. + - **Hover / active overlay** via `::part(switch)::before` with `opacity: 0` baseline → `0.04` under `@media (any-hover: hover)` → `0.1` on `[active]`. `:not([disabled], [readonly])` guards keep the decoration off inert states. + - **Readonly + focus-ring**: no Aura-specific rules. The base styles' dashed-border readonly affordance and `--vaadin-focus-ring-*` ring carry through unmodified — verified across the Aura baselines. + - **Tests**: `test/visual/aura/toggle-switch.test.ts` covers 13 `it` blocks across `states` (basic / checked / required / empty / invalid), `disabled` (basic / checked / required), `readonly` (basic / checked), and `focus` (keyboard / checked / readonly). RTL direction and helper-text / error-message rendering are deliberately not re-asserted in Aura — the layout for those states comes almost entirely from the base styles, which the base visual suite already pins; an Aura-specific baseline would re-test the base behaviour with only the font family swapped in. Baselines live under `screenshots/dark/toggle-switch/baseline/` (13 PNGs). +- **Surprises:** — +- **Spec adjustments:** — + +## Task 7 — Integration, snapshots, type tests, dev page + +- **Commit:** (this commit) +- **Date:** 2026-05-15 +- **Decisions:** + - **Dev pages split** into the minimal `dev/toggle-switch.html` (single default switch + feature-flag boilerplate, used for spec authoring / smoke checks) and the multi-variant `dev/playground/toggle-switch.html` (8 named sections: label & state, disabled, readonly, required, invalid+errorMessage, helper text, tooltip, label-with-link). Mirrors the project convention established by `dev/checkbox.html` vs. `dev/playground/checkbox.html`. The playground page imports `@vaadin/tooltip` alongside the experimental feature-flag boilerplate; it uses the shared `.section` / `.heading` classes from `dev/common.js`. + - **JSDoc completion** on both `vaadin-toggle-switch.js` and `vaadin-toggle-switch.d.ts`: a usage block, a `### Styling` section with three tables (parts: 6 entries; state attributes: 12 entries; CSS custom properties: 13 entries), four `@fires` lines (`change`, `checked-changed`, `invalid-changed`, `validated`), and the `@customElement` / `@extends` markers. + - **Type surface in `vaadin-toggle-switch.d.ts`** mirrors checkbox exactly (minus indeterminate): event-detail interfaces and named `*Event` classes for the four events, `ToggleSwitchCustomEventMap`, `ToggleSwitchEventMap extends HTMLElementEventMap, ToggleSwitchCustomEventMap`, and the matching `addEventListener` / `removeEventListener` overloads on the class. No property-type narrowings — `errorMessage` / `helperText` / `accessibleName` / `accessibleNameRef` inherit the `string | null | undefined` typing from `FieldMixinClass` (matches checkbox; type test asserts the mixin type, not a tighter one). + - **DOM snapshot suite** at `test/dom/toggle-switch.test.js` covers 5 host states (default / checked / disabled / readonly / invalid+errorMessage) plus a single `shadow > default` snapshot. The 4 redundant per-state shadow snapshots that an earlier draft included were dropped — the shadow template doesn't depend on host state, so re-asserting `shadow > checked` etc. just duplicates the default block. Matches checkbox's pattern. + - **TypeScript type test** at `test/typings/toggle-switch.types.ts`: 11 property assertions (`autofocus`, `checked`, `disabled`, `readonly`, `required`, `invalid`, `manualValidation`, `label`, `name`, `value`), 12 mixin assertions (one per class in the mixin chain plus `CheckboxMixinClass`), and 4 event-listener overload assertions. The `document.createElement('vaadin-toggle-switch')` call exercises the `HTMLElementTagNameMap` augmentation by typing the local as `ToggleSwitch`. +- **Surprises:** + - `yarn update:snapshots` (the in-place flag) does not prune orphan snapshot blocks when `it` blocks are deleted — the snapshot file kept the old 4 `shadow > {state}` blocks after the test bodies were removed. `rm test/dom/__snapshots__/toggle-switch.test.snap.js` followed by `yarn test:snapshots --update --group toggle-switch` produces a clean file. + - The implementation agent generated `packages/toggle-switch/test/visual/aura/screenshots/default/` Aura baselines while running the validation suite. Per Task 6's dark-only decision, those are not committed; they stay untracked. +- **Spec adjustments:** — + +## Post-Task 7 — Drop `ThemableMixin` from class chain + +- **Commit:** (this commit) +- **Date:** 2026-05-18 +- **Decisions:** + - `ThemableMixin` removed from the class chain in both `src/vaadin-toggle-switch.js` and `src/vaadin-toggle-switch.d.ts`. The final chain is `CheckboxMixin(ElementMixin(PolylitMixin(LumoInjectionMixin(LitElement))))`. The component does not expose a `theme` attribute and does not register theme-scoped CSS via `registerStyles()`; Lumo styles ship through `LumoInjectionMixin`, Aura styles ship document-side via `::part()`. Neither path reads `ThemableMixin`'s style registry. + - `assertType(toggleSwitch)` and the matching import dropped from `test/typings/toggle-switch.types.ts`. The mixin assertion count goes from 12 to 11. + - `@vaadin/vaadin-themable-mixin` stays in `package.json` runtime dependencies — `LumoInjectionMixin` is exported from the same package. +- **Surprises:** — +- **Spec adjustments:** + - `web-component-spec.md`: import bullet for `@vaadin/vaadin-themable-mixin` rewritten to list `LumoInjectionMixin` only; Discussion entry added. + - `web-component-tasks.md`: Task 1 and Task 2 chain references updated to drop `ThemableMixin`; Discussion entry added. diff --git a/packages/toggle-switch/spec/problem-statement.md b/packages/toggle-switch/spec/problem-statement.md new file mode 100644 index 00000000000..dc71e131cd2 --- /dev/null +++ b/packages/toggle-switch/spec/problem-statement.md @@ -0,0 +1,69 @@ +# Toggle Switch Problem Statement + +## Problem + +Business web apps need a binary on/off control whose visual metaphor — a sliding track with a thumb — communicates that it represents the **current state** of a setting, mode, or feature, not a selection to be reviewed and saved. The control should read at a glance as "is this on?" so that users instantly recognize whether a feature is active. + +The most common usage is **immediate-effect**: the moment the user flips the switch, the change is persisted or applied to the screen. The component must also slot cleanly into Vaadin's standard field apparatus (label, helper text, error message, required/invalid, disabled/read-only, tooltip) so that the second, less common usage — sitting in a save-on-submit form alongside text fields and other inputs — works without a different control or a different mental model. A single Toggle Switch must cover both modes; the application decides which mode applies in a given context. + +A separate Toggle Switch component is needed (alongside Checkbox) because Vaadin business apps regularly expose settings panels, feature flags, mode toggles, and per-row enable/disable controls where the on/off-state semantics fit poorly with checkbox's "select this option" reading. + +## Target Users + +End users of business web applications who interact with the Toggle Switch in: + +- **Personal preference panels** (notification preferences, theme, autosave, accessibility options). +- **Account, workspace, and tenant settings** (default language, security policies, integrations). +- **Per-feature and per-flag admin UIs** (turning capabilities on or off for the workspace, role, or environment). +- **Dashboards and analytical views** (comparing periods, toggling overlays, switching presentational modes). +- **Per-row enable/disable in tables and lists** (activating a user account, publishing an article, enabling a webhook). + +Developers building these applications need a switch that supports both immediate-effect usage (the most common) and the occasional case where a switch sits in a save-on-submit form alongside text fields and other inputs, without two different components and two mental models. + +## Differentiation + +- **Checkbox.** Checkbox marks a selection — "I want this option", "I agree to these terms", "include this row". It is the right control for picking items in a list, multi-selection in forms, and consent-style binary inputs. Toggle Switch represents the **on/off state of a setting** and is the right control for settings that are most naturally read as "is this on?". Both can technically appear in forms; the choice between them is semantic, not structural. Out-of-scope here: indeterminate state — switches do not have one; if a setting has a third "mixed" state (e.g., across multiple selected rows), use a Checkbox. + +- **Radio Button Group.** Radio buttons select one option from two or more mutually exclusive choices, and the choices are typically peer values with no implicit default off-state (e.g., "Small / Medium / Large"). Toggle Switch handles the special two-value case where one value is meaningfully the "off" / disabled / absent state of a feature. If a designer is choosing between a Toggle Switch and a 2-option radio group, prefer the Toggle Switch when one of the values is "off" and prefer the radio group when both values are equally meaningful labels (e.g., "Imperial / Metric"). + +- **Button (and Toggle Button).** A button triggers an action whose effect happens once when invoked (Send, Delete, Refresh). A toggle button — sometimes seen in toolbars (Bold, Italic, grid/list view) — reflects a binary mode of an editor or tool palette and lives next to other tool buttons. Toggle Switch is for **persistent settings and feature state** rather than tool modes; if the control belongs in a toolbar next to other action icons, it is a toggle button, not a switch. + +- **Select / Combo Box with two options.** A two-option dropdown adds an interaction step (open menu, then choose) and hides the off state until opened. Toggle Switch shows both states at a glance and changes state in a single tap or click; prefer Toggle Switch for binary settings unless the two values need long, descriptive labels that don't fit a switch. + +- **Confirmation flows for destructive or expensive toggles.** Out of scope. When flipping the setting is destructive ("Disable two-factor authentication") or expensive ("Pause billing"), the application combines a Toggle Switch with a Confirm Dialog or undo affordance at the application level. The Toggle Switch component itself does not own destructive-action confirmation. + +- **Sliders, multi-step toggles, segmented controls.** Out of scope. Toggle Switch is strictly two-state; ranges and three-or-more discrete options are handled by Slider, Radio Button Group, or Select. + +## Use Cases + +A user wants to flip a single binary setting and have the change take effect immediately, without finding a Save button. The switch sits in a settings panel where each row's flip is independently persisted; the same pattern applies inside data tables where each row carries its own switch (e.g., enable/disable per item). + +_In a project-management app, a team member opens "Notification preferences" and turns on **Email me when I'm @mentioned**. As soon as they flip the switch the preference is persisted to their profile — there is no Save button on the panel — and the next time someone @-mentions them, the email arrives. They flip a second switch, **Daily digest**, off; that change is also persisted instantly. They close the dialog without further action._ + +--- + +A user wants to set a binary preference inside a form that is committed all-at-once with a Save / Submit button, where the switch participates in the same dirty-state, validation, and cancel/revert flow as the form's other fields. The switch's value should not apply to the system until the form is saved; if the user cancels, the switch reverts. The switch should look and align with the surrounding fields and support helper text, error messages, and required indicators consistently with them. + +_In an HR admin tool, an admin opens an **Edit user** dialog. The dialog has fields for "Job title", "Department", "Manager" — and, alongside them, a switch labeled **Two-factor authentication required**. The admin flips the switch on, edits the job title, and clicks Save. The form validates: if validation fails, the switch shows an invalid state with an error message ("Required", or a server-returned message). If validation passes, all changes are applied together. If the admin clicks Cancel instead, the switch reverts to its original state along with the rest of the form._ + +--- + +A user wants to flip between two presentational modes of a screen and see the layout or data update immediately, without applying any backend change. + +_A financial reporting dashboard has a switch labeled **Compare with previous period** in its toolbar. With the switch off, every chart on the page shows the current quarter only. The user flips the switch on and each chart re-renders to overlay the previous quarter as a dotted line, with delta percentages added to the KPI tiles. The user flips the switch back off and the previous-period overlay disappears. No data is sent to the server; the switch state lives only in the dashboard's view._ + +--- + +A user wants to see the current on/off state of a setting they cannot change themselves, in a way that is visually distinguishable from a normal interactive switch and that explains why the setting is locked. The switch must remain focusable and announced to screen readers so the locked state is reachable by keyboard and assistive tech. + +_In a SaaS app, the workspace owner is on the Free plan and opens **Compliance settings**. They see a switch labeled **Audit log retention (90 days)** in the on position, marked read-only, with helper text "Included on the Business plan." The switch announces its state to a screen reader and is reachable by keyboard; clicking or pressing space does not change it._ + +## Discussion + +**Q: Should the Toggle Switch's scope include being used inside a form that requires a Save/Submit button (alongside other fields), or be limited to instant-effect settings only?** + +Both. The Toggle Switch must work as an immediate-apply settings control AND as a form field that participates in deferred submit, validation, helper/error text, and cancel/revert. Vaadin's prototype already exposes the field-mixin apparatus for this; the problem statement reflects that broader scope. (Classical UX guidance recommending switches always apply immediately is still acknowledged in Differentiation, but is not used to exclude the form-field scenario.) + +**Q: Is per-row switching inside data tables / grids (each row has its own switch) a primary use case worth calling out, or should it be treated as just an instance of the core toggle scenario?** + +Treat it as part of the core scenario. The interaction is the same as toggling a single setting; per-row context does not change the user's needs or constraints, so it does not need its own use case. The core use case mentions the table pattern in passing. diff --git a/packages/toggle-switch/spec/requirements.md b/packages/toggle-switch/spec/requirements.md new file mode 100644 index 00000000000..44592dd1536 --- /dev/null +++ b/packages/toggle-switch/spec/requirements.md @@ -0,0 +1,111 @@ +# Toggle Switch Requirements + +## 1. Flip between on and off via pointer or keyboard + +The Toggle Switch must flip between the on and off states when the user activates it. Activation must happen on a single primary-button click anywhere on the switch graphic (the track region and the thumb) or on its associated label, or on a Space-key press while the switch has keyboard focus. The newly entered state must be visible immediately — without waiting for any application acknowledgement — so the user knows the activation was accepted. A freshly rendered Toggle Switch with no value supplied by the application defaults to the off state. + +_A user opens "Notification preferences" and clicks the row labeled "Email me when I'm @mentioned"; the thumb slides to the on side and the track switches to the on color the moment the click registers. Tabbing to the next switch and pressing Space flips that one too. There is no delay between the click and the visible state change._ + +--- + +## 2. Notify the application only on user-initiated state changes + +When the user flips the switch (by click, label click, or Space), the component must emit a state-change notification carrying the new value. When the application sets the state programmatically — for example, hydrating from a server response or restoring a form's pristine state — the component must update its visual state but must NOT emit the user-change notification. Applications use this notification to persist immediate-effect settings or to trigger view-mode changes; receiving it for their own programmatic updates would cause ricochet writes. + +_A dashboard hydrates the "Compare with previous period" switch from a saved view-state object on page load: the switch displays "on" but the app does not re-save the view, because no change notification fires. A moment later the user clicks the switch off; the change notification fires, the dashboard re-renders without the overlay, and the new view-state is saved._ + +--- + +## 3. Render a clickable label next to the switch + +The Toggle Switch must support an optional label rendered as part of the component (not as an external `