From 6d94ac7b9345143f7c995dfa82dd10e00dbed312 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Tue, 27 Jan 2026 01:42:19 +0000
Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20Improve=20Button=20lo?=
=?UTF-8?q?ading=20state=20accessibility?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add `aria-busy` attribute to Button when loading.
- Refactor Button rendering to keep children in DOM (visually hidden) instead of unmounting.
- Fix layout shift regression by replicating flex properties in content wrapper.
- Move inline styles to CSS modules.
- Add tests for loading state accessibility.
Co-authored-by: ragusa-it <196988693+ragusa-it@users.noreply.github.com>
---
.Jules/palette.md | 4 ++++
src/components/ui/Button.module.css | 21 +++++++++++++++++++++
src/components/ui/Button.tsx | 12 ++++++++----
src/components/ui/__tests__/Button.test.tsx | 9 +++++++++
4 files changed, 42 insertions(+), 4 deletions(-)
diff --git a/.Jules/palette.md b/.Jules/palette.md
index 2b51482..5a5b459 100644
--- a/.Jules/palette.md
+++ b/.Jules/palette.md
@@ -1,3 +1,7 @@
## 2024-05-24 - Accessible Input Validation
**Learning:** React 19 renders `aria-invalid={false}` as `aria-invalid="false"`, unlike older versions which might have omitted it. Explicitly handling this in tests is crucial. Also, ensuring DOM cleanup (`cleanup()`) in `afterEach` is vital when testing similar components with same labels across tests to avoid "finding the wrong element" false positives/negatives.
**Action:** Always include `afterEach(() => cleanup())` in `vitest` setup for DOM tests, and expect `aria-invalid="false"` (or explicitly handle `undefined` if omission is desired) when testing valid states in React 19.
+
+## 2024-05-24 - Accessible Loading Buttons
+**Learning:** Replacing button text with a spinner destroys the accessible name.
+**Action:** Use `aria-busy="true"`, keep children in DOM (visually hidden via opacity/class), and overlay spinner absolutely. Ensure wrapper element replicates flex layout (gap/alignment) to prevent layout shifts.
diff --git a/src/components/ui/Button.module.css b/src/components/ui/Button.module.css
index 151f0f9..19c0613 100644
--- a/src/components/ui/Button.module.css
+++ b/src/components/ui/Button.module.css
@@ -1,5 +1,6 @@
.button {
display: inline-flex;
+ position: relative;
align-items: center;
justify-content: center;
gap: var(--space-sm);
@@ -80,3 +81,23 @@
transform: rotate(360deg);
}
}
+
+.content {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-sm);
+ transition: opacity 0.2s;
+}
+
+.contentHidden {
+ opacity: 0;
+}
+
+.loaderWrapper {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
index 43a5144..998208e 100644
--- a/src/components/ui/Button.tsx
+++ b/src/components/ui/Button.tsx
@@ -24,14 +24,18 @@ export function Button({
type={type}
className={`${styles.button} ${styles[variant]} ${styles[size]} ${className || ''}`}
disabled={disabled || isLoading}
+ aria-busy={isLoading}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
{...props}
>
- {isLoading ? (
-
- ) : (
- children
+
+ {children}
+
+ {isLoading && (
+
+
+
)}
);
diff --git a/src/components/ui/__tests__/Button.test.tsx b/src/components/ui/__tests__/Button.test.tsx
index 3fa3714..8144a52 100644
--- a/src/components/ui/__tests__/Button.test.tsx
+++ b/src/components/ui/__tests__/Button.test.tsx
@@ -19,4 +19,13 @@ describe('Button', () => {
const button = screen.getByTestId('custom-button');
expect(button).toBeTruthy();
});
+
+ it('renders loading state correctly', () => {
+ render();
+ const button = screen.getByRole('button', { name: /submit/i }) as HTMLButtonElement;
+ expect(button.getAttribute('aria-busy')).toBe('true');
+ expect(button.disabled).toBe(true);
+ // Verify text is present (opacity: 0 doesn't remove from DOM)
+ expect(screen.getByText('Submit')).toBeTruthy();
+ });
});