diff --git a/libs/react-components/specs/checkbox.browser.spec.tsx b/libs/react-components/specs/checkbox.browser.spec.tsx index 72d7e3fc0..53f1d0c6d 100644 --- a/libs/react-components/specs/checkbox.browser.spec.tsx +++ b/libs/react-components/specs/checkbox.browser.spec.tsx @@ -84,4 +84,64 @@ describe("Checkbox", () => { expect(childValue.element().textContent).toBe("false"); }); }); + + it("should have a 44px x 44px touch target area", async () => { + const result = render( + + ); + + const checkbox = result.getByTestId("test-checkbox"); + await vi.waitFor(() => { + expect(checkbox.element()).toBeTruthy(); + }); + + const container = checkbox.element().querySelector(".container") as HTMLElement; + expect(container).toBeTruthy(); + + // Get computed styles for the ::before pseudo-element (touch target) + const beforeStyles = window.getComputedStyle(container, "::before"); + + // Verify the touch target dimensions + expect(beforeStyles.width).toBe("44px"); + expect(beforeStyles.height).toBe("44px"); + expect(beforeStyles.position).toBe("absolute"); + + // Verify the container itself has position: relative for proper positioning context + const containerStyles = window.getComputedStyle(container); + expect(containerStyles.position).toBe("relative"); + + // Verify the actual visual size of the container (24px) vs touch target (44px) + const containerRect = container.getBoundingClientRect(); + expect(containerRect.width).toBe(24); // Visual checkbox is 24px + expect(containerRect.height).toBe(24); // Visual checkbox is 24px + + // Verify the transform is applied correctly for centering + // CSS: transform: translate(-50%, -50%) converts to matrix(a, b, c, d, tx, ty) + // Matrix breakdown: + // - (1, 0, 0, 1) = identity matrix (no scaling/rotation) + // - (-22, -22) = translate by -22px in X and Y directions + // Math: 50% of 44px = 22px, so translate(-50%, -50%) = translate(-22px, -22px) + // Regex explanation: + // - matrix\(1, 0, 0, 1, = identity matrix + // - -2[0-9.]+ = negative number starting with -2 (e.g., -22, -23.6) + // - Flexible pattern accounts for border widths and sub-pixel rendering + expect(beforeStyles.transform).toMatch(/matrix\(1, 0, 0, 1, -2[0-9.]+, -2[0-9.]+\)/); + + // Check ::after pseudo-element (should not interfere with touch target) + const afterStyles = window.getComputedStyle(container, "::after"); + // ::after should not have conflicting dimensions or positioning + expect(afterStyles.position).not.toBe("absolute"); + + // Final verification: Check that all styles are applied and rendered + // After the page is fully loaded and all CSS is computed + await vi.waitFor(() => { + const finalContainerStyles = window.getComputedStyle(container); + const finalBeforeStyles = window.getComputedStyle(container, "::before"); + + // Verify final computed styles match expectations + expect(finalContainerStyles.position).toBe("relative"); + expect(finalBeforeStyles.width).toBe("44px"); + expect(finalBeforeStyles.height).toBe("44px"); + }); + }); }); diff --git a/libs/react-components/specs/radio.browser.spec.tsx b/libs/react-components/specs/radio.browser.spec.tsx index 5d15c59ca..1145dabc9 100644 --- a/libs/react-components/specs/radio.browser.spec.tsx +++ b/libs/react-components/specs/radio.browser.spec.tsx @@ -98,4 +98,67 @@ describe("Radio", () => { expect(selectedValue.element().textContent).toBe("apple"); }); }); + + it("should have a 44px x 44px touch target area", async () => { + const result = render( + + + + ); + + const radioInput = result.getByTestId("radio-option-option1"); + await vi.waitFor(() => { + expect(radioInput.element()).toBeTruthy(); + }); + + // Get the parent label element and find the .icon element + const label = radioInput.element().closest("label"); + expect(label).toBeTruthy(); + + const icon = label?.querySelector(".icon") as HTMLElement; + expect(icon).toBeTruthy(); + + // Get computed styles for the ::before pseudo-element (touch target) + const beforeStyles = window.getComputedStyle(icon, "::before"); + + // Verify the touch target dimensions + expect(beforeStyles.width).toBe("44px"); + expect(beforeStyles.height).toBe("44px"); + expect(beforeStyles.position).toBe("absolute"); + + // Verify the icon itself has position: relative for proper positioning context + const iconStyles = window.getComputedStyle(icon); + expect(iconStyles.position).toBe("relative"); + + // Verify the actual visual size of the icon (24px) vs touch target (44px) + const iconRect = icon.getBoundingClientRect(); + expect(iconRect.width).toBe(24); // Visual icon is 24px + expect(iconRect.height).toBe(24); // Visual icon is 24px + + // Verify the transform is applied correctly for centering + // CSS: transform: translate(-50%, -50%) converts to matrix(a, b, c, d, tx, ty) + // Matrix breakdown: + // - (1, 0, 0, 1) = identity matrix (no scaling/rotation) + // - (-22, -22) = translate by -22px in X and Y directions + // Math: 50% of 44px = 22px, so translate(-50%, -50%) = translate(-22px, -22px) + // This centers the 44px touch target on the 24px icon + expect(beforeStyles.transform).toBe("matrix(1, 0, 0, 1, -22, -22)"); + + // Check ::after pseudo-element (should not interfere with touch target) + const afterStyles = window.getComputedStyle(icon, "::after"); + // ::after should not have conflicting dimensions or positioning + expect(afterStyles.position).not.toBe("absolute"); + + // Final verification: Check that all styles are applied and rendered + // After the page is fully loaded and all CSS is computed + await vi.waitFor(() => { + const finalIconStyles = window.getComputedStyle(icon); + const finalBeforeStyles = window.getComputedStyle(icon, "::before"); + + // Verify final computed styles match expectations + expect(finalIconStyles.position).toBe("relative"); + expect(finalBeforeStyles.width).toBe("44px"); + expect(finalBeforeStyles.height).toBe("44px"); + }); + }); }); diff --git a/libs/web-components/src/components/checkbox/Checkbox.svelte b/libs/web-components/src/components/checkbox/Checkbox.svelte index 384c9b881..bcfc2270e 100644 --- a/libs/web-components/src/components/checkbox/Checkbox.svelte +++ b/libs/web-components/src/components/checkbox/Checkbox.svelte @@ -165,12 +165,12 @@ const checkboxEl = (_rootEl.getRootNode() as ShadowRoot)?.host as HTMLElement; const fromCheckboxList = checkboxEl?.closest("goa-checkbox-list") !== null; - relay( - _rootEl, - FormFieldMountMsg, - { name, el: _rootEl }, - { bubbles: !fromCheckboxList, timeout: 10 }, - ); + relay( + _rootEl, + FormFieldMountMsg, + { name, el: _rootEl }, + { bubbles: !fromCheckboxList, timeout: 10 }, + ); } function onChange(e: Event) { @@ -387,6 +387,7 @@ max-width: ${maxwidth}; /* Container */ .container { + position: relative; box-sizing: border-box; border: var(--goa-checkbox-border); border-radius: var(--goa-checkbox-border-radius); @@ -398,6 +399,17 @@ max-width: ${maxwidth}; justify-content: center; flex: 0 0 auto; /* prevent squishing of checkbox */ } + + .container::before { + content: ''; + position: absolute; + width: 44px; + height: 44px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + .container:hover { border: var(--goa-checkbox-border-hover); } diff --git a/libs/web-components/src/components/radio-item/RadioItem.svelte b/libs/web-components/src/components/radio-item/RadioItem.svelte index a48bc88f2..f9ef00972 100644 --- a/libs/web-components/src/components/radio-item/RadioItem.svelte +++ b/libs/web-components/src/components/radio-item/RadioItem.svelte @@ -342,6 +342,7 @@ } .icon { + position: relative; display: inline-block; height: var(--goa-radio-size); width: var(--goa-radio-size); @@ -354,6 +355,16 @@ margin-top: var(--font-valign-fix); } + .icon::before { + content: ''; + position: absolute; + width: 44px; + height: 44px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + .radio--disabled .label, .radio--disabled ~ .description { color: var(--goa-radio-label-color-disabled);