Skip to content

feat(plasma-web): Added TabsController in order to use keyboard arrows #687

Merged
merged 2 commits into from
Sep 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions docs/docs/web/components/Tabs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ import { Description } from '@site/src/components/Description';
Набор компонентов для создания вкладок.
Структура для вкладок похожа на структуру маркированных списков.

## TabsController

<Description name="TabsController" />
<PropsTable name="TabsController" />

## Tabs
Основной компонент, контейнер вкладок.

<Description name="Tabs" />
<PropsTable name="Tabs" />

## TabItem
Элемент списка. Нельзя использовать вне компонента `Tabs`.

<Description name="TabItem" />
<PropsTable name="TabItem" />
58 changes: 58 additions & 0 deletions packages/plasma-web/src/components/Tabs/TabItem.tsx
Original file line number Diff line number Diff line change
@@ -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<TabItemProps> = (props) => {
const ref = useRef<HTMLElement>(null);
const { refs } = useContext(TabsContext);

useEffect(() => {
refs?.register(ref);
return () => refs?.unregister(ref);
}, [refs]);

return <StyledTabItem ref={ref} {...props} />;
};
45 changes: 37 additions & 8 deletions packages/plasma-web/src/components/Tabs/Tabs.stories.tsx
Original file line number Diff line number Diff line change
@@ -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'];

Expand All @@ -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<DeafultStoryProps> = ({ itemsNumber, disabled, stretch, text }) => {
export const Default: Story<DeafultStoryProps> = ({ itemsNumber, disabled, stretch, label, enableContentLeft }) => {
const items = Array(itemsNumber).fill(0);
const [index, setIndex] = React.useState(0);
const [index, setIndex] = useState(0);

return (
<Tabs stretch={stretch} disabled={disabled}>
Expand All @@ -32,11 +35,12 @@ export const Default: Story<DeafultStoryProps> = ({ itemsNumber, disabled, stret
key={`item:${i}`}
isActive={i === index}
tabIndex={!disabled ? i : -1}
contentLeft={enableContentLeft && <IconClock color="inherit" />}
onClick={() => !disabled && setIndex(i)}
onFocus={action(`onFocus item #${i}`)}
onBlur={action(`onBlur item #${i}`)}
>
{text}
{label}
</TabItem>
))}
</Tabs>
Expand All @@ -47,5 +51,30 @@ Default.args = {
itemsNumber: 4,
disabled: false,
stretch: true,
text: 'Label',
label: 'Label',
};

export const Arrows: Story<DeafultStoryProps> = ({ itemsNumber, disabled, stretch, label, enableContentLeft }) => {
const items = Array(itemsNumber).fill({
label,
contentLeft: enableContentLeft && <IconClock color="inherit" />,
});
const [index, setIndex] = useState(0);

return (
<TabsController
items={items}
index={index}
onIndexChange={(i) => setIndex(i)}
stretch={stretch}
disabled={disabled}
/>
);
};

Arrows.args = {
itemsNumber: 4,
disabled: false,
stretch: true,
label: 'Label',
};
37 changes: 4 additions & 33 deletions packages/plasma-web/src/components/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,10 @@
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 {}

/**
* Контейнер вкладок.
* Контейнер вкладок, основной компонент для пользовательской сборки вкладок.
*/
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};
`}
`;
22 changes: 22 additions & 0 deletions packages/plasma-web/src/components/Tabs/TabsContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { MutableRefObject, createContext } from 'react';

export class TabItemRefs {
public items: MutableRefObject<HTMLElement | null>[] = [];

public register(ref: MutableRefObject<HTMLElement | null>): number {
this.items.push(ref);
return this.items.length - 1;
}

public unregister(ref: MutableRefObject<HTMLElement | null>) {
this.items.splice(this.items.indexOf(ref), 1);
}
}

interface TabsState {
refs?: TabItemRefs;
}

const initialValue: TabsState = {};

export const TabsContext = createContext<TabsState>(initialValue);
96 changes: 96 additions & 0 deletions packages/plasma-web/src/components/Tabs/TabsController.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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,
}

/**
* Контроллер вкладок.
* Позволяет использовать клавиши ArrowLeft, ArrowRight, Home, End для навигации по вкладкам.
*/
export const TabsController: React.FC<TabsControllerProps> = ({ stretch, disabled, items, index, onIndexChange }) => {
const listRef = useRef<HTMLDivElement>(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 (
<TabsContext.Provider value={{ refs }}>
<Tabs ref={listRef} stretch={stretch}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Сейчас не совсем непонятно, почему у нас именно 2 компоненты - Tabs и TabsController, когда как у TabsController вроде как та же функциональность, но теперь есть возможность использовать стрелочки. Почему мы оставляем Tabs? И нормально ли, что у них api отличается - у TabsController(появляются items, itemsindex, onIndexChange)?

Copy link
Contributor Author

@fanisco fanisco Sep 1, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Эти компоненты переименуются v2.0:
Tabs => TabsRoot
TabsController => Tabs

TabsRoot & TabItem будут использоваться для кастомной сборки
Tabs останется "умным" компонентом, с которым не нужно делать лишних настроек

#699

{items.map(({ label, contentLeft }, i) => (
<TabItem
key={i}
isActive={i === index}
tabIndex={!disabled ? 0 : -1}
contentLeft={contentLeft}
onClick={() => !disabled && onIndexChange?.(i)}
onFocus={onItemFocus}
>
{label}
</TabItem>
))}
</Tabs>
</TabsContext.Provider>
);
};
8 changes: 6 additions & 2 deletions packages/plasma-web/src/components/Tabs/index.ts
Original file line number Diff line number Diff line change
@@ -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';