Skip to content

Commit

Permalink
feat(tabs)!: add delete tabs (#747)
Browse files Browse the repository at this point in the history
* feat(tabs): add delete tabs

* fix: panel border color

* fix: border-radius

* fix: comments

* fix: comments

* fix: comments

* fix: extract scrollable to a hook

* fix: post-reviewed ui

* fix: x focusable seulement le tab active

* fix: focus on panel

* fix: remove stylelint comment

* fix: simplify tests and testid

* fix: tabs focus inside

* fix: post review comments

* fix: better story for tabs

* fix: focus after delete, rename tokens, fix comments

* fix: comments
  • Loading branch information
savutsang authored May 1, 2024
1 parent 1f6246a commit 2249458
Show file tree
Hide file tree
Showing 14 changed files with 1,594 additions and 523 deletions.
4 changes: 2 additions & 2 deletions packages/react/src/components/carousel/carousel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe('Carousel', () => {
it('should add padding slides so looping does not jump', () => {
const wrapper = shallow(<Carousel>{slides}</Carousel>);

const renderedSlides = getByTestId(wrapper, 'carousel-slides', '$');
const renderedSlides = getByTestId(wrapper, 'carousel-slides', { modifier: '$' });

expect(renderedSlides.children().length).toBe(numberOfSlides + 2);
});
Expand Down Expand Up @@ -131,7 +131,7 @@ describe('Carousel', () => {
it('should display one dot per slide', () => {
const wrapper = shallow(<Carousel>{slides}</Carousel>);

const dots = findByTestId(wrapper, 'carousel-dot-', '^');
const dots = findByTestId(wrapper, 'carousel-dot-', { modifier: '^' });

expect(dots.length).toBe(5);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe('Pagination', () => {
describe('pages list', () => {
test('should display pages', () => {
const wrapper = shallow(<Pagination resultsPerPage={5} numberOfResults={25} pagesShown={5} />);
const pages = findByTestId(wrapper, 'page-', '^');
const pages = findByTestId(wrapper, 'page-', { modifier: '^' });

expect(pages).toHaveLength(5);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('Progress Component', () => {

const wrapper = mountWithTheme(<ProgressTracker steps={steps} value={1} />);

const allStepsLabels = findByTestId(wrapper, 'progress-tracker-step-', '^')
const allStepsLabels = findByTestId(wrapper, 'progress-tracker-step-', { modifier: '^' })
.map((w) => getByTestId(w, 'progress-tracker-label').text());
expect(allStepsLabels).toEqual(expect.arrayContaining(['Step 1', 'Step 2', 'Step 3']));
});
Expand Down
10 changes: 5 additions & 5 deletions packages/react/src/components/tabs/tab-button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ describe('TabButton', () => {
const expectedButtonText = 'some text';
const wrapper = mountWithProviders(<TabButton {...focusedAndSelected}>{expectedButtonText}</TabButton>);

const tabPanel = getByTestId(wrapper, 'tabs-tab-text');
const tabPanel = getByTestId(wrapper, 'tab-text');

expect(tabPanel.prop('children')).toBe(expectedButtonText);
});

test('should not have a left icon in button when tab doesn\'t have a left icon name', () => {
const wrapper = mountWithProviders(<TabButton {...focusedAndSelected}>some text</TabButton>);

const tabButtonLeftIcon = findByTestId(wrapper, 'tabs-tab-left-icon');
const tabButtonLeftIcon = findByTestId(wrapper, 'tab-left-icon');

expect(tabButtonLeftIcon.length).toBe(0);
});
Expand All @@ -36,15 +36,15 @@ describe('TabButton', () => {
<TabButton {...focusedAndSelected} leftIcon={expectedLeftIcon}>some text</TabButton>,
);

const tabButtonLeftIcon = getByTestId(wrapper, 'tabs-tab-left-icon');
const tabButtonLeftIcon = getByTestId(wrapper, 'tab-left-icon');

expect(tabButtonLeftIcon.prop('name')).toBe(expectedLeftIcon);
});

test('should not have a right icon in button when tab doesn\'t have a right icon name', () => {
const wrapper = mountWithProviders(<TabButton {...focusedAndSelected}>some text</TabButton>);

const tabButtonRightIcon = findByTestId(wrapper, 'tabs-tab-right-icon');
const tabButtonRightIcon = findByTestId(wrapper, 'tab-right-icon');

expect(tabButtonRightIcon.length).toBe(0);
});
Expand All @@ -55,7 +55,7 @@ describe('TabButton', () => {
<TabButton {...focusedAndSelected} rightIcon={expectedRightIcon}>some text</TabButton>,
);

const tabButtonRightIcon = getByTestId(wrapper, 'tabs-tab-right-icon');
const tabButtonRightIcon = getByTestId(wrapper, 'tab-right-icon');

expect(tabButtonRightIcon.prop('name')).toBe(expectedRightIcon);
});
Expand Down
220 changes: 123 additions & 97 deletions packages/react/src/components/tabs/tab-button.tsx
Original file line number Diff line number Diff line change
@@ -1,90 +1,95 @@
import { forwardRef, KeyboardEvent, ReactElement, Ref } from 'react';
import styled, { css } from 'styled-components';
import { IconButton } from '../buttons/icon-button';
import { useDataAttributes } from '../../hooks/use-data-attributes';
import { useTranslation } from '../../i18n/use-translation';
import { focus } from '../../utils/css-state';
import { useDeviceContext } from '../device-context-provider/device-context-provider';
import { Icon, IconName } from '../icon/icon';

interface IsSelected {
$isSelected: boolean;
}

interface StyledButtonProps extends IsSelected {
$isGlobal?: boolean;
$isMobile: boolean;
}
const selectedIndicatorPosition = (global: boolean | undefined): string => (global ? 'bottom: 0' : 'top: 0');

const StyledButton = styled.button<StyledButtonProps>`
const StyledButton = styled.button<{ $global?: boolean; $selected?: boolean; $removable?: boolean; }>`
align-items: center;
border: 1px solid transparent;
border-bottom: ${({ $isGlobal, theme }) => ($isGlobal ? 'none' : `1px solid ${theme.component['tabs-tab-border-bottom-color']}`)};
border-radius: var(--border-radius-2x) var(--border-radius-2x) 0 0;
bottom: -1px;
color: ${({ $isGlobal, theme }) => ($isGlobal ? `${theme.component['tabs-tab-global-text-color']}` : `${theme.component['tabs-tab-text-color']}`)};
color: ${({ $selected, theme }) => ($selected ? theme.component['tab-selected-text-color'] : theme.component['tab-text-color'])};
display: flex;
justify-content: center;
line-height: 1.5rem;
min-height: ${({ $isMobile }) => ($isMobile ? 'var(--size-3halfx)' : 'var(--size-3x)')};
min-width: 82px;
font-family: var(--font-family);
font-size: 0.875rem;
gap: var(--spacing-half);
padding: 0 var(--spacing-2x);
padding-right: ${({ $removable }) => ($removable && 'var(--spacing-4x)')};
position: relative;
&:hover {
background-color: ${({ theme }) => theme.component['tabs-tab-hover-background-color']};
user-select: none;
&::after {
content: '';
display: block;
height: 4px;
left: 0;
position: absolute;
width: 100%;
${({ $global }) => selectedIndicatorPosition($global)};
}
${({ theme }) => focus({ theme }, { focusType: 'focus-visible' })};
${({ theme }) => focus({ theme }, { focusType: 'focus-visible', insideOnly: true })};
&:focus {
z-index: 2;
}
${({ $selected, theme }) => !$selected && css`
&:active {
color: ${theme.component['tab-active-text-color']};
font-weight: var(--font-semi-bold);
${({ $isGlobal, $isSelected, theme }) => ($isGlobal && $isSelected) && css`
z-index: 1;
::after {
background-color: ${theme.component['tabs-tab-global-selected-background-color']};
bottom: 0;
content: '';
display: block;
height: 4px;
left: 0;
position: absolute;
width: 100%;
&::after {
background-color: ${theme.component['tab-active-indicator-color']} !important;
}
}
`}
${({ $isGlobal, $isSelected, theme }) => (!$isGlobal && $isSelected) && css`
background-color: ${theme.component['tabs-tab-selected-background-color']};
border: 1px solid ${theme.component['tabs-tab-selected-border-color']};
border-bottom: 1px solid transparent;
border-radius: var(--border-radius-2x) var(--border-radius-2x) 0 0;
color: ${theme.component['tabs-tab-selected-text-color']};
z-index: 1;
${({ $selected, theme }) => $selected && css`
background: ${theme.greys.white};
font-weight: var(--font-semi-bold);
&::after {
background-color: ${theme.component['tab-selected-indicator-color']};
}
`}
`;

const StyledButtonText = styled.span<IsSelected & { $isMobile: boolean; }>`
color: ${({ theme }) => theme.component['tabs-tab-button-text-color']};
font-family: var(--font-family);
font-size: ${({ $isMobile }) => ($isMobile ? 1 : 0.875)}rem;
font-weight: ${({ $isSelected }) => ($isSelected ? 'var(--font-semi-bold)' : 'var(--font-normal)')};
line-height: 1.5rem;
const StyledButtonIcon = styled(Icon)`
color: ${({ theme }) => theme.component['tab-icon-color']};
vertical-align: middle;
`;

const StyledTab = styled.div<{ $selected: boolean; }>`
display: flex;
position: relative;
${({ $selected, theme }) => !$selected && css`
&:hover {
${StyledButton} {
&::after {
background-color: ${theme.component['tab-hover-indicator-color']};
color: ${theme.component['tab-hover-text-color']};
}
}
}
`};
`;

const LeftIcon = styled(Icon)<IsSelected>`
color: ${({ theme }) => theme.component['tabs-tab-left-icon-color']};
height: 1rem;
min-width: fit-content;
padding-right: var(--spacing-half);
width: 1rem;
const DeleteButton = styled(IconButton)`
position: absolute;
right: var(--spacing-1x);
top: 50%;
transform: translateY(-50%);
`;

const RightIcon = styled(Icon)<IsSelected>`
color: ${({ theme }) => theme.component['tabs-tab-right-icon-color']};
height: 1rem;
min-width: fit-content;
padding-left: var(--spacing-half);
width: 1rem;
const ButtonLabel = styled.span`
// Prevent width shifting between normal and semi-bold
&::after {
content: attr(data-content);
display: block;
font-weight: var(--font-semi-bold);
height: 0;
visibility: hidden;
}
`;

interface TabButtonProps {
Expand All @@ -95,10 +100,9 @@ interface TabButtonProps {
leftIcon?: IconName
rightIcon?: IconName;
isSelected: boolean;

onClick(): void;

onKeyDown?(event: KeyboardEvent<HTMLButtonElement>): void;
onRemove?(): void;
onKeyDown?(event: KeyboardEvent<HTMLDivElement>): void;
}

export const TabButton = forwardRef(({
Expand All @@ -110,44 +114,66 @@ export const TabButton = forwardRef(({
rightIcon,
isSelected,
onClick,
onRemove,
onKeyDown,
...rest
}: TabButtonProps, ref: Ref<HTMLButtonElement>): ReactElement => {
const { isMobile } = useDeviceContext();
const { t } = useTranslation('tabs');
const dataAttributes = useDataAttributes(rest);
const dataTestId = dataAttributes['data-testid'] ?? 'tab';
const hasRemove = !!onRemove;

return (
<StyledButton
id={id}
aria-controls={panelId}
role="tab"
aria-selected={isSelected}
ref={ref}
data-testid="tab-button"
tabIndex={isSelected ? undefined : -1}
$isGlobal={global}
$isMobile={isMobile}
$isSelected={isSelected}
onClick={onClick}
<StyledTab
$selected={isSelected}
data-testid={dataTestId}
onKeyDown={onKeyDown}
>
{leftIcon && (
<LeftIcon
data-testid="tabs-tab-left-icon"
$isSelected={isSelected}
name={leftIcon}
size="16"
/>
)}
<StyledButtonText data-testid="tabs-tab-text" $isSelected={isSelected} $isMobile={isMobile}>
{children}
</StyledButtonText>
{rightIcon && (
<RightIcon
data-testid="tabs-tab-right-icon"
$isSelected={isSelected}
name={rightIcon}
size="16"
<StyledButton
type="button"
id={id}
aria-controls={panelId}
role="tab"
aria-selected={isSelected}
ref={ref}
data-testid={`${dataTestId}-button`}
tabIndex={isSelected ? undefined : -1}
onClick={onClick}
$removable={hasRemove}
$selected={isSelected}
$global={global}
>
{leftIcon && (
<StyledButtonIcon
aria-hidden="true"
data-testid={`${dataTestId}-left-icon`}
name={leftIcon}
size="16"
/>
)}
<ButtonLabel data-testid={`${dataTestId}-text`} data-content={children}>
{children}
</ButtonLabel>
{rightIcon && (
<StyledButtonIcon
aria-hidden="true"
data-testid={`${dataTestId}-right-icon`}
name={rightIcon}
size="16"
/>
)}
</StyledButton>
{hasRemove && (
<DeleteButton
buttonType="tertiary"
onClick={() => onRemove()}
data-testid={`${dataTestId}-delete`}
aria-label={t('dismissTab', { label: children })}
iconName='x'
focusable={isSelected}
size="small"
/>
)}
</StyledButton>
</StyledTab>
);
});
13 changes: 8 additions & 5 deletions packages/react/src/components/tabs/tab-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import { focus } from '../../utils/css-state';
interface TabPanelProps {
buttonId: string,
children: ReactNode;
contained?: boolean;
hidden: boolean,
id: string,
global?: boolean;
}

const StyledDiv = styled.div<{ $contained?: boolean }>`
border: ${({ $contained, theme }) => ($contained ? `1px solid ${theme.component['tab-panel-border-color']}` : 'none')};
const StyledDiv = styled.div<{ $isGlobal?: boolean; }>`
background: ${({ $isGlobal, theme }) => !$isGlobal && theme.component['tab-panel-background-color']};
border: ${({ $isGlobal, theme }) => !$isGlobal && `1px solid ${theme.component['tab-panel-border-color']}`};
border-radius: ${({ $isGlobal }) => !$isGlobal && '0 0 var(--border-radius-2x) var(--border-radius-2x)'};
border-top: none;
${({ theme }) => focus({ theme }, { focusType: 'focus-visible' })}
Expand All @@ -20,18 +22,19 @@ const StyledDiv = styled.div<{ $contained?: boolean }>`
export const TabPanel: FunctionComponent<PropsWithChildren<TabPanelProps>> = ({
buttonId,
children,
contained,
global = false,
hidden,
id,
}) => (
<StyledDiv
$contained={contained}
$isGlobal={global}
aria-hidden={hidden}
aria-labelledby={buttonId}
hidden={hidden}
id={id}
role="tabpanel"
tabIndex={0}

>
{children}
</StyledDiv>
Expand Down
Loading

0 comments on commit 2249458

Please sign in to comment.