🎨 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:
@@ -1,3 +1,7 @@
|
|||||||
## 2024-05-24 - Accessible Input Validation
|
## 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.
|
**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.
|
**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.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
.button {
|
.button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
position: relative;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
@@ -80,3 +81,23 @@
|
|||||||
transform: rotate(360deg);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,14 +24,18 @@ export function Button({
|
|||||||
type={type}
|
type={type}
|
||||||
className={`${styles.button} ${styles[variant]} ${styles[size]} ${className || ''}`}
|
className={`${styles.button} ${styles[variant]} ${styles[size]} ${className || ''}`}
|
||||||
disabled={disabled || isLoading}
|
disabled={disabled || isLoading}
|
||||||
|
aria-busy={isLoading}
|
||||||
whileHover={{ scale: 1.02 }}
|
whileHover={{ scale: 1.02 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
<span className={`${styles.content} ${isLoading ? styles.contentHidden : ''}`}>
|
||||||
<span className={styles.loader} />
|
{children}
|
||||||
) : (
|
</span>
|
||||||
children
|
{isLoading && (
|
||||||
|
<span className={styles.loaderWrapper} aria-hidden="true">
|
||||||
|
<span className={styles.loader} />
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,4 +19,13 @@ describe('Button', () => {
|
|||||||
const button = screen.getByTestId('custom-button');
|
const button = screen.getByTestId('custom-button');
|
||||||
expect(button).toBeTruthy();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user