Site colours: pick-from-image and palette-from-image modals
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e4ba07b47e
commit
54920dff08
4 files changed
+435
No files matched your search
@@ -0,0 +1,88 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
vi.mock('../../../utils/assets', () => ({ listAssets: vi.fn(), uploadAsset: vi.fn() }));
|
||||
vi.mock('../../../utils/image-sample', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../utils/image-sample')>('../../../utils/image-sample');
|
||||
return { ...actual, loadImageForSampling: vi.fn() };
|
||||
});
|
||||
import { listAssets } from '../../../utils/assets';
|
||||
import { loadImageForSampling, CROSS_ORIGIN_MESSAGE } from '../../../utils/image-sample';
|
||||
import { ImagePickModal } from './ImagePickModal';
|
||||
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const listMock = listAssets as unknown as ReturnType<typeof vi.fn>;
|
||||
const loadMock = loadImageForSampling as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
// 2x1 image: left red, right transparent
|
||||
const imageData = { width: 2, height: 1, data: new Uint8ClampedArray([255, 0, 0, 255, 0, 0, 0, 0]) };
|
||||
const okImage = () => ({ ok: true, width: 2, height: 1, imageData, draw: vi.fn() });
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
async function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
await act(async () => { root = createRoot(container); root.render(ui); });
|
||||
await act(async () => {});
|
||||
}
|
||||
afterEach(() => { act(() => root.unmount()); container.remove(); vi.clearAllMocks(); });
|
||||
beforeEach(() => { vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); listMock.mockResolvedValue([{ name: 'a.png', url: '/a.png', type: 'image/png' }]); });
|
||||
const q = (id: string) => document.querySelector(`[data-testid="${id}"]`) as HTMLElement | null;
|
||||
|
||||
function mouse(el: HTMLElement, type: string, clientX: number) {
|
||||
el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 100, height: 50, right: 100, bottom: 50, x: 0, y: 0, toJSON() {} }) as DOMRect;
|
||||
act(() => { el.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX, clientY: 10 })); });
|
||||
}
|
||||
|
||||
describe('ImagePickModal', () => {
|
||||
test('lists uploaded assets and loads the chosen one', async () => {
|
||||
loadMock.mockResolvedValue(okImage());
|
||||
await render(<ImagePickModal onPick={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(q('image-asset-a.png')).toBeTruthy();
|
||||
expect(loadMock).not.toHaveBeenCalled();
|
||||
await act(async () => { q('image-asset-a.png')!.click(); });
|
||||
expect(loadMock).toHaveBeenCalledWith('/a.png');
|
||||
expect(q('image-pick-canvas')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('initialSrc loads immediately; hover shows hex; click picks and closes', async () => {
|
||||
loadMock.mockResolvedValue(okImage());
|
||||
const onPick = vi.fn(), onClose = vi.fn();
|
||||
await render(<ImagePickModal onPick={onPick} onClose={onClose} initialSrc="/sel.png" />);
|
||||
expect(loadMock).toHaveBeenCalledWith('/sel.png');
|
||||
const canvas = q('image-pick-canvas')!;
|
||||
mouse(canvas, 'mousemove', 10);
|
||||
expect(q('image-pick-hex')!.textContent).toBe('#ff0000');
|
||||
mouse(canvas, 'click', 10);
|
||||
expect(onPick).toHaveBeenCalledWith('#ff0000');
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clicking a transparent pixel does not pick', async () => {
|
||||
loadMock.mockResolvedValue(okImage());
|
||||
const onPick = vi.fn(), onClose = vi.fn();
|
||||
await render(<ImagePickModal onPick={onPick} onClose={onClose} initialSrc="/sel.png" />);
|
||||
mouse(q('image-pick-canvas')!, 'click', 90);
|
||||
expect(onPick).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(container.ownerDocument.body.textContent).toContain('transparent');
|
||||
});
|
||||
|
||||
test('loader failure is shown as role=alert', async () => {
|
||||
loadMock.mockResolvedValue({ ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE });
|
||||
await render(<ImagePickModal onPick={vi.fn()} onClose={vi.fn()} initialSrc="https://x.test/a.png" />);
|
||||
const alert = document.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toBe(CROSS_ORIGIN_MESSAGE);
|
||||
expect(q('image-pick-canvas')).toBeNull();
|
||||
});
|
||||
|
||||
test('Escape closes', async () => {
|
||||
const onClose = vi.fn();
|
||||
await render(<ImagePickModal onPick={vi.fn()} onClose={onClose} />);
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, { useEffect, useRef, useState, CSSProperties } from 'react';
|
||||
import { Modal } from '../../../ui/Modal';
|
||||
import { listAssets, Asset } from '../../../utils/assets';
|
||||
import { clickableProps } from '../../../utils/a11y';
|
||||
import { loadImageForSampling, sampleAt, SampledImage } from '../../../utils/image-sample';
|
||||
|
||||
export type SampleState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; img: SampledImage }
|
||||
| { status: 'failed'; message: string };
|
||||
|
||||
/** Load `src` for pixel reading; re-runs when `src` changes, ignoring stale results. */
|
||||
export function useSampledImage(src: string | null): SampleState {
|
||||
const [state, setState] = useState<SampleState>({ status: 'idle' });
|
||||
useEffect(() => {
|
||||
if (!src) { setState({ status: 'idle' }); return; }
|
||||
let live = true;
|
||||
setState({ status: 'loading' });
|
||||
loadImageForSampling(src).then((r) => {
|
||||
if (!live) return;
|
||||
setState(r.ok ? { status: 'ready', img: r } : { status: 'failed', message: r.message });
|
||||
});
|
||||
return () => { live = false; };
|
||||
}, [src]);
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Uploaded-image thumbnails; `selected` highlights the current one. */
|
||||
export const ImageAssetList: React.FC<{
|
||||
assets: Asset[];
|
||||
selected: string | null;
|
||||
onSelect: (url: string) => void;
|
||||
}> = ({ assets, selected, onSelect }) => (
|
||||
<div data-testid="image-assets" style={assetGridStyle}>
|
||||
{assets.map((a) => (
|
||||
<div
|
||||
key={a.url}
|
||||
{...clickableProps(() => onSelect(a.url))}
|
||||
data-testid={`image-asset-${a.name}`}
|
||||
title={a.name}
|
||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', aspectRatio: '1', border: `2px solid ${a.url === selected ? '#3b82f6' : 'transparent'}` }}
|
||||
>
|
||||
<img src={a.url} alt={a.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
</div>
|
||||
))}
|
||||
{assets.length === 0 && (
|
||||
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>
|
||||
No images uploaded yet. Upload one from an Image block first.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Uploaded images for the picker, loaded once on mount. */
|
||||
export function useImageAssets(): Asset[] {
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
listAssets('image').then((a) => { if (live) setAssets(a); }).catch(() => {});
|
||||
return () => { live = false; };
|
||||
}, []);
|
||||
return assets;
|
||||
}
|
||||
|
||||
export const modalPanelStyle: CSSProperties = {
|
||||
background: '#18181b', border: '1px solid #3f3f46', borderRadius: 8, padding: 16,
|
||||
color: '#e4e4e7', width: 460, maxWidth: '92vw', maxHeight: '88vh', overflowY: 'auto', fontSize: 12,
|
||||
};
|
||||
export const modalTitleStyle: CSSProperties = { fontSize: 15, fontWeight: 600, margin: 0 };
|
||||
export const modalBtnStyle: CSSProperties = {
|
||||
padding: '6px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#27272a', color: '#e4e4e7',
|
||||
};
|
||||
export const errorStyle: CSSProperties = {
|
||||
margin: '10px 0 0', padding: '8px 10px', borderRadius: 4, background: '#3f1d1d', border: '1px solid #7f1d1d', color: '#fecaca', fontSize: 12,
|
||||
};
|
||||
const assetGridStyle: CSSProperties = {
|
||||
maxHeight: 140, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 4, background: '#09090b', borderRadius: 6, padding: 4,
|
||||
};
|
||||
|
||||
export interface ImagePickModalProps {
|
||||
onPick: (hex: string) => void;
|
||||
onClose: () => void;
|
||||
/** The selected image's src, offered as the starting image. */
|
||||
initialSrc?: string;
|
||||
}
|
||||
|
||||
const ZOOM = 9;
|
||||
|
||||
export const ImagePickModal: React.FC<ImagePickModalProps> = ({ onPick, onClose, initialSrc }) => {
|
||||
const assets = useImageAssets();
|
||||
const [src, setSrc] = useState<string | null>(initialSrc || null);
|
||||
const state = useSampledImage(src);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const zoomRef = useRef<HTMLCanvasElement>(null);
|
||||
const [hover, setHover] = useState<string>('');
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'ready' && canvasRef.current) state.img.draw(canvasRef.current);
|
||||
}, [state]);
|
||||
|
||||
const locate = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (state.status !== 'ready') return null;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return null;
|
||||
return {
|
||||
x: Math.floor(((e.clientX - rect.left) / rect.width) * state.img.width),
|
||||
y: Math.floor(((e.clientY - rect.top) / rect.height) * state.img.height),
|
||||
};
|
||||
};
|
||||
|
||||
const onMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const p = locate(e);
|
||||
if (!p || state.status !== 'ready') return;
|
||||
setHover(sampleAt(state.img.imageData, p.x, p.y));
|
||||
setNote('');
|
||||
const z = zoomRef.current?.getContext?.('2d');
|
||||
if (z && canvasRef.current) {
|
||||
z.imageSmoothingEnabled = false;
|
||||
z.clearRect(0, 0, 72, 72);
|
||||
z.drawImage(canvasRef.current, p.x - 4, p.y - 4, ZOOM, ZOOM, 0, 0, 72, 72);
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const p = locate(e);
|
||||
if (!p || state.status !== 'ready') return;
|
||||
const hex = sampleAt(state.img.imageData, p.x, p.y);
|
||||
if (!hex) { setNote('That spot is transparent. Click a coloured part of the image.'); return; }
|
||||
onPick(hex);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} backdropProps={{ role: 'dialog', 'aria-modal': true, 'aria-label': 'Pick a colour from an image' } as React.HTMLAttributes<HTMLDivElement>}>
|
||||
<div style={modalPanelStyle} data-testid="image-pick-modal">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<h3 style={modalTitleStyle}>Pick a colour from an image</h3>
|
||||
<button type="button" style={modalBtnStyle} onClick={onClose} data-testid="image-pick-close">Close</button>
|
||||
</div>
|
||||
<ImageAssetList assets={assets} selected={src} onSelect={setSrc} />
|
||||
{state.status === 'loading' && <p style={{ color: '#a1a1aa' }}>Loading image...</p>}
|
||||
{state.status === 'failed' && <div role="alert" style={errorStyle} data-testid="image-pick-error">{state.message}</div>}
|
||||
{src && state.status !== 'failed' && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
data-testid="image-pick-canvas"
|
||||
onMouseMove={onMove}
|
||||
onMouseLeave={() => setHover('')}
|
||||
onClick={onClick}
|
||||
style={{ display: state.status === 'ready' ? 'block' : 'none', maxWidth: '100%', maxHeight: 280, margin: '0 auto', cursor: 'crosshair', border: '1px solid #3f3f46', borderRadius: 4 }}
|
||||
/>
|
||||
{state.status === 'ready' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 8 }}>
|
||||
<canvas ref={zoomRef} width={72} height={72} aria-hidden="true" style={{ width: 72, height: 72, border: '1px solid #3f3f46', borderRadius: 4, imageRendering: 'pixelated', background: '#09090b' }} />
|
||||
<span data-testid="image-pick-swatch" style={{ width: 28, height: 28, borderRadius: 4, border: '1px solid #3f3f46', background: hover || 'transparent' }} />
|
||||
<code data-testid="image-pick-hex" style={{ fontSize: 13 }}>{hover || 'Move over the image'}</code>
|
||||
</div>
|
||||
)}
|
||||
{note && <p style={{ color: '#fbbf24', margin: '6px 0 0' }}>{note}</p>}
|
||||
</div>
|
||||
)}
|
||||
{!src && <p style={{ color: '#a1a1aa', marginTop: 10 }}>Choose an uploaded image above, then click a spot in it to use that colour.</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
vi.mock('../../../utils/assets', () => ({ listAssets: vi.fn(), uploadAsset: vi.fn() }));
|
||||
vi.mock('../../../utils/image-sample', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../utils/image-sample')>('../../../utils/image-sample');
|
||||
return { ...actual, loadImageForSampling: vi.fn() };
|
||||
});
|
||||
import { listAssets } from '../../../utils/assets';
|
||||
import { loadImageForSampling, CROSS_ORIGIN_MESSAGE } from '../../../utils/image-sample';
|
||||
import { PaletteFromImageModal } from './PaletteFromImageModal';
|
||||
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const listMock = listAssets as unknown as ReturnType<typeof vi.fn>;
|
||||
const loadMock = loadImageForSampling as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
// 10 px: 5 red, 3 blue, 2 green
|
||||
function image() {
|
||||
const px: number[] = [];
|
||||
const push = (c: number[], n: number) => { for (let i = 0; i < n; i++) px.push(...c, 255); };
|
||||
push([255, 0, 0], 5); push([0, 0, 255], 3); push([0, 160, 0], 2);
|
||||
return { ok: true, width: 10, height: 1, imageData: { width: 10, height: 1, data: new Uint8ClampedArray(px) }, draw: vi.fn() };
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
async function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
await act(async () => { root = createRoot(container); root.render(ui); });
|
||||
await act(async () => {});
|
||||
}
|
||||
afterEach(() => { act(() => root.unmount()); container.remove(); vi.clearAllMocks(); });
|
||||
beforeEach(() => { listMock.mockResolvedValue([{ name: 'a.png', url: '/a.png', type: 'image/png' }]); });
|
||||
const q = (id: string) => document.querySelector(`[data-testid="${id}"]`) as HTMLButtonElement | null;
|
||||
const click = (el: HTMLElement | null) => act(() => { el!.click(); });
|
||||
|
||||
describe('PaletteFromImageModal', () => {
|
||||
test('nothing is applied automatically', async () => {
|
||||
loadMock.mockResolvedValue(image());
|
||||
const onAdd = vi.fn();
|
||||
await render(<PaletteFromImageModal onAdd={onAdd} onClose={vi.fn()} existingHexes={[]} initialSrc="/a.png" />);
|
||||
expect(q('palette-row-#ff0000')).toBeTruthy();
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('swatches in population order, Add calls onAdd then shows Added', async () => {
|
||||
loadMock.mockResolvedValue(image());
|
||||
const onAdd = vi.fn();
|
||||
await render(<PaletteFromImageModal onAdd={onAdd} onClose={vi.fn()} existingHexes={[]} initialSrc="/a.png" />);
|
||||
const rows = [...document.querySelectorAll('[data-testid^="palette-row-"]')].map((r) => r.getAttribute('data-testid'));
|
||||
expect(rows).toEqual(['palette-row-#ff0000', 'palette-row-#0000ff', 'palette-row-#00a000']);
|
||||
click(q('palette-add-#0000ff'));
|
||||
expect(onAdd).toHaveBeenCalledWith('#0000ff');
|
||||
expect(q('palette-add-#0000ff')!.disabled).toBe(true);
|
||||
expect(q('palette-add-#0000ff')!.textContent).toBe('Added');
|
||||
});
|
||||
|
||||
test('existing hexes are disabled / Added (case-insensitive)', async () => {
|
||||
loadMock.mockResolvedValue(image());
|
||||
await render(<PaletteFromImageModal onAdd={vi.fn()} onClose={vi.fn()} existingHexes={['#FF0000']} initialSrc="/a.png" />);
|
||||
expect(q('palette-add-#ff0000')!.disabled).toBe(true);
|
||||
expect(q('palette-add-#ff0000')!.textContent).toBe('Added');
|
||||
expect(q('palette-add-#0000ff')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
test('Add all adds only the not-yet-added colours', async () => {
|
||||
loadMock.mockResolvedValue(image());
|
||||
const onAdd = vi.fn();
|
||||
await render(<PaletteFromImageModal onAdd={onAdd} onClose={vi.fn()} existingHexes={['#ff0000']} initialSrc="/a.png" />);
|
||||
click(q('palette-add-all'));
|
||||
expect(onAdd.mock.calls.map((c) => c[0])).toEqual(['#0000ff', '#00a000']);
|
||||
expect(q('palette-add-all')!.disabled).toBe(true);
|
||||
});
|
||||
|
||||
test('picking a listed asset loads it', async () => {
|
||||
loadMock.mockResolvedValue(image());
|
||||
await render(<PaletteFromImageModal onAdd={vi.fn()} onClose={vi.fn()} existingHexes={[]} />);
|
||||
expect(q('palette-row-#ff0000')).toBeNull();
|
||||
await act(async () => { q('image-asset-a.png')!.click(); });
|
||||
expect(loadMock).toHaveBeenCalledWith('/a.png');
|
||||
expect(q('palette-row-#ff0000')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('loader failure shown as role=alert, no swatches', async () => {
|
||||
loadMock.mockResolvedValue({ ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE });
|
||||
await render(<PaletteFromImageModal onAdd={vi.fn()} onClose={vi.fn()} existingHexes={[]} initialSrc="https://x.test/a.png" />);
|
||||
expect(document.querySelector('[role="alert"]')?.textContent).toBe(CROSS_ORIGIN_MESSAGE);
|
||||
expect(document.querySelector('[data-testid^="palette-row-"]')).toBeNull();
|
||||
});
|
||||
|
||||
test('Escape closes', async () => {
|
||||
const onClose = vi.fn();
|
||||
await render(<PaletteFromImageModal onAdd={vi.fn()} onClose={onClose} existingHexes={[]} />);
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Modal } from '../../../ui/Modal';
|
||||
import { extractPalette } from '../../../utils/palette-extract';
|
||||
import {
|
||||
ImageAssetList, useImageAssets, useSampledImage,
|
||||
modalPanelStyle, modalTitleStyle, modalBtnStyle, errorStyle,
|
||||
} from './ImagePickModal';
|
||||
|
||||
export interface PaletteFromImageModalProps {
|
||||
onAdd: (hex: string, label?: string) => void;
|
||||
onClose: () => void;
|
||||
existingHexes: string[];
|
||||
/** The selected image's src, offered as the starting image. */
|
||||
initialSrc?: string;
|
||||
}
|
||||
|
||||
export const PaletteFromImageModal: React.FC<PaletteFromImageModalProps> = ({ onAdd, onClose, existingHexes, initialSrc }) => {
|
||||
const assets = useImageAssets();
|
||||
const [src, setSrc] = useState<string | null>(initialSrc || null);
|
||||
const [count, setCount] = useState(6);
|
||||
const [added, setAdded] = useState<string[]>([]);
|
||||
const state = useSampledImage(src);
|
||||
|
||||
useEffect(() => { setAdded([]); }, [src]);
|
||||
|
||||
const colours = useMemo(
|
||||
() => (state.status === 'ready' ? extractPalette(state.img.imageData.data, { count }) : []),
|
||||
[state, count],
|
||||
);
|
||||
const taken = useMemo(() => new Set([...existingHexes, ...added].map((h) => h.toLowerCase())), [existingHexes, added]);
|
||||
|
||||
const add = (hex: string) => {
|
||||
onAdd(hex);
|
||||
setAdded((a) => [...a, hex]);
|
||||
};
|
||||
const addAll = () => colours.filter((h) => !taken.has(h)).forEach(add);
|
||||
const remaining = colours.filter((h) => !taken.has(h)).length;
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} backdropProps={{ role: 'dialog', 'aria-modal': true, 'aria-label': 'Palette from an image' } as React.HTMLAttributes<HTMLDivElement>}>
|
||||
<div style={modalPanelStyle} data-testid="palette-from-image-modal">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<h3 style={modalTitleStyle}>Palette from an image</h3>
|
||||
<button type="button" style={modalBtnStyle} onClick={onClose} data-testid="palette-close">Close</button>
|
||||
</div>
|
||||
<ImageAssetList assets={assets} selected={src} onSelect={setSrc} />
|
||||
{!src && <p style={{ color: '#a1a1aa', marginTop: 10 }}>Choose an uploaded image to see its main colours. Nothing is added until you press Add.</p>}
|
||||
{state.status === 'loading' && <p style={{ color: '#a1a1aa' }}>Reading colours...</p>}
|
||||
{state.status === 'failed' && <div role="alert" style={errorStyle} data-testid="palette-error">{state.message}</div>}
|
||||
{state.status === 'ready' && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<label htmlFor="palette-count">Colours</label>
|
||||
<select id="palette-count" data-testid="palette-count" value={count} onChange={(e) => setCount(Number(e.target.value))} className="guided-input" style={{ width: 60 }}>
|
||||
{[5, 6, 7, 8].map((n) => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button type="button" style={modalBtnStyle} onClick={addAll} disabled={remaining === 0} data-testid="palette-add-all">Add all</button>
|
||||
</div>
|
||||
{colours.length === 0 && <p style={{ color: '#a1a1aa' }}>No colours found. This image may be fully transparent.</p>}
|
||||
{colours.map((hex) => {
|
||||
const done = taken.has(hex);
|
||||
return (
|
||||
<div key={hex} data-testid={`palette-row-${hex}`} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '4px 0' }}>
|
||||
<span style={{ width: 28, height: 28, borderRadius: 4, border: '1px solid #3f3f46', background: hex }} />
|
||||
<code style={{ flex: 1, fontSize: 13 }}>{hex}</code>
|
||||
<button type="button" style={{ ...modalBtnStyle, opacity: done ? 0.6 : 1 }} disabled={done} onClick={() => add(hex)} data-testid={`palette-add-${hex}`}>
|
||||
{done ? 'Added' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user