🎨 Palette: Improve Button loading state accessibility

- 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>
This commit is contained in:
google-labs-jules[bot]
2026-01-27 01:42:19 +00:00
parent 9223331ee9
commit 6d94ac7b93
4 changed files with 42 additions and 4 deletions

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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 ? (
<span className={`${styles.content} ${isLoading ? styles.contentHidden : ''}`}>
{children}
</span>
{isLoading && (
<span className={styles.loaderWrapper} aria-hidden="true">
<span className={styles.loader} />
) : (
children
</span>
)}
</motion.button>
);

View File

@@ -19,4 +19,13 @@ describe('Button', () => {
const button = screen.getByTestId('custom-button');
expect(button).toBeTruthy();
});
it('renders loading state correctly', () => {
render(<Button isLoading>Submit</Button>);
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();
});
});