Skip to content

Commit 71b4bf3

Browse files
Merge pull request #7 from chiscookeke11/codex/add-helper-for-stable-button-width
Add StableButtonContent helper to reserve button width during loading
2 parents 57a3f8d + d75fe02 commit 71b4bf3

4 files changed

Lines changed: 144 additions & 8 deletions

File tree

src/components/common/TradeDialog.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useEffect, useMemo, useRef, useState } from 'react';
22
import { Button } from '@/components/ui/button';
3+
import { StableButtonContent } from '@/components/ui/stable-button-content';
34
import {
45
Dialog,
56
DialogContent,
@@ -55,7 +56,10 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
5556
const confirmLabel = side === 'buy' ? 'Confirm buy' : 'Confirm sell';
5657

5758
return (
58-
<Dialog open={open} onOpenChange={next => !isSubmitting && onOpenChange(next)}>
59+
<Dialog
60+
open={open}
61+
onOpenChange={next => !isSubmitting && onOpenChange(next)}
62+
>
5963
<DialogContent
6064
className="max-w-md"
6165
showCloseButton={!isSubmitting}
@@ -91,14 +95,18 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
9195
className={cn(
9296
'w-full rounded-xl border bg-white/[0.04] px-3 py-2 text-white outline-none transition-colors',
9397
'border-white/10 focus:border-amber-500/50 focus:ring-2 focus:ring-amber-500/15',
94-
!amountValid && amountText.trim() ? 'border-red-500/40' : ''
98+
!amountValid && amountText.trim()
99+
? 'border-red-500/40'
100+
: ''
95101
)}
96102
aria-label="Trade amount"
97103
data-focus-order="1"
98104
data-testid="trade-dialog-amount"
99105
/>
100106
<div className="flex flex-wrap items-center gap-2 text-xs text-white/45">
101-
<span aria-label={`Current wallet holdings: ${formatNumber(availableHoldings)} keys`}>
107+
<span
108+
aria-label={`Current wallet holdings: ${formatNumber(availableHoldings)} keys`}
109+
>
102110
Holdings: {formatNumber(availableHoldings)} keys
103111
</span>
104112
{side === 'sell' &&
@@ -109,7 +117,9 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
109117
label="of holdings"
110118
value={(parsedAmount / availableHoldings) * 100}
111119
tone={
112-
parsedAmount > availableHoldings ? 'negative' : 'neutral'
120+
parsedAmount > availableHoldings
121+
? 'negative'
122+
: 'neutral'
113123
}
114124
/>
115125
)}
@@ -145,10 +155,16 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
145155
type="button"
146156
onClick={() => onConfirm(parsedAmount)}
147157
disabled={!amountValid || isSubmitting}
158+
aria-busy={isSubmitting || undefined}
148159
data-focus-order="3"
149160
data-testid="trade-dialog-confirm"
150161
>
151-
{isSubmitting ? 'Submitting…' : confirmLabel}
162+
<StableButtonContent
163+
isLoading={isSubmitting}
164+
loadingLabel="Submitting…"
165+
>
166+
{confirmLabel}
167+
</StableButtonContent>
152168
</Button>
153169
</DialogFooter>
154170
</DialogContent>

src/components/common/__tests__/TradeDialog.focusOrder.test.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import TradeDialog from '@/components/common/TradeDialog';
1010
* DOM (which would also swap them in the tab sequence) or remove markers.
1111
*/
1212
describe('TradeDialog focus order', () => {
13-
function renderDialog(overrides: Partial<React.ComponentProps<typeof TradeDialog>> = {}) {
13+
function renderDialog(
14+
overrides: Partial<React.ComponentProps<typeof TradeDialog>> = {}
15+
) {
1416
return render(
1517
<TradeDialog
1618
open={true}
@@ -45,9 +47,12 @@ describe('TradeDialog focus order', () => {
4547
renderDialog();
4648

4749
const elements = Array.from(
48-
document.querySelectorAll('[data-slot="dialog-close"], [data-focus-order]')
50+
document.querySelectorAll(
51+
'[data-slot="dialog-close"], [data-focus-order]'
52+
)
4953
).map(el => ({
50-
identifier: el.getAttribute('data-testid') || el.getAttribute('data-slot'),
54+
identifier:
55+
el.getAttribute('data-testid') || el.getAttribute('data-slot'),
5156
order: el.getAttribute('data-focus-order') || '0',
5257
}));
5358

@@ -76,6 +81,17 @@ describe('TradeDialog focus order', () => {
7681
expect(cancel.getAttribute('tabindex')).not.toBe('-1');
7782
});
7883

84+
it('reserves the confirm button width while showing the submitting state', () => {
85+
renderDialog({ isSubmitting: true });
86+
87+
const confirm = screen.getByTestId('trade-dialog-confirm');
88+
89+
expect(confirm).toHaveAttribute('aria-busy', 'true');
90+
expect(screen.getByText('Confirm buy')).toHaveClass('invisible');
91+
expect(screen.getByText('Submitting…')).toBeVisible();
92+
expect(confirm.querySelector('.animate-spin')).toBeInTheDocument();
93+
});
94+
7995
it('preserves the same focus order in the sell variant', () => {
8096
renderDialog({ side: 'sell' });
8197

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { Button } from '@/components/ui/button';
5+
import { StableButtonContent } from '@/components/ui/stable-button-content';
6+
7+
describe('StableButtonContent', () => {
8+
it('keeps idle and loading content mounted so button width can be reserved', () => {
9+
render(
10+
<Button>
11+
<StableButtonContent isLoading={false} loadingLabel="Saving…">
12+
Save changes
13+
</StableButtonContent>
14+
</Button>
15+
);
16+
17+
const idleContent = screen.getByText('Save changes');
18+
const loadingContent = screen.getByText('Saving…');
19+
20+
expect(idleContent).toBeVisible();
21+
expect(idleContent).not.toHaveAttribute('aria-hidden');
22+
expect(loadingContent).toHaveClass('invisible');
23+
expect(loadingContent).toHaveAttribute('aria-hidden', 'true');
24+
});
25+
26+
it('reveals the loading label and spinner while reserving the idle label', () => {
27+
render(
28+
<Button aria-busy="true">
29+
<StableButtonContent isLoading loadingLabel="Saving…">
30+
Save changes
31+
</StableButtonContent>
32+
</Button>
33+
);
34+
35+
const idleContent = screen.getByText('Save changes');
36+
const loadingContent = screen.getByText('Saving…');
37+
38+
expect(idleContent).toHaveClass('invisible');
39+
expect(idleContent).toHaveAttribute('aria-hidden', 'true');
40+
expect(loadingContent).toBeVisible();
41+
expect(loadingContent).not.toHaveAttribute('aria-hidden');
42+
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
43+
});
44+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import * as React from 'react';
2+
import { Loader2 } from 'lucide-react';
3+
4+
import { cn } from '@/lib/utils';
5+
6+
export interface StableButtonContentProps {
7+
/** Whether the loading slot should be visible to sighted users and assistive tech. */
8+
isLoading: boolean;
9+
/** Button label or content shown when the action is idle. */
10+
children: React.ReactNode;
11+
/** Optional loading label. Defaults to keeping only the spinner visible. */
12+
loadingLabel?: React.ReactNode;
13+
/** Override the default spinner. Pass null to hide it. */
14+
spinner?: React.ReactNode;
15+
className?: string;
16+
idleClassName?: string;
17+
loadingClassName?: string;
18+
}
19+
20+
/**
21+
* Renders idle and loading button content in the same grid cell so both states
22+
* contribute to the button's intrinsic width. The inactive state stays
23+
* invisible (not `display: none`), which reserves enough space before a loading
24+
* transition and prevents neighboring layout from jumping.
25+
*/
26+
export function StableButtonContent({
27+
isLoading,
28+
children,
29+
loadingLabel,
30+
spinner = <Loader2 className="size-4 animate-spin" aria-hidden="true" />,
31+
className,
32+
idleClassName,
33+
loadingClassName,
34+
}: StableButtonContentProps) {
35+
return (
36+
<span className={cn('grid items-center justify-items-center', className)}>
37+
<span
38+
className={cn(
39+
'col-start-1 row-start-1 inline-flex items-center justify-center gap-2',
40+
isLoading && 'invisible',
41+
idleClassName
42+
)}
43+
aria-hidden={isLoading || undefined}
44+
>
45+
{children}
46+
</span>
47+
<span
48+
className={cn(
49+
'col-start-1 row-start-1 inline-flex items-center justify-center gap-2',
50+
!isLoading && 'invisible',
51+
loadingClassName
52+
)}
53+
aria-hidden={!isLoading || undefined}
54+
>
55+
{spinner}
56+
{loadingLabel}
57+
</span>
58+
</span>
59+
);
60+
}

0 commit comments

Comments
 (0)