diff --git a/.changeset/late-hotels-approve.md b/.changeset/late-hotels-approve.md new file mode 100644 index 00000000000..4ecd0ac6f0a --- /dev/null +++ b/.changeset/late-hotels-approve.md @@ -0,0 +1,8 @@ +--- +"@siemens/ix": patch +--- + +__ix-radio__: Now doesn't change height/layout anymore when clicked. This is achieved by changing the way one of the component's divs is rendered. +__ix-checkbox__: Now doesn't change height/layout anymore when clicked. This is achieved by changing the way one of the component's SVGs is rendered. + +Fixes #1702 diff --git a/packages/core/src/components/checkbox/checkbox.tsx b/packages/core/src/components/checkbox/checkbox.tsx index b1344c607c3..ec9c44552f6 100644 --- a/packages/core/src/components/checkbox/checkbox.tsx +++ b/packages/core/src/components/checkbox/checkbox.tsx @@ -18,6 +18,7 @@ import { h, Element, Method, + Fragment, } from '@stencil/core'; import { HookValidationLifecycle, IxFormComponent } from '../utils/input'; import { makeRef } from '../utils/make-ref'; @@ -134,44 +135,36 @@ export class Checkbox implements IxFormComponent { } private renderCheckmark() { - if (this.checked) { - return ( - + return ( + + {this.indeterminate && ( + + + + + )} + + {this.checked && ( - - ); - } - - if (this.indeterminate) { - return ( - - - - - ); - } + )} + + ); } render() { diff --git a/packages/core/src/components/checkbox/tests/checkbox.ct.ts b/packages/core/src/components/checkbox/tests/checkbox.ct.ts index 96d82c86187..b2180e01168 100644 --- a/packages/core/src/components/checkbox/tests/checkbox.ct.ts +++ b/packages/core/src/components/checkbox/tests/checkbox.ct.ts @@ -82,3 +82,35 @@ test('label', async ({ mount, page }) => { const checkboxElement = page.locator('ix-checkbox').locator('label'); await expect(checkboxElement).toHaveText('some label'); }); + +test('Checkbox should not cause layout shift when checked', async ({ + mount, + page, +}) => { + await mount(` + +
This element should not move
+ `); + + await page.waitForSelector('ix-checkbox', { state: 'attached' }); + + const initialBounds = await page.$eval('#element-below', (el) => { + const rect = el.getBoundingClientRect(); + return { top: rect.top, left: rect.left }; + }); + + await page.click('ix-checkbox'); + + await page.waitForFunction(() => { + const checkbox = document.querySelector('ix-checkbox'); + return checkbox?.getAttribute('aria-checked') === 'true'; + }); + + const newBounds = await page.$eval('#element-below', (el) => { + const rect = el.getBoundingClientRect(); + return { top: rect.top, left: rect.left }; + }); + + expect(newBounds.top).toBeCloseTo(initialBounds.top, 0); + expect(newBounds.left).toBeCloseTo(initialBounds.left, 0); +}); diff --git a/packages/core/src/components/radio-group/test/radio-group.ct.ts b/packages/core/src/components/radio-group/test/radio-group.ct.ts index e5dbbffcf25..1d56d155ef9 100644 --- a/packages/core/src/components/radio-group/test/radio-group.ct.ts +++ b/packages/core/src/components/radio-group/test/radio-group.ct.ts @@ -48,7 +48,7 @@ test('initial checked', async ({ mount, page }) => { await expect(radioOption2).toHaveClass(/hydrated/); await expect(radioOption3).toHaveClass(/hydrated/); - await expect(radioOption2.locator('.checkmark')).toBeAttached(); + await expect(radioOption2.locator('.checkmark')).toBeVisible(); }); test('change checked', async ({ mount, page }) => { @@ -68,14 +68,14 @@ test('change checked', async ({ mount, page }) => { await expect(radioGroupElement).toHaveClass(/hydrated/); await expect(radioOption1).toHaveClass(/hydrated/); await expect(radioOption2).toHaveClass(/hydrated/); - await expect(radioOption2.locator('.checkmark')).toBeAttached(); + await expect(radioOption2.locator('.checkmark')).toBeVisible(); await expect(radioOption3).toHaveClass(/hydrated/); await radioOption3.click(); await expect(radioOption2).not.toHaveAttribute('checked'); - await expect(radioOption2.locator('.checkmark')).not.toBeAttached(); - await expect(radioOption3.locator('.checkmark')).toBeAttached(); + await expect(radioOption2.locator('.checkmark')).not.toBeVisible(); + await expect(radioOption3.locator('.checkmark')).toBeVisible(); await expect(radioOption3).toHaveAttribute('checked'); }); @@ -119,5 +119,5 @@ test('disabled', async ({ mount, page }) => { ); const radioOption3 = page.locator('ix-radio').nth(2); await expect(radioOption3).not.toBeEnabled(); - await expect(radioOption3.locator('.checkmark')).not.toBeAttached(); + await expect(radioOption3.locator('.checkmark')).not.toBeVisible(); }); diff --git a/packages/core/src/components/radio/radio.tsx b/packages/core/src/components/radio/radio.tsx index b52aaaeaa7f..fbe07550651 100644 --- a/packages/core/src/components/radio/radio.tsx +++ b/packages/core/src/components/radio/radio.tsx @@ -162,7 +162,10 @@ export class Radio implements IxFormComponent { }} onClick={() => this.setCheckedState(!this.checked)} > - {this.checked &&
} +
{ const disableLabelColor = 'rgba(245, 252, 255, 0.93)'; await expect(label).toHaveCSS('color', disableLabelColor); }); + +test('Radio button should not cause layout shift when checked', async ({ + mount, + page, +}) => { + await mount(` + +
This element should not move
+ `); + + await page.waitForSelector('ix-radio', { state: 'attached' }); + + const initialBounds = await page.$eval('#element-below', (el) => { + const rect = el.getBoundingClientRect(); + return { top: rect.top, left: rect.left }; + }); + + await page.click('ix-radio'); + + await page.waitForFunction(() => { + const radio = document.querySelector('ix-radio'); + return radio?.getAttribute('aria-checked') === 'true'; + }); + + const newBounds = await page.$eval('#element-below', (el) => { + const rect = el.getBoundingClientRect(); + return { top: rect.top, left: rect.left }; + }); + + expect(newBounds.top).toBeCloseTo(initialBounds.top, 0); + expect(newBounds.left).toBeCloseTo(initialBounds.left, 0); +}); diff --git a/packages/storybook-docs/src/stories/checkbox.stories.ts b/packages/storybook-docs/src/stories/checkbox.stories.ts new file mode 100644 index 00000000000..a1d2ac2d5eb --- /dev/null +++ b/packages/storybook-docs/src/stories/checkbox.stories.ts @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: 2024 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import type { ArgTypes, Meta, StoryObj } from '@storybook/web-components'; +import type { Components } from '@siemens/ix/components'; +import { genericRender, makeArgTypes } from './utils/generic-render'; +import { action } from '@storybook/addon-actions'; + +type Element = Components.IxCheckbox & { + defaultSlot: string; + ['checked-change']: any; + validation: string; + 'text-on': string; +}; + +type GroupElement = Components.IxCheckboxGroup & { + label: string; + defaultSlot: string; +}; + +const CheckboxRender = (args: Element) => { + const container = genericRender('ix-checkbox', args); + const ixCheckbox = container.querySelector( + 'ix-checkbox' + ) as HTMLIxCheckboxElement; + ixCheckbox.addEventListener('checkedChange', action('checkedChange')); + return container; +}; + +const CheckboxGroupRender = (args: GroupElement) => { + const container = genericRender('ix-checkbox-group', args); + const checkboxGroup = container.querySelector( + 'ix-checkbox-group' + ) as HTMLIxCheckboxGroupElement; + checkboxGroup.setAttribute('label', args.label || 'Group'); + + const checkbox1 = document.createElement('ix-checkbox'); + checkbox1.setAttribute('label', 'Checkbox 1'); + checkbox1.setAttribute('name', 'checkbox1'); + checkbox1.addEventListener('checkedChange', action('checkbox1Change')); + + const checkbox2 = document.createElement('ix-checkbox'); + checkbox2.setAttribute('label', 'Checkbox 2'); + checkbox2.setAttribute('name', 'checkbox2'); + checkbox2.addEventListener('checkedChange', action('checkbox2Change')); + + checkboxGroup.appendChild(checkbox1); + checkboxGroup.appendChild(checkbox2); + container.appendChild(checkboxGroup); + + return container; +}; + +const meta = { + title: 'Example/Checkbox', + tags: [], + render: (args) => CheckboxRender(args), + argTypes: makeArgTypes>>('ix-checkbox', { + validation: { + control: { type: 'select' }, + }, + }), + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/r2nqdNNXXZtPmWuVjIlM1Q/iX-Components?node-id=42365-150769&p=f&t=eGUQESg89t8bPyiB-0', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; +type GroupStory = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args +export const Default: Story = { + args: { + disabled: false, + label: 'Checkbox', + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + label: 'Checkbox', + }, +}; + +export const Group: GroupStory = { + render: (args) => CheckboxGroupRender(args), + args: { + label: 'Checkbox Group', + }, +}; diff --git a/packages/storybook-docs/src/stories/radio.stories.ts b/packages/storybook-docs/src/stories/radio.stories.ts new file mode 100644 index 00000000000..72c895f60d4 --- /dev/null +++ b/packages/storybook-docs/src/stories/radio.stories.ts @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: 2024 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import type { ArgTypes, Meta, StoryObj } from '@storybook/web-components'; +import type { Components } from '@siemens/ix/components'; +import { genericRender, makeArgTypes } from './utils/generic-render'; +import { action } from '@storybook/addon-actions'; + +type Element = Components.IxRadio & { + defaultSlot: string; + ['checked-change']: any; + validation: string; + 'text-on': string; +}; + +type GroupElement = Components.IxRadioGroup & { + label: string; + defaultSlot: string; +}; + +const radioRender = (args: Element) => { + const container = genericRender('ix-radio', args); + const ixradio = container.querySelector('ix-radio') as HTMLIxRadioElement; + ixradio.addEventListener('checkedChange', action('checkedChange')); + return container; +}; + +const radioGroupRender = (args: GroupElement) => { + const container = genericRender('ix-radio-group', args); + const radioGroup = container.querySelector( + 'ix-radio-group' + ) as HTMLIxRadioGroupElement; + radioGroup.setAttribute('label', args.label || 'Group'); + + const radio1 = document.createElement('ix-radio'); + radio1.setAttribute('label', 'Radio 1'); + radio1.setAttribute('name', 'radio1'); + radio1.addEventListener('checkedChange', action('radio1Change')); + + const radio2 = document.createElement('ix-radio'); + radio2.setAttribute('label', 'Radio 2'); + radio2.setAttribute('name', 'radio2'); + radio2.addEventListener('checkedChange', action('radio2Change')); + + radioGroup.appendChild(radio1); + radioGroup.appendChild(radio2); + container.appendChild(radioGroup); + + return container; +}; + +const meta = { + title: 'Example/Radio', + tags: [], + render: (args) => radioRender(args), + argTypes: makeArgTypes>>('ix-radio', { + validation: { + control: { type: 'select' }, + }, + }), + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/r2nqdNNXXZtPmWuVjIlM1Q/iX-Components?node-id=42365-150769&p=f&t=eGUQESg89t8bPyiB-0', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; +type GroupStory = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args +export const Default: Story = { + args: { + disabled: false, + label: 'Radio', + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + label: 'Radio', + }, +}; + +export const Group: GroupStory = { + render: (args) => radioGroupRender(args), + args: { + label: 'Radio Group', + }, +};