Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Palette-based color picker #6028

Merged
merged 9 commits into from Jan 27, 2021
3 changes: 3 additions & 0 deletions .eslintrc.js
Expand Up @@ -75,6 +75,9 @@ module.exports = {
'error',
{ ignoreRestSiblings: true },
],
// This rule does not support FunctionComponent<Props> and so
// makes using (eg) children props more of a pain than it should be
'react/prop-types': 'off',
},
},
],
Expand Down
6 changes: 6 additions & 0 deletions common/babel.js
Expand Up @@ -10,9 +10,15 @@ module.exports = function(api) {
},
],
];
const env = {
test: {
plugins: ['dynamic-import-node'],
},
};

return {
presets,
plugins,
env,
};
};
13 changes: 13 additions & 0 deletions common/test/setupTests.js
Expand Up @@ -2,3 +2,16 @@ import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

configure({ adapter: new Adapter() });

// This is required for dynamic imports to work in jest
// Solution from here: https://github.com/vercel/next.js/discussions/18855
jest.mock('next/dynamic', () => (func: () => Promise<any>) => {
let component: any = null;
func().then((module: any) => {
component = module.default;
});
const DynamicComponent = (...args) => component(...args);
DynamicComponent.displayName = 'LoadableComponent';
DynamicComponent.preload = jest.fn();
return DynamicComponent;
});
2 changes: 2 additions & 0 deletions common/utils/numeric.ts
@@ -0,0 +1,2 @@
export const clamp = (x: number, min = 0, max = 1): number =>
x > max ? max : x < min ? min : x;
2 changes: 1 addition & 1 deletion common/views/components/ColorPicker/ColorPicker.tsx
Expand Up @@ -12,7 +12,7 @@ import debounce from 'lodash.debounce';

interface Props {
name: string;
color: string | null;
color?: string;
onChangeColor: (color?: string) => void;
}

Expand Down
16 changes: 14 additions & 2 deletions common/views/components/ModalFilters/ModalFilters.tsx
@@ -1,4 +1,10 @@
import { useState, useRef, FunctionComponent, ReactElement } from 'react';
import {
useState,
useRef,
FunctionComponent,
ReactElement,
useContext,
} from 'react';
import NextLink from 'next/link';
import dynamic from 'next/dynamic';
import { worksLink } from '../../../services/catalogue/routes';
Expand All @@ -14,10 +20,14 @@ import ButtonSolid, {
SolidButton,
} from '../ButtonSolid/ButtonSolid';
import { SearchFiltersSharedProps } from '../SearchFilters/SearchFilters';
import TogglesContext from '../TogglesContext/TogglesContext';

