Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/settings-clear-data-confirm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphiql/react': patch
---

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.
81 changes: 81 additions & 0 deletions packages/graphiql-react/src/components/settings-dialog/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,84 @@
letter-spacing: var(--letter-spacing-ui);
margin: 0;
}

/* Sized to sit in the segmented-control family: the same typography as a
segment option, and 4px vertical padding (a segment cell's 3px plus the
track's own 1px) so the button matches a full segmented bar's height. */
.graphiql-settings-clear-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--px-4) var(--px-8);
border: 1px solid oklch(var(--border-muted));
border-radius: var(--radius-sm);
background: oklch(var(--bg-subtle));
color: oklch(var(--fg-default));
font-family: var(--font-family);
font-size: var(--font-size-small);
font-weight: 500;
letter-spacing: var(--letter-spacing-ui);
cursor: pointer;
transition:
background 0.12s ease,
border-color 0.12s ease;
}

.graphiql-settings-clear-button:hover {
background: oklch(var(--bg-overlay));
}

.graphiql-settings-clear-button:focus-visible {
outline: 2px solid oklch(var(--accent-blue));
outline-offset: 1px;
}

/* The label stays in the DOM while the check shows, so the button keeps a
fixed width and its accessible name. The two cross-fade in place. */
.graphiql-settings-clear-button-label {
transition: opacity 0.15s ease;
}

.graphiql-settings-clear-button[data-confirmed]
.graphiql-settings-clear-button-label {
opacity: 0;
}

.graphiql-settings-clear-button-check {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s ease;
}

.graphiql-settings-clear-button[data-confirmed]
.graphiql-settings-clear-button-check {
opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
.graphiql-settings-clear-button,
.graphiql-settings-clear-button-label,
.graphiql-settings-clear-button-check {
transition: none;
}
}

.graphiql-settings-check-icon {
color: oklch(var(--accent-green));
height: 14px;
width: 14px;
}

.graphiql-settings-sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
74 changes: 55 additions & 19 deletions packages/graphiql-react/src/components/settings-dialog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import type { FC } from 'react';
import { Dialog } from '../dialog';
import { SegmentedControl } from '../segmented-control';
import { Button } from '../button';
import { useGraphiQL, useGraphiQLActions } from '../provider';
import {
useGraphiQLSettings,
Expand All @@ -15,6 +14,27 @@ import { useMonaco } from '../../stores';
import { useEffect, useState } from 'react';
import './index.css';

// Time the clear-storage button shows its checkmark confirmation before it
// reverts, mirroring the collections plugin's copy/share confirmation.
const CLEAR_STORAGE_CONFIRMATION_MS = 1500;

const CheckIcon: FC = () => (
<svg
aria-hidden="true"
viewBox="0 0 16 16"
fill="none"
className="graphiql-settings-check-icon"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.75"
d="m3.5 8.5 3 3 6-7"
/>
</svg>
);

const FONT_SIZE_PX: Record<FontSize, number> = {
compact: 12,
default: 13,
Expand Down Expand Up @@ -82,24 +102,31 @@ export const SettingsDialog: FC<SettingsDialogProps> = ({
const shouldPersistHeaders = useGraphiQL(state => state.shouldPersistHeaders);
const storage = useGraphiQL(state => state.storage);
const { setShouldPersistHeaders } = useGraphiQLActions();
const [clearStorageStatus, setClearStorageStatus] = useState<
'success' | 'error' | undefined
>();
const [isDataCleared, setIsDataCleared] = useState(false);

// Reset the clear-storage button state when the dialog closes.
// Reset the clear-storage confirmation when the dialog closes.
useEffect(() => {
if (!open) {
setClearStorageStatus(undefined);
setIsDataCleared(false);
}
}, [open]);

function handleClearData() {
try {
storage.clear();
setClearStorageStatus('success');
} catch {
setClearStorageStatus('error');
// The confirmation is transient: flash the checkmark, then hide it again,
// mirroring the collections plugin's share confirmation.
useEffect(() => {
if (!isDataCleared) {
return;
}
const timer = setTimeout(
() => setIsDataCleared(false),
CLEAR_STORAGE_CONFIRMATION_MS,
);
return () => clearTimeout(timer);
}, [isDataCleared]);

function handleClearData() {
storage.clear();
setIsDataCleared(true);
}

// Keep Monaco editor font size in sync with the active preset.
Expand Down Expand Up @@ -192,16 +219,25 @@ export const SettingsDialog: FC<SettingsDialogProps> = ({
Remove all locally stored data and start fresh.
</p>
</div>
<Button
<button
type="button"
state={clearStorageStatus}
disabled={clearStorageStatus === 'success'}
className="graphiql-settings-clear-button"
onClick={handleClearData}
data-confirmed={isDataCleared || undefined}
>
{{ success: 'Cleared data', error: 'Failed' }[
clearStorageStatus!
] ?? 'Clear data'}
</Button>
<span className="graphiql-settings-clear-button-label">
Clear data
</span>
<span
className="graphiql-settings-clear-button-check"
aria-hidden="true"
>
<CheckIcon />
</span>
</button>
<span className="graphiql-settings-sr-only" role="status">
{isDataCleared ? 'Data cleared' : ''}
</span>
</section>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
import { render, screen, within, waitFor } from '@testing-library/react';
import {
render,
screen,
within,
waitFor,
fireEvent,
act,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SettingsDialog, type SettingsDialogProps } from './index';
import { SETTINGS_STORAGE_KEY } from '../../hooks/use-graphiql-settings';
Expand Down Expand Up @@ -261,14 +268,43 @@ describe('SettingsDialog — persist headers', () => {
});

describe('SettingsDialog — clear storage', () => {
it('clears storage and shows confirmation when clicked', async () => {
it('clears storage when clicked', async () => {
const user = userEvent.setup();
renderDialog();
await user.click(screen.getByRole('button', { name: 'Clear data' }));
const button = screen.getByRole('button', { name: 'Clear data' });
await user.click(button);
expect(mockStorage.clear).toHaveBeenCalledOnce();
expect(
screen.getByRole('button', { name: 'Cleared data' }),
).toBeInTheDocument();
// The label stays put — the checkmark swaps in over it, so the button
// keeps its accessible name.
expect(button).toHaveAccessibleName('Clear data');
});

it('briefly swaps the label for a checkmark, then reverts', () => {
vi.useFakeTimers();
try {
renderDialog();
const button = screen.getByRole('button', { name: 'Clear data' });

expect(button).not.toHaveAttribute('data-confirmed');
expect(screen.getByRole('status')).toBeEmptyDOMElement();

fireEvent.click(button);
expect(button).toHaveAttribute('data-confirmed');
expect(screen.getByRole('status')).toHaveTextContent('Data cleared');

act(() => {
vi.advanceTimersByTime(1499);
});
expect(button).toHaveAttribute('data-confirmed');

act(() => {
vi.advanceTimersByTime(1);
});
expect(button).not.toHaveAttribute('data-confirmed');
expect(screen.getByRole('status')).toBeEmptyDOMElement();
} finally {
vi.useRealTimers();
}
});
});

Expand Down
Loading