Skip to content

Commit ef25ab4

Browse files
committed
feat(#2361): increase clickable area for radio and checkbox
1 parent a6613df commit ef25ab4

File tree

4 files changed

+152
-6
lines changed

4 files changed

+152
-6
lines changed

libs/react-components/specs/checkbox.browser.spec.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,64 @@ describe("Checkbox", () => {
8484
expect(childValue.element().textContent).toBe("false");
8585
});
8686
});
87+
88+
it("should have a 44px x 44px touch target area", async () => {
89+
const result = render(
90+
<GoabCheckbox testId="test-checkbox" name="test" text="Test Checkbox" />
91+
);
92+
93+
const checkbox = result.getByTestId("test-checkbox");
94+
await vi.waitFor(() => {
95+
expect(checkbox.element()).toBeTruthy();
96+
});
97+
98+
const container = checkbox.element().querySelector(".container") as HTMLElement;
99+
expect(container).toBeTruthy();
100+
101+
// Get computed styles for the ::before pseudo-element (touch target)
102+
const beforeStyles = window.getComputedStyle(container, "::before");
103+
104+
// Verify the touch target dimensions
105+
expect(beforeStyles.width).toBe("44px");
106+
expect(beforeStyles.height).toBe("44px");
107+
expect(beforeStyles.position).toBe("absolute");
108+
109+
// Verify the container itself has position: relative for proper positioning context
110+
const containerStyles = window.getComputedStyle(container);
111+
expect(containerStyles.position).toBe("relative");
112+
113+
// Verify the actual visual size of the container (24px) vs touch target (44px)
114+
const containerRect = container.getBoundingClientRect();
115+
expect(containerRect.width).toBe(24); // Visual checkbox is 24px
116+
expect(containerRect.height).toBe(24); // Visual checkbox is 24px
117+
118+
// Verify the transform is applied correctly for centering
119+
// CSS: transform: translate(-50%, -50%) converts to matrix(a, b, c, d, tx, ty)
120+
// Matrix breakdown:
121+
// - (1, 0, 0, 1) = identity matrix (no scaling/rotation)
122+
// - (-22, -22) = translate by -22px in X and Y directions
123+
// Math: 50% of 44px = 22px, so translate(-50%, -50%) = translate(-22px, -22px)
124+
// Regex explanation:
125+
// - matrix\(1, 0, 0, 1, = identity matrix
126+
// - -2[0-9.]+ = negative number starting with -2 (e.g., -22, -23.6)
127+
// - Flexible pattern accounts for border widths and sub-pixel rendering
128+
expect(beforeStyles.transform).toMatch(/matrix\(1, 0, 0, 1, -2[0-9.]+, -2[0-9.]+\)/);
129+
130+
// Check ::after pseudo-element (should not interfere with touch target)
131+
const afterStyles = window.getComputedStyle(container, "::after");
132+
// ::after should not have conflicting dimensions or positioning
133+
expect(afterStyles.position).not.toBe("absolute");
134+
135+
// Final verification: Check that all styles are applied and rendered
136+
// After the page is fully loaded and all CSS is computed
137+
await vi.waitFor(() => {
138+
const finalContainerStyles = window.getComputedStyle(container);
139+
const finalBeforeStyles = window.getComputedStyle(container, "::before");
140+
141+
// Verify final computed styles match expectations
142+
expect(finalContainerStyles.position).toBe("relative");
143+
expect(finalBeforeStyles.width).toBe("44px");
144+
expect(finalBeforeStyles.height).toBe("44px");
145+
});
146+
});
87147
});

