Skip to content

Commit

Permalink
Merge pull request #6028 from wellcomecollection/palette-picker
Browse files Browse the repository at this point in the history
  • Loading branch information
jamieparkinson committed Jan 27, 2021
2 parents bff1374 + 74aff9b commit a6d54e7
Show file tree
Hide file tree
Showing 12 changed files with 446 additions and 8 deletions.
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 =
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;

0 comments on commit a6d54e7

Please sign in to comment.