Skip to content

Commit a8254bc

Browse files
v6: Add a check-mark confirmation to the Settings Clear Data button (#4401)
## Summary The Settings dialog's "Clear data" button was unstyled, and clicking it turned the whole button green. This restyles it to match the segmented controls it sits beside — same typography (`font-size-small`, weight 500, `letter-spacing-ui`), `bg-subtle` fill + `border-muted` border, and the same height as a full segmented bar (4px vertical padding = a segment cell's 3px plus the track's own 1px). It no longer reads as a foreign element in that row. On click, the label cross-fades to a green checkmark **in place** and back after ~1.5s — rather than the check appearing beside the label. The label stays in the DOM (at `opacity: 0`) so the button keeps a fixed width and its accessible name, and an `sr-only` live region announces "Data cleared" for screen-reader users. The swap respects `prefers-reduced-motion`. ## Test plan - [x] Open Settings. The "Clear data" button should read as a sibling of the Density/Font size segmented controls — same font, weight, and height — not a larger, heavier button. - [x] Click "Clear data". The label should swap to a green checkmark in place (no sideways shift, no reflow), then revert to "Clear data" after ~1.5s. - [x] Confirm the stored settings (theme, density, font size, etc.) are actually cleared after clicking. - [x] Check both Dark and Light themes — the button and checkmark should look right in each. - [x] With a screen reader, clicking should announce "Data cleared". Refs: #4219
1 parent c1c33d0 commit a8254bc

4 files changed

Lines changed: 183 additions & 25 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@graphiql/react': patch
3+
---
4+
5+
Restyle the Settings dialog's "Clear data" button to match the segmented controls beside it, and briefly swap its label for a green checkmark on click to confirm.

packages/graphiql-react/src/components/settings-dialog/index.css

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,84 @@
4545
letter-spacing: var(--letter-spacing-ui);
4646
margin: 0;
4747
}
48+
49+
/* Sized to sit in the segmented-control family: the same typography as a
50+
segment option, and 4px vertical padding (a segment cell's 3px plus the
51+
track's own 1px) so the button matches a full segmented bar's height. */
52+
.graphiql-settings-clear-button {
53+
position: relative;
54+
display: inline-flex;
55+
align-items: center;
56+
justify-content: center;
57+
padding: var(--px-4) var(--px-8);
58+
border: 1px solid oklch(var(--border-muted));
59+
border-radius: var(--radius-sm);
60+
background: oklch(var(--bg-subtle));
61+
color: oklch(var(--fg-default));
62+
font-family: var(--font-family);
63+
font-size: var(--font-size-small);
64+
font-weight: 500;
65+
letter-spacing: var(--letter-spacing-ui);
66+
cursor: pointer;
67+
transition:
68+
background 0.12s ease,
69+
border-color 0.12s ease;
70+
}
71+
72+
.graphiql-settings-clear-button:hover {
73+
background: oklch(var(--bg-overlay));
74+
}
75+
76+
.graphiql-settings-clear-button:focus-visible {
77+
outline: 2px solid oklch(var(--accent-blue));
78+
outline-offset: 1px;
79+
}
80+
81+
/* The label stays in the DOM while the check shows, so the button keeps a
82+
fixed width and its accessible name. The two cross-fade in place. */
83+
.graphiql-settings-clear-button-label {
84+
transition: opacity 0.15s ease;
85+
}
86+
87+
.graphiql-settings-clear-button[data-confirmed]
88+
.graphiql-settings-clear-button-label {
89+
opacity: 0;
90+
}
91+
92+
.graphiql-settings-clear-button-check {
93+
position: absolute;
94+
inset: 0;
95+
display: flex;
96+
align-items: center;
97+
justify-content: center;
98+
opacity: 0;
99+
transition: opacity 0.15s ease;
100+
}
101+
102+
.graphiql-settings-clear-button[data-confirmed]
103+
.graphiql-settings-clear-button-check {
104+
opacity: 1;
105+
}
106+
107+
@media (prefers-reduced-motion: reduce) {
108+
.graphiql-settings-clear-button,
109+
.graphiql-settings-clear-button-label,
110+
.graphiql-settings-clear-button-check {
111+
transition: none;
112+
}
113+
}
114+
115+
.graphiql-settings-check-icon {
116+
color: oklch(var(--accent-green));
117+
height: 14px;
118+
width: 14px;
119+
}
120+
121+
.graphiql-settings-sr-only {
122+
position: absolute;
123+
width: 1px;
124+
height: 1px;
125+
overflow: hidden;
126+
clip-path: inset(50%);
127+
white-space: nowrap;
128+
}

