feat(ui): enhance custom color picker and fix docs (#169) thank you @non-stop-dev

* Update link to prioritized tasks document

* Fix link to FOSSFLOW_TODO.md in CONTRIBUTING.md

* Fix link to FOSSFLOW_TODO.md in CONTRIBUTING.md

* feat: Introduce CustomColorInput component with hex input and eyedropper, and integrate it into ConnectorControls and RectangleControls.

* feat: add custom color input component with eyedropper functionality

* test: Add comprehensive tests for the CustomColorInput component and include jest-dom matchers in ColorSelector tests.

* docs: Update encyclopedia and TODO list file references to FOSSFLOW_ prefix.

* Update packages/fossflow-lib/src/components/ColorSelector/CustomColorInput.tsx

Thanks Leo for confirming the Spanish translation, much appreciated!
This commit is contained in:
Leo
2025-11-28 01:31:39 -05:00
committed by GitHub
parent dd92c7ebc3
commit f56812c24e
9 changed files with 305 additions and 39 deletions

View File

@@ -135,7 +135,7 @@ FossFLOW/
- `bug` - Bug fixes
- `enhancement` - New features
3. Check [ISOFLOW_TODO.md](./ISOFLOW_TODO.md) for prioritized tasks
3. Check [FOSSFLOW_TODO.md](./FOSSFLOW_TODO.md) for prioritized tasks
### Types of Contributions
@@ -442,8 +442,8 @@ docker run -p 80:80 stnsmith/fossflow:latest
- **GitHub Issues**: For bugs and feature requests
- **Discussions**: For questions and ideas
- **Code Encyclopedia**: See [ISOFLOW_ENCYCLOPEDIA.md](./ISOFLOW_ENCYCLOPEDIA.md)
- **TODO List**: See [ISOFLOW_TODO.md](./ISOFLOW_TODO.md)
- **Code Encyclopedia**: See [FOSSFLOW_ENCYCLOPEDIA.md](./FOSSFLOW_ENCYCLOPEDIA.md)
- **TODO List**: See [FOSSFLOW_TODO.md](./FOSSFLOW_TODO.md)
### Communication Guidelines

22
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "fossflow-monorepo",
"version": "1.5.1",
"version": "1.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fossflow-monorepo",
"version": "1.5.1",
"version": "1.7.0",
"workspaces": [
"packages/*"
],
@@ -2560,9 +2560,9 @@
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -12476,9 +12476,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -20927,7 +20927,7 @@
}
},
"packages/fossflow-app": {
"version": "1.5.1",
"version": "1.7.0",
"dependencies": {
"@isoflow/isopacks": "^0.0.10",
"fossflow": "^1.1.0",
@@ -20951,7 +20951,7 @@
}
},
"packages/fossflow-backend": {
"version": "1.5.1",
"version": "1.7.0",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.6.1",
@@ -20964,7 +20964,7 @@
},
"packages/fossflow-lib": {
"name": "fossflow",
"version": "1.5.1",
"version": "1.7.0",
"license": "MIT",
"dependencies": {
"@emotion/react": "^11.14.0",

View File

@@ -52,7 +52,7 @@
"diagramExists": "Ya existe un diagrama llamado \"{{name}}\" en esta sesión. Esto lo sobrescribirá. ¿Estás seguro de que deseas continuar?",
"unsavedChanges": "Tienes cambios sin guardar. ¿Continuar cargando?",
"createNewDiagram": "¿Crear un nuevo diagrama?",
"unsavedChangesExport": "Tienes cambios sin guardar. Exporta tu diagrama primero para guardarlo. ¿Continuar?",
"unsavedChangesExport": "Tienes cambios sin guardar. Exporta tu diagrama primero si no quieres perder los cambios o continúa para comenzar uno nuevo.",
"confirmDelete": "¿Estás seguro de que deseas eliminar este diagrama?",
"storageFull": "¡Almacenamiento lleno! Abriendo el gestor de almacenamiento...",
"autoSaveFailed": "¡Almacenamiento lleno! Por favor usa el gestor de almacenamiento para liberar espacio.",

View File

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,86 @@
import React, { useState, useEffect } from 'react';
import { Box, TextField, IconButton, Tooltip } from '@mui/material';
import { Colorize as ColorizeIcon } from '@mui/icons-material';
import { ColorPicker } from './ColorPicker';
interface EyeDropper {
open: (options?: { signal?: AbortSignal }) => Promise<{ sRGBHex: string }>;
}
declare global {
interface Window {
EyeDropper?: {
new (): EyeDropper;
};
}
}
interface Props {
value: string;
onChange: (color: string) => void;
}
export const CustomColorInput = ({ value, onChange }: Props) => {
const [localValue, setLocalValue] = useState(value);
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleEyeDropper = async () => {
if (!window.EyeDropper) return;
const eyeDropper = new window.EyeDropper();
try {
const result = await eyeDropper.open();
onChange(result.sRGBHex);
} catch (e) {
// User canceled or failed
}
};
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
// If it's a valid hex, update immediately
if (/^#[0-9A-F]{6}$/i.test(newValue)) {
onChange(newValue);
}
};
const handleBlur = () => {
// On blur, if invalid, revert to prop value
if (!/^#[0-9A-F]{6}$/i.test(localValue)) {
setLocalValue(value);
}
};
const hasEyeDropper = typeof window !== 'undefined' && !!window.EyeDropper;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ColorPicker value={value} onChange={onChange} />
<TextField
value={localValue}
onChange={handleTextChange}
onBlur={handleBlur}
variant="standard"
size="small"
InputProps={{
disableUnderline: true,
sx: {
fontSize: '0.875rem',
color: 'text.secondary',
width: '80px'
}
}}
/>
{hasEyeDropper && (
<Tooltip title="Pick color from screen">
<IconButton onClick={handleEyeDropper} size="small">
<ColorizeIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
);
};

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ColorSelector } from '../ColorSelector';
import { ThemeProvider } from '@mui/material/styles';
import { theme } from 'src/styles/theme';

View File

@@ -0,0 +1,165 @@
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import '@testing-library/jest-dom';
import { CustomColorInput } from '../CustomColorInput';
import { ThemeProvider } from '@mui/material/styles';
import { theme } from 'src/styles/theme';
// Mock ColorPicker since we don't need to test external library behavior
jest.mock('../ColorPicker', () => ({
ColorPicker: ({ value, onChange }: { value: string; onChange: (color: string) => void }) => (
<div data-testid="color-picker" onClick={() => onChange('#FFFFFF')}>
{value}
</div>
)
}));
describe('CustomColorInput', () => {
const defaultProps = {
value: '#FF0000',
onChange: jest.fn()
};
const renderComponent = (props = {}) => {
return render(
<ThemeProvider theme={theme}>
<CustomColorInput {...defaultProps} {...props} />
</ThemeProvider>
);
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly with initial value', () => {
renderComponent();
const input = screen.getByRole('textbox') as HTMLInputElement;
expect(input.value).toBe('#FF0000');
expect(screen.getByTestId('color-picker')).toHaveTextContent('#FF0000');
});
it('updates input value on change', () => {
renderComponent();
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: '#00FF00' } });
expect(input.value).toBe('#00FF00');
});
it('calls onChange when valid hex is entered', () => {
const onChange = jest.fn();
renderComponent({ onChange });
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '#00FF00' } });
expect(onChange).toHaveBeenCalledWith('#00FF00');
});
it('does not call onChange when invalid hex is entered', () => {
const onChange = jest.fn();
renderComponent({ onChange });
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'invalid' } });
expect(onChange).not.toHaveBeenCalled();
});
it('reverts to prop value on blur if input is invalid', () => {
renderComponent({ value: '#FF0000' });
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'invalid' } });
fireEvent.blur(input);
expect(input.value).toBe('#FF0000');
});
it('keeps valid value on blur', () => {
const onChange = jest.fn();
renderComponent({ value: '#FF0000', onChange });
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: '#00FF00' } });
fireEvent.blur(input);
expect(input.value).toBe('#00FF00');
expect(onChange).toHaveBeenCalledWith('#00FF00');
});
it('updates local state when prop value changes', () => {
const { rerender } = renderComponent({ value: '#FF0000' });
const input = screen.getByRole('textbox') as HTMLInputElement;
expect(input.value).toBe('#FF0000');
rerender(
<ThemeProvider theme={theme}>
<CustomColorInput {...defaultProps} value="#0000FF" />
</ThemeProvider>
);
expect(input.value).toBe('#0000FF');
});
describe('EyeDropper interaction', () => {
beforeAll(() => {
// Mock EyeDropper API
Object.defineProperty(window, 'EyeDropper', {
writable: true,
value: jest.fn().mockImplementation(() => ({
open: jest.fn().mockResolvedValue({ sRGBHex: '#123456' })
}))
});
});
afterAll(() => {
// @ts-ignore
delete window.EyeDropper;
});
it('renders eyedropper button when API is supported', () => {
renderComponent();
expect(screen.getByRole('button', { name: /pick color/i })).toBeInTheDocument();
});
it('calls onChange with picked color', async () => {
const onChange = jest.fn();
renderComponent({ onChange });
const button = screen.getByRole('button', { name: /pick color/i });
await act(async () => {
fireEvent.click(button);
});
expect(onChange).toHaveBeenCalledWith('#123456');
});
it('handles EyeDropper cancellation gracefully', async () => {
const onChange = jest.fn();
// Mock rejection (user cancelled)
(window.EyeDropper as any).mockImplementation(() => ({
open: jest.fn().mockRejectedValue(new Error('Canceled'))
}));
renderComponent({ onChange });
const button = screen.getByRole('button', { name: /pick color/i });
await act(async () => {
fireEvent.click(button);
});
expect(onChange).not.toHaveBeenCalled();
});
});
describe('EyeDropper unsupported', () => {
beforeAll(() => {
// @ts-ignore
window.EyeDropper = undefined;
});
it('does not render eyedropper button when API is not supported', () => {
renderComponent();
expect(screen.queryByRole('button', { name: /pick color/i })).not.toBeInTheDocument();
});
});
});

