ContrastNote: inline low-contrast hint with suggestion
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
453268a31c
commit
e4ba07b47e
2 files changed
+119
No files matched your search
@@ -0,0 +1,59 @@
|
||||
import { describe, test, expect, vi, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { ContrastNote } from './ContrastNote';
|
||||
import { SiteDesignProvider } from '../../../state/SiteDesignContext';
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root;
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => { root = createRoot(container!); root.render(<SiteDesignProvider>{ui}</SiteDesignProvider>); });
|
||||
}
|
||||
afterEach(() => {
|
||||
if (!container) return;
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('ContrastNote', () => {
|
||||
test('renders nothing when the pair passes', () => {
|
||||
render(<ContrastNote fg="#000000" bg="#ffffff" />);
|
||||
expect(container!.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
test('renders nothing for an unresolved colour', () => {
|
||||
render(<ContrastNote fg="var(--nope)" bg="#ffffff" />);
|
||||
expect(container!.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
test('fail shows ratio and threshold in a status region', () => {
|
||||
render(<ContrastNote fg="#aaaaaa" bg="#ffffff" />);
|
||||
const el = container!.querySelector('[role="status"]')!;
|
||||
expect(el.textContent).toBe('Low contrast 2.32:1 (needs 4.5:1)');
|
||||
expect(container!.querySelector('button')).toBeNull();
|
||||
});
|
||||
|
||||
test('large text uses the 3:1 threshold; label prefixes', () => {
|
||||
render(<ContrastNote fg="#cccccc" bg="#ffffff" large label="Heading" />);
|
||||
expect(container!.textContent).toBe('Heading: Low contrast 1.61:1 (needs 3:1)');
|
||||
});
|
||||
|
||||
test('suggestion button calls back with a passing hex', () => {
|
||||
const cb = vi.fn();
|
||||
render(<ContrastNote fg="#aaaaaa" bg="#ffffff" onUseSuggestion={cb} />);
|
||||
const btn = container!.querySelector('button')!;
|
||||
expect(btn.textContent).toMatch(/^Use #[0-9a-f]{6}$/);
|
||||
act(() => { btn.click(); });
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb.mock.calls[0][0]).toMatch(/^#[0-9a-f]{6}$/);
|
||||
});
|
||||
|
||||
test('gradient background shows a muted not-checked note', () => {
|
||||
render(<ContrastNote fg="#ffffff" bg="linear-gradient(red, blue)" />);
|
||||
expect(container!.querySelector('[role="status"]')!.textContent).toBe('Contrast not checked (gradient)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { useSiteDesign } from '../../../state/SiteDesignContext';
|
||||
import { checkContrast, suggestFix } from '../../../utils/contrast';
|
||||
|
||||
export interface ContrastNoteProps {
|
||||
fg: string;
|
||||
bg: string;
|
||||
large?: boolean;
|
||||
label?: string;
|
||||
onUseSuggestion?: (hex: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact inline contrast hint. Silent when the pair passes or cannot be
|
||||
* judged for a reason not worth mentioning; amber (not alarm red) on failure;
|
||||
* muted "not checked" for gradient / image backgrounds.
|
||||
*/
|
||||
export const ContrastNote: React.FC<ContrastNoteProps> = ({ fg, bg, large, label, onUseSuggestion }) => {
|
||||
const { design } = useSiteDesign();
|
||||
const v = checkContrast(fg, bg, design, { large });
|
||||
|
||||
if (v.status === 'pass') return null;
|
||||
|
||||
if (v.status === 'unchecked') {
|
||||
if (v.reason !== 'gradient' && v.reason !== 'image') return null;
|
||||
return (
|
||||
<div
|
||||
className="contrast-note contrast-note--unchecked"
|
||||
role="status"
|
||||
style={{ fontSize: 11, lineHeight: 1.4, color: 'var(--color-text-muted, #8b8b96)' }}
|
||||
>
|
||||
Contrast not checked ({v.reason})
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fix = onUseSuggestion ? suggestFix(fg, bg, design, { large }) : null;
|
||||
return (
|
||||
<div
|
||||
className="contrast-note contrast-note--fail"
|
||||
role="status"
|
||||
style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 6, fontSize: 11, lineHeight: 1.4, color: 'var(--color-warning, #d97706)' }}
|
||||
>
|
||||
<span>
|
||||
{label ? `${label}: ` : ''}Low contrast {v.ratio!.toFixed(2)}:1 (needs {v.required}:1)
|
||||
</span>
|
||||
{fix && (
|
||||
<button
|
||||
type="button"
|
||||
className="contrast-note__use"
|
||||
onClick={() => onUseSuggestion!(fix.hex)}
|
||||
title={`Use ${fix.hex} (${fix.ratio.toFixed(1)}:1)`}
|
||||
style={{ font: 'inherit', fontWeight: 600, color: 'inherit', background: 'none', border: '1px solid currentColor', borderRadius: 4, padding: '0 6px', cursor: 'pointer' }}
|
||||
>
|
||||
Use {fix.hex}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user