libs/react-components/specs/radio.browser.spec.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,67 @@ describe("Radio", () => {
9898
expect(selectedValue.element().textContent).toBe("apple");
9999
});
100100
});
101+
102+
it("should have a 44px x 44px touch target area", async () => {
103+
const result = render(
104+
<GoabRadioGroup name="test" value="">
105+
<GoabRadioItem name="test" value="option1" label="Option 1" />
106+
</GoabRadioGroup>
107+
);
108+
109+
const radioInput = result.getByTestId("radio-option-option1");
110+
await vi.waitFor(() => {
111+
expect(radioInput.element()).toBeTruthy();
112+
});
113+
114+
// Get the parent label element and find the .icon element
115+
const label = radioInput.element().closest("label");
116+
expect(label).toBeTruthy();
117+
118+
const icon = label?.querySelector(".icon") as HTMLElement;
119+
expect(icon).toBeTruthy();
120+
121+
// Get computed styles for the ::before pseudo-element (touch target)
122+
const beforeStyles = window.getComputedStyle(icon, "::before");
123+
124+
// Verify the touch target dimensions
125+
expect(beforeStyles.width).toBe("44px");
126+
expect(beforeStyles.height).toBe("44px");
127+
expect(beforeStyles.position).toBe("absolute");
128+
129+
// Verify the icon itself has position: relative for proper positioning context
130+
const iconStyles = window.getComputedStyle(icon);
131+
expect(iconStyles.position).toBe("relative");
132+
133+
// Verify the actual visual size of the icon (24px) vs touch target (44px)
134+
const iconRect = icon.getBoundingClientRect();
135+
expect(iconRect.width).toBe(24); // Visual icon is 24px
136+
expect(iconRect.height).toBe(24); // Visual icon is 24px
137+
138+
// Verify the transform is applied correctly for centering
139+
// CSS: transform: translate(-50%, -50%) converts to matrix(a, b, c, d, tx, ty)
140+
// Matrix breakdown:
141+
// - (1, 0, 0, 1) = identity matrix (no scaling/rotation)
142+
// - (-22, -22) = translate by -22px in X and Y directions
143+
// Math: 50% of 44px = 22px, so translate(-50%, -50%) = translate(-22px, -22px)
144+
// This centers the 44px touch target on the 24px icon
145+
expect(beforeStyles.transform).toBe("matrix(1, 0, 0, 1, -22, -22)");
146+
147+
// Check ::after pseudo-element (should not interfere with touch target)
148+
const afterStyles = window.getComputedStyle(icon, "::after");
149+
// ::after should not have conflicting dimensions or positioning
150+
expect(afterStyles.position).not.toBe("absolute");
151+
152+
// Final verification: Check that all styles are applied and rendered
153+
// After the page is fully loaded and all CSS is computed
154+
await vi.waitFor(() => {
155+
const finalIconStyles = window.getComputedStyle(icon);
156+
const finalBeforeStyles = window.getComputedStyle(icon, "::before");
157+
158+
// Verify final computed styles match expectations
159+
expect(finalIconStyles.position).toBe("relative");
160+
expect(finalBeforeStyles.width).toBe("44px");
161+
expect(finalBeforeStyles.height).toBe("44px");
162+
});
163+
});
101164
});

libs/web-components/src/components/checkbox/Checkbox.svelte

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,12 @@
165165
const checkboxEl = (_rootEl.getRootNode() as ShadowRoot)?.host as HTMLElement;
166166
const fromCheckboxList = checkboxEl?.closest("goa-checkbox-list") !== null;
167167
168-
relay<FormFieldMountRelayDetail>(
169-
_rootEl,
170-
FormFieldMountMsg,
171-
{ name, el: _rootEl },
172-
{ bubbles: !fromCheckboxList, timeout: 10 },
173-
);
168+
relay<FormFieldMountRelayDetail>(
169+
_rootEl,
170+
FormFieldMountMsg,
171+
{ name, el: _rootEl },
172+
{ bubbles: !fromCheckboxList, timeout: 10 },
173+
);
174174
}
175175
176176
function onChange(e: Event) {
@@ -387,6 +387,7 @@ max-width: ${maxwidth};
387387
388388
/* Container */
389389
.container {
390+
position: relative;
390391
box-sizing: border-box;
391392
border: var(--goa-checkbox-border);
392393
border-radius: var(--goa-checkbox-border-radius);
@@ -398,6 +399,17 @@ max-width: ${maxwidth};
398399
justify-content: center;
399400
flex: 0 0 auto; /* prevent squishing of checkbox */
400401
}
402+
403+
.container::before {
404+
content: '';
405+
position: absolute;
406+
width: 44px;
407+
height: 44px;
408+
top: 50%;
409+
left: 50%;
410+
transform: translate(-50%, -50%);
411+
}
412+
401413
.container:hover {
402414
border: var(--goa-checkbox-border-hover);
403415
}

libs/web-components/src/components/radio-item/RadioItem.svelte

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,7 @@
342342
}
343343
344344
.icon {
345+
position: relative;
345346
display: inline-block;
346347
height: var(--goa-radio-size);
347348
width: var(--goa-radio-size);
@@ -354,6 +355,16 @@
354355
margin-top: var(--font-valign-fix);
355356
}
356357
358+
.icon::before {
359+
content: '';
360+
position: absolute;
361+
width: 44px;
362+
height: 44px;
363+
top: 50%;
364+
left: 50%;
365+
transform: translate(-50%, -50%);
366+
}
367+
357368
.radio--disabled .label,
358369
.radio--disabled ~ .description {
359370
color: var(--goa-radio-label-color-disabled);

0 commit comments

Comments
 (0)