View File

@@ -21,6 +21,7 @@ import {
import { useConnector } from 'src/hooks/useConnector';
import { ColorSelector } from 'src/components/ColorSelector/ColorSelector';
import { ColorPicker } from 'src/components/ColorSelector/ColorPicker';
import { CustomColorInput } from 'src/components/ColorSelector/CustomColorInput';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useScene } from 'src/hooks/useScene';
import {
@@ -317,17 +318,12 @@ export const ConnectorControls = ({ id }: Props) => {
sx={{ mb: 2 }}
/>
{useCustomColor ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<ColorPicker
value={connector.customColor || '#000000'}
onChange={(color) => {
updateConnector(connector.id, { customColor: color });
}}
/>
<Typography variant="body2" color="text.secondary">
{connector.customColor || '#000000'}
</Typography>
</Box>
<CustomColorInput
value={connector.customColor || '#000000'}
onChange={(color) => {
updateConnector(connector.id, { customColor: color });
}}
/>
) : (
<ColorSelector
onChange={(color) => {

View File

@@ -3,6 +3,7 @@ import { Box, IconButton as MUIIconButton, FormControlLabel, Switch, Typography
import { useRectangle } from 'src/hooks/useRectangle';
import { ColorSelector } from 'src/components/ColorSelector/ColorSelector';
import { ColorPicker } from 'src/components/ColorSelector/ColorPicker';
import { CustomColorInput } from 'src/components/ColorSelector/CustomColorInput';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useScene } from 'src/hooks/useScene';
import { Close as CloseIcon } from '@mui/icons-material';
@@ -63,17 +64,12 @@ export const RectangleControls = ({ id }: Props) => {
sx={{ mb: 2 }}
/>
{useCustomColor ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<ColorPicker
value={rectangle.customColor || '#000000'}
onChange={(color) => {
updateRectangle(rectangle.id, { customColor: color });
}}
/>
<Typography variant="body2" color="text.secondary">
{rectangle.customColor || '#000000'}
</Typography>
</Box>
<CustomColorInput
value={rectangle.customColor || '#000000'}
onChange={(color) => {
updateRectangle(rectangle.id, { customColor: color });
}}
/>
) : (
<ColorSelector
onChange={(color) => {