From 5ce703f9b0da81493262035e8259d0f0aedc3552 Mon Sep 17 00:00:00 2001 From: Evgenij Shangin Date: Wed, 2 Oct 2024 15:21:56 +0300 Subject: [PATCH] feat(ActionsPanel): add component and showcases (#1873) --- CODEOWNERS | 1 + src/components/ActionsPanel/ActionsPanel.scss | 24 ++ src/components/ActionsPanel/ActionsPanel.tsx | 58 +++ src/components/ActionsPanel/README.md | 382 ++++++++++++++++++ .../__stories__/ActionsPanel.stories.tsx | 57 +++ .../ActionsPanel/__stories__/Docs.mdx | 35 ++ .../ActionsPanel/__stories__/actions.tsx | 298 ++++++++++++++ .../components/CollapseActions.scss | 47 +++ .../components/CollapseActions.tsx | 86 ++++ .../ActionsPanel/components/hooks/index.ts | 2 + .../ActionsPanel/components/hooks/types.ts | 1 + .../components/hooks/useCollapseActions.ts | 59 +++ .../components/hooks/useDropdownActions.ts | 44 ++ .../hooks/useObserveIntersection.ts | 84 ++++ src/components/ActionsPanel/i18n/en.json | 4 + src/components/ActionsPanel/i18n/index.ts | 8 + src/components/ActionsPanel/i18n/ru.json | 4 + src/components/ActionsPanel/index.ts | 2 + src/components/ActionsPanel/types.ts | 34 ++ src/components/index.ts | 1 + src/i18n/types.ts | 4 +- 21 files changed, 1234 insertions(+), 1 deletion(-) 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__/Docs.mdx 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/i18n/en.json create mode 100644 src/components/ActionsPanel/i18n/index.ts create mode 100644 src/components/ActionsPanel/i18n/ru.json create mode 100644 src/components/ActionsPanel/index.ts create mode 100644 src/components/ActionsPanel/types.ts diff --git a/CODEOWNERS b/CODEOWNERS index dc1b672284..9724de97a6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,4 +1,5 @@ * @amje @ValeraS @korvin89 +/src/components/ActionsPanel @jhoncool /src/components/ActionTooltip @amje /src/components/Alert @IsaevAlexandr /src/components/ArrowToggle @Marginy605 diff --git a/src/components/ActionsPanel/ActionsPanel.scss b/src/components/ActionsPanel/ActionsPanel.scss new file mode 100644 index 0000000000..9a166a48ca --- /dev/null +++ b/src/components/ActionsPanel/ActionsPanel.scss @@ -0,0 +1,24 @@ +@use '../variables'; + +$block: '.#{variables.$ns}actions-panel'; + +#{$block} { + box-sizing: border-box; + background-color: var(--g-color-base-brand); + min-width: 200px; + height: 52px; + padding: 4px 20px; + border-radius: 10px; + display: flex; + align-items: center; + + &__note-wrapper { + min-width: 100px; + margin-inline-end: 40px; + } + + &__button-close { + flex-shrink: 0; + margin-inline-start: auto; + } +} diff --git a/src/components/ActionsPanel/ActionsPanel.tsx b/src/components/ActionsPanel/ActionsPanel.tsx new file mode 100644 index 0000000000..68a6d5ad8b --- /dev/null +++ b/src/components/ActionsPanel/ActionsPanel.tsx @@ -0,0 +1,58 @@ +'use client'; + +import React from 'react'; + +import {Xmark} from '@gravity-ui/icons'; + +import {Button} from '../Button'; +import {Icon} from '../Icon'; +import {Text} from '../Text'; +import {block} from '../utils/cn'; + +import {CollapseActions} from './components/CollapseActions'; +import i18n from './i18n'; +import type {ActionsPanelProps} from './types'; + +import './ActionsPanel.scss'; + +const b = block('actions-panel'); + +export const ActionsPanel = ({ + className, + actions, + onClose, + renderNote, + noteClassName, + qa, + maxRowActions, +}: 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 0000000000..403459a106 --- /dev/null +++ b/src/components/ActionsPanel/README.md @@ -0,0 +1,382 @@ + + +# ActionsPanel + +Use an `ActionsPanel` to render multiple buttons in a row. +When there is not enough space, buttons that don't fit will be added to an overflow menu. + +## Example + +```jsx +const actions: ActionsPanelProps['actions'] = [ + { + id: 'action_1', + 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: 'action_2', + button: { + props: { + children: 'Action 2', + onClick: () => console.log('click button action 2'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 2'), + text: 'Action 2', + }, + }, + }, +]; + + +``` + + + + + +## Action icons + +Use `Button` or `DropdownMenu` properties to set icons. + +```jsx +const actions: ActionsPanelProps['actions'] = [ + { + id: 'edit', + button: { + props: { + children: [, 'Edit'], + onClick: () => console.log('Edit'), + }, + }, + dropdown: { + item: { + action: () => console.log('Edit'), + text: ( + + + Edit + + ), + }, + }, + }, + { + id: 'copy', + button: { + props: { + children: [, 'Copy'], + onClick: () => console.log('Copy'), + }, + }, + dropdown: { + item: { + action: () => console.log('Copy'), + text: ( + + + Copy + + ), + }, + }, + }, + { + id: 'delete', + collapsed: true, + button: { + props: { + children: [, 'Delete'], + onClick: () => console.log('Delete'), + }, + }, + dropdown: { + item: { + action: () => console.log('Delete'), + text: ( + + + Delete + + ), + }, + }, + }, +]; + + +``` + + + + + +## Note + +Use the `renderNote` property to render a note. + +```jsx +const actions: ActionsPanelProps['actions'] = [ + { + id: 'action_1', + button: { + props: { + children: 'Action 1', + onClick: () => console.log('click button action 1'), + view: 'normal-contrast', + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 1'), + text: 'Action 1', + }, + }, + }, + { + id: 'action_2', + 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: 'action_3', + 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: 'action_4', + button: { + props: { + children: 'Action 4', + onClick: () => console.log('click button action 4'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 4'), + text: 'Action 4', + }, + }, + }, +]; + + console.log('click close handle')} + renderNote={() => '10 items'} + maxRowActions={2} +/> +``` + + + + + +## Groups in dropdown menu + +Use `action.dropdown.group` for groping actions in dropdown menu. + +```jsx +const actions: ActionsPanelProps['actions'] = [ + { + id: 'action_1', + collapsed: true, + button: { + props: { + children: 'Action 1', + onClick: () => console.log('click button action 1'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 1'), + text: 'Action 1', + }, + group: '1', + }, + }, + { + id: 'action_2', + 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: '2', + }, + }, + { + id: 'action_3', + 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', + }, + group: '1', + }, + }, +]; + + +``` + + + + + +## Action sub-menu and nested dropdown menu + +See `actions` example below and documentation about the `DropdownMenu` component. + +```jsx +const actions: ActionsPanelProps['actions'] = [ + { + id: 'button-with-sub-menu', + button: { + props: { + children: ['Sub-menu', ], + view: 'outlined-contrast', + onClick: () => console.log('click button action 2'), + }, + }, + dropdown: { + item: { + text: 'Sub-menu', + items: [ + { + action: () => console.log('Edit'), + text: 'Edit', + }, + { + action: () => console.log('Delete'), + text: 'Delete', + theme: 'danger', + }, + ], + }, + }, + }, + { + id: 'nested-menu', + collapsed: true, + button: { + props: { + children: 'Nested', + onClick: () => console.log('click button nested'), + }, + }, + dropdown: { + item: { + text: 'Other', + items: [ + { + text: 'Select', + items: [ + { + action: () => console.log('Select One'), + text: 'One', + }, + { + action: () => console.log('Select All'), + text: 'All', + }, + ], + }, + { + action: () => console.log('Copy'), + text: 'Copy', + }, + { + text: 'Move to', + items: [ + { + action: () => console.log('Move to folder 1'), + text: 'Folder 1', + }, + { + action: () => console.log('Move to folder 2'), + text: 'Folder 2', + }, + ], + }, + ], + }, + }, + }, +]; + + +``` + + + + + +## Properties + +| Name | Description | Type | Default | +| :------------ | :-------------------------------------------------------- | :---------------------: | :-----: | +| actions | Array of actions | `ActionsPanelItem[]` | | +| onClose | Optional close button click handler | `() => void` | | +| renderNote | Optional render-prop for displaying the content of a note | `() => React.ReactNode` | | +| className | Optional HTML `class` attribute | `string` | | +| noteClassName | Optional HTML `class` attribute | `string` | | +| maxRowActions | Maximum number of actions in a row | `number` | `4` | + +## ActionsPanelItem: + +| Name | Description | Type | Default | +| :-------- | :-------------------------------------------- | :----------------------------------------: | :-----: | +| id | Unique action id | `string` | | +| dropdown | Settings for dropdown action in overflow menu | `{item: DropdownMenuItem; group?: string}` | | +| button | Settings for button action | `{props: ButtonProps}` | | +| collapsed | If true, then item always inside the dropdown | `boolean` | | + + diff --git a/src/components/ActionsPanel/__stories__/ActionsPanel.stories.tsx b/src/components/ActionsPanel/__stories__/ActionsPanel.stories.tsx new file mode 100644 index 0000000000..b919c9ffd9 --- /dev/null +++ b/src/components/ActionsPanel/__stories__/ActionsPanel.stories.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +import type {Meta, StoryObj} from '@storybook/react'; + +import {ActionsPanel} from '../../ActionsPanel'; + +import {actions, actionsGroups, actionsSubmenu, actionsWithIcons, actionsWithNote} from './actions'; + +const meta: Meta = { + title: 'Components/Data Display/ActionsPanel', + component: ActionsPanel, + parameters: { + a11y: { + element: '#storybook-root', + config: { + rules: [ + { + id: 'color-contrast', + enabled: false, + }, + ], + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default = { + render: (args) => , +} satisfies Story; + +export const WithIcons = { + render: (args) => , +} satisfies Story; + +export const WithNote = { + render: (args) => ( + console.log('click close handle')} + renderNote={() => '10 items'} + maxRowActions={2} + /> + ), +} satisfies Story; + +export const Groups = { + render: (args) => , +} satisfies Story; + +export const Submenu = { + render: (args) => , +} satisfies Story; diff --git a/src/components/ActionsPanel/__stories__/Docs.mdx b/src/components/ActionsPanel/__stories__/Docs.mdx new file mode 100644 index 0000000000..26b5f8a96a --- /dev/null +++ b/src/components/ActionsPanel/__stories__/Docs.mdx @@ -0,0 +1,35 @@ +import { + Meta, + Markdown, + Canvas, + AnchorMdx, + CodeOrSourceMdx, + HeadersMdx, +} from '@storybook/addon-docs'; +import * as Stories from './ActionsPanel.stories'; +import Readme from '../README.md?raw'; + +export const ActionsPanelExample = () => ; +export const ActionsPanelWithIcons = () => ; +export const ActionsPanelWithNote = () => ; +export const ActionsPanelGroups = () => ; +export const ActionsPanelSubmenu = () => ; + + + + + {Readme} + diff --git a/src/components/ActionsPanel/__stories__/actions.tsx b/src/components/ActionsPanel/__stories__/actions.tsx new file mode 100644 index 0000000000..351b49b6b0 --- /dev/null +++ b/src/components/ActionsPanel/__stories__/actions.tsx @@ -0,0 +1,298 @@ +import React from 'react'; + +import {ChevronDown, Files, PencilToSquare, TrashBin} from '@gravity-ui/icons'; + +import type {ActionsPanelProps} from '../../ActionsPanel'; +import {Icon} from '../../Icon'; +import {Flex} from '../../layout'; + +export const actions: ActionsPanelProps['actions'] = [ + { + id: 'action_1', + 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: 'action_2', + button: { + props: { + children: 'Action 2', + onClick: () => console.log('click button action 2'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 2'), + text: 'Action 2', + }, + }, + }, +]; + +export const actionsWithIcons: ActionsPanelProps['actions'] = [ + { + id: 'edit', + button: { + props: { + children: [, 'Edit'], + onClick: () => console.log('Edit'), + }, + }, + dropdown: { + item: { + action: () => console.log('Edit'), + text: ( + + + Edit + + ), + }, + }, + }, + { + id: 'copy', + button: { + props: { + children: [, 'Copy'], + onClick: () => console.log('Copy'), + }, + }, + dropdown: { + item: { + action: () => console.log('Copy'), + text: ( + + + Copy + + ), + }, + }, + }, + { + id: 'delete', + collapsed: true, + button: { + props: { + children: [, 'Delete'], + onClick: () => console.log('Delete'), + }, + }, + dropdown: { + item: { + action: () => console.log('Delete'), + text: ( + + + Delete + + ), + }, + }, + }, +]; + +export const actionsWithNote: ActionsPanelProps['actions'] = [ + { + id: 'action_1', + button: { + props: { + children: 'Action 1', + onClick: () => console.log('click button action 1'), + view: 'normal-contrast', + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 1'), + text: 'Action 1', + }, + }, + }, + { + id: 'action_2', + 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: 'action_3', + 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: 'action_4', + button: { + props: { + children: 'Action 4', + onClick: () => console.log('click button action 4'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 4'), + text: 'Action 4', + }, + }, + }, +]; + +export const actionsGroups: ActionsPanelProps['actions'] = [ + { + id: 'action_1', + collapsed: true, + button: { + props: { + children: 'Action 1', + onClick: () => console.log('click button action 1'), + }, + }, + dropdown: { + item: { + action: () => console.log('click dropdown action 1'), + text: 'Action 1', + }, + group: '1', + }, + }, + { + id: 'action_2', + 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: '2', + }, + }, + { + id: 'action_3', + 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', + }, + group: '1', + }, + }, +]; + +export const actionsSubmenu: ActionsPanelProps['actions'] = [ + { + id: 'button-with-sub-menu', + button: { + props: { + children: ['Sub-menu', ], + view: 'outlined-contrast', + onClick: () => console.log('click button action 2'), + }, + }, + dropdown: { + item: { + text: 'Sub-menu', + items: [ + { + action: () => console.log('Edit'), + text: 'Edit', + }, + { + action: () => console.log('Delete'), + text: 'Delete', + theme: 'danger', + }, + ], + }, + }, + }, + { + id: 'nested-menu', + collapsed: true, + button: { + props: { + children: 'Nested', + onClick: () => console.log('click button nested'), + }, + }, + dropdown: { + item: { + text: 'Other', + items: [ + { + text: 'Select', + items: [ + { + action: () => console.log('Select One'), + text: 'One', + }, + { + action: () => console.log('Select All'), + text: 'All', + }, + ], + }, + { + action: () => console.log('Copy'), + text: 'Copy', + }, + { + text: 'Move to', + items: [ + { + action: () => console.log('Move to folder 1'), + text: 'Folder 1', + }, + { + action: () => console.log('Move to folder 2'), + text: 'Folder 2', + }, + ], + }, + ], + }, + }, + }, +]; diff --git a/src/components/ActionsPanel/components/CollapseActions.scss b/src/components/ActionsPanel/components/CollapseActions.scss new file mode 100644 index 0000000000..d27704e555 --- /dev/null +++ b/src/components/ActionsPanel/components/CollapseActions.scss @@ -0,0 +1,47 @@ +@use '../../variables'; + +$block: '.#{variables.$ns}actions-panel-collapse'; + +#{$block} { + $minSize: 32px; + + flex-shrink: 2; + min-width: $minSize; + overflow: hidden; + position: relative; + display: flex; + align-items: center; + height: 100%; + padding-inline-end: 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; + width: $minSize; + height: $minSize; + display: flex; + align-items: center; + justify-content: center; + } +} diff --git a/src/components/ActionsPanel/components/CollapseActions.tsx b/src/components/ActionsPanel/components/CollapseActions.tsx new file mode 100644 index 0000000000..e9d9970366 --- /dev/null +++ b/src/components/ActionsPanel/components/CollapseActions.tsx @@ -0,0 +1,86 @@ +'use client'; + +import React from 'react'; + +import {Ellipsis} from '@gravity-ui/icons'; + +import {Button} from '../../Button'; +import {DropdownMenu} from '../../DropdownMenu'; +import {Icon} from '../../Icon'; +import {block} from '../../utils/cn'; +import i18n from '../i18n'; +import type {ActionsPanelItem} from '../types'; + +import {OBSERVER_TARGET_ATTR, useCollapseActions} from './hooks'; + +import './CollapseActions.scss'; + +const b = block('actions-panel-collapse'); + +type Props = { + actions: ActionsPanelItem[]; + maxRowActions?: number; +}; + +export const CollapseActions = ({actions, maxRowActions}: Props) => { + const {buttonActions, dropdownItems, parentRef, offset, visibilityMap, showDropdown} = + useCollapseActions(actions, maxRowActions); + + return ( +
+
+ {buttonActions.map((action) => { + const {id} = action; + const attr = {[OBSERVER_TARGET_ATTR]: id}; + const invisible = visibilityMap[id] === false; + + const node = Array.isArray(action.dropdown.item.items) ? ( + ( +
+ {showDropdown && ( + +
+
+ ( + + )} + /> +
+ + )} +
+ ); +}; diff --git a/src/components/ActionsPanel/components/hooks/index.ts b/src/components/ActionsPanel/components/hooks/index.ts new file mode 100644 index 0000000000..aa55499c77 --- /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 0000000000..722fd81ace --- /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 0000000000..61edd22cad --- /dev/null +++ b/src/components/ActionsPanel/components/hooks/useCollapseActions.ts @@ -0,0 +1,59 @@ +'use client'; + +import React from 'react'; + +import type {ActionsPanelItem} from '../../types'; + +import {useDropdownActions} from './useDropdownActions'; +import {useObserveIntersection} from './useObserveIntersection'; + +const DEFAULT_MAX_BUTTON_ACTIONS = 4; + +export const useCollapseActions = (actions: ActionsPanelItem[], maxRowActions?: number) => { + const maxActions = Math.max( + 0, + typeof maxRowActions === 'undefined' ? DEFAULT_MAX_BUTTON_ACTIONS : maxRowActions, + ); + + const allActionsCollapsed = React.useMemo(() => { + return actions.every((action) => action.collapsed); + }, [actions]); + + const updateObserveKey = React.useMemo( + () => actions.map(({id}) => id).join('/') + maxActions, + [actions, maxActions], + ); + + const [buttonActions, restActions] = React.useMemo(() => { + const buttonItems: ActionsPanelItem[] = []; + const restItems: ActionsPanelItem[] = []; + + actions.forEach((action) => { + if (buttonItems.length < maxActions && !action.collapsed) { + buttonItems.push(action); + } else { + restItems.push(action); + } + }); + + return [buttonItems, restItems]; + }, [actions, maxActions]); + + const {parentRef, visibilityMap, offset} = useObserveIntersection(updateObserveKey); + + const dropdownItems = useDropdownActions({buttonActions, restActions, visibilityMap}); + + const isDefaultOffset = allActionsCollapsed || maxActions === 0; + + const showDropdown = + (Object.keys(visibilityMap).length > 0 || isDefaultOffset) && dropdownItems.length > 0; + + return { + buttonActions, + dropdownItems, + parentRef, + offset: isDefaultOffset ? 0 : offset, + visibilityMap, + showDropdown, + }; +}; diff --git a/src/components/ActionsPanel/components/hooks/useDropdownActions.ts b/src/components/ActionsPanel/components/hooks/useDropdownActions.ts new file mode 100644 index 0000000000..0e00ab380c --- /dev/null +++ b/src/components/ActionsPanel/components/hooks/useDropdownActions.ts @@ -0,0 +1,44 @@ +'use client'; + +import groupBy from 'lodash/groupBy'; + +import type {DropdownMenuItem} from '../../../DropdownMenu'; +import type {ActionsPanelItem} from '../../types'; + +import type {VisibilityMap} from './types'; + +type UseDropdownActionsArg = { + buttonActions: ActionsPanelItem[]; + restActions: ActionsPanelItem[]; + 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 0000000000..969aff4c81 --- /dev/null +++ b/src/components/ActionsPanel/components/hooks/useObserveIntersection.ts @@ -0,0 +1,84 @@ +'use client'; + +import React from 'react'; + +import {useDirection} from '../../../theme'; + +import type {VisibilityMap} from './types'; + +export const OBSERVER_TARGET_ATTR = 'data-observer-id'; +const GAP = 4; + +export const useObserveIntersection = (updateObserveKey: string) => { + const direction = useDirection(); + const parentRef = React.useRef(null); + const [visibilityMap, setVisibilityMap] = React.useState({}); + const [offset, setOffset] = React.useState(0); + + const handleIntersection = React.useCallback( + (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 = + direction === 'ltr' + ? rect.left - parentRect.left + : parentRect.right - rect.right; + } else if (parentRect && lastVisibleEntry) { + const rect = lastVisibleEntry.target.getBoundingClientRect(); + newOffest = + direction === 'ltr' + ? rect.right - parentRect.left + GAP + : parentRect.right - rect.left + GAP; + } + + setVisibilityMap((prev) => ({ + ...prev, + ...updatedEntries, + })); + + setOffset(newOffest); + }, + [direction], + ); + + 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(); + }, [handleIntersection, updateObserveKey]); + + return {parentRef, visibilityMap, offset}; +}; diff --git a/src/components/ActionsPanel/i18n/en.json b/src/components/ActionsPanel/i18n/en.json new file mode 100644 index 0000000000..f26328fe5b --- /dev/null +++ b/src/components/ActionsPanel/i18n/en.json @@ -0,0 +1,4 @@ +{ + "label_close": "Close", + "label_more": "Show more" +} diff --git a/src/components/ActionsPanel/i18n/index.ts b/src/components/ActionsPanel/i18n/index.ts new file mode 100644 index 0000000000..56fc73a964 --- /dev/null +++ b/src/components/ActionsPanel/i18n/index.ts @@ -0,0 +1,8 @@ +import {addComponentKeysets} from '../../../i18n'; + +import en from './en.json'; +import ru from './ru.json'; + +const COMPONENT = 'ActionsPanel'; + +export default addComponentKeysets({en, ru}, COMPONENT); diff --git a/src/components/ActionsPanel/i18n/ru.json b/src/components/ActionsPanel/i18n/ru.json new file mode 100644 index 0000000000..171199f19b --- /dev/null +++ b/src/components/ActionsPanel/i18n/ru.json @@ -0,0 +1,4 @@ +{ + "label_close": "Закрыть", + "label_more": "Показать больше" +} diff --git a/src/components/ActionsPanel/index.ts b/src/components/ActionsPanel/index.ts new file mode 100644 index 0000000000..a4bf09ae37 --- /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 0000000000..cb8bb6ef74 --- /dev/null +++ b/src/components/ActionsPanel/types.ts @@ -0,0 +1,34 @@ +import type {ButtonProps} from '../Button'; +import type {DropdownMenuItem} from '../DropdownMenu'; +import type {QAProps} from '../types'; + +export interface ActionsPanelItem { + /** 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 interface ActionsPanelProps extends QAProps { + /** Array of actions ActionsPanelItem[] */ + actions: ActionsPanelItem[]; + /** ClassName of element */ + className?: string; + /** Close button click handler */ + onClose?: () => void; + /** Render-prop for displaying the content of a note */ + renderNote?: () => React.ReactNode; + /** ClassName of note */ + noteClassName?: string; + /** Maximum number of actions in a row */ + maxRowActions?: number; +} diff --git a/src/components/index.ts b/src/components/index.ts index 9257d9dca5..94391c3539 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,6 +2,7 @@ export * from './types'; export * from './mobile'; export * from './theme'; +export * from './ActionsPanel'; export * from './ActionTooltip'; export * from './Alert'; export * from './ArrowToggle'; diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 4094478e1a..71348cbfcc 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -1,3 +1,4 @@ +import type {default as ActionsPanelKeyset} from '../components/ActionsPanel/i18n'; import type {default as AlertKeyset} from '../components/Alert/i18n'; import type {default as AvatarStackKeyset} from '../components/AvatarStack/i18n'; import type {default as BreadcrumbsKeyset} from '../components/Breadcrumbs/i18n'; @@ -16,7 +17,8 @@ import type {default as ClearButtonKeyset} from '../components/controls/common/C import type {default as LabBreadcrumbsKeyset} from '../components/lab/Breadcrumbs/i18n'; import type {DeepPartial} from '../types/utils'; -export type Keysets = typeof AlertKeyset.keysetData & +export type Keysets = typeof ActionsPanelKeyset & + typeof AlertKeyset.keysetData & typeof AvatarStackKeyset.keysetData & typeof BreadcrumbsKeyset.keysetData & typeof ClipboardButtonKeyset.keysetData &