const ColorPicker = dynamic(import('../ColorPicker/ColorPicker'), {
const OldColorPicker = dynamic(import('../ColorPicker/ColorPicker'), {
ssr: false,
});
const PaletteColorPicker = dynamic(
import('../PaletteColorPicker/PaletteColorPicker')
);

const ActiveFilters = styled(Space).attrs({
h: {
Expand Down Expand Up @@ -93,6 +103,8 @@ const ModalFilters: FunctionComponent<SearchFiltersSharedProps> = ({
}: SearchFiltersSharedProps): ReactElement<SearchFiltersSharedProps> => {
const [isActive, setIsActive] = useState(false);
const openButtonRef = useRef(null);
const { paletteColorFilter } = useContext(TogglesContext);
const ColorPicker = paletteColorFilter ? PaletteColorPicker : OldColorPicker;

function handleOkFiltersButtonClick() {
setIsActive(false);
Expand Down
175 changes: 175 additions & 0 deletions common/views/components/PaletteColorPicker/HueSlider.tsx
@@ -0,0 +1,175 @@
import styled from 'styled-components';
import React, {
FunctionComponent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { clamp } from '../../../utils/numeric';

type Props = {
hue: number;
onChangeHue: (value: number) => void;
};

const HueBar = styled.div`
position: relative;
width: 100%;
height: 28px;
background: linear-gradient(
to right,
#f00 0%,
#ff0 17%,
#0f0 33%,
#0ff 50%,
#00f 67%,
#f0f 83%,
#f00 100%
);
`;

type HandleProps = {
leftOffset: number;
};

const Handle = styled.span.attrs<HandleProps>(({ leftOffset }) => ({
style: {
left: `${leftOffset * 100}%`,
},
}))<HandleProps>`
display: inline-block;
position: absolute;
top: 50%;
width: 6px;
height: 24px;
transform: translate(-50%, -50%);
background-color: white;
box-shadow: 0 0 1px rgba(black, 0.5);
border-radius: 2px;
`;

type InteractionEvent = MouseEvent | TouchEvent;
type ReactInteractionEvent = React.MouseEvent | React.TouchEvent;

const isTouch = (e: InteractionEvent): e is TouchEvent => 'touches' in e;
const useIsomorphicLayoutEvent =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't see where this is going to be useful? useLayoutEffect is only ever run on the client?

Unless the check is doing something that isn't that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, I wonder if we could just use useLayoutEffect though. Happy for either.

typeof window === 'undefined' ? useEffect : useLayoutEffect;
const nKeyboardDetents = 50;

const getPosition = <T extends HTMLElement>(
node: T,
event: InteractionEvent
): number => {
const { left, width } = node.getBoundingClientRect();
const { pageX } = isTouch(event) ? event.touches[0] : event;
return clamp((pageX - (left + window.pageXOffset)) / width);
};

const HueSlider: FunctionComponent<Props> = ({
hue,
onChangeHue,
...otherProps
}) => {
const container = useRef<HTMLDivElement>(null);
const hasTouched = useRef(false);
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState(hue / 360);

// Prevent mobile from handling mouse events as well as touch
const isValid = (event: InteractionEvent): boolean => {
if (hasTouched.current && !isTouch(event)) {
return false;
}
if (!hasTouched.current) {
hasTouched.current = isTouch(event);
}
return true;
};

const handleMove = useCallback((event: InteractionEvent) => {
event.preventDefault();
const mouseIsDown = isTouch(event)
? event.touches.length > 0
: event.buttons > 0;
if (mouseIsDown && container.current) {
const pos = getPosition(container.current, event);
setPosition(pos);
} else {
setIsDragging(false);
}
}, []);

const handleMoveStart = useCallback(
({ nativeEvent: event }: ReactInteractionEvent) => {
event.preventDefault();
if (isValid(event) && container.current) {
const pos = getPosition(container.current, event);
setPosition(pos);
setIsDragging(true);
}
},
[]
);

const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
const keyCode = parseInt(event.key, 10) || event.which || event.keyCode;
if (keyCode === 39 /* right */ || keyCode === 37 /* left */) {
event.preventDefault();
const delta = (keyCode === 39 ? 360 : -360) / nKeyboardDetents;
const nextValue = clamp(hue + delta, 0, 360);
onChangeHue(nextValue);
}
},
[hue, onChangeHue]
);

const handleMoveEnd = useCallback(() => {
setIsDragging(false);
onChangeHue(Math.round(position * 360));
}, [onChangeHue, position]);

const toggleDocumentEvents = useCallback(
(attach: boolean) => {
const toggleEvent = attach
? window.addEventListener
: window.removeEventListener;
toggleEvent(hasTouched.current ? 'touchmove' : 'mousemove', handleMove);
toggleEvent(hasTouched.current ? 'touchend' : 'mouseup', handleMoveEnd);
},
[handleMove, handleMoveEnd]
);

useIsomorphicLayoutEvent(() => {
toggleDocumentEvents(isDragging);
return () => {
if (isDragging) {
toggleDocumentEvents(false);
}
};
}, [isDragging, toggleDocumentEvents]);

useEffect(() => {
setPosition(hue / 360);
}, [hue]);

return (
<HueBar
ref={container}
onTouchStart={handleMoveStart}
onMouseDown={handleMoveStart}
onKeyDown={handleKeyDown}
tabIndex={0}
role="slider"
aria-label="Hue"
aria-valuetext={hue.toString()}
{...otherProps}
>
<Handle leftOffset={isDragging ? position : hue / 360} />
</HueBar>
);
};

export default HueSlider;
124 changes: 124 additions & 0 deletions common/views/components/PaletteColorPicker/PaletteColorPicker.tsx
@@ -0,0 +1,124 @@
import styled from 'styled-components';
import { FunctionComponent, useEffect, useRef, useState } from 'react';
import HueSlider from './HueSlider';
import { hexToHsv, hsvToHex } from './conversions';

type Props = {
name: string;
color?: string;
onChangeColor: (color?: string) => void;
};

const palette: string[] = [
'e02020',
'ff47d1',
'fa6400',
'f7b500',
'8b572a',
'6dd400',
'22bbff',
'8339e8',
'000000',
'd9d3d3',
];

const Wrapper = styled.div`
padding-top: 6px;
max-width: 250px;
`;

const Swatches = styled.div`
display: flex;
flex-wrap: wrap;
`;

const Swatch = styled.button.attrs({ type: 'button' })<{
color: string;
selected: boolean;
}>`
height: 32px;
width: 32px;
border-radius: 50%;
display: inline-block;
background-color: ${({ color }) => `#${color}`};
margin: 4px;
border: ${({ selected }) => (selected ? '3px solid #555' : 'none')};
`;

const Slider = styled(HueSlider)`
margin-top: 15px;
`;

const ColorLabel = styled.label<{ active: boolean }>`
font-style: italic;
color: ${({ active }) => (active ? '#121212' : '#565656')};
font-size: 14px;
`;

const ClearButton = styled.button.attrs({ type: 'button' })`
border: 0;
padding: 0;
background: none;
text-decoration: underline;
color: #121212;
font-size: 12px;
`;

const TextWrapper = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
`;

const PaletteColorPicker: FunctionComponent<Props> = ({
name,
color,
onChangeColor,
}) => {
// Because the form is not controlled we need to maintain state internally
const [colorState, setColorState] = useState(color);
const firstRender = useRef(true);

useEffect(() => {
setColorState(color);
}, [color]);

useEffect(() => {
if (!firstRender.current) {
onChangeColor(color);
} else {
firstRender.current = false;
}
}, [colorState]);

return (
<Wrapper>
<input type="hidden" name={name} value={colorState || ''} />
<Swatches>
{palette.map(swatch => (
<Swatch
key={swatch}
color={swatch}
selected={colorState === swatch}
onClick={() => setColorState(swatch)}
/>
))}
</Swatches>
<Slider
hue={hexToHsv(colorState || palette[0]).h}
onChangeHue={h => setColorState(hsvToHex({ h, s: 80, v: 90 }))}
/>
<TextWrapper>
<ColorLabel active={!!colorState}>
{colorState ? `#${colorState.toUpperCase()}` : 'None'}
</ColorLabel>
<ClearButton onClick={() => setColorState(undefined)}>
Clear
</ClearButton>
</TextWrapper>
</Wrapper>
);
};

export default PaletteColorPicker;