packages/graphiql-react/src/components/settings-dialog/index.tsx

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import type { FC } from 'react';
44
import { Dialog } from '../dialog';
55
import { SegmentedControl } from '../segmented-control';
6-
import { Button } from '../button';
76
import { useGraphiQL, useGraphiQLActions } from '../provider';
87
import {
98
useGraphiQLSettings,
@@ -15,6 +14,27 @@ import { useMonaco } from '../../stores';
1514
import { useEffect, useState } from 'react';
1615
import './index.css';
1716

17+
// Time the clear-storage button shows its checkmark confirmation before it
18+
// reverts, mirroring the collections plugin's copy/share confirmation.
19+
const CLEAR_STORAGE_CONFIRMATION_MS = 1500;
20+
21+
const CheckIcon: FC = () => (
22+
<svg
23+
aria-hidden="true"
24+
viewBox="0 0 16 16"
25+
fill="none"
26+
className="graphiql-settings-check-icon"
27+
>
28+
<path
29+
stroke="currentColor"
30+
strokeLinecap="round"
31+
strokeLinejoin="round"
32+
strokeWidth="1.75"
33+
d="m3.5 8.5 3 3 6-7"
34+
/>
35+
</svg>
36+
);
37+
1838
const FONT_SIZE_PX: Record<FontSize, number> = {
1939
compact: 12,
2040
default: 13,
@@ -82,24 +102,31 @@ export const SettingsDialog: FC<SettingsDialogProps> = ({
82102
const shouldPersistHeaders = useGraphiQL(state => state.shouldPersistHeaders);
83103
const storage = useGraphiQL(state => state.storage);
84104
const { setShouldPersistHeaders } = useGraphiQLActions();
85-
const [clearStorageStatus, setClearStorageStatus] = useState<
86-
'success' | 'error' | undefined
87-
>();
105+
const [isDataCleared, setIsDataCleared] = useState(false);
88106

89-
// Reset the clear-storage button state when the dialog closes.
107+
// Reset the clear-storage confirmation when the dialog closes.
90108
useEffect(() => {
91109
if (!open) {
92-
setClearStorageStatus(undefined);
110+
setIsDataCleared(false);
93111
}
94112
}, [open]);
95113

96-
function handleClearData() {
97-
try {
98-
storage.clear();
99-
setClearStorageStatus('success');
100-
} catch {
101-
setClearStorageStatus('error');
114+
// The confirmation is transient: flash the checkmark, then hide it again,
115+
// mirroring the collections plugin's share confirmation.
116+
useEffect(() => {
117+
if (!isDataCleared) {
118+
return;
102119
}
120+
const timer = setTimeout(
121+
() => setIsDataCleared(false),
122+
CLEAR_STORAGE_CONFIRMATION_MS,
123+
);
124+
return () => clearTimeout(timer);
125+
}, [isDataCleared]);
126+
127+
function handleClearData() {
128+
storage.clear();
129+
setIsDataCleared(true);
103130
}
104131

105132
// Keep Monaco editor font size in sync with the active preset.
@@ -192,16 +219,25 @@ export const SettingsDialog: FC<SettingsDialogProps> = ({
192219
Remove all locally stored data and start fresh.
193220
</p>
194221
</div>
195-
<Button
222+
<button
196223
type="button"
197-
state={clearStorageStatus}
198-
disabled={clearStorageStatus === 'success'}
224+
className="graphiql-settings-clear-button"
199225
onClick={handleClearData}
226+
data-confirmed={isDataCleared || undefined}
200227
>
201-
{{ success: 'Cleared data', error: 'Failed' }[
202-
clearStorageStatus!
203-
] ?? 'Clear data'}
204-
</Button>
228+
<span className="graphiql-settings-clear-button-label">
229+
Clear data
230+
</span>
231+
<span
232+
className="graphiql-settings-clear-button-check"
233+
aria-hidden="true"
234+
>
235+
<CheckIcon />
236+
</span>
237+
</button>
238+
<span className="graphiql-settings-sr-only" role="status">
239+
{isDataCleared ? 'Data cleared' : ''}
240+
</span>
205241
</section>
206242
</div>
207243
</div>

packages/graphiql-react/src/components/settings-dialog/settings-dialog.test.tsx

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
2-
import { render, screen, within, waitFor } from '@testing-library/react';
2+
import {
3+
render,
4+
screen,
5+
within,
6+
waitFor,
7+
fireEvent,
8+
act,
9+
} from '@testing-library/react';
310
import userEvent from '@testing-library/user-event';
411
import { SettingsDialog, type SettingsDialogProps } from './index';
512
import { SETTINGS_STORAGE_KEY } from '../../hooks/use-graphiql-settings';
@@ -261,14 +268,43 @@ describe('SettingsDialog — persist headers', () => {
261268
});
262269

263270
describe('SettingsDialog — clear storage', () => {
264-
it('clears storage and shows confirmation when clicked', async () => {
271+
it('clears storage when clicked', async () => {
265272
const user = userEvent.setup();
266273
renderDialog();
267-
await user.click(screen.getByRole('button', { name: 'Clear data' }));
274+
const button = screen.getByRole('button', { name: 'Clear data' });
275+
await user.click(button);
268276
expect(mockStorage.clear).toHaveBeenCalledOnce();
269-
expect(
270-
screen.getByRole('button', { name: 'Cleared data' }),
271-
).toBeInTheDocument();
277+
// The label stays put — the checkmark swaps in over it, so the button
278+
// keeps its accessible name.
279+
expect(button).toHaveAccessibleName('Clear data');
280+
});
281+
282+
it('briefly swaps the label for a checkmark, then reverts', () => {
283+
vi.useFakeTimers();
284+
try {
285+
renderDialog();
286+
const button = screen.getByRole('button', { name: 'Clear data' });
287+
288+
expect(button).not.toHaveAttribute('data-confirmed');
289+
expect(screen.getByRole('status')).toBeEmptyDOMElement();
290+
291+
fireEvent.click(button);
292+
expect(button).toHaveAttribute('data-confirmed');
293+
expect(screen.getByRole('status')).toHaveTextContent('Data cleared');
294+
295+
act(() => {
296+
vi.advanceTimersByTime(1499);
297+
});
298+
expect(button).toHaveAttribute('data-confirmed');
299+
300+
act(() => {
301+
vi.advanceTimersByTime(1);
302+
});
303+
expect(button).not.toHaveAttribute('data-confirmed');
304+
expect(screen.getByRole('status')).toBeEmptyDOMElement();
305+
} finally {
306+
vi.useRealTimers();
307+
}
272308
});
273309
});
274310

0 commit comments

Comments
 (0)