diff options
author | stanislavh <stanislav.honcharov@sonarsource.com> | 2024-01-26 13:54:12 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2024-01-29 20:03:16 +0000 |
commit | ec89bff4f8bbe324bf9a4f48cd137e192c283d30 (patch) | |
tree | ebf119a11db03768415f9884d9e8cb2476a08ae1 /server/sonar-web/design-system/src | |
parent | 9234a58514677a11b4ed680af855e22cd5d538ce (diff) | |
download | sonarqube-ec89bff4f8bbe324bf9a4f48cd137e192c283d30.tar.gz sonarqube-ec89bff4f8bbe324bf9a4f48cd137e192c283d30.zip |
SONAR-21467 Implement new Tabs component
Diffstat (limited to 'server/sonar-web/design-system/src')
5 files changed, 213 insertions, 11 deletions
diff --git a/server/sonar-web/design-system/src/components/Badge.tsx b/server/sonar-web/design-system/src/components/Badge.tsx index 4d8ea88fed3..b1cc762001e 100644 --- a/server/sonar-web/design-system/src/components/Badge.tsx +++ b/server/sonar-web/design-system/src/components/Badge.tsx @@ -19,16 +19,17 @@ */ import styled from '@emotion/styled'; import tw from 'twin.macro'; -import { themeColor, themeContrast } from '../helpers/theme'; +import { themeBorder, themeColor, themeContrast } from '../helpers/theme'; import { ThemeColors } from '../types/theme'; -type BadgeVariant = 'default' | 'new' | 'deleted' | 'counter'; +type BadgeVariant = 'default' | 'new' | 'deleted' | 'counter' | 'counterFailed'; const variantList: Record<BadgeVariant, ThemeColors> = { default: 'badgeDefault', new: 'badgeNew', deleted: 'badgeDeleted', counter: 'badgeCounter', + counterFailed: 'badgeCounterFailed', }; interface BadgeProps { @@ -45,13 +46,13 @@ export function Badge({ className, children, title, variant = 'default' }: Badge role: 'status', title, }; - if (variant === 'counter') { - return <StyledCounter {...commonProps}>{children}</StyledCounter>; - } + + const Component = ['counter', 'counterFailed'].includes(variant) ? StyledCounter : StyledBadge; + return ( - <StyledBadge variantInfo={variantList[variant]} {...commonProps}> + <Component variantInfo={variantList[variant]} {...commonProps}> {children} - </StyledBadge> + </Component> ); } @@ -80,17 +81,25 @@ const StyledBadge = styled.span<{ } `; -const StyledCounter = styled.span` +const StyledCounter = styled.span<{ + variantInfo: ThemeColors; +}>` + ${tw`sw-min-w-5 sw-min-h-5`}; ${tw`sw-text-[0.75rem]`}; ${tw`sw-font-regular`}; - ${tw`sw-px-2`}; + ${tw`sw-box-border sw-px-[5px]`}; ${tw`sw-inline-flex`}; ${tw`sw-leading-[1.125rem]`}; ${tw`sw-items-center sw-justify-center`}; ${tw`sw-rounded-pill`}; - color: ${themeContrast('badgeCounter')}; - background-color: ${themeColor('badgeCounter')}; + color: ${({ variantInfo }) => themeContrast(variantInfo)}; + background-color: ${({ variantInfo }) => themeColor(variantInfo)}; + border: ${({ variantInfo }) => + themeBorder( + 'default', + variantInfo === 'badgeCounterFailed' ? 'badgeCounterFailedBorder' : 'transparent', + )}; &:empty { ${tw`sw-hidden`} diff --git a/server/sonar-web/design-system/src/components/Tabs.tsx b/server/sonar-web/design-system/src/components/Tabs.tsx new file mode 100644 index 00000000000..dd0381f3284 --- /dev/null +++ b/server/sonar-web/design-system/src/components/Tabs.tsx @@ -0,0 +1,135 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import styled from '@emotion/styled'; +import { PropsWithChildren } from 'react'; +import tw from 'twin.macro'; +import { OPACITY_20_PERCENT, themeBorder, themeColor } from '../helpers'; +import { getTabId, getTabPanelId } from '../helpers/tabs'; +import { Badge } from './Badge'; +import { BareButton } from './buttons'; + +type TabValueType = string | number | boolean; + +export interface TabOption<T extends TabValueType> { + counter?: number; + disabled?: boolean; + label: string | React.ReactNode; + value: T; +} + +export interface TabsProps<T extends TabValueType> { + className?: string; + disabled?: boolean; + label?: string; + onChange: (value: T) => void; + options: ReadonlyArray<TabOption<T>>; + value?: T; +} + +export function Tabs<T extends TabValueType>(props: PropsWithChildren<TabsProps<T>>) { + const { disabled = false, label, options, value, className, children } = props; + + return ( + <TabsContainer className={className}> + <TabList aria-label={label} role="tablist"> + {options.map((option) => ( + <TabButton + aria-controls={getTabPanelId(String(option.value))} + aria-current={option.value === value} + aria-selected={option.value === value} + data-value={option.value} + disabled={disabled || option.disabled} + id={getTabId(String(option.value))} + key={option.value.toString()} + onClick={() => { + if (option.value !== value) { + props.onChange(option.value); + } + }} + role="tab" + selected={option.value === value} + > + {option.label} + {option.counter ? ( + <Badge className="sw-ml-2" variant="counterFailed"> + {option.counter} + </Badge> + ) : null} + </TabButton> + ))} + </TabList> + <RightSection>{children}</RightSection> + </TabsContainer> + ); +} + +const TabsContainer = styled.div` + ${tw`sw-w-full`}; + ${tw`sw-flex sw-justify-between`}; + border-bottom: ${themeBorder('default')}; +`; + +const TabList = styled.div` + ${tw`sw-inline-flex`}; +`; + +const TabButton = styled(BareButton)<{ selected: boolean }>` + ${tw`sw-relative`}; + ${tw`sw-px-3 sw-py-1 sw-mb-[-1px]`}; + ${tw`sw-flex sw-items-center`}; + ${tw`sw-body-sm`}; + ${tw`sw-font-semibold`}; + ${tw`sw-rounded-t-1`}; + + height: 34px; + background: ${(props) => (props.selected ? themeColor('backgroundSecondary') : 'none')}; + color: ${(props) => (props.selected ? themeColor('tabSelected') : themeColor('tab'))}; + border: ${(props) => + props.selected ? themeBorder('default') : themeBorder('default', 'transparent')}; + border-bottom: ${(props) => + themeBorder('default', props.selected ? 'backgroundSecondary' : undefined)}; + + &:hover { + background: ${themeColor('tabHover')}; + } + + &:focus, + &:active { + outline: ${themeBorder('xsActive', 'tabSelected', OPACITY_20_PERCENT)}; + z-index: 1; + } + + // Active line + &::after { + content: ''; + ${tw`sw-absolute`}; + ${tw`sw-rounded-t-1`}; + top: 0; + left: 0; + width: calc(100%); + height: 2px; + background: ${(props) => (props.selected ? themeColor('tabSelected') : 'none')}; + } +`; + +const RightSection = styled.div` + max-height: 43px; + ${tw`sw-flex sw-items-center`}; +`; diff --git a/server/sonar-web/design-system/src/components/__tests__/Tabs-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Tabs-test.tsx new file mode 100644 index 00000000000..5b6eb4cc2b2 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/Tabs-test.tsx @@ -0,0 +1,49 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '../../helpers/testUtils'; +import { FCProps } from '../../types/misc'; +import { TabOption, Tabs } from '../Tabs'; + +it('should render all options', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + const options: Array<TabOption<number>> = [ + { value: 1, label: 'first' }, + { value: 2, label: 'disabled', disabled: true }, + { value: 3, label: 'has counter', counter: 7 }, + ]; + renderToggleButtons({ onChange, options, value: 1 }); + + expect(screen.getAllByRole('tab')).toHaveLength(3); + + await user.click(screen.getByText('first')); + + expect(onChange).not.toHaveBeenCalled(); + + await user.click(screen.getByText('has counter')); + + expect(onChange).toHaveBeenCalledWith(3); +}); + +function renderToggleButtons(props: Partial<FCProps<typeof Tabs>> = {}) { + return render(<Tabs onChange={jest.fn()} options={[]} {...props} />); +} diff --git a/server/sonar-web/design-system/src/components/index.ts b/server/sonar-web/design-system/src/components/index.ts index 92661b36e23..c23b99a2d68 100644 --- a/server/sonar-web/design-system/src/components/index.ts +++ b/server/sonar-web/design-system/src/components/index.ts @@ -73,6 +73,7 @@ export { Spinner } from './Spinner'; export * from './SpotlightTour'; export * from './Switch'; export * from './Table'; +export * from './Tabs'; export * from './Tags'; export * from './Text'; export * from './TextAccordion'; diff --git a/server/sonar-web/design-system/src/theme/light.ts b/server/sonar-web/design-system/src/theme/light.ts index 35ec15d93bb..1eb37c7c559 100644 --- a/server/sonar-web/design-system/src/theme/light.ts +++ b/server/sonar-web/design-system/src/theme/light.ts @@ -305,6 +305,8 @@ export const lightTheme = { badgeDefault: COLORS.blueGrey[100], badgeDeleted: COLORS.red[100], badgeCounter: COLORS.blueGrey[100], + badgeCounterFailed: COLORS.red[50], + badgeCounterFailedBorder: COLORS.red[200], // pills pillDanger: COLORS.red[50], @@ -325,6 +327,11 @@ export const lightTheme = { // tab tabBorder: primary.light, + // tabs + tab: COLORS.blueGrey[400], + tabSelected: primary.default, + tabHover: COLORS.blueGrey[25], + //table tableRowHover: COLORS.indigo[25], tableRowSelected: COLORS.indigo[300], @@ -709,6 +716,7 @@ export const lightTheme = { badgeDefault: COLORS.blueGrey[700], badgeDeleted: COLORS.red[900], badgeCounter: secondary.darker, + badgeCounterFailed: danger.dark, // pills pillDanger: COLORS.red[800], |