Skip to content

Commit

Permalink
feat: add Palette component (#1304)
Browse files Browse the repository at this point in the history
  • Loading branch information
Ruminat authored Mar 15, 2024
1 parent 9251714 commit e731838
Show file tree
Hide file tree
Showing 11 changed files with 943 additions and 0 deletions.
35 changes: 35 additions & 0 deletions src/components/Palette/Palette.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
@use '../variables';

$block: '.#{variables.$ns}palette';

#{$block} {
display: inline-flex;
flex-flow: column wrap;
gap: 8px;

&:focus {
border: none;
outline: none;
}

&__row {
display: inline-flex;
gap: 8px;
}

&_size_xs &__option {
font-size: 12px;
}
&_size_s &__option {
font-size: 16px;
}
&_size_m &__option {
font-size: 16px;
}
&_size_l &__option {
font-size: 16px;
}
&_size_xl &__option {
font-size: 20px;
}
}
210 changes: 210 additions & 0 deletions src/components/Palette/Palette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import React from 'react';

import {useSelect} from '../../hooks';
import {useForkRef} from '../../hooks/useForkRef/useForkRef';
import type {ButtonProps} from '../Button';
import {Button} from '../Button';
import type {ControlGroupProps, DOMProps, QAProps} from '../types';
import {block} from '../utils/cn';

import {usePaletteGrid} from './hooks';
import {getPaletteRows} from './utils';

import './Palette.scss';

const b = block('palette');

export type PaletteOption = Pick<ButtonProps, 'disabled' | 'title'> & {
/**
* Option value, which you can use in state or send to back-end and so on.
*/
value: string;
/**
* Content inside the option (emoji/image/GIF/symbol etc).
*
* Uses `value` as default, if `value` is a number, then it is treated as a unicode symbol (emoji for example).
*
* @default props.value
*/
content?: React.ReactNode;
};

export interface PaletteProps
extends Pick<ControlGroupProps, 'aria-label' | 'aria-labelledby'>,
Pick<ButtonProps, 'disabled' | 'size'>,
DOMProps,
QAProps {
/**
* Allows selecting multiple options.
*
* @default true
*/
multiple?: boolean;
/**
* Current value (which options are selected).
*/
value?: string[];
/**
* The control's default value. Use when the component is not controlled.
*/
defaultValue?: string[];
/**
* List of Palette options (the grid).
*/
options?: PaletteOption[];
/**
* How many options are there per row.
*
* @default 6
*/
columns?: number;
/**
* HTML class attribute for a grid row.
*/
rowClassName?: string;
/**
* HTML class attribute for a grid option.
*/
optionClassName?: string;
/**
* Fires when a user (un)selects an option.
*/
onUpdate?: (value: string[]) => void;
/**
* Fires when a user focuses on the Palette.
*/
onFocus?: (event: React.FocusEvent) => void;
/**
* Fires when a user blurs from the Palette.
*/
onBlur?: (event: React.FocusEvent) => void;
}

interface PaletteComponent
extends React.ForwardRefExoticComponent<PaletteProps & React.RefAttributes<HTMLDivElement>> {}

export const Palette = React.forwardRef<HTMLDivElement, PaletteProps>(function Palette(props, ref) {
const {
size = 'm',
multiple = true,
options = [],
columns = 6,
disabled,
style,
className,
rowClassName,
optionClassName,
qa,
onFocus,
onBlur,
} = props;

const [focusedOptionIndex, setFocusedOptionIndex] = React.useState<number | undefined>(
undefined,
);
const focusedOption =
focusedOptionIndex === undefined ? undefined : options[focusedOptionIndex];

const innerRef = React.useRef<HTMLDivElement>(null);
const handleRef = useForkRef(ref, innerRef);

const {value, handleSelection} = useSelect({
value: props.value,
defaultValue: props.defaultValue,
multiple,
onUpdate: props.onUpdate,
});

const rows = React.useMemo(() => getPaletteRows(options, columns), [columns, options]);

const focusOnOptionWithIndex = React.useCallback((index: number) => {
if (!innerRef.current) return;

const $options = Array.from(
innerRef.current.querySelectorAll(`.${b('option')}`),
) as HTMLButtonElement[];

if (!$options[index]) return;

$options[index].focus();

setFocusedOptionIndex(index);
}, []);

const tryToFocus = (newIndex: number) => {
if (newIndex === focusedOptionIndex || newIndex < 0 || newIndex >= options.length) {
return;
}

focusOnOptionWithIndex(newIndex);
};

const gridProps = usePaletteGrid({
disabled,
onFocus: (event) => {
focusOnOptionWithIndex(0);
onFocus?.(event);
},
onBlur: (event) => {
setFocusedOptionIndex(undefined);
onBlur?.(event);
},
whenFocused:
focusedOptionIndex !== undefined && focusedOption
? {
selectItem: () => handleSelection(focusedOption),
nextItem: () => tryToFocus(focusedOptionIndex + 1),
previousItem: () => tryToFocus(focusedOptionIndex - 1),
nextRow: () => tryToFocus(focusedOptionIndex + columns),
previousRow: () => tryToFocus(focusedOptionIndex - columns),
}
: undefined,
});

return (
<div
{...gridProps}
aria-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
ref={handleRef}
className={b({size}, className)}
style={style}
data-qa={qa}
>
{rows.map((row, rowNumber) => (
<div className={b('row', rowClassName)} key={`row-${rowNumber}`} role="row">
{row.map((option) => {
const isSelected = Boolean(value.includes(option.value));
const focused = option === focusedOption;

return (
<div
key={option.value}
role="gridcell"
aria-selected={focused ? 'true' : undefined}
aria-readonly={option.disabled}
>
<Button
className={b('option', optionClassName)}
tabIndex={-1}
style={style}
disabled={disabled || option.disabled}
title={option.title}
view={isSelected ? 'normal' : 'flat'}
selected={isSelected}
extraProps={{value: option.value}}
size={size}
onClick={() => handleSelection(option)}
>
<Button.Icon>{option.content ?? option.value}</Button.Icon>
</Button>
</div>
);
})}
</div>
))}
</div>
);
}) as PaletteComponent;

Palette.displayName = 'Palette';
Loading

0 comments on commit e731838

Please sign in to comment.