From 991a567cf33296814e54e97e9c3b1cfe52948b58 Mon Sep 17 00:00:00 2001 From: Evgenij Shangin Date: Thu, 18 May 2023 15:32:22 +0300 Subject: [PATCH] feat: add new component ActionsPanel (#39) --- src/components/ActionsPanel/ActionsPanel.scss | 22 ++ src/components/ActionsPanel/ActionsPanel.tsx | 36 ++ src/components/ActionsPanel/README.md | 73 ++++ .../__stories__/ActionsPanel.stories.tsx | 36 ++ .../ActionsPanel/__stories__/actions.tsx | 373 ++++++++++++++++++ .../components/CollapseActions.scss | 40 ++ .../components/CollapseActions.tsx | 67 ++++ .../ActionsPanel/components/hooks/index.ts | 2 + .../ActionsPanel/components/hooks/types.ts | 1 + .../components/hooks/useCollapseActions.ts | 35 ++ .../components/hooks/useDropdownActions.ts | 40 ++ .../hooks/useObserveIntersection.ts | 70 ++++ src/components/ActionsPanel/index.ts | 2 + src/components/ActionsPanel/types.ts | 27 ++ src/components/index.ts | 1 + 15 files changed, 825 insertions(+) create mode 100644 src/components/ActionsPanel/ActionsPanel.scss create mode 100644 src/components/ActionsPanel/ActionsPanel.tsx create mode 100644 src/components/ActionsPanel/README.md create mode 100644 src/components/ActionsPanel/__stories__/ActionsPanel.stories.tsx create mode 100644 src/components/ActionsPanel/__stories__/actions.tsx create mode 100644 src/components/ActionsPanel/components/CollapseActions.scss create mode 100644 src/components/ActionsPanel/components/CollapseActions.tsx create mode 100644 src/components/ActionsPanel/components/hooks/index.ts create mode 100644 src/components/ActionsPanel/components/hooks/types.ts create mode 100644 src/components/ActionsPanel/components/hooks/useCollapseActions.ts create mode 100644 src/components/ActionsPanel/components/hooks/useDropdownActions.ts create mode 100644 src/components/ActionsPanel/components/hooks/useObserveIntersection.ts create mode 100644 src/components/ActionsPanel/index.ts create mode 100644 src/components/ActionsPanel/types.ts diff --git a/src/components/ActionsPanel/ActionsPanel.scss b/src/components/ActionsPanel/ActionsPanel.scss new file mode 100644 index 00000000..ab460f54 --- /dev/null +++ b/src/components/ActionsPanel/ActionsPanel.scss @@ -0,0 +1,22 @@ +@use '../variables'; + +.#{variables.$ns}actions-panel { + box-sizing: border-box; + background-color: var(--yc-color-base-special); + min-width: 200px; + height: 52px; + padding: 4px 20px; + border-radius: 10px; + display: flex; + align-items: center; + + &__note-wrapper { + min-width: 100px; + margin-right: 4px; + } + + &__button-close-wrapper { + flex-shrink: 0; + margin-left: auto; + } +} diff --git a/src/components/ActionsPanel/ActionsPanel.tsx b/src/components/ActionsPanel/ActionsPanel.tsx new file mode 100644 index 00000000..a81a5f5a --- /dev/null +++ b/src/components/ActionsPanel/ActionsPanel.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import {block} from '../utils/cn'; +import {Button, Icon, Text} from '@gravity-ui/uikit'; +import {Xmark} from '@gravity-ui/icons'; +import {CollapseActions} from './components/CollapseActions'; +import {ActionsPanelProps} from './types'; + +import './ActionsPanel.scss'; + +const b = block('actions-panel'); + +export const ActionsPanel = ({className, actions, onClose, renderNote}: ActionsPanelProps) => { + return ( +
+ {typeof renderNote === 'function' && ( + + {renderNote()} + + )} + + {typeof onClose === 'function' && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/components/ActionsPanel/README.md b/src/components/ActionsPanel/README.md new file mode 100644 index 00000000..17706da7 --- /dev/null +++ b/src/components/ActionsPanel/README.md @@ -0,0 +1,73 @@ +# ActionsPanel + +## Usage + +```tsx +import React from 'react'; +import {ActionsPanel, ActionsPanelProps} from '@gravity-ui/components'; + +const actions: ActionsPanelProps['actions'] = [ + { + id: 'id1', + button: { + props: { + children: 'Action 1', + onClick: () => console.log('click button action 1'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 1'), + text: 'Action 1', + }, + }, + }, + { + id: 'id2', + button: { + props: { + children: 'Action 2', + onClick: () => console.log('click button action 2'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 2'), + text: 'Action 2', + }, + }, + }, +]; + +const panel = ; +``` + +## Props + +```ts +type ActionItem = { + /** Uniq action id */ + id: string; + /** If true, then always inside the dropdown */ + collapsed?: boolean; + /** Settings for dropdown action */ + dropdown: { + item: DropdownMenuItem; + group?: string; + }; + /** Settings for button action */ + button: { + props: ButtonProps; + }; +}; + +type ActionsPanelProps = { + /** Array of actions ActionItem[] */ + actions: ActionItem[]; + /** Close button click handler */ + onClose?: () => void; + /** Render-prop for displaying the content of a note */ + renderNote?: () => React.ReactNode; + className?: string; +}; +``` diff --git a/src/components/ActionsPanel/__stories__/ActionsPanel.stories.tsx b/src/components/ActionsPanel/__stories__/ActionsPanel.stories.tsx new file mode 100644 index 00000000..6c1077fb --- /dev/null +++ b/src/components/ActionsPanel/__stories__/ActionsPanel.stories.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import {Meta, Story} from '@storybook/react'; +import {ActionsPanel, ActionsPanelProps} from '../../ActionsPanel'; +import {actions, actionsWithIcons, actionsGroups, actionsSubmenu} from './actions'; + +export default { + title: 'Components/ActionsPanel', + component: ActionsPanel, +} as Meta; + +const DefaultTemplate: Story = (args) => { + return ( + console.log('onClose called')} + renderNote={() => '10 items'} + /> + ); +}; +export const Default = DefaultTemplate.bind({}); + +const WithIconsTemplate: Story = (args) => { + return ; +}; +export const WithIcons = WithIconsTemplate.bind({}); + +const GroupsTemplate: Story = (args) => { + return ; +}; +export const Groups = GroupsTemplate.bind({}); + +const SubmenuTemplate: Story = (args) => { + return ; +}; +export const Submenu = SubmenuTemplate.bind({}); diff --git a/src/components/ActionsPanel/__stories__/actions.tsx b/src/components/ActionsPanel/__stories__/actions.tsx new file mode 100644 index 00000000..ce12d045 --- /dev/null +++ b/src/components/ActionsPanel/__stories__/actions.tsx @@ -0,0 +1,373 @@ +import React from 'react'; +import {Icon} from '@gravity-ui/uikit'; +import {PencilToSquare, ChevronDown} from '@gravity-ui/icons'; +import {ActionsPanelProps} from '../../ActionsPanel'; + +export const actions: ActionsPanelProps['actions'] = [ + { + id: 'id1', + button: { + props: { + children: 'Action 1', + onClick: () => console.log('click button action 1'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 1'), + text: 'Action 1', + }, + }, + }, + { + id: 'id2', + button: { + props: { + children: 'Action 2', + onClick: () => console.log('click button action 2'), + view: 'normal-contrast', + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 2'), + text: 'Action 2', + }, + }, + }, + { + id: 'id3', + button: { + props: { + children: 'Action 3', + onClick: () => console.log('click button action 3'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 3'), + text: 'Action 3', + }, + }, + }, + { + id: 'id4', + button: { + props: { + children: 'Action 4', + onClick: () => console.log('click button action 4'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 4'), + text: 'Action 4', + }, + }, + }, + { + id: 'id5', + button: { + props: { + children: 'Action 5', + onClick: () => console.log('click button action 5'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 5'), + text: 'Action 5', + }, + }, + }, + { + id: 'id6', + button: { + props: { + children: 'Action 6', + onClick: () => console.log('click button action 6'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 6'), + text: 'Action 6', + }, + }, + }, +]; + +export const actionsWithIcons: ActionsPanelProps['actions'] = [ + { + id: 'id1', + button: { + props: { + children: [, 'Action 1'], + onClick: () => console.log('click button action 1'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 1'), + text: 'Action 1', + }, + }, + }, + { + id: 'id2', + button: { + props: { + children: [, 'Action 2'], + onClick: () => console.log('click button action 2'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 2'), + text: 'Action 2', + }, + }, + }, + { + id: 'id3', + collapsed: true, + button: { + props: { + children: [, 'Action 3'], + onClick: () => console.log('click button action 3'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 3'), + text: ( +
+ + Action 3 +
+ ), + }, + }, + }, +]; + +export const actionsGroups: ActionsPanelProps['actions'] = [ + { + id: 'id1', + button: { + props: { + children: 'Action 1', + onClick: () => console.log('click button action 1'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 1'), + text: 'Action 1', + }, + }, + }, + { + id: 'id2', + collapsed: true, + button: { + props: { + children: 'Action 2', + onClick: () => console.log('click button action 2'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 2'), + text: 'Action 2', + }, + group: '1', + }, + }, + { + id: 'id3', + collapsed: true, + button: { + props: { + children: 'Action 3', + onClick: () => console.log('click button action 3'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 3'), + text: 'Action 3', + }, + }, + }, + { + id: 'id4', + collapsed: true, + button: { + props: { + children: 'Action 4', + onClick: () => console.log('click button action 4'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 4'), + text: 'Action 4', + }, + group: '1', + }, + }, + { + id: 'id5', + button: { + props: { + children: 'Action 5', + onClick: () => console.log('click button action 5'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 5'), + text: 'Action 5', + }, + }, + }, + { + id: 'id6', + collapsed: true, + button: { + props: { + children: 'Action 6', + onClick: () => console.log('click button action 6'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 6'), + text: 'Action 6', + }, + group: '6', + }, + }, +]; + +export const actionsSubmenu: ActionsPanelProps['actions'] = [ + { + id: 'id1', + button: { + props: { + children: 'Action 1', + onClick: () => console.log('click button action 1'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 1'), + text: 'Action 1', + }, + }, + }, + { + id: 'id2', + button: { + props: { + children: ['Submenu', ], + view: 'outlined-contrast', + }, + }, + dropdown: { + item: { + text: 'Submenu', + items: [ + { + action: () => console.log('==> action "Edit" called'), + text: 'Edit', + }, + { + action: () => console.log('==> action "Delete" called'), + text: 'Delete', + theme: 'danger', + }, + ], + }, + }, + }, + { + id: 'id3', + button: { + props: { + children: 'Action 3', + onClick: () => console.log('click button action 3'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 3'), + text: 'Action 3', + }, + }, + }, + { + id: 'id4', + collapsed: true, + button: { + props: { + children: 'Action 4', + onClick: () => console.log('click button action 4'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 4'), + text: 'Action 4', + }, + }, + }, + { + id: 'id5', + collapsed: true, + button: { + props: { + children: 'Action 5', + onClick: () => console.log('click button action 5'), + }, + }, + dropdown: { + item: { + text: 'Other', + items: [ + { + text: 'Select', + items: [ + { + action: () => console.log('==> action "Select one" called'), + text: 'One', + }, + { + action: () => console.log('==> action "Select all" called'), + text: 'All', + }, + ], + }, + { + action: () => console.log('==> action "Copy" called'), + text: 'Copy', + }, + { + text: 'Move to', + items: [ + { + action: () => console.log('==> action "Move to Folder 1" called'), + text: 'Folder 1', + }, + { + action: () => console.log('==> action "Move to Folder 2" called'), + text: 'Folder 2', + }, + ], + }, + ], + }, + }, + }, +]; diff --git a/src/components/ActionsPanel/components/CollapseActions.scss b/src/components/ActionsPanel/components/CollapseActions.scss new file mode 100644 index 00000000..fa91db1d --- /dev/null +++ b/src/components/ActionsPanel/components/CollapseActions.scss @@ -0,0 +1,40 @@ +@use '../../variables'; + +.#{variables.$ns}actions-panel-collapse { + $minSize: 32px; + + flex-shrink: 2; + min-width: $minSize; + overflow: hidden; + position: relative; + display: flex; + align-items: center; + height: 100%; + margin-right: 8px; + + &__container { + display: flex; + align-items: center; + overflow: hidden; + height: 100%; + } + + &__button-action-wrapper { + margin: 0 4px; + + &_invisible { + visibility: hidden; + pointer-events: none; + } + } + + &__menu-placeholder { + flex-shrink: 0; + width: $minSize; + height: $minSize; + } + + &__menu-wrapper { + position: absolute; + } +} diff --git a/src/components/ActionsPanel/components/CollapseActions.tsx b/src/components/ActionsPanel/components/CollapseActions.tsx new file mode 100644 index 00000000..df8e869d --- /dev/null +++ b/src/components/ActionsPanel/components/CollapseActions.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import {Button, DropdownMenu, Icon} from '@gravity-ui/uikit'; +import {Ellipsis} from '@gravity-ui/icons'; +import {block} from '../../utils/cn'; +import {ActionItem} from '../types'; +import {useCollapseActions, OBSERVER_TARGET_ATTR} from './hooks'; + +import './CollapseActions.scss'; + +const b = block('actions-panel-collapse'); + +type Props = { + actions: ActionItem[]; +}; + +export const CollapseActions = ({actions}: Props) => { + const {buttonActions, dropdownItems, parentRef, offset, visibilityMap} = + useCollapseActions(actions); + + const showDropdown = dropdownItems.length > 0; + + return ( +
+
+ {buttonActions.map((action) => { + const {id} = action; + const attr = {[OBSERVER_TARGET_ATTR]: id}; + const invisible = !visibilityMap[id]; + const switcher = ( +
+ {showDropdown && ( + +
+
+ + + + } + /> +
+ + )} +
+ ); +}; diff --git a/src/components/ActionsPanel/components/hooks/index.ts b/src/components/ActionsPanel/components/hooks/index.ts new file mode 100644 index 00000000..aa55499c --- /dev/null +++ b/src/components/ActionsPanel/components/hooks/index.ts @@ -0,0 +1,2 @@ +export {useCollapseActions} from './useCollapseActions'; +export {OBSERVER_TARGET_ATTR} from './useObserveIntersection'; diff --git a/src/components/ActionsPanel/components/hooks/types.ts b/src/components/ActionsPanel/components/hooks/types.ts new file mode 100644 index 00000000..722fd81a --- /dev/null +++ b/src/components/ActionsPanel/components/hooks/types.ts @@ -0,0 +1 @@ +export type VisibilityMap = Record; diff --git a/src/components/ActionsPanel/components/hooks/useCollapseActions.ts b/src/components/ActionsPanel/components/hooks/useCollapseActions.ts new file mode 100644 index 00000000..179b9fab --- /dev/null +++ b/src/components/ActionsPanel/components/hooks/useCollapseActions.ts @@ -0,0 +1,35 @@ +import React from 'react'; +import {ActionItem} from '../../types'; +import {useObserveIntersection} from './useObserveIntersection'; +import {useDropdownActions} from './useDropdownActions'; + +const MAX_BUTTON_ACTIONS = 4; + +export const useCollapseActions = (actions: ActionItem[]) => { + const [buttonActions, restActions] = React.useMemo(() => { + const buttonItems: ActionItem[] = []; + const restItems: ActionItem[] = []; + + actions.forEach((action) => { + if (buttonItems.length < MAX_BUTTON_ACTIONS && !action.collapsed) { + buttonItems.push(action); + } else { + restItems.push(action); + } + }); + + return [buttonItems, restItems]; + }, [actions]); + + const {parentRef, visibilityMap, offset} = useObserveIntersection(actions); + + const dropdownItems = useDropdownActions({buttonActions, restActions, visibilityMap}); + + return { + buttonActions, + dropdownItems, + parentRef, + offset, + visibilityMap, + }; +}; diff --git a/src/components/ActionsPanel/components/hooks/useDropdownActions.ts b/src/components/ActionsPanel/components/hooks/useDropdownActions.ts new file mode 100644 index 00000000..2e857099 --- /dev/null +++ b/src/components/ActionsPanel/components/hooks/useDropdownActions.ts @@ -0,0 +1,40 @@ +import {DropdownMenuItem} from '@gravity-ui/uikit'; +import groupBy from 'lodash/groupBy'; +import {ActionItem} from '../../types'; +import {VisibilityMap} from './types'; + +type UseDropdownActionsArg = { + buttonActions: ActionItem[]; + restActions: ActionItem[]; + visibilityMap: VisibilityMap; +}; + +export const useDropdownActions = ({ + buttonActions, + restActions, + visibilityMap, +}: UseDropdownActionsArg) => { + const actions = [ + ...buttonActions.filter((action) => !visibilityMap[action.id]), + ...restActions, + ]; + const groups = groupBy(actions, (action) => action.dropdown.group); + + const usedGroups = new Set(); + const dropdownItems: (DropdownMenuItem | DropdownMenuItem[])[] = []; + + for (const action of actions) { + const group = action.dropdown.group; + if (typeof group === 'undefined') { + dropdownItems.push(action.dropdown.item); + continue; + } + if (usedGroups.has(group)) { + continue; + } + usedGroups.add(group); + dropdownItems.push(groups[group].map((groupedAction) => groupedAction.dropdown.item)); + } + + return dropdownItems; +}; diff --git a/src/components/ActionsPanel/components/hooks/useObserveIntersection.ts b/src/components/ActionsPanel/components/hooks/useObserveIntersection.ts new file mode 100644 index 00000000..9025551f --- /dev/null +++ b/src/components/ActionsPanel/components/hooks/useObserveIntersection.ts @@ -0,0 +1,70 @@ +import React from 'react'; +import {ActionItem} from '../../types'; +import {VisibilityMap} from './types'; + +export const OBSERVER_TARGET_ATTR = 'data-observer-id'; +const GAP = 8; + +export const useObserveIntersection = (actions: ActionItem[]) => { + const parentRef = React.useRef(null); + const [visibilityMap, setVisibilityMap] = React.useState({}); + const [offset, setOffset] = React.useState(0); + + const handleIntersection = (entries: IntersectionObserverEntry[]) => { + const updatedEntries: VisibilityMap = {}; + let newOffest = 0; + let lastVisibleEntry: IntersectionObserverEntry | undefined; + let firstInvisible: IntersectionObserverEntry | undefined; + entries.forEach((entry) => { + const targetId = entry.target.getAttribute(OBSERVER_TARGET_ATTR); + if (!targetId) { + return; + } + if (entry.isIntersecting) { + lastVisibleEntry = entry; + updatedEntries[targetId] = true; + } else { + if (!firstInvisible) { + firstInvisible = entry; + } + updatedEntries[targetId] = false; + } + }); + + const parentRect = parentRef.current?.getBoundingClientRect(); + + if (parentRect && firstInvisible) { + const rect = firstInvisible.target.getBoundingClientRect(); + newOffest = rect.left - parentRect.left; + } else if (parentRect && lastVisibleEntry) { + const rect = lastVisibleEntry.target.getBoundingClientRect(); + newOffest = rect.right - parentRect.left + GAP; + } + + setVisibilityMap((prev) => ({ + ...prev, + ...updatedEntries, + })); + + setOffset(newOffest); + }; + + React.useEffect(() => { + setVisibilityMap({}); + + const observer = new IntersectionObserver(handleIntersection, { + root: parentRef.current, + threshold: 1, + }); + + Array.from(parentRef.current?.children || []).forEach((item) => { + if (item.hasAttribute(OBSERVER_TARGET_ATTR)) { + observer.observe(item); + } + }); + + return () => observer.disconnect(); + }, [actions]); + + return {parentRef, visibilityMap, offset}; +}; diff --git a/src/components/ActionsPanel/index.ts b/src/components/ActionsPanel/index.ts new file mode 100644 index 00000000..a4bf09ae --- /dev/null +++ b/src/components/ActionsPanel/index.ts @@ -0,0 +1,2 @@ +export {ActionsPanel} from './ActionsPanel'; +export type {ActionsPanelProps} from './types'; diff --git a/src/components/ActionsPanel/types.ts b/src/components/ActionsPanel/types.ts new file mode 100644 index 00000000..c7aa7a02 --- /dev/null +++ b/src/components/ActionsPanel/types.ts @@ -0,0 +1,27 @@ +import {ButtonProps, DropdownMenuItem} from '@gravity-ui/uikit'; + +export type ActionItem = { + /** Uniq action id */ + id: string; + /** If true, then always inside the dropdown */ + collapsed?: boolean; + /** Settings for dropdown action */ + dropdown: { + item: DropdownMenuItem; + group?: string; + }; + /** Settings for button action */ + button: { + props: ButtonProps; + }; +}; + +export type ActionsPanelProps = { + /** Array of actions ActionItem[] */ + actions: ActionItem[]; + /** Close button click handler */ + onClose?: () => void; + /** Render-prop for displaying the content of a note */ + renderNote?: () => React.ReactNode; + className?: string; +}; diff --git a/src/components/index.ts b/src/components/index.ts index ca37624d..d8b37efc 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,5 +3,6 @@ export * from './FormRow'; export * from './InfiniteScroll'; export * from './ItemSelector'; export * from './PlaceholderContainer'; +export * from './ActionsPanel'; export {Lang, configure} from './utils/configure';