diff --git a/web/packages/design/src/Alert/Alert.tsx b/web/packages/design/src/Alert/Alert.tsx index 68c5487b7788e..f8ce4b7269310 100644 --- a/web/packages/design/src/Alert/Alert.tsx +++ b/web/packages/design/src/Alert/Alert.tsx @@ -22,6 +22,8 @@ import { style, color, ColorProps } from 'styled-system'; import { IconProps } from 'design/Icon/Icon'; +import { StatusIcon, StatusKind } from 'design/StatusIcon'; + import { space, SpaceProps, width, WidthProps } from '../system'; import { Theme } from '../theme'; import * as Icon from '../Icon'; @@ -193,7 +195,12 @@ export const Alert = ({ - + ` ${backgroundColor} `; -const AlertIcon = ({ - kind, - customIcon: CustomIcon, - ...otherProps -}: { - kind: AlertKind | BannerKind; - customIcon?: React.ComponentType; -} & IconProps) => { - const commonProps = { role: 'graphics-symbol', ...otherProps }; - if (CustomIcon) { - return ; - } - switch (kind) { - case 'success': - return ; - case 'danger': - case 'outline-danger': - return ; - case 'info': - case 'outline-info': - return ; - case 'warning': - case 'outline-warn': - return ; - case 'neutral': - case 'primary': - return ; - } -}; - const iconContainerStyles = ({ kind, theme, @@ -468,7 +445,12 @@ export const Banner = ({ gap={3} alignItems="center" > - + {children} {details} @@ -525,3 +507,18 @@ const bannerColors = (theme: Theme, kind: BannerKind) => { }; } }; + +const iconKind = (kind: AlertKind | BannerKind): StatusKind => { + switch (kind) { + case 'outline-danger': + return 'danger'; + case 'outline-warn': + return 'warning'; + case 'outline-info': + return 'info'; + case 'primary': + return 'neutral'; + default: + return kind; + } +}; diff --git a/web/packages/design/src/SlideTabs/SlideTabs.story.tsx b/web/packages/design/src/SlideTabs/SlideTabs.story.tsx index fa2ce7dbafa2b..bb3eb8c49818a 100644 --- a/web/packages/design/src/SlideTabs/SlideTabs.story.tsx +++ b/web/packages/design/src/SlideTabs/SlideTabs.story.tsx @@ -21,7 +21,7 @@ import React, { useState } from 'react'; import * as Icon from 'design/Icon'; import Flex from 'design/Flex'; -import { SlideTabs } from './SlideTabs'; +import { SlideTabs, TabSpec } from './SlideTabs'; export default { title: 'Design/SlideTabs', @@ -120,9 +120,33 @@ export const Small = () => { { ); }; -export const LoadingTab = () => { +export const StatusIcons = () => { + const [activeIndex, setActiveIndex] = useState(0); + const tabs: TabSpec[] = [ + { key: 'warning', title: 'warning', status: { kind: 'warning' } }, + { key: 'danger', title: 'danger', status: { kind: 'danger' } }, + { key: 'neutral', title: 'neutral', status: { kind: 'neutral' } }, + ]; return ( - null} - activeIndex={1} - isProcessing={true} - /> + + + + + ); }; diff --git a/web/packages/design/src/SlideTabs/SlideTabs.test.tsx b/web/packages/design/src/SlideTabs/SlideTabs.test.tsx index 4636655d25700..fe4137f34b28b 100644 --- a/web/packages/design/src/SlideTabs/SlideTabs.test.tsx +++ b/web/packages/design/src/SlideTabs/SlideTabs.test.tsx @@ -21,6 +21,8 @@ import { screen } from '@testing-library/react'; import { render, userEvent } from 'design/utils/testing'; +import * as Icon from 'design/Icon'; + import { SlideTabs, SlideTabsProps } from './SlideTabs'; describe('design/SlideTabs', () => { @@ -87,7 +89,12 @@ describe('design/SlideTabs', () => { ); diff --git a/web/packages/design/src/SlideTabs/SlideTabs.tsx b/web/packages/design/src/SlideTabs/SlideTabs.tsx index 6b92c5803fdf0..9a81f79fd99ac 100644 --- a/web/packages/design/src/SlideTabs/SlideTabs.tsx +++ b/web/packages/design/src/SlideTabs/SlideTabs.tsx @@ -17,10 +17,13 @@ */ import React, { useEffect, useRef } from 'react'; -import styled from 'styled-components'; +import styled, { useTheme } from 'styled-components'; import { Flex, Indicator } from 'design'; import { IconProps } from 'design/Icon/Icon'; +import { HoverTooltip } from 'design/Tooltip'; +import { Position } from 'design/Popover/Popover'; +import { StatusIcon, StatusKind } from 'design/StatusIcon'; export function SlideTabs({ appearance = 'square', @@ -31,7 +34,9 @@ export function SlideTabs({ isProcessing = false, disabled = false, fitContent = false, + hideStatusIconOnActiveTab, }: SlideTabsProps) { + const theme = useTheme(); const activeTab = useRef(null); const tabContainer = useRef(null); @@ -75,7 +80,15 @@ export function SlideTabs({ icon: Icon, ariaLabel, controls, + tooltip: { + content: tooltipContent, + position: tooltipPosition, + } = {}, + status: { kind: statusKind, ariaLabel: statusAriaLabel } = {}, } = toFullTabSpec(tabSpec, tabIndex); + const statusIconColorActive = hideStatusIconOnActiveTab + ? 'transparent' + : theme.colors.text.primaryInverse; let onClick = undefined; if (!disabled && !isProcessing) { @@ -86,32 +99,45 @@ export function SlideTabs({ } return ( - - {/* We need a separate tab content component, since the spinner, - when displayed, shouldn't take up space to prevent layout - jumping. TabContent serves as a positioning anchor whose left - edge is the left edge of the content (not the tab button, - which can be much wider). */} - - {selected && isProcessing && } - {Icon && } - {title} - - + + {/* We need a separate tab content component, since the status + icon, when displayed, shouldn't take up space to prevent + layout jumping. TabContent serves as a positioning anchor + whose left edge is the left edge of the content (not the tab + button, which can be much wider). */} + + + + + {Icon && } + {title} + + + ); })} {/* The tab slider is positioned absolutely and appears below the @@ -169,6 +195,13 @@ export type SlideTabsProps = { * but instead wraps its contents. */ fitContent?: boolean; + /** + * Hides the status icon on active tab. Note that this is not the same as + * simply making some tab's `tabSpec.status` field set conditionally on + * whether that tab is active or not. Using this field provides a smooth + * animation for transitions between active and inactive state. + */ + hideStatusIconOnActiveTab?: boolean; }; /** @@ -179,7 +212,7 @@ export type SlideTabsProps = { * TODO(bl-nero): remove the string option once Enterprise is migrated to * simplify it a bit. */ -type TabSpec = string | FullTabSpec; +export type TabSpec = string | FullTabSpec; type FullTabSpec = TabContentSpec & { /** Iteration key for the tab. */ @@ -190,6 +223,19 @@ type FullTabSpec = TabContentSpec & { * attribute set to "tabpanel". */ controls?: string; + tooltip?: { + content: React.ReactNode; + position?: Position; + }; + /** + * An icon that will be displayed on the side. The layout will stay the same + * whether the icon is there or not. If `isProcessing` prop is set to `true`, + * the icon for an active tab is replaced by a spinner. + */ + status?: { + kind: StatusKind; + ariaLabel?: string; + }; }; /** @@ -220,6 +266,48 @@ function toFullTabSpec(spec: TabSpec, index: number): FullTabSpec { }; } +function StatusIconOrSpinner({ + showSpinner, + statusKind, + size, + color, + ariaLabel, +}: { + showSpinner: boolean; + statusKind: StatusKind | undefined; + size: Size; + color: string | undefined; + ariaLabel: string | undefined; +}) { + if (showSpinner) { + return ; + } + + // This is one of these rare cases when there is a difference between + // property being undefined and not present at all: undefined props would + // override the default ones, but we want it them to interfere at all. + const optionalProps: { color?: string; 'aria-label'?: string } = {}; + if (color !== undefined) { + optionalProps.color = color; + } + if (ariaLabel !== undefined) { + optionalProps['aria-label'] = ariaLabel; + } + + if (!statusKind) { + return null; + } + + return ( + + ); +} + const TabSliderInner = styled.div<{ appearance: Appearance }>` height: 100%; background-color: ${({ theme }) => theme.colors.brand}; @@ -343,6 +431,9 @@ const TabList = styled.div<{ itemCount: number }>` const Spinner = styled(Indicator)` color: ${p => p.theme.colors.levels.deep}; +`; + +const StatusIconContainer = styled.div` position: absolute; left: -${p => p.theme.space[5]}px; `; diff --git a/web/packages/design/src/StatusIcon/StatusIcon.story.tsx b/web/packages/design/src/StatusIcon/StatusIcon.story.tsx new file mode 100644 index 0000000000000..5c9897c6d3896 --- /dev/null +++ b/web/packages/design/src/StatusIcon/StatusIcon.story.tsx @@ -0,0 +1,44 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { StoryObj } from '@storybook/react'; + +import Flex from 'design/Flex'; + +import { StatusIcon } from '.'; + +export default { + title: 'Design', +}; + +export const Story: StoryObj = { + name: 'StatusIcon', + render() { + return ( + + {(['neutral', 'danger', 'info', 'warning', 'success'] as const).map( + status => ( + + {status} + + ) + )} + + ); + }, +}; diff --git a/web/packages/design/src/StatusIcon/StatusIcon.tsx b/web/packages/design/src/StatusIcon/StatusIcon.tsx new file mode 100644 index 0000000000000..08fe46a020b22 --- /dev/null +++ b/web/packages/design/src/StatusIcon/StatusIcon.tsx @@ -0,0 +1,78 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from 'react'; + +import { useTheme } from 'styled-components'; + +import * as Icon from 'design/Icon'; +import { IconProps } from 'design/Icon/Icon'; + +export type StatusKind = 'neutral' | 'danger' | 'info' | 'warning' | 'success'; + +export const StatusIcon = ({ + kind, + customIcon: CustomIcon, + ...otherProps +}: { + kind: StatusKind; + customIcon?: React.ComponentType; +} & IconProps) => { + const commonProps = { role: 'graphics-symbol', ...otherProps }; + const theme = useTheme(); + + if (CustomIcon) { + return ; + } + switch (kind) { + case 'success': + return ( + + ); + case 'danger': + return ( + + ); + case 'info': + return ( + + ); + case 'warning': + return ( + + ); + case 'neutral': + return ; + } +}; diff --git a/web/packages/design/src/StatusIcon/index.ts b/web/packages/design/src/StatusIcon/index.ts new file mode 100644 index 0000000000000..7c674b723d6af --- /dev/null +++ b/web/packages/design/src/StatusIcon/index.ts @@ -0,0 +1,19 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +export { StatusIcon, type StatusKind } from './StatusIcon';