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();
+ });
});