From 51931108e0f89d2ee7eee0b674860c5cc2cd3d0b Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Fri, 18 Feb 2022 17:54:15 -0300 Subject: [PATCH] feat(fuselage): Extend menu options props (#650) * feat: extend menu options props * Rearrange stories * Handle option types on `useCursor` * Prefer optional call * Keep `MenuProps` not exported * Prefer `ReactNode` * chore: duplicated menuOptions * Revert "chore: duplicated menuOptions" This reverts commit b0cd0b9355a9f7d0c1cfe566fd76392dfbf68b58. Co-authored-by: Tasso Evangelista --- .../src/components/Menu/Menu.stories.tsx | 61 ++++++++++++- .../fuselage/src/components/Menu/Menu.tsx | 21 +++-- .../components/Options/Options.stories.tsx | 17 +--- .../src/components/Options/Options.tsx | 59 +++++++------ .../src/components/Options/styles.scss | 4 +- .../src/components/Options/useCursor.ts | 86 ++++++++++++++++--- 6 files changed, 186 insertions(+), 62 deletions(-) diff --git a/packages/fuselage/src/components/Menu/Menu.stories.tsx b/packages/fuselage/src/components/Menu/Menu.stories.tsx index 7de0e46b43..7cc79b6978 100644 --- a/packages/fuselage/src/components/Menu/Menu.stories.tsx +++ b/packages/fuselage/src/components/Menu/Menu.stories.tsx @@ -1,8 +1,9 @@ +import { action } from '@storybook/addon-actions'; import { ComponentStory, ComponentMeta } from '@storybook/react'; import React from 'react'; import { Box, Menu } from '..'; -import { menuOptions } from '../../../.storybook/helpers.js'; +import { Icon } from '../Icon'; export default { title: 'Navigation/Menu', @@ -13,11 +14,63 @@ export default { component: 'Kebab Menu', }, }, + layout: 'centered', }, } as ComponentMeta; -export const Template: ComponentStory = () => ( - - +const Template: ComponentStory = (args) => ( + + ); + +export const simple = Template.bind({}); +simple.args = { + options: { + makeAdmin: { + label: ( + + + Make Admin + + ), + action: action('makeAdmin.action'), + }, + delete: { + label: ( + + + Delete + + ), + action: action('delete.action'), + }, + }, +}; + +export const complex = Template.bind({}); +complex.args = { + options: { + example: { + label: 'Example', + action: action('example.action'), + }, + divider1: { + type: 'divider', + }, + heading: { + type: 'heading', + label: 'Heading Example', + }, + delete: { + type: 'option', + label: ( + + + Delete + + ), + action: action('delete.action'), + }, + }, +}; diff --git a/packages/fuselage/src/components/Menu/Menu.tsx b/packages/fuselage/src/components/Menu/Menu.tsx index 2c052a5ce1..8ffa8db2fd 100644 --- a/packages/fuselage/src/components/Menu/Menu.tsx +++ b/packages/fuselage/src/components/Menu/Menu.tsx @@ -3,18 +3,19 @@ import React, { useRef, useCallback, ComponentProps, - ReactElement, ElementType, + ReactNode, } from 'react'; import { ActionButton, PositionAnimated, Options, useCursor, Box } from '..'; import type { OptionType } from '../Options'; -export type MenuProps = Omit, 'icon'> & { +type MenuProps = Omit, 'icon'> & { options: { [id: string]: { - label: ReactElement | string; - action: () => void; + type?: 'option' | 'heading' | 'divider'; + label?: ReactNode; + action?: () => void; }; }; optionWidth?: ComponentProps['width']; @@ -24,11 +25,16 @@ export type MenuProps = Omit, 'icon'> & { }; const menuAction = ([selected]: OptionType, options: MenuProps['options']) => { - options[selected].action(); + options[selected].action?.(); }; const mapOptions = (options: MenuProps['options']): OptionType[] => - Object.entries(options).map(([value, { label }]) => [value, label]); + Object.entries(options).map(([value, { type = 'option', label }]) => [ + value, + label, + undefined, + type, + ]); export const Menu = ({ tiny, @@ -61,7 +67,7 @@ export const Menu = ({ show(); ref.current.classList.add('focus-visible'); } - }, [show]); + }, [hide, show]); const handleSelection = useCallback( (args) => { @@ -71,6 +77,7 @@ export const Menu = ({ }, [hide, reset, options] ); + return ( <> = (args) => ( - - - - - Title - CheckOption Example - CheckOption Example - CheckOption Example With Ellipsis - - -); diff --git a/packages/fuselage/src/components/Options/Options.tsx b/packages/fuselage/src/components/Options/Options.tsx index f36c2c5264..49523b9fd1 100644 --- a/packages/fuselage/src/components/Options/Options.tsx +++ b/packages/fuselage/src/components/Options/Options.tsx @@ -14,7 +14,7 @@ import React, { import { Box } from '../Box'; import Scrollable from '../Scrollable'; import Tile from '../Tile'; -import Option from './Option'; +import Option, { OptionHeader, OptionDivider } from './Option'; import { useCursor } from './useCursor'; export { useCursor }; @@ -24,11 +24,16 @@ const prevent = (e: SyntheticEvent) => { e.stopPropagation(); }; -export type OptionType = [string | number, ReactNode, boolean?]; +export type OptionType = [ + value: string | number, + label: ReactNode, + selected?: boolean, + type?: 'heading' | 'divider' | 'option' +]; type OptionsProps = Omit, 'onSelect'> & { multiple?: boolean; - options: Array; + options: OptionType[]; cursor: number; renderItem?: ElementType; renderEmpty?: ElementType; @@ -51,7 +56,6 @@ export const Options = forwardRef( renderItem: OptionComponent = Option, onSelect, customEmpty, - children, ...props }: OptionsProps, ref: Ref @@ -78,22 +82,31 @@ export const Options = forwardRef( const optionsMemoized = useMemo( () => - options?.map(([value, label, selected], i) => ( - { - prevent(e); - onSelect([value, label]); - return false; - }} - key={value} - value={value} - selected={selected || (multiple !== true && null)} - focus={cursor === i || null} - /> - )), - [options, multiple, cursor, onSelect] + options?.map(([value, label, selected, type], i) => { + switch (type) { + case 'heading': + return {label}; + case 'divider': + return ; + default: + return ( + { + prevent(e); + onSelect([value, label]); + return false; + }} + key={value} + value={value} + selected={selected || (multiple !== true && null)} + focus={cursor === i || null} + /> + ); + } + }), + [options, multiple, cursor, onSelect, OptionComponent] ); return ( @@ -116,10 +129,8 @@ export const Options = forwardRef( : undefined } > - {options?.length ? optionsMemoized : children} - {!options?.length && !children && ( - - )} + {!options.length && } + {optionsMemoized} diff --git a/packages/fuselage/src/components/Options/styles.scss b/packages/fuselage/src/components/Options/styles.scss index c527dfafa6..739e0a21b2 100644 --- a/packages/fuselage/src/components/Options/styles.scss +++ b/packages/fuselage/src/components/Options/styles.scss @@ -80,7 +80,7 @@ $variants: ( opacity: 0; } - &.rcx-option__column { + .rcx-option__column { @extend %column; display: flex; @@ -91,7 +91,7 @@ $variants: ( min-height: lengths.size(20); } - &.rcx-option__description { + .rcx-option__description { @include typography.use-font-scale(p2); @extend %column; display: inline; diff --git a/packages/fuselage/src/components/Options/useCursor.ts b/packages/fuselage/src/components/Options/useCursor.ts index 539bb87c74..3804e1de17 100644 --- a/packages/fuselage/src/components/Options/useCursor.ts +++ b/packages/fuselage/src/components/Options/useCursor.ts @@ -15,12 +15,60 @@ const keyCodes = { ENTER: 13, }; +const findLastIndex = ( + options: T[], + predicate: (value: T, index: number, obj: T[]) => unknown +) => { + for (let i = options.length - 1; i >= 0; i--) { + if (predicate(options[i], i, options)) { + return i; + } + } + + return -1; +}; + +const findPreviousIndex = ( + options: T[], + predicate: (value: T, index: number, obj: T[]) => unknown, + currentIndex: number +) => { + for (let i = currentIndex - 1; i >= 0; i--) { + if (predicate(options[i], i, options)) { + return i; + } + } + + return -1; +}; + +const findNextIndex = ( + options: T[], + predicate: (value: T, index: number, obj: T[]) => unknown, + currentIndex: number +) => { + for (let i = currentIndex + 1; i < options.length; i++) { + if (predicate(options[i], i, options)) { + return i; + } + } + + return -1; +}; + export type UseCursorOnChange = ( option: T, visibilityHandler: ReturnType ) => void; -export const useCursor = ( +export const useCursor = < + T extends [ + value: unknown, + label: unknown, + selected?: unknown, + type?: OptionType[3] + ] = OptionType +>( initial: number, options: Array, onChange: UseCursorOnChange @@ -49,7 +97,10 @@ export const useCursor = ( const handleKeyDown = useMutableCallback( (e: KeyboardEvent) => { - const lastIndex = options.length - 1; + const isSelectableOption = ([, , , type]: T) => + !type || type === 'option'; + const getLastIndex = () => findLastIndex(options, isSelectableOption); + const { keyCode, key } = e; if ( AnimatedVisibility.HIDDEN === visibility && @@ -62,21 +113,28 @@ export const useCursor = ( case keyCodes.HOME: e.preventDefault(); return reset(); + case keyCodes.END: e.preventDefault(); - return setCursor(lastIndex); + return setCursor(getLastIndex()); + case keyCodes.KEY_UP: e.preventDefault(); if (cursor < 1) { - return setCursor(lastIndex); + return setCursor(getLastIndex()); } - return setCursor(cursor - 1); + return setCursor((cursor) => + findPreviousIndex(options, isSelectableOption, cursor) + ); + case keyCodes.KEY_DOWN: e.preventDefault(); - if (cursor === lastIndex) { + if (cursor === getLastIndex()) { return setCursor(0); } - return setCursor(cursor + 1); + return setCursor((cursor) => + findNextIndex(options, isSelectableOption, cursor) + ); case keyCodes.ENTER: e.preventDefault(); @@ -88,6 +146,7 @@ export const useCursor = ( hide(); onChange(options[cursor], visibilityHandler); return; + case keyCodes.ESC: e.preventDefault(); reset(); @@ -99,12 +158,19 @@ export const useCursor = ( return false; } break; + default: if (key.match(/^[\d\w]$/i)) { - const index = options.findIndex( - ([, label]) => + const index = options.findIndex((option) => { + if (!isSelectableOption(option)) { + return false; + } + + const [, label] = option; + return ( typeof label === 'string' && label[0].toLowerCase() === key - ); + ); + }); ~index && setCursor(index); } }