From 86be887fb76a779703d0463b4597eb61e8cbc12a Mon Sep 17 00:00:00 2001 From: Fanil Zubairov Date: Thu, 26 Aug 2021 12:18:29 +0200 Subject: [PATCH 1/2] feat(plasma-web): Added `TabsController` in order to use keyboard arrows --- .../src/components/Tabs/TabItem.tsx | 58 +++++++++++ .../src/components/Tabs/Tabs.stories.tsx | 45 +++++++-- .../plasma-web/src/components/Tabs/Tabs.tsx | 35 +------ .../src/components/Tabs/TabsContext.tsx | 22 +++++ .../src/components/Tabs/TabsController.tsx | 95 +++++++++++++++++++ .../plasma-web/src/components/Tabs/index.ts | 8 +- 6 files changed, 221 insertions(+), 42 deletions(-) create mode 100644 packages/plasma-web/src/components/Tabs/TabItem.tsx create mode 100644 packages/plasma-web/src/components/Tabs/TabsContext.tsx create mode 100644 packages/plasma-web/src/components/Tabs/TabsController.tsx diff --git a/packages/plasma-web/src/components/Tabs/TabItem.tsx b/packages/plasma-web/src/components/Tabs/TabItem.tsx new file mode 100644 index 000000000..3102b29ea --- /dev/null +++ b/packages/plasma-web/src/components/Tabs/TabItem.tsx @@ -0,0 +1,58 @@ +import React, { FC, useRef, useEffect, useContext } from 'react'; +import styled, { css } from 'styled-components'; +import { TabItem as BaseTabItem, secondary, footnote2 } from '@sberdevices/plasma-core'; +import type { TabItemProps as BaseTabItemProps } from '@sberdevices/plasma-core'; + +import { link, linkHover, linkActive } from '../../tokens'; + +import { TabsContext } from './TabsContext'; + +export interface TabItemProps extends BaseTabItemProps {} + +const StyledTabItem = styled(BaseTabItem)` + ${footnote2}; + + /* stylelint-disable-next-line number-max-precision */ + padding: 1rem 1.3125rem; + height: 3.75rem; + + /* stylelint-disable-next-line number-max-precision */ + box-shadow: inset 0 -0.0625rem 0 rgba(0, 0, 0, 0.16); + color: ${secondary}; + + transition: color 0.1s ease-in-out, box-shadow 0.3s ease-in-out; + + &:hover { + color: ${linkHover}; + } + + /** + * Состояние активности + */ + ${({ isActive }) => + isActive && + css` + color: ${link}; + box-shadow: inset 0 -0.125rem 0 ${link}; + `} + + &.focus-visible:focus { + color: ${linkActive}; + box-shadow: inset 0 -0.125rem 0 ${linkActive}; + } +`; + +/** + * Элемент списка, недопустимо импользовать вне компонента Tabs. + */ +export const TabItem: FC = (props) => { + const ref = useRef(null); + const { refs } = useContext(TabsContext); + + useEffect(() => { + refs?.register(ref); + return () => refs?.unregister(ref); + }, [refs]); + + return ; +}; diff --git a/packages/plasma-web/src/components/Tabs/Tabs.stories.tsx b/packages/plasma-web/src/components/Tabs/Tabs.stories.tsx index c004ca15b..ce3e42495 100644 --- a/packages/plasma-web/src/components/Tabs/Tabs.stories.tsx +++ b/packages/plasma-web/src/components/Tabs/Tabs.stories.tsx @@ -1,10 +1,11 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { IconClock } from '@sberdevices/plasma-icons'; import { Story, Meta } from '@storybook/react'; import { action } from '@storybook/addon-actions'; -import { disableProps } from '../../helpers'; +import { InSpacingDecorator, disableProps } from '../../helpers'; -import { Tabs, TabsProps, TabItem } from '.'; +import { Tabs, TabsProps, TabItem, TabsController } from '.'; const propsToDisable = ['ref', 'theme', 'as', 'forwardedAs']; @@ -14,16 +15,18 @@ export default { argTypes: { ...disableProps(propsToDisable), }, + decorators: [InSpacingDecorator], } as Meta; interface DeafultStoryProps extends TabsProps { itemsNumber: number; - text: string; + label: string; + enableContentLeft: boolean; } -export const Default: Story = ({ itemsNumber, disabled, stretch, text }) => { +export const Default: Story = ({ itemsNumber, disabled, stretch, label, enableContentLeft }) => { const items = Array(itemsNumber).fill(0); - const [index, setIndex] = React.useState(0); + const [index, setIndex] = useState(0); return ( @@ -32,11 +35,12 @@ export const Default: Story = ({ itemsNumber, disabled, stret key={`item:${i}`} isActive={i === index} tabIndex={!disabled ? i : -1} + contentLeft={enableContentLeft && } onClick={() => !disabled && setIndex(i)} onFocus={action(`onFocus item #${i}`)} onBlur={action(`onBlur item #${i}`)} > - {text} + {label} ))} @@ -47,5 +51,30 @@ Default.args = { itemsNumber: 4, disabled: false, stretch: true, - text: 'Label', + label: 'Label', +}; + +export const Arrows: Story = ({ itemsNumber, disabled, stretch, label, enableContentLeft }) => { + const items = Array(itemsNumber).fill({ + label, + contentLeft: enableContentLeft && , + }); + const [index, setIndex] = useState(0); + + return ( + setIndex(i)} + stretch={stretch} + disabled={disabled} + /> + ); +}; + +Arrows.args = { + itemsNumber: 4, + disabled: false, + stretch: true, + label: 'Label', }; diff --git a/packages/plasma-web/src/components/Tabs/Tabs.tsx b/packages/plasma-web/src/components/Tabs/Tabs.tsx index 2ee4ec93f..f0d119231 100644 --- a/packages/plasma-web/src/components/Tabs/Tabs.tsx +++ b/packages/plasma-web/src/components/Tabs/Tabs.tsx @@ -1,6 +1,6 @@ -import styled, { css } from 'styled-components'; -import { Tabs as BaseTabs, TabItem as BaseTabItem, accent, secondary, footnote2 } from '@sberdevices/plasma-core'; -import type { TabsProps as BaseTabsProps, TabItemProps as BaseTabItemProps } from '@sberdevices/plasma-core'; +import styled from 'styled-components'; +import { Tabs as BaseTabs } from '@sberdevices/plasma-core'; +import type { TabsProps as BaseTabsProps } from '@sberdevices/plasma-core'; export interface TabsProps extends BaseTabsProps {} @@ -8,32 +8,3 @@ export interface TabsProps extends BaseTabsProps {} * Контейнер вкладок. */ export const Tabs = styled(BaseTabs)``; - -export interface TabItemProps extends BaseTabItemProps {} - -/** - * Элемент списка, недопустимо импользовать вне компонента Tabs. - */ -export const TabItem = styled(BaseTabItem)` - ${footnote2}; - - /* stylelint-disable-next-line number-max-precision */ - padding: 1rem 1.3125rem; - height: 3.75rem; - - /* stylelint-disable-next-line number-max-precision */ - box-shadow: inset 0 -0.0625rem 0 rgba(0, 0, 0, 0.16); - color: ${secondary}; - - transition: color 0.1s ease-in-out, box-shadow 0.1s ease-in-out; - - /** - * Состояние активности - */ - ${({ isActive }) => - isActive && - css` - color: ${accent}; - box-shadow: inset 0 -0.125rem 0 ${accent}; - `} -`; diff --git a/packages/plasma-web/src/components/Tabs/TabsContext.tsx b/packages/plasma-web/src/components/Tabs/TabsContext.tsx new file mode 100644 index 000000000..1add29f37 --- /dev/null +++ b/packages/plasma-web/src/components/Tabs/TabsContext.tsx @@ -0,0 +1,22 @@ +import { MutableRefObject, createContext } from 'react'; + +export class TabItemRefs { + public items: MutableRefObject[] = []; + + public register(ref: MutableRefObject): number { + this.items.push(ref); + return this.items.length - 1; + } + + public unregister(ref: MutableRefObject) { + this.items.splice(this.items.indexOf(ref), 1); + } +} + +interface TabsState { + refs?: TabItemRefs; +} + +const initialValue: TabsState = {}; + +export const TabsContext = createContext(initialValue); diff --git a/packages/plasma-web/src/components/Tabs/TabsController.tsx b/packages/plasma-web/src/components/Tabs/TabsController.tsx new file mode 100644 index 000000000..fe1ae1367 --- /dev/null +++ b/packages/plasma-web/src/components/Tabs/TabsController.tsx @@ -0,0 +1,95 @@ +import React, { ReactNode, useRef, useMemo, useCallback, useEffect } from 'react'; + +import { TabItemRefs, TabsContext } from './TabsContext'; +import { Tabs, TabsProps } from './Tabs'; +import { TabItem } from './TabItem'; + +export interface TabsControllerProps extends TabsProps { + items: Array<{ label: string; contentLeft: ReactNode }>; + index: number; + onIndexChange: (index: number) => void; + children?: never; +} + +enum Keys { + end = 35, + home = 36, + left = 37, + right = 39, +} + +/** + * Контроллер вкладок. + */ +export const TabsController: React.FC = ({ stretch, disabled, items, index, onIndexChange }) => { + const listRef = useRef(null); + const refs = useMemo(() => new TabItemRefs(), []); + + const onItemFocus = useCallback( + (event) => { + const focusIndex = refs.items.findIndex((itemRef) => itemRef.current === event.target); + + if (focusIndex !== index) { + onIndexChange?.(focusIndex); + } + }, + [refs, index, onIndexChange], + ); + + useEffect(() => { + const onKeyup = (event: KeyboardEvent) => { + const focusIndex = refs.items.findIndex((itemRef) => itemRef.current === document.activeElement); + const minIndex = 0; + const maxIndex = refs.items.length - 1; + let nextIndex; + + switch (event.keyCode) { + case Keys.end: + nextIndex = maxIndex; + break; + case Keys.left: + nextIndex = focusIndex > minIndex ? focusIndex - 1 : maxIndex; + break; + case Keys.right: + nextIndex = focusIndex < maxIndex ? focusIndex + 1 : minIndex; + break; + case Keys.home: + nextIndex = minIndex; + break; + default: + return; + } + + event.preventDefault(); + refs.items[nextIndex].current?.focus(); + }; + + if (listRef.current) { + listRef.current.addEventListener('keyup', onKeyup); + } + return () => { + if (listRef.current) { + listRef.current.removeEventListener('keyup', onKeyup); + } + }; + }, [refs]); + + return ( + + + {items.map(({ label, contentLeft }, i) => ( + !disabled && onIndexChange?.(i)} + onFocus={onItemFocus} + > + {label} + + ))} + + + ); +}; diff --git a/packages/plasma-web/src/components/Tabs/index.ts b/packages/plasma-web/src/components/Tabs/index.ts index 041b2734e..1453804d9 100644 --- a/packages/plasma-web/src/components/Tabs/index.ts +++ b/packages/plasma-web/src/components/Tabs/index.ts @@ -1,2 +1,6 @@ -export { Tabs, TabItem } from './Tabs'; -export type { TabsProps, TabItemProps } from './Tabs'; +export { TabsController } from './TabsController'; +export type { TabsControllerProps } from './TabsController'; +export { Tabs } from './Tabs'; +export type { TabsProps } from './Tabs'; +export { TabItem } from './TabItem'; +export type { TabItemProps } from './TabItem'; From 699d34beb769b8e2524d269bb9ee9578e092e432 Mon Sep 17 00:00:00 2001 From: Fanil Zubairov Date: Fri, 3 Sep 2021 10:13:51 +0200 Subject: [PATCH 2/2] docs(plasma-web): Describe `TabsController` --- docs/docs/web/components/Tabs.mdx | 9 +++++++-- packages/plasma-web/src/components/Tabs/TabItem.tsx | 2 +- packages/plasma-web/src/components/Tabs/Tabs.tsx | 2 +- .../plasma-web/src/components/Tabs/TabsController.tsx | 1 + 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/docs/web/components/Tabs.mdx b/docs/docs/web/components/Tabs.mdx index 75b114067..3be787799 100644 --- a/docs/docs/web/components/Tabs.mdx +++ b/docs/docs/web/components/Tabs.mdx @@ -10,12 +10,17 @@ import { Description } from '@site/src/components/Description'; Набор компонентов для создания вкладок. Структура для вкладок похожа на структуру маркированных списков. +## TabsController + + + + ## Tabs -Основной компонент, контейнер вкладок. + ## TabItem -Элемент списка. Нельзя использовать вне компонента `Tabs`. + diff --git a/packages/plasma-web/src/components/Tabs/TabItem.tsx b/packages/plasma-web/src/components/Tabs/TabItem.tsx index 3102b29ea..30bfa9c7a 100644 --- a/packages/plasma-web/src/components/Tabs/TabItem.tsx +++ b/packages/plasma-web/src/components/Tabs/TabItem.tsx @@ -43,7 +43,7 @@ const StyledTabItem = styled(BaseTabItem)` `; /** - * Элемент списка, недопустимо импользовать вне компонента Tabs. + * Элемент списка вкладок, недопустимо импользовать вне компонента Tabs. */ export const TabItem: FC = (props) => { const ref = useRef(null); diff --git a/packages/plasma-web/src/components/Tabs/Tabs.tsx b/packages/plasma-web/src/components/Tabs/Tabs.tsx index f0d119231..674f48565 100644 --- a/packages/plasma-web/src/components/Tabs/Tabs.tsx +++ b/packages/plasma-web/src/components/Tabs/Tabs.tsx @@ -5,6 +5,6 @@ import type { TabsProps as BaseTabsProps } from '@sberdevices/plasma-core'; export interface TabsProps extends BaseTabsProps {} /** - * Контейнер вкладок. + * Контейнер вкладок, основной компонент для пользовательской сборки вкладок. */ export const Tabs = styled(BaseTabs)``; diff --git a/packages/plasma-web/src/components/Tabs/TabsController.tsx b/packages/plasma-web/src/components/Tabs/TabsController.tsx index fe1ae1367..7feb1e3a6 100644 --- a/packages/plasma-web/src/components/Tabs/TabsController.tsx +++ b/packages/plasma-web/src/components/Tabs/TabsController.tsx @@ -20,6 +20,7 @@ enum Keys { /** * Контроллер вкладок. + * Позволяет использовать клавиши ArrowLeft, ArrowRight, Home, End для навигации по вкладкам. */ export const TabsController: React.FC = ({ stretch, disabled, items, index, onIndexChange }) => { const listRef = useRef(null);