From 56aa4f4c930c5ed8c85f91a8fa45cc5966ea6b37 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Wed, 8 Jul 2026 14:28:02 +0200 Subject: [PATCH 01/15] fix(textfield): use aria-labelledby for the tooltip association Follow our own guideline --- packages/textfield/textfield.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/textfield/textfield.ts b/packages/textfield/textfield.ts index 89ab0a7f2..5ade8bdcf 100644 --- a/packages/textfield/textfield.ts +++ b/packages/textfield/textfield.ts @@ -343,7 +343,7 @@ class WarpTextField extends FormControlMixin(LitElement) { id="tooltip-target" class="appearance-none align-text-top bg-transparent m-0 p-0 ml-4" part="tooltip-target" - aria-details="tooltip" + aria-labelledby="tooltip" > From c2860fe93567ddbe6624b82e3cfd341946872d95 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Wed, 8 Jul 2026 14:28:29 +0200 Subject: [PATCH 02/15] fix(textfield): export the tooltip CSS parts --- packages/textfield/docs/styling.md | 2 +- packages/textfield/textfield.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/textfield/docs/styling.md b/packages/textfield/docs/styling.md index 90e68462b..5736aec1c 100644 --- a/packages/textfield/docs/styling.md +++ b/packages/textfield/docs/styling.md @@ -12,7 +12,7 @@ Before changing the default styles, remember that doing so can result in less co ### Parts -The textfield exposes a minimal set of parts that can be targeted for last‑mile layout or typography tweaks. +The textfield exposes a minimal set of parts that can be targeted for last‑mile layout or typography tweaks. If you use the `tooltip` property you can also style the tooltip component's parts. | Part | Targets | Typical use | |---|---|---| diff --git a/packages/textfield/textfield.ts b/packages/textfield/textfield.ts index 5ade8bdcf..de95387bf 100644 --- a/packages/textfield/textfield.ts +++ b/packages/textfield/textfield.ts @@ -347,7 +347,11 @@ class WarpTextField extends FormControlMixin(LitElement) { > - + ${this.tooltip} ` From dd045ee032d78e797b93250bf6901633498843ef Mon Sep 17 00:00:00 2001 From: William Killerud Date: Wed, 8 Jul 2026 14:34:30 +0200 Subject: [PATCH 03/15] test(textfield): look for the presence of w-tooltip --- packages/textfield/textfield.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/textfield/textfield.test.ts b/packages/textfield/textfield.test.ts index de120ec7b..da8c382b1 100644 --- a/packages/textfield/textfield.test.ts +++ b/packages/textfield/textfield.test.ts @@ -7,6 +7,7 @@ import { render } from "vitest-browser-lit"; import "../affix/affix.js"; import "./textfield.js"; import { messages } from "./locales/en/messages.mjs"; +import type { WarpTextField } from "./textfield.js"; // Initialize i18n with English locale for tests i18n.load("en", messages); @@ -310,3 +311,21 @@ test("renders localized optional text based on document lang", async () => { document.documentElement.lang = originalLang; }); + +test("shows info tooltip after label when the tooltip property is set", async () => { + const page = render( + html``, + ); + + const el = page.getByTestId("field").element() as WarpTextField; + await el.updateComplete; + + const tooltip = el?.shadowRoot?.querySelector("w-tooltip"); + + expect(tooltip).toBeTruthy(); +}); From c851a918caae1ff546c4eb41ef2825e7764cef0c Mon Sep 17 00:00:00 2001 From: William Killerud Date: Wed, 8 Jul 2026 14:49:04 +0200 Subject: [PATCH 04/15] docs: add stories for all the forms that should have tooltip --- packages/checkbox/checkbox.stories.ts | 26 ++++++++++ packages/combobox/combobox.stories.ts | 12 +++++ packages/datepicker/datepicker.stories.ts | 10 ++++ packages/radio/radio.stories.ts | 10 ++++ packages/select/select.stories.ts | 23 +++++++++ packages/slider/slider.stories.ts | 60 +++++++++++++++++++++++ packages/textarea/Textarea.mdx | 6 +++ packages/textarea/textarea.stories.ts | 19 +++++++ packages/textfield/Textfield.mdx | 12 +++++ packages/textfield/textfield.stories.ts | 20 ++++++++ 10 files changed, 198 insertions(+) diff --git a/packages/checkbox/checkbox.stories.ts b/packages/checkbox/checkbox.stories.ts index bddbd1632..62d05a305 100644 --- a/packages/checkbox/checkbox.stories.ts +++ b/packages/checkbox/checkbox.stories.ts @@ -32,6 +32,32 @@ type Story = StoryObj; export const Default: Story = {}; +export const WithTooltip: Story = { + args: { + optional: true, + required: false, + helpText: + "Help text is available, but might not be enough, or the added context is not important enough that we use help-text", + tooltip: "This tooltip adds supplementary information", + }, + render({ optional, required }) { + return html` + + Foo + Bar + Baz + + `; + }, +}; + export const Invalid: Story = { args: { name: "invalidfoo", diff --git a/packages/combobox/combobox.stories.ts b/packages/combobox/combobox.stories.ts index 8b6f07240..102327557 100644 --- a/packages/combobox/combobox.stories.ts +++ b/packages/combobox/combobox.stories.ts @@ -119,6 +119,18 @@ export const Optional: Story = { }, }; +export const WithTooltip: Story = { + args: { + label: "Select a fruit", + placeholder: "Type to search...", + optional: true, + required: false, + helpText: + "Help text is available, but might not be enough, or the added context is not important enough that we use help-text", + tooltip: "This tooltip adds supplementary information", + }, +}; + export const DisableStaticFiltering: Story = { render: () => html` + + + + + `; + }, +}; diff --git a/packages/slider/slider.stories.ts b/packages/slider/slider.stories.ts index e1f0ccf1f..7cfb0fb07 100644 --- a/packages/slider/slider.stories.ts +++ b/packages/slider/slider.stories.ts @@ -67,6 +67,30 @@ export const SingleOptional: Story = { }, }; +export const SingleWithTooltip: Story = { + args: { + optional: true, + required: false, + }, + render({ optional, required }) { + return html` +
+ + + + +
+ `; + }, +}; + export const Range: Story = { render() { return html` @@ -117,6 +141,42 @@ export const RangeOptional: Story = { }, }; +export const RangeWithTooltip: Story = { + args: { + optional: true, + required: false, + }, + render({ optional, required }) { + return html` +
+ + + + +
+ Reset + Submit +
+
+ `; + }, +}; + // Take a suffix attribute on `` I think, have `` get from that. export const SuffixSquareMeters: Story = { args: { diff --git a/packages/textarea/Textarea.mdx b/packages/textarea/Textarea.mdx index b3d09348f..b3a481792 100644 --- a/packages/textarea/Textarea.mdx +++ b/packages/textarea/Textarea.mdx @@ -29,6 +29,12 @@ You can use the `help-text` property to show a descriptive message below the tex +### Tooltip + +If you need to add supplementary information you can use the `tooltip` property. + + + ### Placeholder Placeholder text can be used to describe the expected value or formatting for the w-textarea. Placeholder text will only appear when the w-textarea is empty, and should not be used as a substitute for labeling the element with a visible label. diff --git a/packages/textarea/textarea.stories.ts b/packages/textarea/textarea.stories.ts index 04283f4bb..9d09485e3 100644 --- a/packages/textarea/textarea.stories.ts +++ b/packages/textarea/textarea.stories.ts @@ -137,3 +137,22 @@ export const FormParticipation: Story = { }, }, }; + +export const WithTooltip: Story = { + args: { + optional: true, + required: false, + }, + render({ optional, required }) { + return html` + + `; + }, +}; diff --git a/packages/textfield/Textfield.mdx b/packages/textfield/Textfield.mdx index a03d354a0..555f097ed 100644 --- a/packages/textfield/Textfield.mdx +++ b/packages/textfield/Textfield.mdx @@ -19,6 +19,18 @@ Text fields can communicate to the user whether the current value is invalid. Im ## Visual options +### Help text + +You can use the `help-text` property to show a descriptive message below the textarea. + + + +### Tooltip + +If you need to add supplementary information you can use the `tooltip` property. + + + ### Placeholder Placeholder text can be used to describe the expected value or formatting for the w-textfield. Placeholder text will only appear when the w-textfield is empty, and should not be used as a substitute for labeling the element with a visible label. diff --git a/packages/textfield/textfield.stories.ts b/packages/textfield/textfield.stories.ts index 4db8c2706..e1d867c23 100644 --- a/packages/textfield/textfield.stories.ts +++ b/packages/textfield/textfield.stories.ts @@ -221,3 +221,23 @@ export const FormParticipation: Story = { `; }, }; + +export const WithTooltip: Story = { + args: { + optional: true, + required: false, + }, + render({ optional, required }) { + return html` + + `; + }, +}; From 682011b4921751756ed1cf66ab7e4a40dc8e15ed Mon Sep 17 00:00:00 2001 From: William Killerud Date: Thu, 9 Jul 2026 09:55:40 +0200 Subject: [PATCH 05/15] feat(textarea): add tooltip property --- packages/textarea/input-styles.ts | 8 ++++++++ packages/textarea/textarea.stories.ts | 1 + packages/textarea/textarea.ts | 26 +++++++++++++++++++++++++ packages/textfield/input-styles.ts | 8 ++++++++ packages/textfield/textfield.stories.ts | 1 + packages/textfield/textfield.ts | 3 --- 6 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/textarea/input-styles.ts b/packages/textarea/input-styles.ts index 542fd200b..f6e78c81e 100644 --- a/packages/textarea/input-styles.ts +++ b/packages/textarea/input-styles.ts @@ -52,6 +52,14 @@ export const inputLabelStyles = css` line-height: var(--_line-height); color: var(--_color); } + + [part="tooltip-target"] { + appearance: none; + background: transparent; + margin: 0 0 0 4px; + padding: 0; + vertical-align: text-top; + } `; export const inputHelpTextStyles = css` diff --git a/packages/textarea/textarea.stories.ts b/packages/textarea/textarea.stories.ts index 9d09485e3..892199879 100644 --- a/packages/textarea/textarea.stories.ts +++ b/packages/textarea/textarea.stories.ts @@ -7,6 +7,7 @@ import { prespread } from "../../.storybook/utilities.js"; import type { WarpTextarea } from "./textarea.js"; import "./textarea.js"; +import "../tooltip/tooltip.js"; const { events, args, argTypes } = getStorybookHelpers("w-textarea"); diff --git a/packages/textarea/textarea.ts b/packages/textarea/textarea.ts index 6c5bc6942..31e3191b7 100644 --- a/packages/textarea/textarea.ts +++ b/packages/textarea/textarea.ts @@ -132,6 +132,14 @@ class WarpTextarea extends FormControlMixin(LitElement) { @property({ type: Boolean, reflect: true }) required = false; + /** + * Suplementary information that should show in a tooltip behind an information icon after the label. + * + * You must provide a label to be able to show an info icon with a tooltip. + */ + @property({ type: String, reflect: true }) + tooltip?: string; + /** * Lets you set the current value */ @@ -436,6 +444,24 @@ class WarpTextarea extends FormControlMixin(LitElement) { ` : nothing} + ${this.tooltip + ? html` + + + ${this.tooltip} + + ` + : nothing} ` : nothing} diff --git a/packages/textfield/input-styles.ts b/packages/textfield/input-styles.ts index 1f6ed9a5f..3b5984a72 100644 --- a/packages/textfield/input-styles.ts +++ b/packages/textfield/input-styles.ts @@ -52,6 +52,14 @@ export const inputLabelStyles = css` line-height: var(--_line-height); color: var(--_color); } + + [part="tooltip-target"] { + appearance: none; + background: transparent; + margin: 0 0 0 4px; + padding: 0; + vertical-align: text-top; + } `; export const inputHelpTextStyles = css` diff --git a/packages/textfield/textfield.stories.ts b/packages/textfield/textfield.stories.ts index e1d867c23..c918f8eee 100644 --- a/packages/textfield/textfield.stories.ts +++ b/packages/textfield/textfield.stories.ts @@ -8,6 +8,7 @@ import { prespread } from "../../.storybook/utilities.js"; import type { WarpTextField } from "./textfield.js"; import "./textfield.js"; import "../affix/affix.js"; +import "../tooltip/tooltip.js"; const { events, args, argTypes } = getStorybookHelpers("w-textfield"); diff --git a/packages/textfield/textfield.ts b/packages/textfield/textfield.ts index de95387bf..d2489e0a1 100644 --- a/packages/textfield/textfield.ts +++ b/packages/textfield/textfield.ts @@ -216,8 +216,6 @@ class WarpTextField extends FormControlMixin(LitElement) { /** * Suplementary information that should show in a tooltip behind an information icon after the label. * - * Use the `tooltip` slot if you need markup and not just text. - * * You must provide a label to be able to show an info icon with a tooltip. */ @property({ type: String, reflect: true }) @@ -341,7 +339,6 @@ class WarpTextField extends FormControlMixin(LitElement) { ? html` + + ${this.tooltip} + + ` + : nothing; return this.label - ? html` + ? html` + + + ${this.label} + + ${optionalLabel} ${tooltipTarget} + + ` + : html` ${this.label}${optional} - ` - : html`${optional}`; + > + + ${optionalLabel} ${tooltipTarget} + `; } render() { diff --git a/packages/slider/styles/w-slider.styles.ts b/packages/slider/styles/w-slider.styles.ts index 9cce4cd82..3202212ab 100644 --- a/packages/slider/styles/w-slider.styles.ts +++ b/packages/slider/styles/w-slider.styles.ts @@ -58,6 +58,14 @@ export const wSliderStyles = css` color: var(--w-s-color-text); } + [part="tooltip-target"] { + appearance: none; + background: transparent; + margin: 0 0 0 4px; + padding: 0; + vertical-align: text-top; + } + .w-slider__description { grid-area: description; } From 230531ff7f0899242522975fbefc90e0cbb25ad6 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Thu, 9 Jul 2026 10:14:12 +0200 Subject: [PATCH 07/15] feat(select): add the tooltip property --- packages/select/input-styles.ts | 8 ++++ packages/select/select.stories.ts | 1 + packages/select/select.ts | 71 ++++++++++++++++++++++--------- 3 files changed, 61 insertions(+), 19 deletions(-) diff --git a/packages/select/input-styles.ts b/packages/select/input-styles.ts index 542fd200b..f6e78c81e 100644 --- a/packages/select/input-styles.ts +++ b/packages/select/input-styles.ts @@ -52,6 +52,14 @@ export const inputLabelStyles = css` line-height: var(--_line-height); color: var(--_color); } + + [part="tooltip-target"] { + appearance: none; + background: transparent; + margin: 0 0 0 4px; + padding: 0; + vertical-align: text-top; + } `; export const inputHelpTextStyles = css` diff --git a/packages/select/select.stories.ts b/packages/select/select.stories.ts index 66c3ec159..a7c70e83f 100644 --- a/packages/select/select.stories.ts +++ b/packages/select/select.stories.ts @@ -7,6 +7,7 @@ import { prespread } from "../../.storybook/utilities.js"; import type { WarpSelect } from "./select.js"; import "./select.js"; +import "../tooltip/tooltip.js"; const { events, args, argTypes } = getStorybookHelpers("w-select"); diff --git a/packages/select/select.ts b/packages/select/select.ts index 660291649..71541a671 100644 --- a/packages/select/select.ts +++ b/packages/select/select.ts @@ -3,7 +3,14 @@ import { classNames } from "@chbphone55/classnames"; import { i18n } from "@lingui/core"; import { FormControlMixin } from "@open-wc/form-control"; -import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { + css, + html, + LitElement, + nothing, + PropertyValues, + TemplateResult, +} from "lit"; import { property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { when } from "lit/directives/when.js"; @@ -107,6 +114,14 @@ export class WarpSelect extends FormControlMixin(LitElement) { @property({ type: Boolean, reflect: true }) optional = false; + /** + * Suplementary information that should show in a tooltip behind an information icon after the label. + * + * You must provide a label to be able to show an info icon with a tooltip. + */ + @property({ type: String, reflect: true }) + tooltip?: string; + /** * Renders the field in a disabled state. */ @@ -424,24 +439,42 @@ export class WarpSelect extends FormControlMixin(LitElement) { render() { return html`
- ${when( - this.label, - () => - html``, - )} + ${this.label + ? html` + + ` + : nothing}
@@ -531,6 +745,9 @@ class WarpDatepicker extends FormControlMixin(LitElement) { >
+ ${this.helpText + ? html`
${this.helpText}
` + : nothing}
Date: Thu, 9 Jul 2026 13:33:20 +0200 Subject: [PATCH 11/15] feat(tooltip): forward the tooltip property to w-textfield --- packages/combobox/combobox.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/combobox/combobox.ts b/packages/combobox/combobox.ts index 1212f89ef..143298d8e 100644 --- a/packages/combobox/combobox.ts +++ b/packages/combobox/combobox.ts @@ -64,6 +64,14 @@ export class WarpCombobox extends FormControlMixin(LitElement) { @property({ type: String, reflect: true, useDefault: true }) label?: string = ""; + /** + * Suplementary information that should show in a tooltip behind an information icon after the label. + * + * You must provide a label to be able to show an info icon with a tooltip. + */ + @property({ type: String, reflect: true }) + tooltip?: string; + /** * Placeholder text displayed when the input is empty. * @@ -693,6 +701,7 @@ export class WarpCombobox extends FormControlMixin(LitElement) { .optional=${this.optional} .name=${this.name} .autocomplete="${this.autocomplete || "off"}" + .tooltip="${this.tooltip}" role="combobox" aria-autocomplete="list" aria-expanded=${this._isOpen && this._currentOptions.length !== 0} From ebe8b8ca0e5915c0f319f8395547c1fa0db5b5f9 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Thu, 9 Jul 2026 13:40:30 +0200 Subject: [PATCH 12/15] feat(checkbox): add the tooltip prop --- packages/checkbox-group/checkbox-group.ts | 40 ++++++++ packages/checkbox-group/input-styles.ts | 104 +++++++++++++++++++++ packages/radio-group/radio-group-styles.ts | 1 + 3 files changed, 145 insertions(+) create mode 100644 packages/checkbox-group/input-styles.ts diff --git a/packages/checkbox-group/checkbox-group.ts b/packages/checkbox-group/checkbox-group.ts index 3228850a4..3d470b037 100644 --- a/packages/checkbox-group/checkbox-group.ts +++ b/packages/checkbox-group/checkbox-group.ts @@ -53,6 +53,14 @@ export class WarpCheckboxGroup extends FormControlMixin(LitElement) { @property({ type: String, reflect: true }) label: string | undefined; + /** + * Suplementary information that should show in a tooltip behind an information icon after the label. + * + * You must provide a label to be able to show an info icon with a tooltip. + */ + @property({ type: String, reflect: true }) + tooltip?: string; + /** * The name applied to child checkboxes when they do not provide one. * @@ -142,6 +150,20 @@ export class WarpCheckboxGroup extends FormControlMixin(LitElement) { .error { color: var(--w-s-color-text-negative); } + + [part="tooltip-target"] { + appearance: none; + background: transparent; + border: none; + height: 16px; + margin: 0 0 0 4px; + padding: 0; + vertical-align: text-top; + } + + w-tooltip { + display: inline-block; + } `; render() { @@ -171,6 +193,24 @@ export class WarpCheckboxGroup extends FormControlMixin(LitElement) { ` : nothing} + ${this.tooltip + ? html` + + + ${this.tooltip} + + ` + : nothing}
` : nothing} diff --git a/packages/checkbox-group/input-styles.ts b/packages/checkbox-group/input-styles.ts new file mode 100644 index 000000000..73d3dfb4e --- /dev/null +++ b/packages/checkbox-group/input-styles.ts @@ -0,0 +1,104 @@ +import { css } from "lit"; + +/** + * Shared styles for input components (textfield, textarea, select) + * using the internal token pattern with a public styling API. + * + * Public API tokens (customizable): + * - Label: --w-c-input-label-* + * - Help text: --w-c-input-help-text-* + * - Optional indicator: --w-c-input-optional-* + */ + +export const inputLabelStyles = css` + /* Label component tokens with semantic fallbacks */ + label { + /* Internal tokens - not part of public API */ + --_color: var(--w-c-input-label-color, var(--w-s-color-text)); + --_font-size: var(--w-c-input-label-font-size, var(--w-font-size-s)); + --_line-height: var(--w-c-input-label-line-height, var(--w-line-height-s)); + --_font-weight: var(--w-c-input-label-font-weight, 700); + --_padding-bottom: var(--w-c-input-label-padding-bottom, 0.4rem); + --_cursor: var(--w-c-input-label-cursor, pointer); + --_display: var(--w-c-input-label-display, block); + + /* Apply styles */ + display: var(--_display); + position: relative; + font-size: var(--_font-size); + line-height: var(--_line-height); + font-weight: var(--_font-weight); + padding-bottom: var(--_padding-bottom); + cursor: var(--_cursor); + color: var(--_color); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + /* Optional text styling */ + label span { + --_padding-left: var(--w-c-input-optional-padding-left, 0.8rem); + --_font-weight: var(--w-c-input-optional-font-weight, 400); + --_font-size: var(--w-c-input-optional-font-size, var(--w-font-size-s)); + --_line-height: var( + --w-c-input-optional-line-height, + var(--w-line-height-s) + ); + --_color: var(--w-c-input-optional-color, var(--w-s-color-text-subtle)); + + padding-left: var(--_padding-left); + font-weight: var(--_font-weight); + font-size: var(--_font-size); + line-height: var(--_line-height); + color: var(--_color); + } + + [part="tooltip-target"] { + appearance: none; + background: transparent; + height: 16px; + margin: 0 0 0 4px; + padding: 0; + vertical-align: text-top; + } + + w-tooltip { + display: inline-block; + } +`; + +export const inputHelpTextStyles = css` + /* Help text component tokens with semantic fallbacks */ + :host { + --_help-text-color: var( + --w-c-input-help-text-color, + var(--w-s-color-text-subtle) + ); + --_help-text-font-size: var( + --w-c-input-help-text-font-size, + var(--w-font-size-xs) + ); + --_help-text-line-height: var( + --w-c-input-help-text-line-height, + var(--w-line-height-xs) + ); + --_help-text-margin-top: var(--w-c-input-help-text-margin-top, 0.4rem); + --_help-text-display: var(--w-c-input-help-text-display, block); + } + + /* Invalid state overrides color */ + :host([invalid]) { + --_help-text-color: var( + --w-c-input-help-text-color-invalid, + var(--w-s-color-text-negative) + ); + } + + .help-text { + font-size: var(--_help-text-font-size); + line-height: var(--_help-text-line-height); + margin-top: var(--_help-text-margin-top); + display: var(--_help-text-display); + color: var(--_help-text-color); + } +`; diff --git a/packages/radio-group/radio-group-styles.ts b/packages/radio-group/radio-group-styles.ts index 8c13d2f82..1bf911d24 100644 --- a/packages/radio-group/radio-group-styles.ts +++ b/packages/radio-group/radio-group-styles.ts @@ -117,6 +117,7 @@ export const styles = css` [part="tooltip-target"] { appearance: none; background: transparent; + border: none; height: 16px; margin: 0 0 0 4px; padding: 0; From 949ad8c21be13595bba030f8f538cf3753e33b52 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Thu, 9 Jul 2026 13:40:30 +0200 Subject: [PATCH 13/15] feat(checkbox): add the tooltip prop --- packages/checkbox/checkbox.stories.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/checkbox/checkbox.stories.ts b/packages/checkbox/checkbox.stories.ts index 62d05a305..53c365e89 100644 --- a/packages/checkbox/checkbox.stories.ts +++ b/packages/checkbox/checkbox.stories.ts @@ -7,6 +7,7 @@ import { prespread } from "../../.storybook/utilities.js"; import "./checkbox.js"; import "../checkbox-group/checkbox-group.js"; +import "../tooltip/tooltip.js"; import type { WarpCheckbox } from "./checkbox.js"; const { events, args, argTypes } = From 5d9b35d9b054c59f6576b0080767222f36f02012 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Thu, 9 Jul 2026 13:56:18 +0200 Subject: [PATCH 14/15] chore: lint --- packages/datepicker/styles/w-datepicker.styles.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/datepicker/styles/w-datepicker.styles.ts b/packages/datepicker/styles/w-datepicker.styles.ts index ccce27957..7c1b95e92 100644 --- a/packages/datepicker/styles/w-datepicker.styles.ts +++ b/packages/datepicker/styles/w-datepicker.styles.ts @@ -57,7 +57,6 @@ export const inputLabelStyles = css` } `; - export const inputHelpTextStyles = css` /* Help text component tokens with semantic fallbacks */ :host { From c4484a43c521b8345c5553cc8da0343fa6def20a Mon Sep 17 00:00:00 2001 From: William Killerud Date: Thu, 9 Jul 2026 14:45:14 +0200 Subject: [PATCH 15/15] test: datepicker form validation tests and bugfixes --- packages/datepicker/datepicker.stories.ts | 17 ++ packages/datepicker/datepicker.test.ts | 234 ++++++++++++++++++++++ packages/datepicker/datepicker.ts | 29 ++- 3 files changed, 278 insertions(+), 2 deletions(-) diff --git a/packages/datepicker/datepicker.stories.ts b/packages/datepicker/datepicker.stories.ts index 10cbb25c8..b56bb622d 100644 --- a/packages/datepicker/datepicker.stories.ts +++ b/packages/datepicker/datepicker.stories.ts @@ -48,6 +48,23 @@ export const Default: Story = { }, }; +export const WithHelpText: Story = { + args: { + label: "Date", + optional: true, + required: false, + helpText: "Help text is available", + }, +}; + +export const Invalid: Story = { + args: { + label: "Date", + invalid: true, + helpText: "Description of how to fix the problem", + }, +}; + export const WithTooltip: Story = { args: { label: "Date", diff --git a/packages/datepicker/datepicker.test.ts b/packages/datepicker/datepicker.test.ts index a961a3095..62cf12a92 100644 --- a/packages/datepicker/datepicker.test.ts +++ b/packages/datepicker/datepicker.test.ts @@ -5,6 +5,7 @@ import { render } from "vitest-browser-lit"; import "../button/button.js"; import "./datepicker.js"; +import type { WarpDatepicker } from "./datepicker.js"; test("can pick a date using a pointer", async () => { const component = html` @@ -158,3 +159,236 @@ test("submits the associated form when datepicker input field has focus and user await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled()); }); + +test("renders help text if provided", async () => { + const component = html` + + + `; + + const page = render(component); + await expect.element(page.getByText("Helpful help text")).toBeVisible(); +}); + +test("marks input field as aria-invalid if the invalid prop is true", async () => { + const component = html` + + `; + + const page = render(component); + await expect + .element(page.getByLabelText("Test label")) + .toHaveAccessibleErrorMessage("No, bad input!"); + await expect + .element(page.getByLabelText("Test label")) + .toHaveAttribute("aria-invalid", "true"); +}); + +test("aria-invalid attribute is absent when input is valid", async () => { + const component = html` + + `; + + const page = render(component); + const input = page.getByLabelText("Test label"); + + await expect.element(input).toBeVisible(); + + // aria-invalid should NOT be present when valid + await expect.element(input).not.toHaveAttribute("aria-invalid"); +}); + +test("marks input field as readonly if the readonly prop is true", async () => { + const component = html``; + + const page = render(component); + await expect + .element(page.getByLabelText("Test label")) + .toHaveAttribute("readonly", ""); +}); + +test("can reset datepicker by resetting surrounding form", async () => { + render(html` +
+ +
+ `); + + const form = document.querySelector("form"); + const input = document.querySelector("w-datepicker") as WarpDatepicker; + + await expect.poll(() => input.value).toBe("2026-07-09"); + + input.value = "2025-01-13"; + await expect.poll(() => input.value).toBe("2025-01-13"); + + // Reset the form to its initial value + form!.reset(); + await expect.poll(() => input.value).toBe("2026-07-09"); +}); + +test("when required checkValidity returns false if empty", async () => { + render(html``); + + const input = document.querySelector("w-datepicker") as WarpDatepicker; + + // Wait for component to be ready + await expect.poll(() => input.checkValidity()).toBe(false); + expect(input.validity.valueMissing).toBe(true); + expect(input.validationMessage).not.toBe(""); +}); + +test("when required checkValidity returns true if the field has a value", async () => { + render(html` + + + `); + + const input = document.querySelector("w-datepicker") as WarpDatepicker; + + // Wait for component to be ready + // Wait for component to be ready + await expect.poll(() => input.checkValidity()).toBe(true); + expect(input.validity.valid).toBe(true); +}); + +test("form submission is blocked when required datepicker is empty", async () => { + const submitHandler = vi.fn((e: Event) => e.preventDefault()); + + render(html` +
+ + +
+ `); + + const form = document.querySelector("form") as HTMLFormElement; + form.addEventListener("submit", submitHandler); + + const input = document.querySelector("w-datepicker") as WarpDatepicker; + + // Wait for component to be ready + await expect.poll(() => input.checkValidity()).toBe(false); + + // Try to submit - should be blocked by validation + const submitButton = document.querySelector("button") as HTMLButtonElement; + submitButton.click(); + + // Component should show invalid state with error message + await expect.poll(() => input.invalid).toBe(true); + await expect.poll(() => input.helpText).not.toBe(""); + + // Form should not have been submitted + expect(submitHandler).not.toHaveBeenCalled(); +}); + +test("form submission succeeds when required textarea has a value", async () => { + const submitHandler = vi.fn((e: Event) => e.preventDefault()); + + render(html` +
+ + +
+ `); + + const form = document.querySelector("form") as HTMLFormElement; + form.addEventListener("submit", submitHandler); + + const input = document.querySelector("w-datepicker") as WarpDatepicker; + + // Wait for component to be ready + await expect.poll(() => input.checkValidity()).toBe(true); + + // Submit should succeed + const submitButton = document.querySelector("button") as HTMLButtonElement; + submitButton.click(); + + // Form should have been submitted + await expect.poll(() => submitHandler.mock.calls.length).toBeGreaterThan(0); +}); + +test.skipIf(server.browser === "firefox")( + "shows validation error on blur when required field is empty", + async () => { + const page = render( + html``, + ); + + const input = document.querySelector("w-datepicker") as WarpDatepicker; + + // Initially should not show error (no interaction yet) + await expect.poll(() => input.invalid).toBeFalsy(); + + // Focus and blur the input + await page.getByLabelText("Test label").click(); + await userEvent.tab(); // blur from yyyy + await userEvent.tab(); // blur from mm + await userEvent.tab(); // blur from dd + + // Should now show validation error + await expect.poll(() => input.invalid).toBe(true); + await expect.poll(() => input.helpText).not.toBe(""); + }, +); + +test.skipIf(server.browser === "firefox")( + "restores original help text when validation passes", + async () => { + const page = render(html` + + `); + + const input = document.querySelector("w-datepicker") as WarpDatepicker; + + // Initially should show original help text + await expect.poll(() => input.helpText).toBe("Enter your date"); + + // Focus and blur to trigger validation + const textarea = page.getByLabelText("Test label"); + await textarea.click(); + await userEvent.tab(); // blur from yyyy + await userEvent.tab(); // blur from mm + await userEvent.tab(); // blur from dd + + // Should show validation error + await expect.poll(() => input.invalid).toBe(true); + await expect.poll(() => input.helpText).not.toBe("Enter your date"); + + // Fill in a value + input.value = "2026-07-09"; + await input.updateComplete; + + // Wait for value + validity to update, then restore original help text + await expect.poll(() => input.value).toBe("2026-07-09"); + await expect.poll(() => input.checkValidity()).toBe(true); + await expect.poll(() => input.invalid).toBe(false); + await expect.poll(() => input.helpText).toBe("Enter your date"); + }, +); diff --git a/packages/datepicker/datepicker.ts b/packages/datepicker/datepicker.ts index 5e595b6b8..a469f08fa 100644 --- a/packages/datepicker/datepicker.ts +++ b/packages/datepicker/datepicker.ts @@ -23,7 +23,7 @@ import { subMonths, } from "date-fns"; import { da, enGB, fi, nb, sv } from "date-fns/locale"; -import { html, LitElement, nothing } from "lit"; +import { html, LitElement, nothing, PropertyValues } from "lit"; import { property, query, state } from "lit/decorators.js"; import { classMap } from "lit/directives/class-map.js"; import { ifDefined } from "lit/directives/if-defined.js"; @@ -377,6 +377,7 @@ class WarpDatepicker extends FormControlMixin(LitElement) { } #onInputBlur() { + this.#hasInteracted = true; this.#updateValidity(); } @@ -576,6 +577,15 @@ class WarpDatepicker extends FormControlMixin(LitElement) { } } + /** @internal */ + #handleInvalid = (e: Event) => { + // Prevent browser's native validation bubble + e.preventDefault(); + // Mark as interacted and show validation state + this.#hasInteracted = true; + this.#updateValidity(); + }; + /** @internal */ #updateValidity(): void { // Skip validation if disabled @@ -607,6 +617,12 @@ class WarpDatepicker extends FormControlMixin(LitElement) { super.connectedCallback(); this.#initialValue = this.value; + if (typeof this.value !== "undefined") { + this.setValue(this.value); + } + + // Listen for invalid event on the host element (fired by form validation) + this.addEventListener("invalid", this.#handleInvalid); // Local lang attribute takes precedence const lang = this.lang; @@ -631,6 +647,7 @@ class WarpDatepicker extends FormControlMixin(LitElement) { disconnectedCallback(): void { super.disconnectedCallback(); + this.removeEventListener("invalid", this.#handleInvalid); document.removeEventListener("mousedown", this._onClickOutside); document.removeEventListener("touchend", this._onClickOutside); document.removeEventListener("focusin", this._onClickOutside); @@ -649,6 +666,12 @@ class WarpDatepicker extends FormControlMixin(LitElement) { } } + firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); + // Initialize validity after the shadow DOM is ready + this.#updateValidity(); + } + render() { return html`
@@ -706,7 +729,9 @@ class WarpDatepicker extends FormControlMixin(LitElement) { ? "aria-description" : undefined, )}" - aria-errormessage="${ifDefined(this._error)}" + aria-errormessage="${ifDefined( + this._error ? "help-text" : undefined, + )}" aria-invalid=${ifDefined(this.invalid ? "true" : undefined)} @click="${this.#onInputClick}" @input="${this.#onInput}"