From 19d479ad51a0bfce143352452154c97ff9a661a8 Mon Sep 17 00:00:00 2001 From: clintcs <114178960+clintcs@users.noreply.github.com> Date: Fri, 10 Oct 2025 11:54:58 -0400 Subject: [PATCH 1/2] Remove Form Controls Layout --- .changeset/cyan-baths-raise.md | 15 + .storybook/preview.js | 46 -- custom-elements.json | 563 ++++++++---------- eslint.config.js | 1 - src/checkbox-group.stories.ts | 18 + src/checkbox-group.test.visuals.ts | 42 ++ src/checkbox-group.ts | 10 +- src/checkbox.stories.ts | 20 + src/checkbox.test.miscellaneous.ts | 17 + src/checkbox.test.visuals.ts | 42 ++ src/checkbox.ts | 25 +- src/dropdown.stories.ts | 21 + src/dropdown.test.basics.ts | 12 + src/dropdown.test.visuals.ts | 42 ++ src/dropdown.ts | 133 +++-- src/form-controls-layout.stories.ts | 349 ----------- src/form-controls-layout.styles.ts | 11 - ...form-controls-layout.test.miscellaneous.ts | 131 ---- src/form-controls-layout.test.visuals.ts | 55 -- src/form-controls-layout.ts | 102 ---- src/input.stories.ts | 21 + src/input.test.miscellaneous.ts | 17 + src/input.test.visuals.ts | 42 ++ src/input.ts | 29 +- src/label.stories.ts | 2 +- src/library/form-control.ts | 2 +- src/playwright/playwright.config.ts | 2 - src/radio-group.stories.ts | 18 + src/radio-group.test.miscellaneous.ts | 2 +- src/radio-group.test.visuals.ts | 42 ++ src/radio-group.ts | 16 +- src/slider.stories.ts | 20 + src/slider.test.basics.ts | 13 + src/slider.test.visuals.ts | 42 ++ src/slider.ts | 283 ++++----- src/textarea.stories.ts | 20 + src/textarea.test.basics.ts | 13 + src/textarea.test.visuals.ts | 42 ++ src/textarea.ts | 25 +- src/toggle.stories.ts | 20 + src/toggle.test.miscellaneous.ts | 17 + src/toggle.test.visuals.ts | 42 ++ src/toggle.ts | 21 +- web-test-runner.config.js | 2 - 44 files changed, 1151 insertions(+), 1257 deletions(-) create mode 100644 .changeset/cyan-baths-raise.md delete mode 100644 src/form-controls-layout.stories.ts delete mode 100644 src/form-controls-layout.styles.ts delete mode 100644 src/form-controls-layout.test.miscellaneous.ts delete mode 100644 src/form-controls-layout.test.visuals.ts delete mode 100644 src/form-controls-layout.ts diff --git a/.changeset/cyan-baths-raise.md b/.changeset/cyan-baths-raise.md new file mode 100644 index 000000000..7eb9cabd1 --- /dev/null +++ b/.changeset/cyan-baths-raise.md @@ -0,0 +1,15 @@ +--- +'@crowdstrike/glide-core': minor +--- + +Our Form Controls Layout component has been removed. To replace Form Controls Layout, a `split` attribute has been added to Checkbox, Checkbox Group, Dropdown, Input, Radio Group, Slider, and Textarea. + +The value of each component's `split` attribute matches that of Form Controls Layout: `split` can be `'left"`, `'middle'`, `'right'`, or undefined. You'll need to set `split` on each component in your form—which isn't as convenient. But it does allow form controls to be laid out independent of Form Controls Layout and of each other if desired. + +The impetus for these changes is to prepare for Dropdown moving out of Glide Core: + +For components to participate in Form Controls Layout, they previously exposed a `privateSplit` property that Form Controls Layout set. When Dropdown is moved out of Glide Core, its contract with Form Controls Layout will no longer be guaranteed. + +So Dropdown won't be able to participate in Form Controls Layout, and Form Controls Layout would work with every Glide form control component except Dropdown—leaving Dropdown consumers to manually set `split` on Dropdown. + +Rather than oddly have some components that work with Form Controls Layout and some that don't, we decided to remove Form Controls Layout and instead expose `split` on each component. diff --git a/.storybook/preview.js b/.storybook/preview.js index 9e9d5f20f..c2e52f09f 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -140,52 +140,6 @@ export default { $component.removeAttribute('style'); } - if (context.componentId === 'form-controls-layout') { - const hasValueChanged = - context.args['.value'].toString() !== - context.initialArgs['.value'].toString(); - - if (hasValueChanged) { - $component - .querySelector('glide-core-dropdown') - .setAttribute( - 'value', - JSON.stringify(context.args['.value']), - ); - } - - const isCheckboxGroupValueChanged = - context.args['.value'].toString() !== - context.initialArgs[ - '.value' - ].toString(); - - if (isCheckboxGroupValueChanged) { - $component - .querySelector('glide-core-checkbox-group') - .setAttribute( - 'value', - JSON.stringify( - context.args['.value'], - ), - ); - } - - for (const $option of $component.querySelectorAll( - 'glide-core-dropdown-option', - )) { - $option.removeAttribute('aria-selected'); - } - - for (const $radio of $component.querySelectorAll( - 'glide-core-radio-group-radio', - )) { - $radio.removeAttribute('aria-checked'); - $radio.removeAttribute('aria-disabled'); - $radio.removeAttribute('aria-label'); - } - } - if (context.componentId === 'dropdown') { const hasValueChanged = context.args.value.toString() !== diff --git a/custom-elements.json b/custom-elements.json index 282602f82..8e82f88dd 100644 --- a/custom-elements.json +++ b/custom-elements.json @@ -1038,14 +1038,6 @@ "attribute": "orientation", "reflects": true }, - { - "kind": "field", - "name": "privateSplit", - "type": { - "text": "'left' | 'middle' | 'right' | undefined" - }, - "attribute": "privateSplit" - }, { "kind": "field", "name": "required", @@ -1056,6 +1048,14 @@ "attribute": "required", "reflects": true }, + { + "kind": "field", + "name": "split", + "type": { + "text": "'left' | 'middle' | 'right' | undefined | undefined" + }, + "attribute": "split" + }, { "kind": "field", "name": "summary", @@ -1259,13 +1259,6 @@ }, "fieldName": "orientation" }, - { - "name": "privateSplit", - "type": { - "text": "'left' | 'middle' | 'right' | undefined" - }, - "fieldName": "privateSplit" - }, { "name": "required", "type": { @@ -1274,6 +1267,13 @@ "default": "false", "fieldName": "required" }, + { + "name": "split", + "type": { + "text": "'left' | 'middle' | 'right' | undefined | undefined" + }, + "fieldName": "split" + }, { "name": "summary", "type": { @@ -1449,14 +1449,6 @@ "attribute": "name", "reflects": true }, - { - "kind": "field", - "name": "privateSplit", - "type": { - "text": "'left' | 'middle' | 'right' | undefined" - }, - "attribute": "privateSplit" - }, { "kind": "field", "name": "privateVariant", @@ -1475,6 +1467,15 @@ "attribute": "required", "reflects": true }, + { + "kind": "field", + "name": "split", + "type": { + "text": "'left' | 'middle' | 'right' | undefined" + }, + "default": "undefined", + "attribute": "split" + }, { "kind": "field", "name": "summary", @@ -1730,13 +1731,6 @@ "default": "''", "fieldName": "name" }, - { - "name": "privateSplit", - "type": { - "text": "'left' | 'middle' | 'right' | undefined" - }, - "fieldName": "privateSplit" - }, { "name": "private-variant", "type": { @@ -1752,6 +1746,14 @@ "default": "false", "fieldName": "required" }, + { + "name": "split", + "type": { + "text": "'left' | 'middle' | 'right' | undefined" + }, + "default": "undefined", + "fieldName": "split" + }, { "name": "summary", "type": { @@ -2528,6 +2530,16 @@ "attribute": "loading", "reflects": true }, + { + "kind": "field", + "name": "multiple", + "type": { + "text": "boolean" + }, + "default": "false", + "attribute": "multiple", + "reflects": true + }, { "kind": "field", "name": "name", @@ -2567,14 +2579,6 @@ "attribute": "placeholder", "reflects": true }, - { - "kind": "field", - "name": "privateSplit", - "type": { - "text": "'left' | 'middle' | 'right' | undefined" - }, - "attribute": "privateSplit" - }, { "kind": "field", "name": "readonly", @@ -2587,33 +2591,32 @@ }, { "kind": "field", - "name": "selectAll", + "name": "required", "type": { "text": "boolean" }, "default": "false", - "attribute": "select-all", + "attribute": "required", "reflects": true }, { "kind": "field", - "name": "required", + "name": "selectAll", "type": { "text": "boolean" }, "default": "false", - "attribute": "required", + "attribute": "select-all", "reflects": true }, { "kind": "field", - "name": "multiple", + "name": "split", "type": { - "text": "boolean" + "text": "'left' | 'middle' | 'right' | undefined" }, - "default": "false", - "attribute": "multiple", - "reflects": true + "default": "undefined", + "attribute": "split" }, { "kind": "field", @@ -2871,6 +2874,14 @@ "default": "false", "fieldName": "loading" }, + { + "name": "multiple", + "type": { + "text": "boolean" + }, + "default": "false", + "fieldName": "multiple" + }, { "name": "name", "type": { @@ -2902,13 +2913,6 @@ }, "fieldName": "placeholder" }, - { - "name": "privateSplit", - "type": { - "text": "'left' | 'middle' | 'right' | undefined" - }, - "fieldName": "privateSplit" - }, { "name": "readonly", "type": { @@ -2918,28 +2922,28 @@ "fieldName": "readonly" }, { - "name": "select-all", + "name": "required", "type": { "text": "boolean" }, "default": "false", - "fieldName": "selectAll" + "fieldName": "required" }, { - "name": "required", + "name": "select-all", "type": { "text": "boolean" }, "default": "false", - "fieldName": "required" + "fieldName": "selectAll" }, { - "name": "multiple", + "name": "split", "type": { - "text": "boolean" + "text": "'left' | 'middle' | 'right' | undefined" }, - "default": "false", - "fieldName": "multiple" + "default": "undefined", + "fieldName": "split" }, { "name": "tooltip", @@ -2999,113 +3003,6 @@ } ] }, - { - "kind": "javascript-module", - "path": "src/form-controls-layout.styles.ts", - "declarations": [], - "exports": [ - { - "kind": "js", - "name": "default", - "declaration": { - "module": "src/form-controls-layout.styles.ts" - } - } - ] - }, - { - "kind": "javascript-module", - "path": "src/form-controls-layout.ts", - "declarations": [ - { - "kind": "class", - "description": "", - "name": "FormControlsLayout", - "slots": [ - { - "name": "", - "description": "", - "type": { - "text": "Checkbox | CheckboxGroup | Dropdown | Input | RadioGroup | Slider | TextArea" - } - } - ], - "members": [ - { - "kind": "field", - "name": "shadowRootOptions", - "type": { - "text": "ShadowRootInit" - }, - "static": true, - "default": "{ ...LitElement.shadowRootOptions, mode: window.navigator.webdriver ? 'open' : 'closed', }" - }, - { - "kind": "field", - "name": "split", - "type": { - "text": "'left' | 'middle' | 'right'" - }, - "default": "'left'", - "attribute": "split", - "reflects": true - }, - { - "kind": "field", - "name": "version", - "type": { - "text": "string" - }, - "readonly": true, - "attribute": "version", - "reflects": true - } - ], - "attributes": [ - { - "name": "split", - "type": { - "text": "'left' | 'middle' | 'right'" - }, - "default": "'left'", - "fieldName": "split" - }, - { - "name": "version", - "type": { - "text": "string" - }, - "readonly": true, - "fieldName": "version" - } - ], - "superclass": { - "name": "LitElement", - "package": "lit" - }, - "tagName": "glide-core-form-controls-layout", - "customElement": true - } - ], - "exports": [ - { - "kind": "js", - "name": "default", - "declaration": { - "name": "FormControlsLayout", - "module": "src/form-controls-layout.ts" - } - }, - { - "kind": "custom-element-definition", - "name": "glide-core-form-controls-layout", - "declaration": { - "name": "FormControlsLayout", - "module": "src/form-controls-layout.ts" - } - } - ] - }, { "kind": "javascript-module", "path": "src/icon-button.styles.ts", @@ -3726,14 +3623,6 @@ "attribute": "pattern", "reflects": true }, - { - "kind": "field", - "name": "privateSplit", - "type": { - "text": "'left' | 'middle' | 'right' | undefined" - }, - "attribute": "privateSplit" - }, { "kind": "field", "name": "readonly", @@ -3764,6 +3653,15 @@ "attribute": "spellcheck", "reflects": true }, + { + "kind": "field", + "name": "split", + "type": { + "text": "'left' | 'middle' | 'right' | undefined" + }, + "default": "undefined", + "attribute": "split" + }, { "kind": "field", "name": "tooltip", @@ -4045,13 +3943,6 @@ "default": "''", "fieldName": "pattern" }, - { - "name": "privateSplit", - "type": { - "text": "'left' | 'middle' | 'right' | undefined" - }, - "fieldName": "privateSplit" - }, { "name": "readonly", "type": { @@ -4076,6 +3967,14 @@ "default": "false", "fieldName": "spellcheck" }, + { + "name": "split", + "type": { + "text": "'left' | 'middle' | 'right' | undefined" + }, + "default": "undefined", + "fieldName": "split" + }, { "name": "tooltip", "type": { @@ -6460,29 +6359,29 @@ }, { "kind": "field", - "name": "privateSplit", + "name": "required", "type": { - "text": "'left' | 'middle' | 'right' | undefined" + "text": "boolean" }, - "attribute": "privateSplit" + "default": "false", + "attribute": "required", + "reflects": true }, { "kind": "field", - "name": "tooltip", + "name": "split", "type": { - "text": "string | undefined" + "text": "'left' | 'middle' | 'right' | undefined | undefined" }, - "attribute": "tooltip", - "reflects": true + "attribute": "split" }, { "kind": "field", - "name": "required", + "name": "tooltip", "type": { - "text": "boolean" + "text": "string | undefined" }, - "default": "false", - "attribute": "required", + "attribute": "tooltip", "reflects": true }, { @@ -6671,26 +6570,26 @@ "fieldName": "orientation" }, { - "name": "privateSplit", + "name": "required", "type": { - "text": "'left' | 'middle' | 'right' | undefined" + "text": "boolean" }, - "fieldName": "privateSplit" + "default": "false", + "fieldName": "required" }, { - "name": "tooltip", + "name": "split", "type": { - "text": "string | undefined" + "text": "'left' | 'middle' | 'right' | undefined | undefined" }, - "fieldName": "tooltip" + "fieldName": "split" }, { - "name": "required", + "name": "tooltip", "type": { - "text": "boolean" + "text": "string | undefined" }, - "default": "false", - "fieldName": "required" + "fieldName": "tooltip" }, { "name": "value", @@ -7178,25 +7077,6 @@ "static": true, "default": "{ ...LitElement.shadowRootOptions, mode: window.navigator.webdriver ? 'open' : 'closed', delegatesFocus: true, }" }, - { - "kind": "field", - "name": "name", - "type": { - "text": "string" - }, - "default": "''", - "attribute": "name", - "reflects": true - }, - { - "kind": "field", - "name": "value", - "type": { - "text": "number[]" - }, - "default": "[]", - "attribute": "value" - }, { "kind": "field", "name": "label", @@ -7208,12 +7088,12 @@ }, { "kind": "field", - "name": "orientation", + "name": "disabled", "type": { - "text": "'horizontal' | 'vertical'" + "text": "boolean" }, - "default": "'horizontal'", - "attribute": "orientation", + "default": "false", + "attribute": "disabled", "reflects": true }, { @@ -7227,88 +7107,89 @@ }, { "kind": "field", - "name": "required", + "name": "max", "type": { - "text": "boolean" + "text": "number" }, - "default": "false", - "attribute": "required", + "default": "100", + "attribute": "max", "reflects": true }, { "kind": "field", - "name": "readonly", + "name": "min", "type": { - "text": "boolean" + "text": "number" }, - "default": "false", - "attribute": "readonly" + "default": "0", + "attribute": "min", + "reflects": true }, { "kind": "field", - "name": "disabled", + "name": "multiple", "type": { "text": "boolean" }, "default": "false", - "attribute": "disabled", - "reflects": true + "attribute": "multiple" }, { "kind": "field", - "name": "max", + "name": "name", "type": { - "text": "number" + "text": "string" }, - "default": "100", - "attribute": "max", + "default": "''", + "attribute": "name", "reflects": true }, { "kind": "field", - "name": "min", + "name": "orientation", "type": { - "text": "number" + "text": "'horizontal' | 'vertical'" }, - "default": "0", - "attribute": "min", + "default": "'horizontal'", + "attribute": "orientation", "reflects": true }, { "kind": "field", - "name": "multiple", + "name": "readonly", "type": { "text": "boolean" }, "default": "false", - "attribute": "multiple" + "attribute": "readonly" }, { "kind": "field", - "name": "step", + "name": "required", "type": { - "text": "number" + "text": "boolean" }, - "default": "1", - "attribute": "step", + "default": "false", + "attribute": "required", "reflects": true }, { "kind": "field", - "name": "privateSplit", + "name": "split", "type": { - "text": "'left' | 'middle' | undefined" + "text": "'left' | 'middle' | 'right' | undefined" }, - "attribute": "privateSplit" + "default": "undefined", + "attribute": "split" }, { "kind": "field", - "name": "version", + "name": "step", "type": { - "text": "string" + "text": "number" }, - "readonly": true, - "attribute": "version", + "default": "1", + "attribute": "step", "reflects": true }, { @@ -7320,6 +7201,25 @@ "attribute": "tooltip", "reflects": true }, + { + "kind": "field", + "name": "value", + "type": { + "text": "number[]" + }, + "default": "[]", + "attribute": "value" + }, + { + "kind": "field", + "name": "version", + "type": { + "text": "string" + }, + "readonly": true, + "attribute": "version", + "reflects": true + }, { "kind": "field", "name": "form", @@ -7445,22 +7345,6 @@ } ], "attributes": [ - { - "name": "name", - "type": { - "text": "string" - }, - "default": "''", - "fieldName": "name" - }, - { - "name": "value", - "type": { - "text": "number[]" - }, - "default": "[]", - "fieldName": "value" - }, { "name": "label", "type": { @@ -7470,12 +7354,12 @@ "required": true }, { - "name": "orientation", + "name": "disabled", "type": { - "text": "'horizontal' | 'vertical'" + "text": "boolean" }, - "default": "'horizontal'", - "fieldName": "orientation" + "default": "false", + "fieldName": "disabled" }, { "name": "hide-label", @@ -7486,52 +7370,68 @@ "fieldName": "hideLabel" }, { - "name": "required", + "name": "max", "type": { - "text": "boolean" + "text": "number" }, - "default": "false", - "fieldName": "required" + "default": "100", + "fieldName": "max" }, { - "name": "readonly", + "name": "min", "type": { - "text": "boolean" + "text": "number" }, - "default": "false", - "fieldName": "readonly" + "default": "0", + "fieldName": "min" }, { - "name": "disabled", + "name": "multiple", "type": { "text": "boolean" }, "default": "false", - "fieldName": "disabled" + "fieldName": "multiple" }, { - "name": "max", + "name": "name", "type": { - "text": "number" + "text": "string" }, - "default": "100", - "fieldName": "max" + "default": "''", + "fieldName": "name" }, { - "name": "min", + "name": "orientation", "type": { - "text": "number" + "text": "'horizontal' | 'vertical'" }, - "default": "0", - "fieldName": "min" + "default": "'horizontal'", + "fieldName": "orientation" }, { - "name": "multiple", + "name": "readonly", "type": { "text": "boolean" }, "default": "false", - "fieldName": "multiple" + "fieldName": "readonly" + }, + { + "name": "required", + "type": { + "text": "boolean" + }, + "default": "false", + "fieldName": "required" + }, + { + "name": "split", + "type": { + "text": "'left' | 'middle' | 'right' | undefined" + }, + "default": "undefined", + "fieldName": "split" }, { "name": "step", @@ -7542,26 +7442,27 @@ "fieldName": "step" }, { - "name": "privateSplit", + "name": "tooltip", "type": { - "text": "'left' | 'middle' | undefined" + "text": "string | undefined" }, - "fieldName": "privateSplit" + "fieldName": "tooltip" }, { - "name": "version", + "name": "value", "type": { - "text": "string" + "text": "number[]" }, - "readonly": true, - "fieldName": "version" + "default": "[]", + "fieldName": "value" }, { - "name": "tooltip", + "name": "version", "type": { - "text": "string | undefined" + "text": "string" }, - "fieldName": "tooltip" + "readonly": true, + "fieldName": "version" } ], "superclass": { @@ -9245,14 +9146,6 @@ "attribute": "placeholder", "reflects": true }, - { - "kind": "field", - "name": "privateSplit", - "type": { - "text": "'left' | 'middle' | 'right' | undefined" - }, - "attribute": "privateSplit" - }, { "kind": "field", "name": "spellcheck", @@ -9283,6 +9176,15 @@ "attribute": "readonly", "reflects": true }, + { + "kind": "field", + "name": "split", + "type": { + "text": "'left' | 'middle' | 'right' | undefined" + }, + "default": "undefined", + "attribute": "split" + }, { "kind": "field", "name": "tooltip", @@ -9506,13 +9408,6 @@ }, "fieldName": "placeholder" }, - { - "name": "privateSplit", - "type": { - "text": "'left' | 'middle' | 'right' | undefined" - }, - "fieldName": "privateSplit" - }, { "name": "spellcheck", "type": { @@ -9537,6 +9432,14 @@ "default": "false", "fieldName": "readonly" }, + { + "name": "split", + "type": { + "text": "'left' | 'middle' | 'right' | undefined" + }, + "default": "undefined", + "fieldName": "split" + }, { "name": "tooltip", "type": { @@ -10018,11 +9921,12 @@ }, { "kind": "field", - "name": "privateSplit", + "name": "split", "type": { "text": "'left' | 'middle' | 'right' | undefined" }, - "attribute": "privateSplit" + "default": "undefined", + "attribute": "split" }, { "kind": "field", @@ -10126,11 +10030,12 @@ "fieldName": "orientation" }, { - "name": "privateSplit", + "name": "split", "type": { "text": "'left' | 'middle' | 'right' | undefined" }, - "fieldName": "privateSplit" + "default": "undefined", + "fieldName": "split" }, { "name": "summary", diff --git a/eslint.config.js b/eslint.config.js index d6e995b93..2b1553cbd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -384,7 +384,6 @@ export default defineConfig([ 'src/checkbox.test.*.ts', 'src/checkbox-group.test.*.ts', 'src/drawer.test.*.ts', - 'src/form-controls-layout.test.*.ts', 'src/icon-button.test.*.ts', 'src/inline-alert.test.*.ts', 'src/input.test.*.ts', diff --git a/src/checkbox-group.stories.ts b/src/checkbox-group.stories.ts index cc5ec01f4..72c36bf65 100644 --- a/src/checkbox-group.stories.ts +++ b/src/checkbox-group.stories.ts @@ -46,6 +46,7 @@ const meta: Meta = { 'setCustomValidity(message)': '', 'setValidity(flags, message)': '', 'slot="description"': '', + split: '', tooltip: '', value: [], version: '', @@ -163,6 +164,22 @@ const meta: Meta = { type: { summary: 'Element' }, }, }, + split: { + control: 'select', + options: ['', 'left', 'middle', 'right'], + table: { + type: { + summary: '"left" | "middle" | "right"', + detail: ` +// The split between the label and checkboxes: +// +// - "left": 1/3 of the available space for the label. 2/3 for the checkboxes. +// - "middle": 1/2 of the available space the label. 1/2 for the checkboxes. +// - "right": 2/3 of the available space the label. 1/3 for the checkboxes. +`, + }, + }, + }, tooltip: { table: { type: { summary: 'string' }, @@ -269,6 +286,7 @@ const meta: Meta = { ? nothing : arguments_.horizontal } + split=${arguments_.split || nothing} tooltip=${arguments_.tooltip || nothing} ?disabled=${arguments_.disabled} ?hide-label=${arguments_['hide-label'] || nothing} diff --git a/src/checkbox-group.test.visuals.ts b/src/checkbox-group.test.visuals.ts index 33ffd6050..5347e3ef0 100644 --- a/src/checkbox-group.test.visuals.ts +++ b/src/checkbox-group.test.visuals.ts @@ -87,6 +87,48 @@ for (const story of stories) { ); }); + test('split="left"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-checkbox-group') + .evaluate((element) => { + element.split = 'left'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + + test('split="middle"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-checkbox-group') + .evaluate((element) => { + element.split = 'middle'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + + test('split="right"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-checkbox-group') + .evaluate((element) => { + element.split = 'right'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + test('tooltip', async ({ page }, test) => { await page.goto(`?id=${story.id}&globals=theme:${theme}`); diff --git a/src/checkbox-group.ts b/src/checkbox-group.ts index 4a3fdd4fe..246e20d36 100644 --- a/src/checkbox-group.ts +++ b/src/checkbox-group.ts @@ -27,6 +27,7 @@ declare global { * @attr {string} [name=''] * @attr {'horizontal'} [orientation='horizontal'] * @attr {boolean} [required=false] + * @attr {'left'|'middle'|'right'} [split] * @attr {string} [summary] * @attr {string} [tooltip] * @attr {string[]} [value=[]] @@ -106,10 +107,6 @@ export default class CheckboxGroup extends LitElement implements FormControl { @property({ reflect: true, useDefault: true }) orientation = 'horizontal' as const; - // Private because it's only meant to be used by Form Controls Layout. - @property() - privateSplit?: 'left' | 'middle' | 'right'; - /** * @default false */ @@ -123,6 +120,9 @@ export default class CheckboxGroup extends LitElement implements FormControl { this.#setValidity(); } + @property() + split?: 'left' | 'middle' | 'right' | undefined; + @property({ reflect: true }) summary?: string; @@ -272,7 +272,7 @@ export default class CheckboxGroup extends LitElement implements FormControl { { + await expect( + mount( + () => + html``, + ), + ).rejects.toThrow(); + }, +); diff --git a/src/checkbox.test.visuals.ts b/src/checkbox.test.visuals.ts index ebe5aa0b1..ca4fb3b79 100644 --- a/src/checkbox.test.visuals.ts +++ b/src/checkbox.test.visuals.ts @@ -221,6 +221,48 @@ for (const story of stories) { ); }); + test('split="left"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-checkbox') + .evaluate((element) => { + element.split = 'left'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + + test('split="middle"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-checkbox') + .evaluate((element) => { + element.split = 'middle'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + + test('split="right"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-checkbox') + .evaluate((element) => { + element.split = 'right'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + test('summary', async ({ page }, test) => { await page.goto(`?id=${story.id}&globals=theme:${theme}`); diff --git a/src/checkbox.ts b/src/checkbox.ts index 111e63597..70b104fa8 100644 --- a/src/checkbox.ts +++ b/src/checkbox.ts @@ -30,6 +30,7 @@ declare global { * @attr {string} [name=''] * @attr {'horizontal'|'vertical'} [orientation='horizontal'] * @attr {boolean} [required=false] + * @attr {'left'|'middle'|'right'} [split] * @attr {string} [summary] * @attr {string} [tooltip] * @attr {string} [value=''] @@ -147,10 +148,6 @@ export default class Checkbox extends LitElement implements FormControl { @property({ reflect: true, useDefault: true }) name = ''; - // Private because it's only meant to be used by Form Controls Layout. - @property() - privateSplit?: 'left' | 'middle' | 'right'; - // Private because it's only meant to be used by Checkbox Group. // // This variant exists because Checkbox Group has a requirement for Checkbox's @@ -177,6 +174,22 @@ export default class Checkbox extends LitElement implements FormControl { this.#setValidity(); } + /** + * @default undefined + */ + @property() + get split(): 'left' | 'middle' | 'right' | undefined { + return this.#split; + } + + set split(split: 'left' | 'middle' | 'right' | undefined) { + if (this.orientation === 'vertical') { + throw new Error('`split` is unsupported with `orientation="vertical"`.'); + } + + this.#split = split; + } + @property({ reflect: true }) summary?: string; @@ -327,7 +340,7 @@ export default class Checkbox extends LitElement implements FormControl { html`(); + #split?: 'left' | 'middle' | 'right'; + #value = ''; // An arrow function field instead of a method so `this` is closed over and diff --git a/src/dropdown.stories.ts b/src/dropdown.stories.ts index b7b6081c9..590556bfe 100644 --- a/src/dropdown.stories.ts +++ b/src/dropdown.stories.ts @@ -58,6 +58,7 @@ const meta: Meta = { 'setValidity(flags, message)': '', 'slot="description"': '', 'slot="icon:"': '', + split: '', tooltip: '', value: [], variant: '', @@ -321,6 +322,24 @@ class Component extends LitElement { +`, + }, + }, + }, + split: { + control: 'select', + options: ['', 'left', 'middle', 'right'], + table: { + type: { + summary: '"left" | "middle" | "right"', + detail: ` +// The split between the label and dropdown: +// +// - "left": 1/3 of the available space for the label. 2/3 for the dropdown. +// - "middle": 1/2 of the available space the label. 1/2 for the dropdown. +// - "right": 2/3 of the available space the label. 1/3 for the dropdown. +// +// Unsupported with \`orientation="vertical"\`. `, }, }, @@ -567,6 +586,7 @@ class Component extends LitElement { ? nothing : arguments_.orientation} placeholder=${arguments_.placeholder || nothing} + split=${arguments_.split || nothing} tooltip=${arguments_.tooltip || nothing} variant=${arguments_.variant || nothing} ?add-button=${arguments_['add-button']} @@ -660,6 +680,7 @@ export const WithIcons: StoryObj = { ? nothing : arguments_.orientation} placeholder=${arguments_.placeholder || nothing} + split=${arguments_.split || nothing} variant=${arguments_.variant || nothing} ?add-button=${arguments_['add-button']} ?disabled=${arguments_.disabled} diff --git a/src/dropdown.test.basics.ts b/src/dropdown.test.basics.ts index ac92850f0..31edc91eb 100644 --- a/src/dropdown.test.basics.ts +++ b/src/dropdown.test.basics.ts @@ -275,3 +275,15 @@ it('throws when its default slot is the wrong type', async () => { ); }); }); + +it('throws when split and vertical', async () => { + await expectWindowError(() => { + return fixture( + html``, + ); + }); +}); diff --git a/src/dropdown.test.visuals.ts b/src/dropdown.test.visuals.ts index 7ce3bc1f0..26e0a3451 100644 --- a/src/dropdown.test.visuals.ts +++ b/src/dropdown.test.visuals.ts @@ -313,6 +313,48 @@ for (const story of stories) { ); }); + test('split="left"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-dropdown') + .evaluate((element) => { + element.split = 'left'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + + test('split="middle"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-dropdown') + .evaluate((element) => { + element.split = 'middle'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + + test('split="right"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-dropdown') + .evaluate((element) => { + element.split = 'right'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + test(`value="['one']"`, async ({ page }, test) => { await page.goto(`?id=${story.id}&globals=theme:${theme}`); diff --git a/src/dropdown.ts b/src/dropdown.ts index 22adc2954..4b5a6133e 100644 --- a/src/dropdown.ts +++ b/src/dropdown.ts @@ -48,6 +48,7 @@ declare global { * @attr {boolean} [readonly=false] * @attr {boolean} [required=false] * @attr {boolean} [select-all=false] + * @attr {'left'|'middle'|'right'} [split] * @attr {string} [tooltip] * @attr {string[]} [value=[]] * @attr {'quiet'} [variant] @@ -168,6 +169,65 @@ export default class Dropdown extends LitElement implements FormControl { @property({ reflect: true, type: Boolean }) loading = false; + /** + * @default false + */ + @property({ reflect: true, type: Boolean }) + get multiple(): boolean { + return this.#isMultiple; + } + + set multiple(isMultiple) { + const wasMultiple = this.#isMultiple && !isMultiple; + const wasSingle = !this.#isMultiple && isMultiple; + + this.#isMultiple = isMultiple; + + for (const option of this.#optionElements) { + option.privateMultiple = isMultiple; + + // A single-select Dropdown can only have one option selected. So all but the + // last selected and enabled option is deselected. + if (wasMultiple && option !== this.lastSelectedAndEnabledOption) { + option.selected = false; + } + } + + if (wasMultiple && this.lastSelectedAndEnabledOption) { + this.#value = this.lastSelectedAndEnabledOption?.value + ? [this.lastSelectedAndEnabledOption.value] + : []; + + this.selectedAndEnabledOptions = [this.lastSelectedAndEnabledOption]; + + this.isShowSingleSelectIcon = Boolean( + this.lastSelectedAndEnabledOption?.value, + ); + + if ( + this.#inputElementRef.value && + this.lastSelectedAndEnabledOption?.label + ) { + this.#inputElementRef.value.value = + this.lastSelectedAndEnabledOption.label; + + this.inputValue = this.lastSelectedAndEnabledOption.label; + } + } else if (wasSingle && this.lastSelectedAndEnabledOption) { + // If Dropdown was single-select and filterable and an option is selected, + // then the value of its `` is set to the label of the selected option. + // That behavior doesn't apply to multiselect Dropdown because its selected + // option or options are represented by tags. So we clear input field. + if (this.#inputElementRef.value) { + this.#inputElementRef.value.value = ''; + this.inputValue = ''; + } + + this.lastSelectedAndEnabledOption.privateUpdateCheckbox(); + this.isShowSingleSelectIcon = false; + } + } + @property({ reflect: true, useDefault: true }) name = ''; @@ -230,76 +290,29 @@ export default class Dropdown extends LitElement implements FormControl { @property({ reflect: true }) placeholder?: string; - // Private because it's only meant to be used by Form Controls Layout. - @property() - privateSplit?: 'left' | 'middle' | 'right'; - @property({ reflect: true, type: Boolean }) readonly = false; - @property({ attribute: 'select-all', reflect: true, type: Boolean }) - selectAll = false; - @property({ reflect: true, type: Boolean }) required = false; + @property({ attribute: 'select-all', reflect: true, type: Boolean }) + selectAll = false; + /** - * @default false + * @default undefined */ - @property({ reflect: true, type: Boolean }) - get multiple(): boolean { - return this.#isMultiple; + @property() + get split(): 'left' | 'middle' | 'right' | undefined { + return this.#split; } - set multiple(isMultiple) { - const wasMultiple = this.#isMultiple && !isMultiple; - const wasSingle = !this.#isMultiple && isMultiple; - - this.#isMultiple = isMultiple; - - for (const option of this.#optionElements) { - option.privateMultiple = isMultiple; - - // A single-select Dropdown can only have one option selected. So all but the - // last selected and enabled option is deselected. - if (wasMultiple && option !== this.lastSelectedAndEnabledOption) { - option.selected = false; - } + set split(split: 'left' | 'middle' | 'right' | undefined) { + if (this.orientation === 'vertical') { + throw new Error('`split` is unsupported with `orientation="vertical"`.'); } - if (wasMultiple && this.lastSelectedAndEnabledOption) { - this.#value = this.lastSelectedAndEnabledOption?.value - ? [this.lastSelectedAndEnabledOption.value] - : []; - - this.selectedAndEnabledOptions = [this.lastSelectedAndEnabledOption]; - - this.isShowSingleSelectIcon = Boolean( - this.lastSelectedAndEnabledOption?.value, - ); - - if ( - this.#inputElementRef.value && - this.lastSelectedAndEnabledOption?.label - ) { - this.#inputElementRef.value.value = - this.lastSelectedAndEnabledOption.label; - - this.inputValue = this.lastSelectedAndEnabledOption.label; - } - } else if (wasSingle && this.lastSelectedAndEnabledOption) { - // If Dropdown was single-select and filterable and an option is selected, - // then the value of its `` is set to the label of the selected option. - // That behavior doesn't apply to multiselect Dropdown because its selected - // option or options are represented by tags. So we clear input field. - if (this.#inputElementRef.value) { - this.#inputElementRef.value.value = ''; - this.inputValue = ''; - } - - this.lastSelectedAndEnabledOption.privateUpdateCheckbox(); - this.isShowSingleSelectIcon = false; - } + this.#split = split; } @property({ reflect: true }) @@ -672,7 +685,7 @@ export default class Dropdown extends LitElement implements FormControl { (); #value: string[] = []; diff --git a/src/form-controls-layout.stories.ts b/src/form-controls-layout.stories.ts deleted file mode 100644 index 9bfb47542..000000000 --- a/src/form-controls-layout.stories.ts +++ /dev/null @@ -1,349 +0,0 @@ -import './form-controls-layout.js'; -import './radio-group.js'; -import { UPDATE_STORY_ARGS } from '@storybook/core-events'; -import { addons } from '@storybook/preview-api'; -import { html, nothing } from 'lit'; -import { withActions } from '@storybook/addon-actions/decorator'; -import type { Meta, StoryObj } from '@storybook/web-components'; -import CheckboxGroupComponent from './checkbox-group.js'; -import DropdownComponent from './dropdown.js'; -import InputComponent from './input.js'; -import RadioGroupRadioComponent from './radio-group.radio.js'; -import SliderComponent from './slider.js'; -import TextareaComponent from './textarea.js'; - -const meta: Meta = { - title: 'Form Controls Layout', - decorators: [ - withActions, - (story) => - html`
- - - ${story()} -
`, - ], - parameters: { - actions: { - handles: ['change', 'input', 'invalid', 'toggle'], - }, - docs: { - story: { - autoplay: true, - }, - }, - }, - args: { - 'slot="default"': '', - split: 'left', - version: '', - '.value': [], - '.open': false, - '.value': [], - '.value': '', - '.value': '', - '.one.checked': false, - '.two.checked': false, - '.three.checked': false, - '.value': [], - '.value': '', - }, - argTypes: { - 'slot="default"': { - table: { - type: { - summary: - 'Checkbox | CheckboxGroup | Dropdown | Input | RadioGroup | Slider | TextArea', - }, - }, - type: { name: 'function', required: true }, - }, - split: { - control: { type: 'radio' }, - options: ['left', 'middle', 'right'], - table: { - defaultValue: { summary: '"left"' }, - type: { summary: '"left" | "middle" | "right"' }, - }, - }, - version: { - control: false, - table: { - defaultValue: { - summary: import.meta.env.VITE_GLIDE_CORE_VERSION, - }, - type: { summary: 'string', detail: '// For debugging' }, - }, - }, - '.value': { - table: { - disable: true, - }, - }, - '.open': { - table: { - disable: true, - }, - }, - '.value': { - table: { - disable: true, - }, - }, - '.value': { - table: { - disable: true, - }, - }, - '.value': { - table: { - disable: true, - }, - }, - '.one.checked': { - table: { - disable: true, - }, - }, - '.two.checked': { - table: { - disable: true, - }, - }, - '.three.checked': { - table: { - disable: true, - }, - }, - '.value': { - table: { - disable: true, - }, - }, - '.value': { - table: { - disable: true, - }, - }, - }, - play(context) { - context.canvasElement - .querySelector('form') - ?.addEventListener('submit', (event: Event) => { - event.preventDefault(); - - // We reload the page to give the impression of a submission while keeping - // the user on the same page. - window.location.reload(); - }); - - const checkboxGroup = context.canvasElement.querySelector( - 'glide-core-checkbox-group', - ); - - if (checkboxGroup instanceof CheckboxGroupComponent) { - checkboxGroup.addEventListener('change', () => { - addons.getChannel().emit(UPDATE_STORY_ARGS, { - storyId: context.id, - updatedArgs: { - '.value': checkboxGroup.value, - }, - }); - }); - } - - const dropdown = context.canvasElement.querySelector('glide-core-dropdown'); - - if (dropdown instanceof DropdownComponent) { - dropdown.addEventListener('change', () => { - const option = context.canvasElement.querySelector( - 'glide-core-dropdown-option', - ); - - if (option) { - addons.getChannel().emit(UPDATE_STORY_ARGS, { - storyId: context.id, - updatedArgs: { - '.value': dropdown.value, - }, - }); - } - }); - - dropdown.addEventListener('toggle', () => { - if (dropdown instanceof DropdownComponent) { - addons.getChannel().emit(UPDATE_STORY_ARGS, { - storyId: context.id, - updatedArgs: { - '.open': dropdown.open, - }, - }); - } - }); - } - - const input = context.canvasElement.querySelector('glide-core-input'); - - if (input instanceof InputComponent) { - input.addEventListener('input', () => { - addons.getChannel().emit(UPDATE_STORY_ARGS, { - storyId: context.id, - updatedArgs: { - '.value': input.value, - }, - }); - }); - } - - const radioGroup = context.canvasElement.querySelector( - 'glide-core-radio-group', - ); - - radioGroup?.addEventListener('change', (event: Event) => { - if (event.target instanceof RadioGroupRadioComponent) { - addons.getChannel().emit(UPDATE_STORY_ARGS, { - storyId: context.id, - updatedArgs: { - '.value': radioGroup.value, - '.one.checked': - event.target.value === 'one', - '.two.checked': - event.target.value === 'two', - '.three.checked': - event.target.value === 'three', - }, - }); - } - }); - - const slider = context.canvasElement.querySelector('glide-core-slider'); - - if (slider instanceof SliderComponent) { - slider.addEventListener('change', () => { - addons.getChannel().emit(UPDATE_STORY_ARGS, { - storyId: context.id, - updatedArgs: { - '.value': slider.value, - }, - }); - }); - } - - const textarea = context.canvasElement.querySelector('glide-core-textarea'); - - if (textarea instanceof TextareaComponent) { - textarea.addEventListener('input', () => { - addons.getChannel().emit(UPDATE_STORY_ARGS, { - storyId: context.id, - updatedArgs: { - '.value': textarea.value, - }, - }); - }); - } - }, - render(arguments_) { - /* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/prefer-nullish-coalescing */ - return html` - - - .value'].includes( - 'one', - )} - > - .value'].includes( - 'two', - )} - > - .value'].includes( - 'three', - )} - > - - - .open']} - > - .value'].includes( - 'one', - )} - > - - .value'].includes( - 'two', - )} - > - - .value'].includes( - 'three', - )} - > - - - .value'] || nothing} - > - - - .one.checked']} - > - - .two.checked']} - > - - .three.checked' - ]} - > - - - .value'] || nothing} - > - - .value'] || nothing} - > - - `; - }, -}; - -export default meta; - -export const FormControlsLayout: StoryObj = {}; diff --git a/src/form-controls-layout.styles.ts b/src/form-controls-layout.styles.ts deleted file mode 100644 index 3f3be8252..000000000 --- a/src/form-controls-layout.styles.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { css } from 'lit'; - -export default [ - css` - .component { - display: flex; - flex-direction: column; - row-gap: 0.625rem; - } - `, -]; diff --git a/src/form-controls-layout.test.miscellaneous.ts b/src/form-controls-layout.test.miscellaneous.ts deleted file mode 100644 index 57148771b..000000000 --- a/src/form-controls-layout.test.miscellaneous.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { html } from 'lit'; -import { expect, test } from './playwright/test.js'; - -test('defines itself', { tag: '@miscellaneous' }, async ({ mount, page }) => { - await mount( - () => html` - - - - - `, - ); - - const host = page.locator('glide-core-form-controls-layout'); - - await expect(host).toBeInTheCustomElementRegistry( - 'glide-core-form-controls-layout', - ); -}); - -test( - 'sets `privateSplit` on each control', - { tag: '@miscellaneous' }, - async ({ mount, page }) => { - await mount( - () => html` - - - - - `, - ); - - const input = page.locator('glide-core-input'); - const checkbox = page.locator('glide-core-checkbox'); - - await expect(input).toHaveJSProperty('privateSplit', 'left'); - await expect(checkbox).toHaveJSProperty('privateSplit', 'left'); - }, -); - -test( - 'sets `privateSplit` on each control when `split` is set programmatically', - { tag: '@miscellaneous' }, - async ({ mount, page, setProperty }) => { - await mount( - () => html` - - - - - `, - ); - - const host = page.locator('glide-core-form-controls-layout'); - const input = page.locator('glide-core-input'); - const checkbox = page.locator('glide-core-checkbox'); - - await setProperty(host, 'split', 'middle'); - - await expect(input).toHaveJSProperty('privateSplit', 'middle'); - await expect(checkbox).toHaveJSProperty('privateSplit', 'middle'); - }, -); - -test( - 'cannot be extended', - { tag: '@miscellaneous' }, - async ({ mount, page }) => { - await mount( - () => html` - - - - - `, - ); - - const host = page.locator('glide-core-form-controls-layout'); - - await expect(host).not.toBeExtensible(); - }, -); - -test( - 'throws when it does not have a default slot', - { tag: '@miscellaneous' }, - async ({ mount }) => { - await expect( - mount( - () => - html``, - ), - ).rejects.toThrow(); - }, -); - -test( - 'throws when its default slot is the wrong type', - { tag: '@miscellaneous' }, - async ({ mount }) => { - await expect( - mount( - () => html` - - Text - - `, - ), - ).rejects.toThrow(); - }, -); - -test( - 'throws if a vertical control is present', - { tag: '@miscellaneous' }, - async ({ mount }) => { - await expect( - mount( - () => html` - - - - `, - ), - ).rejects.toThrow(); - }, -); diff --git a/src/form-controls-layout.test.visuals.ts b/src/form-controls-layout.test.visuals.ts deleted file mode 100644 index 525be1b6c..000000000 --- a/src/form-controls-layout.test.visuals.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { expect, test } from './playwright/test.js'; -import type FormControlsLayout from './form-controls-layout.js'; -import fetchStories from './playwright/fetch-stories.js'; - -const stories = await fetchStories('Form Controls Layout'); - -for (const story of stories) { - test.describe(story.id, () => { - for (const theme of story.themes) { - test.describe(theme, () => { - test('split="left"', async ({ page }, test) => { - await page.goto(`?id=${story.id}&globals=theme:${theme}`); - - await page - .locator('glide-core-form-controls-layout') - .evaluate((element) => { - element.split = 'left'; - }); - - await expect(page).toHaveScreenshot( - `${test.titlePath.join('.')}.png`, - ); - }); - - test('split="middle"', async ({ page }, test) => { - await page.goto(`?id=${story.id}&globals=theme:${theme}`); - - await page - .locator('glide-core-form-controls-layout') - .evaluate((element) => { - element.split = 'middle'; - }); - - await expect(page).toHaveScreenshot( - `${test.titlePath.join('.')}.png`, - ); - }); - - test('split="right"', async ({ page }, test) => { - await page.goto(`?id=${story.id}&globals=theme:${theme}`); - - await page - .locator('glide-core-form-controls-layout') - .evaluate((element) => { - element.split = 'right'; - }); - - await expect(page).toHaveScreenshot( - `${test.titlePath.join('.')}.png`, - ); - }); - }); - } - }); -} diff --git a/src/form-controls-layout.ts b/src/form-controls-layout.ts deleted file mode 100644 index 6302afea7..000000000 --- a/src/form-controls-layout.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { html, LitElement } from 'lit'; -import { createRef, ref } from 'lit/directives/ref.js'; -import { customElement, property } from 'lit/decorators.js'; -import packageJson from '../package.json' with { type: 'json' }; -import Checkbox from './checkbox.js'; -import CheckboxGroup from './checkbox-group.js'; -import Dropdown from './dropdown.js'; -import Input from './input.js'; -import RadioGroup from './radio-group.js'; -import Slider from './slider.js'; -import TextArea from './textarea.js'; -import styles from './form-controls-layout.styles.js'; -import assertSlot from './library/assert-slot.js'; -import final from './library/final.js'; - -declare global { - interface HTMLElementTagNameMap { - 'glide-core-form-controls-layout': FormControlsLayout; - } -} - -/** - * @attr {'left'|'middle'|'right'} [split='left'] - * - * @readonly - * @attr {string} [version] - * - * @slot {Checkbox | CheckboxGroup | Dropdown | Input | RadioGroup | Slider | TextArea} - */ -@customElement('glide-core-form-controls-layout') -@final -export default class FormControlsLayout extends LitElement { - /* v8 ignore start */ - static override shadowRootOptions: ShadowRootInit = { - ...LitElement.shadowRootOptions, - mode: window.navigator.webdriver ? 'open' : 'closed', - }; - /* v8 ignore stop */ - - static override styles = styles; - - /** - * @default 'left' - */ - @property({ reflect: true }) - get split(): 'left' | 'middle' | 'right' { - return this.#split; - } - - set split(split: 'left' | 'middle' | 'right') { - this.#split = split; - - if (this.#slotElementRef.value) { - for (const element of this.#slotElementRef.value.assignedElements()) { - if ('privateSplit' in element) { - element.privateSplit = this.split; - } - } - } - } - - @property({ reflect: true }) - readonly version: string = packageJson.version; - - override render() { - return html`
- - - -
`; - } - - #slotElementRef = createRef(); - - #split: 'left' | 'middle' | 'right' = 'left'; - - #onSlotChange() { - if (this.#slotElementRef.value) { - for (const element of this.#slotElementRef.value.assignedElements()) { - if ('privateSplit' in element) { - element.privateSplit = this.split; - } - - if ('orientation' in element && element.orientation !== 'horizontal') { - throw new TypeError('Only horizontal controls are supported.'); - } - } - } - } -} diff --git a/src/input.stories.ts b/src/input.stories.ts index 3e00ee2c2..06ffaca76 100644 --- a/src/input.stories.ts +++ b/src/input.stories.ts @@ -56,6 +56,7 @@ const meta: Meta = { 'slot="suffix-icon"': '', tooltip: '', spellcheck: 'false', + split: '', type: 'text', value: '', version: '', @@ -119,6 +120,7 @@ const meta: Meta = { spellcheck=${arguments_.spellcheck === 'false' ? nothing : arguments_.spellcheck} + split=${arguments_.split || nothing} tooltip=${arguments_.tooltip || nothing} type=${arguments_.type === 'text' ? nothing : arguments_.type} value=${arguments_.value || nothing} @@ -324,6 +326,24 @@ const meta: Meta = { type: { summary: 'Element' }, }, }, + split: { + control: 'select', + options: ['', 'left', 'middle', 'right'], + table: { + type: { + summary: '"left" | "middle" | "right"', + detail: ` +// The split between the label and input field: +// +// - "left": 1/3 of the available space for the label. 2/3 for the input field. +// - "middle": 1/2 of the available space the label. 1/2 for the input field. +// - "right": 2/3 of the available space the label. 1/3 for the input field. +// +// Unsupported with \`orientation="vertical"\`. +`, + }, + }, + }, tooltip: { table: { type: { summary: 'string' }, @@ -405,6 +425,7 @@ export const WithIcons: StoryObj = { spellcheck=${arguments_.spellcheck === 'false' ? nothing : arguments_.spellcheck} + split=${arguments_.split || nothing} tooltip=${arguments_.tooltip || nothing} type=${arguments_.type === 'text' ? nothing : arguments_.type} value=${arguments_.value || nothing} diff --git a/src/input.test.miscellaneous.ts b/src/input.test.miscellaneous.ts index d16151118..d03e96481 100644 --- a/src/input.test.miscellaneous.ts +++ b/src/input.test.miscellaneous.ts @@ -112,3 +112,20 @@ test( ).rejects.toThrow(); }, ); + +test( + 'throws when split and vertical', + { tag: '@miscellaneous' }, + async ({ mount }) => { + await expect( + mount( + () => + html``, + ), + ).rejects.toThrow(); + }, +); diff --git a/src/input.test.visuals.ts b/src/input.test.visuals.ts index 6724400b5..90c9daff7 100644 --- a/src/input.test.visuals.ts +++ b/src/input.test.visuals.ts @@ -295,6 +295,48 @@ for (const story of stories) { ); }); + test('split="left"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-input') + .evaluate((element) => { + element.split = 'left'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + + test('split="middle"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-input') + .evaluate((element) => { + element.split = 'middle'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + + test('split="right"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-input') + .evaluate((element) => { + element.split = 'right'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + test('type="color"', async ({ page }, test) => { await page.goto(`?id=${story.id}&globals=theme:${theme}`); diff --git a/src/input.ts b/src/input.ts index 514c59a04..8ca08eb3d 100644 --- a/src/input.ts +++ b/src/input.ts @@ -42,6 +42,7 @@ declare global { * @attr {boolean} [readonly=false] * @attr {boolean} [required=false] * @attr {boolean} [spellcheck=false] + * @attr {'left'|'middle'|'right'} [split] * @attr {string} [tooltip] * @attr {'color'|'date'|'email'|'number'|'password'|'search'|'tel'|'text'|'time'|'url'} [type='text'] * @attr {string} [value=''] @@ -155,10 +156,6 @@ export default class Input extends LitElement implements FormControl { @property({ reflect: true, useDefault: true }) pattern: string | undefined = ''; - // Private because it's only meant to be used by Form Controls Layout. - @property() - privateSplit?: 'left' | 'middle' | 'right'; - @property({ reflect: true, type: Boolean }) readonly = false; @@ -169,6 +166,22 @@ export default class Input extends LitElement implements FormControl { @property({ reflect: true, type: Boolean, useDefault: true }) override spellcheck = false; + /** + * @default undefined + */ + @property() + get split(): 'left' | 'middle' | 'right' | undefined { + return this.#split; + } + + set split(split: 'left' | 'middle' | 'right' | undefined) { + if (this.orientation === 'vertical') { + throw new Error('`split` is unsupported with `orientation="vertical"`.'); + } + + this.#split = split; + } + @property({ reflect: true }) tooltip?: string; @@ -288,12 +301,12 @@ export default class Input extends LitElement implements FormControl { return html`
`, ), diff --git a/src/radio-group.test.visuals.ts b/src/radio-group.test.visuals.ts index 83d352d14..126456bf7 100644 --- a/src/radio-group.test.visuals.ts +++ b/src/radio-group.test.visuals.ts @@ -74,6 +74,48 @@ for (const story of stories) { ); }); + test('split="left"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-radio-group') + .evaluate((element) => { + element.split = 'left'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + + test('split="middle"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-radio-group') + .evaluate((element) => { + element.split = 'middle'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + + test('split="right"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-radio-group') + .evaluate((element) => { + element.split = 'right'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + test('tooltip', async ({ page }, test) => { await page.goto(`?id=${story.id}&globals=theme:${theme}`); diff --git a/src/radio-group.ts b/src/radio-group.ts index 87d45f92f..6880e946e 100644 --- a/src/radio-group.ts +++ b/src/radio-group.ts @@ -28,6 +28,7 @@ declare global { * @attr {string} [name=''] * @attr {'horizontal'} [orientation='horizontal'] * @attr {boolean} [required=false] + * @attr {'left'|'middle'|'right'} [split] * @attr {string} [tooltip] * @attr {string} [value=''] * @@ -124,13 +125,6 @@ export default class RadioGroup extends LitElement implements FormControl { @property({ reflect: true }) orientation = 'horizontal' as const; - // Private because it's only meant to be used by Form Controls Layout. - @property() - privateSplit?: 'left' | 'middle' | 'right'; - - @property({ reflect: true }) - tooltip?: string; - /** * @default false */ @@ -153,6 +147,12 @@ export default class RadioGroup extends LitElement implements FormControl { this.#setValidity(); } + @property() + split?: 'left' | 'middle' | 'right' | undefined; + + @property({ reflect: true }) + tooltip?: string; + // Intentionally not reflected to match native. /** * @default '' @@ -323,7 +323,7 @@ export default class RadioGroup extends LitElement implements FormControl { { expect(spy.callCount).to.equal(1); }); + +it('throws when split and vertical', async () => { + await expectWindowError(() => { + return fixture( + html``, + ); + }); +}); diff --git a/src/slider.test.visuals.ts b/src/slider.test.visuals.ts index cd0d02fe2..1e6160d3f 100644 --- a/src/slider.test.visuals.ts +++ b/src/slider.test.visuals.ts @@ -209,6 +209,48 @@ for (const story of stories) { ); }); + test('split="left"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-slider') + .evaluate((element) => { + element.split = 'left'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + + test('split="middle"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-slider') + .evaluate((element) => { + element.split = 'middle'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + + test('split="right"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-slider') + .evaluate((element) => { + element.split = 'right'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + test('slot="description"', async ({ page }, test) => { await page.goto(`?id=${story.id}&globals=theme:${theme}`); diff --git a/src/slider.ts b/src/slider.ts index e8432598a..6615fcd10 100644 --- a/src/slider.ts +++ b/src/slider.ts @@ -30,6 +30,7 @@ declare global { * @attr {'horizontal'|'vertical'} [orientation='horizontal'] * @attr {boolean} [readonly=false] * @attr {boolean} [required=false] + * @attr {'left'|'middle'|'right'} [split] * @attr {number} [step=1] * @attr {string} [tooltip] * @attr {number[]} [value=[]] @@ -82,139 +83,16 @@ export default class Slider extends LitElement implements FormControl { static override styles = styles; - @property({ reflect: true, useDefault: true }) - name = ''; - - // Intentionally not reflected to match native. - /** - * @default [] - */ - @property({ type: Array }) - get value(): number[] { - if ( - this.multiple && - this.minimumValue !== undefined && - this.maximumValue !== undefined - ) { - return [this.minimumValue, this.maximumValue]; - } - - if (this.minimumValue !== undefined) { - return [this.minimumValue]; - } - - return []; - } - - set value(value: number[]) { - if (value.length === 0) { - const rangeSize = this.max - this.min; - - // To match native, when the value is emptied, we create one. - // Native sets it to 50% of the max, but we have a design - // requirement for a 25/75% split of the range size. - this.minimumValue = this.min + Math.floor(rangeSize * 0.25); - - this.maximumValue = this.multiple - ? this.min + Math.ceil(rangeSize * 0.75) - : undefined; - - this.#updateHandlesAndTrack(); - - return; - } - - if ( - this.multiple && - value.length === 2 && - value[0] !== undefined && - value[1] !== undefined - ) { - if (value[0] > value[1]) { - throw new Error('The first value must be less than the second.'); - } - - // Normalize values to snap to the closest valid step - // increment, even if a developer sets a value between - // steps. - // - // Doing so creates consistent behavior between programmatic - // updates and user interactions. Users can select values that - // align with steps when dragging the handles or when entering - // values into the inputs. This ensures programmatic updates - // follow the same rules. - // - // It also prevents the Slider from ending up in a state where - // the position visually doesn't match where a user would - // expect based on the step configuration. - const normalizedMinimum = Math.round(value[0] / this.step) * this.step; - const normalizedMaximum = Math.round(value[1] / this.step) * this.step; - - // Clamp the normalized values to the allowed ranges. - this.minimumValue = Math.max(normalizedMinimum, this.min); - this.maximumValue = Math.min(normalizedMaximum, this.max); - - this.#updateHandlesAndTrack(); - return; - } - - if (this.multiple && value.length > 2) { - throw new Error('Only two values are allowed when `multiple`.'); - } - - if (!this.multiple && value.length > 0 && value[0] !== undefined) { - // Normalize the value to snap to the closest valid step - // increment, even if a developer sets a value between steps. - // - // Doing so creates consistent behavior between programmatic - // updates and user interactions. Users can select values that - // align with step when dragging the handle or when entering - // values into the input. This ensures programmatic updates - // follow the same rules. - // - // It also prevents the Slider from ending up in a state where - // the position visually doesn't match where a user would - // expect based on the step configuration. - const normalizedValue = Math.round(value[0] / this.step) * this.step; - - // Clamp the normalized value to the allowed range. - this.minimumValue = Math.max( - Math.min(normalizedValue, this.max), - this.min, - ); - - // When not in multiple mode, we clear the maximumValue to - // ensure the Slider won't get in an odd state as the - // minimumValue is adjusted. If a consumers add the multiple - // attribute, we recalculate what the maximumValue should be - // based on the current position of the minimum handle with - // respect to max and step. - this.maximumValue = undefined; - - this.#updateHandlesAndTrack(); - return; - } - } - @property({ reflect: true }) @required label?: string; - @property({ reflect: true, useDefault: true }) - orientation: 'horizontal' | 'vertical' = 'horizontal'; + @property({ reflect: true, type: Boolean }) + disabled = false; @property({ attribute: 'hide-label', type: Boolean }) hideLabel = false; - @property({ reflect: true, type: Boolean }) - required = false; - - @property({ type: Boolean }) - readonly = false; - - @property({ reflect: true, type: Boolean }) - disabled = false; - /** * @default 100 */ @@ -341,6 +219,34 @@ export default class Slider extends LitElement implements FormControl { } } + @property({ reflect: true, useDefault: true }) + name = ''; + + @property({ reflect: true, useDefault: true }) + orientation: 'horizontal' | 'vertical' = 'horizontal'; + + @property({ type: Boolean }) + readonly = false; + + @property({ reflect: true, type: Boolean }) + required = false; + + /** + * @default undefined + */ + @property() + get split(): 'left' | 'middle' | 'right' | undefined { + return this.#split; + } + + set split(split: 'left' | 'middle' | 'right' | undefined) { + if (this.orientation === 'vertical') { + throw new Error('`split` is unsupported with `orientation="vertical"`.'); + } + + this.#split = split; + } + /** * @default 1 */ @@ -353,15 +259,122 @@ export default class Slider extends LitElement implements FormControl { this.#step = step > 0 ? step : 1; } - // Private because it's only meant to be used by Form Controls Layout. - @property() - privateSplit?: 'left' | 'middle'; - @property({ reflect: true }) - readonly version: string = packageJson.version; + tooltip?: string; + + // Intentionally not reflected to match native. + /** + * @default [] + */ + @property({ type: Array }) + get value(): number[] { + if ( + this.multiple && + this.minimumValue !== undefined && + this.maximumValue !== undefined + ) { + return [this.minimumValue, this.maximumValue]; + } + + if (this.minimumValue !== undefined) { + return [this.minimumValue]; + } + + return []; + } + + set value(value: number[]) { + if (value.length === 0) { + const rangeSize = this.max - this.min; + + // To match native, when the value is emptied, we create one. + // Native sets it to 50% of the max, but we have a design + // requirement for a 25/75% split of the range size. + this.minimumValue = this.min + Math.floor(rangeSize * 0.25); + + this.maximumValue = this.multiple + ? this.min + Math.ceil(rangeSize * 0.75) + : undefined; + + this.#updateHandlesAndTrack(); + + return; + } + + if ( + this.multiple && + value.length === 2 && + value[0] !== undefined && + value[1] !== undefined + ) { + if (value[0] > value[1]) { + throw new Error('The first value must be less than the second.'); + } + + // Normalize values to snap to the closest valid step + // increment, even if a developer sets a value between + // steps. + // + // Doing so creates consistent behavior between programmatic + // updates and user interactions. Users can select values that + // align with steps when dragging the handles or when entering + // values into the inputs. This ensures programmatic updates + // follow the same rules. + // + // It also prevents the Slider from ending up in a state where + // the position visually doesn't match where a user would + // expect based on the step configuration. + const normalizedMinimum = Math.round(value[0] / this.step) * this.step; + const normalizedMaximum = Math.round(value[1] / this.step) * this.step; + + // Clamp the normalized values to the allowed ranges. + this.minimumValue = Math.max(normalizedMinimum, this.min); + this.maximumValue = Math.min(normalizedMaximum, this.max); + + this.#updateHandlesAndTrack(); + return; + } + + if (this.multiple && value.length > 2) { + throw new Error('Only two values are allowed when `multiple`.'); + } + + if (!this.multiple && value.length > 0 && value[0] !== undefined) { + // Normalize the value to snap to the closest valid step + // increment, even if a developer sets a value between steps. + // + // Doing so creates consistent behavior between programmatic + // updates and user interactions. Users can select values that + // align with step when dragging the handle or when entering + // values into the input. This ensures programmatic updates + // follow the same rules. + // + // It also prevents the Slider from ending up in a state where + // the position visually doesn't match where a user would + // expect based on the step configuration. + const normalizedValue = Math.round(value[0] / this.step) * this.step; + + // Clamp the normalized value to the allowed range. + this.minimumValue = Math.max( + Math.min(normalizedValue, this.max), + this.min, + ); + + // When not in multiple mode, we clear the maximumValue to + // ensure the Slider won't get in an odd state as the + // minimumValue is adjusted. If a consumers add the multiple + // attribute, we recalculate what the maximumValue should be + // based on the current position of the minimum handle with + // respect to max and step. + this.maximumValue = undefined; + + this.#updateHandlesAndTrack(); + return; + } + } @property({ reflect: true }) - tooltip?: string; + readonly version: string = packageJson.version; get form(): HTMLFormElement | null { return this.#internals.form; @@ -458,12 +471,12 @@ export default class Slider extends LitElement implements FormControl { return html` (); + #split?: 'left' | 'middle' | 'right'; + #step = 1; get #isShowValidationFeedback() { diff --git a/src/textarea.stories.ts b/src/textarea.stories.ts index 9e1be5367..e9069809f 100644 --- a/src/textarea.stories.ts +++ b/src/textarea.stories.ts @@ -89,6 +89,7 @@ const meta: Meta = { 'setValidity(flags, message)': '', 'slot="description"': '', spellcheck: 'false', + split: '', tooltip: '', value: '', version: '', @@ -248,6 +249,24 @@ const meta: Meta = { type: { summary: '"true" | "false"' }, }, }, + split: { + control: 'select', + options: ['', 'left', 'middle', 'right'], + table: { + type: { + summary: '"left" | "middle" | "right"', + detail: ` +// The split between the label and text area: +// +// - "left": 1/3 of the available space for the label. 2/3 for the text area. +// - "middle": 1/2 of the available space the label. 1/2 for the text area. +// - "right": 2/3 of the available space the label. 1/3 for the text area. +// +// Unsupported with \`orientation="vertical"\`. +`, + }, + }, + }, tooltip: { table: { type: { summary: 'string' }, @@ -289,6 +308,7 @@ const meta: Meta = { spellcheck=${arguments_.spellcheck === 'false' ? nothing : arguments_.spellcheck} + split=${arguments_.split || nothing} tooltip=${arguments_.tooltip || nothing} value=${arguments_.value || nothing} ?disabled=${arguments_.disabled} diff --git a/src/textarea.test.basics.ts b/src/textarea.test.basics.ts index e1cece1bf..d9203c205 100644 --- a/src/textarea.test.basics.ts +++ b/src/textarea.test.basics.ts @@ -2,6 +2,7 @@ import { expect, fixture, html } from '@open-wc/testing'; import { customElement } from 'lit/decorators.js'; import sinon from 'sinon'; import Textarea from './textarea.js'; +import expectWindowError from './library/expect-window-error.js'; @customElement('glide-core-subclassed') class Subclassed extends Textarea {} @@ -90,3 +91,15 @@ it('throws when subclassed', async () => { expect(spy.callCount).to.equal(1); }); + +it('throws when split and vertical', async () => { + await expectWindowError(() => { + return fixture( + html``, + ); + }); +}); diff --git a/src/textarea.test.visuals.ts b/src/textarea.test.visuals.ts index bdb36d41b..b17b67936 100644 --- a/src/textarea.test.visuals.ts +++ b/src/textarea.test.visuals.ts @@ -283,6 +283,48 @@ for (const story of stories) { ); }); + test('split="left"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-textarea') + .evaluate((element) => { + element.split = 'left'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + + test('split="middle"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-textarea') + .evaluate((element) => { + element.split = 'middle'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + + test('split="right"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-textarea') + .evaluate((element) => { + element.split = 'right'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + test('value', async ({ page }, test) => { await page.goto(`?id=${story.id}&globals=theme:${theme}`); diff --git a/src/textarea.ts b/src/textarea.ts index 0b2d716b0..ab65d112e 100644 --- a/src/textarea.ts +++ b/src/textarea.ts @@ -32,6 +32,7 @@ declare global { * @attr {boolean} [readonly=false] * @attr {boolean} [required=false] * @attr {boolean} [spellcheck=false] + * @attr {'left'|'middle'|'right'} [split] * @attr {string} [tooltip] * @attr {string} [value=''] * @@ -123,10 +124,6 @@ export default class Textarea extends LitElement implements FormControl { @property({ reflect: true }) placeholder?: string; - // Private because it's only meant to be used by Form Controls Layout. - @property() - privateSplit?: 'left' | 'middle' | 'right'; - // It's typed by TypeScript as a boolean. But we treat it as a string throughout. @property({ reflect: true, type: Boolean, useDefault: true }) override spellcheck = false; @@ -137,6 +134,22 @@ export default class Textarea extends LitElement implements FormControl { @property({ reflect: true, type: Boolean }) readonly = false; + /** + * @default undefined + */ + @property() + get split(): 'left' | 'middle' | 'right' | undefined { + return this.#split; + } + + set split(split: 'left' | 'middle' | 'right' | undefined) { + if (this.orientation === 'vertical') { + throw new Error('`split` is unsupported with `orientation="vertical"`.'); + } + + this.#split = split; + } + @property({ reflect: true }) tooltip?: string; @@ -206,7 +219,7 @@ export default class Textarea extends LitElement implements FormControl { override render() { return html`(); // An arrow function field instead of a method so `this` is closed over and diff --git a/src/toggle.stories.ts b/src/toggle.stories.ts index e97fb528f..e26f1a3b0 100644 --- a/src/toggle.stories.ts +++ b/src/toggle.stories.ts @@ -35,6 +35,7 @@ const meta: Meta = { 'hide-label': false, orientation: 'horizontal', 'slot="description"': '', + split: '', summary: '', tooltip: '', version: '', @@ -88,6 +89,24 @@ const meta: Meta = { type: { summary: 'string' }, }, }, + split: { + control: 'select', + options: ['', 'left', 'middle', 'right'], + table: { + type: { + summary: '"left" | "middle" | "right"', + detail: ` +// The split between the label and toggle: +// +// - "left": 1/3 of the available space for the label. 2/3 for the toggle. +// - "middle": 1/2 of the available space the label. 1/2 for the toggle. +// - "right": 2/3 of the available space the label. 1/3 for the toggle. +// +// Unsupported with \`orientation="vertical"\`. +`, + }, + }, + }, summary: { table: { type: { summary: 'string' }, @@ -125,6 +144,7 @@ const meta: Meta = { orientation=${arguments_.orientation === 'horizontal' ? nothing : arguments_.orientation} + split=${arguments_.split || nothing} summary=${arguments_.summary || nothing} tooltip=${arguments_.tooltip || nothing} ?checked=${arguments_.checked} diff --git a/src/toggle.test.miscellaneous.ts b/src/toggle.test.miscellaneous.ts index 08e53e91d..4d7064750 100644 --- a/src/toggle.test.miscellaneous.ts +++ b/src/toggle.test.miscellaneous.ts @@ -51,3 +51,20 @@ test( ).rejects.toThrow(); }, ); + +test( + 'throws when split and vertical', + { tag: '@miscellaneous' }, + async ({ mount }) => { + await expect( + mount( + () => + html``, + ), + ).rejects.toThrow(); + }, +); diff --git a/src/toggle.test.visuals.ts b/src/toggle.test.visuals.ts index e9bd37f79..bc113647d 100644 --- a/src/toggle.test.visuals.ts +++ b/src/toggle.test.visuals.ts @@ -193,6 +193,48 @@ for (const story of stories) { ); }); + test('split="left"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-toggle') + .evaluate((element) => { + element.split = 'left'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + + test('split="middle"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-toggle') + .evaluate((element) => { + element.split = 'middle'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + + test('split="right"', async ({ page }, test) => { + await page.goto(`?id=${story.id}&globals=theme:${theme}`); + + await page + .locator('glide-core-toggle') + .evaluate((element) => { + element.split = 'right'; + }); + + await expect(page).toHaveScreenshot( + `${test.titlePath.join('.')}.png`, + ); + }); + test('summary', async ({ page }, test) => { await page.goto(`?id=${story.id}&globals=theme:${theme}`); diff --git a/src/toggle.ts b/src/toggle.ts index 9a98f4e67..db94c8b0d 100644 --- a/src/toggle.ts +++ b/src/toggle.ts @@ -21,6 +21,7 @@ declare global { * @attr {boolean} [disabled=false] * @attr {boolean} [hide-label=false] * @attr {'horizontal'|'vertical'} [orientation='horizontal'] + * @attr {'left'|'middle'|'right'} [split] * @attr {string} [summary] * @attr {string} [tooltip] * @@ -60,9 +61,21 @@ export default class Toggle extends LitElement { @property({ reflect: true, useDefault: true }) orientation: 'horizontal' | 'vertical' = 'horizontal'; - // Private because it's only meant to be used by Form Controls Layout. + /** + * @default undefined + */ @property() - privateSplit?: 'left' | 'middle' | 'right'; + get split(): 'left' | 'middle' | 'right' | undefined { + return this.#split; + } + + set split(split: 'left' | 'middle' | 'right' | undefined) { + if (this.orientation === 'vertical') { + throw new Error('`split` is unsupported with `orientation="vertical"`.'); + } + + this.#split = split; + } @property({ reflect: true }) summary?: string; @@ -86,7 +99,7 @@ export default class Toggle extends LitElement { (); + #split?: 'left' | 'middle' | 'right'; + // Only "change" would need to be handled if not for some consumers needing // to force Toggle checked or unchecked until the user has completed some action. // diff --git a/web-test-runner.config.js b/web-test-runner.config.js index a8b1be9e7..991f446e5 100644 --- a/web-test-runner.config.js +++ b/web-test-runner.config.js @@ -32,7 +32,6 @@ export default { 'src/checkbox.ts', 'src/checkbox-group.ts', 'src/drawer.ts', - 'src/form-controls-layout.ts', 'src/icon-button.ts', 'src/inline-alert.ts', 'src/input.ts', @@ -87,7 +86,6 @@ export default { '!src/checkbox.test.*.ts', '!src/checkbox-group.test.*.ts', '!src/drawer.test.*.ts', - '!src/form-controls-layout.test.*.ts', '!src/icon-button.test.*.ts', '!src/inline-alert.test.*.ts', '!src/input.test.*.ts', From 87233391570e02ea728ad7701dfd95a82d703088 Mon Sep 17 00:00:00 2001 From: clintcs <114178960+clintcs@users.noreply.github.com> Date: Fri, 10 Oct 2025 12:02:50 -0400 Subject: [PATCH 2/2] Only throw if `split` is defined --- src/checkbox.ts | 2 +- src/dropdown.ts | 2 +- src/input.ts | 2 +- src/slider.ts | 2 +- src/textarea.ts | 2 +- src/toggle.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/checkbox.ts b/src/checkbox.ts index 70b104fa8..9eae79f79 100644 --- a/src/checkbox.ts +++ b/src/checkbox.ts @@ -183,7 +183,7 @@ export default class Checkbox extends LitElement implements FormControl { } set split(split: 'left' | 'middle' | 'right' | undefined) { - if (this.orientation === 'vertical') { + if (split && this.orientation === 'vertical') { throw new Error('`split` is unsupported with `orientation="vertical"`.'); } diff --git a/src/dropdown.ts b/src/dropdown.ts index 4b5a6133e..ea906f99a 100644 --- a/src/dropdown.ts +++ b/src/dropdown.ts @@ -308,7 +308,7 @@ export default class Dropdown extends LitElement implements FormControl { } set split(split: 'left' | 'middle' | 'right' | undefined) { - if (this.orientation === 'vertical') { + if (split && this.orientation === 'vertical') { throw new Error('`split` is unsupported with `orientation="vertical"`.'); } diff --git a/src/input.ts b/src/input.ts index 8ca08eb3d..c6f0f401e 100644 --- a/src/input.ts +++ b/src/input.ts @@ -175,7 +175,7 @@ export default class Input extends LitElement implements FormControl { } set split(split: 'left' | 'middle' | 'right' | undefined) { - if (this.orientation === 'vertical') { + if (split && this.orientation === 'vertical') { throw new Error('`split` is unsupported with `orientation="vertical"`.'); } diff --git a/src/slider.ts b/src/slider.ts index 6615fcd10..0b38e4c91 100644 --- a/src/slider.ts +++ b/src/slider.ts @@ -240,7 +240,7 @@ export default class Slider extends LitElement implements FormControl { } set split(split: 'left' | 'middle' | 'right' | undefined) { - if (this.orientation === 'vertical') { + if (split && this.orientation === 'vertical') { throw new Error('`split` is unsupported with `orientation="vertical"`.'); } diff --git a/src/textarea.ts b/src/textarea.ts index ab65d112e..bd60a0b19 100644 --- a/src/textarea.ts +++ b/src/textarea.ts @@ -143,7 +143,7 @@ export default class Textarea extends LitElement implements FormControl { } set split(split: 'left' | 'middle' | 'right' | undefined) { - if (this.orientation === 'vertical') { + if (split && this.orientation === 'vertical') { throw new Error('`split` is unsupported with `orientation="vertical"`.'); } diff --git a/src/toggle.ts b/src/toggle.ts index db94c8b0d..6a01fdd94 100644 --- a/src/toggle.ts +++ b/src/toggle.ts @@ -70,7 +70,7 @@ export default class Toggle extends LitElement { } set split(split: 'left' | 'middle' | 'right' | undefined) { - if (this.orientation === 'vertical') { + if (split && this.orientation === 'vertical') { throw new Error('`split` is unsupported with `orientation="vertical"`.'); }