diff --git a/package.json b/package.json index 2c08839d6d..a38d1f11b9 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dompurify": "^3.4.11", + "es-toolkit": "^1.46.0", "fast-glob": "^3.3.3", "front-matter": "^4.0.2", "fs-extra": "^11.3.4", diff --git a/src/components/Examples/ExamplesRenderer.tsx b/src/components/Examples/ExamplesRenderer.tsx index 32bf26cdd7..8ff34448aa 100644 --- a/src/components/Examples/ExamplesRenderer.tsx +++ b/src/components/Examples/ExamplesRenderer.tsx @@ -5,8 +5,9 @@ import { CodeEditor } from 'src/components/CodeEditor'; import { LanguageKey } from 'src/data/languages/types'; import { ExampleFiles, ExampleWithContent } from 'src/data/examples/types'; import { updateAblyConnectionKey } from 'src/utilities/update-ably-connection-keys'; +import Icon from 'src/components/Icon'; import { IconName } from 'src/components/Icon/types'; -import SegmentedControl from '@ably/ui/core/SegmentedControl'; +import SegmentedControl from 'src/components/ui/SegmentedControl'; import dotGrid from './images/dot-grid.svg'; import cn from 'src/utilities/cn'; import { getRandomChannelName } from '../../utilities/get-random-channel-name'; @@ -164,7 +165,7 @@ const ExamplesRenderer = ({ variant="subtle" active={activeLanguage === languageKey} rounded - leftIcon={`icon-tech-${languageKey}` as IconName} + leftIcon={} onClick={() => setActiveLanguage(languageKey as LanguageKey)} > {languageKey.charAt(0).toUpperCase() + languageKey.slice(1)} diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx index 78abe89f2d..139300867a 100644 --- a/src/components/Layout/Header.tsx +++ b/src/components/Layout/Header.tsx @@ -6,7 +6,7 @@ import * as Tooltip from '@radix-ui/react-tooltip'; import { throttle } from 'es-toolkit/compat'; import cn from 'src/utilities/cn'; import Icon from 'src/components/Icon'; -import TabMenu from '@ably/ui/core/TabMenu'; +import TabMenu from 'src/components/ui/TabMenu'; import Logo from 'src/images/ably-logo.svg'; import { track } from '@ably/ui/core/insights'; import { componentMaxHeight, HEADER_BOTTOM_MARGIN, HEADER_HEIGHT } from 'src/utilities/heights'; diff --git a/src/components/ui/SegmentedControl.tsx b/src/components/ui/SegmentedControl.tsx new file mode 100644 index 0000000000..d2070e3b92 --- /dev/null +++ b/src/components/ui/SegmentedControl.tsx @@ -0,0 +1,130 @@ +import React, { PropsWithChildren, ReactNode } from 'react'; +import cn from 'src/utilities/cn'; +import type { IconSize } from 'src/components/Icon/types'; +import IconSlot from './IconSlot'; + +export type SegmentedControlSize = 'md' | 'sm' | 'xs'; + +export type SegmentedControlProps = { + className?: string; + rounded?: boolean; + leftIcon?: ReactNode; + rightIcon?: ReactNode; + active?: boolean; + variant?: 'default' | 'subtle' | 'strong'; + size?: SegmentedControlSize; + onClick?: () => void; + disabled?: boolean; +}; + +const SegmentedControl: React.FC> = ({ + className, + rounded = false, + leftIcon, + rightIcon, + active = false, + variant = 'default', + size = 'md', + children, + onClick, + disabled, +}) => { + const colorStyles = { + default: { + active: 'bg-neutral-200 dark:bg-neutral-1100', + inactive: + 'bg-neutral-000 dark:bg-neutral-1300 hover:bg-neutral-100 dark:hover:bg-neutral-1200 active:bg-neutral-100 dark:active:bg-neutral-1200', + }, + subtle: { + active: 'bg-neutral-000 dark:bg-neutral-1000', + inactive: + 'bg-neutral-100 dark:bg-neutral-1200 hover:bg-neutral-200 dark:hover:bg-neutral-1100 active:bg-neutral-200 dark:active:bg-neutral-1100', + }, + strong: { + active: 'bg-neutral-1000 dark:bg-neutral-300', + inactive: + 'bg-neutral-100 dark:bg-neutral-1200 hover:bg-neutral-200 dark:hover:bg-neutral-1100 active:bg-neutral-200 dark:active:bg-neutral-1100', + }, + }; + + const contentColorStyles = { + default: { + active: 'text-neutral-1300 dark:text-neutral-000', + inactive: 'text-neutral-1000 dark:text-neutral-300 hover:text-neutral-1300 dark:hover:text-neutral-000', + }, + subtle: { + active: 'text-neutral-1300 dark:text-neutral-000', + inactive: 'text-neutral-1000 dark:text-neutral-300 hover:text-neutral-1300 dark:hover:text-neutral-000', + }, + strong: { + active: 'text-neutral-000 dark:text-neutral-1300', + inactive: 'text-neutral-1000 dark:text-neutral-300 hover:text-neutral-1300 dark:hover:text-neutral-000', + }, + }; + + const sizeStyles = { + md: cn('h-12 p-3 gap-2.5', rounded && 'px-[1.125rem]'), + sm: cn('h-10 p-[0.5625rem] gap-[0.5625rem]', rounded && 'px-3.5'), + xs: cn('h-9 p-2 gap-2', rounded && 'px-3'), + }; + + const textStyles = { + md: 'ui-text-label2', + sm: 'ui-text-label3', + xs: 'ui-text-label4', + }; + + const iconSizes: Record = { + md: '23px', + sm: '22px', + xs: '20px', + }; + + const activeKey = active ? 'active' : 'inactive'; + + const iconColor = contentColorStyles[variant][activeKey]; + + return ( +
{ + if ((e.key === 'Enter' || e.key === ' ') && !disabled && onClick) { + e.preventDefault(); + onClick(); + } + }} + className={cn( + 'focus-base flex items-center justify-center cursor-pointer select-none transition-colors', + colorStyles[variant][activeKey], + contentColorStyles[variant][activeKey], + sizeStyles[size], + textStyles[size], + disabled && + 'cursor-not-allowed hover:bg-inherit dark:hover:bg-inherit active:bg-inherit dark:active:bg-inherit', + rounded ? 'rounded-full' : 'rounded-lg', + className, + )} + tabIndex={disabled ? -1 : 0} + role="button" + aria-pressed={active} + aria-disabled={disabled} + > + {leftIcon && } + {children && ( + + {children} + + )} + {rightIcon && } +
+ ); +}; + +export default SegmentedControl; diff --git a/src/components/ui/TabMenu.tsx b/src/components/ui/TabMenu.tsx new file mode 100644 index 0000000000..f57f5de86e --- /dev/null +++ b/src/components/ui/TabMenu.tsx @@ -0,0 +1,204 @@ +import React, { ReactNode, useEffect } from 'react'; +import * as Tabs from '@radix-ui/react-tabs'; +import { throttle } from 'es-toolkit/compat'; +import cn from 'src/utilities/cn'; + +type TabTriggerContent = string | { label: string; disabled?: boolean } | ReactNode; + +/** + * Props for the TabMenu component. + */ + +export type TabMenuProps = { + /** + * An array of tabs, which can be either a string or an object with a label and an optional disabled state. + */ + tabs: TabTriggerContent[]; + + /** + * An optional array of React nodes representing the content for each tab. + */ + contents?: ReactNode[]; + + /** + * An optional callback function that is called when a tab is clicked, receiving the index of the clicked tab. + */ + tabOnClick?: (index: number) => void; + + /** + * An optional class name to apply to each tab. + */ + tabClassName?: string; + + /** + * An optional class name to apply to the Tabs.Root element. + */ + rootClassName?: string; + + /** + * An optional class name to apply to the Tabs.Content element. + */ + contentClassName?: string; + + /** + * Optional configuration options for the TabMenu. + */ + options?: { + /** + * The index of the tab that should be selected by default. + */ + defaultTabIndex?: number; + + /** + * Whether to show an underline below the selected tab. + */ + underline?: boolean; + + /** + * Whether to animate the transition between tabs. + */ + animated?: boolean; + + /** + * Whether the tab width should be flexible. + */ + flexibleTabWidth?: boolean; + + /** + * Whether the tab height should be flexible. + */ + flexibleTabHeight?: boolean; + }; +}; + +const DEFAULT_TAILWIND_ANIMATION_DURATION = 150; + +const TabMenu: React.FC = ({ + tabs = [], + contents = [], + tabOnClick, + tabClassName, + rootClassName, + contentClassName, + options, +}) => { + const { + defaultTabIndex = 0, + underline = true, + animated: animatedOption = true, + flexibleTabWidth = false, + flexibleTabHeight = false, + } = options ?? {}; + + const listRef = React.useRef(null); + const [animated, setAnimated] = React.useState(false); + const [highlight, setHighlight] = React.useState({ offset: 0, width: 0 }); + + useEffect(() => { + if (animatedOption && highlight.width > 0) { + setTimeout(() => { + setAnimated(true); + }, DEFAULT_TAILWIND_ANIMATION_DURATION); + } + }, [animatedOption, highlight.width]); + + const updateHighlightDimensions = (element: HTMLButtonElement) => { + const { left: parentLeft } = listRef.current?.getBoundingClientRect() ?? {}; + const { left, width } = element.getBoundingClientRect() ?? {}; + + setHighlight({ + offset: (left ?? 0) - (parentLeft ?? 0), + width: width ?? 0, + }); + }; + + useEffect(() => { + const handleResize = throttle(() => { + const activeTabElement = listRef.current?.querySelector(`[data-state="active"]`); + + if (activeTabElement) { + updateHighlightDimensions(activeTabElement); + } + }, 100); + + handleResize(); + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + const handleTabClick = (event: React.MouseEvent, index: number) => { + tabOnClick?.(index); + updateHighlightDimensions(event.currentTarget as HTMLButtonElement); + }; + + const tabTriggerContent = (tab: TabTriggerContent) => { + if (!tab) { + return null; + } + + if (React.isValidElement(tab) || typeof tab === 'string') { + return tab; + } + + if (typeof tab === 'object' && 'label' in tab) { + return tab.label; + } + + return null; + }; + + return ( + + + {tabs.map( + (tab, index) => + tab && ( + handleTabClick(event, index)} + disabled={typeof tab === 'object' && 'disabled' in tab ? tab.disabled : false} + > + {tabTriggerContent(tab)} + + ), + )} +
+
+ {contents.map((content, index) => ( + + {content} + + ))} +
+ ); +}; + +export default TabMenu; diff --git a/yarn.lock b/yarn.lock index f65f611d71..ff8645bddb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13813,7 +13813,7 @@ react-helmet@^6.1.0: react-fast-compare "^3.1.1" react-side-effect "^2.1.0" -"react-is-18@npm:react-is@^18.3.1": +"react-is-18@npm:react-is@^18.3.1", "react-is@^16.12.0 || ^17.0.0 || ^18.0.0", react-is@^18.0.0, react-is@^18.3.1: version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== @@ -13823,11 +13823,6 @@ react-helmet@^6.1.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.7.tgz#57668ee86a78574a542b0a539455212b2c086df2" integrity sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A== -"react-is@^16.12.0 || ^17.0.0 || ^18.0.0", react-is@^18.0.0, react-is@^18.3.1: - version "18.3.1" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" - integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== - react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -15022,16 +15017,7 @@ string-similarity@^1.2.2: lodash.map "^4.6.0" lodash.maxby "^4.6.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -15141,7 +15127,7 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -15162,13 +15148,6 @@ strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.2" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" @@ -15340,14 +15319,6 @@ swr@^2.4.0: dequal "^2.0.3" use-sync-external-store "^1.6.0" -swr@^2.4.0: - version "2.4.1" - resolved "https://registry.yarnpkg.com/swr/-/swr-2.4.1.tgz#c9e48abff6bf4b04846342e2f1f6be108a078cf6" - integrity sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA== - dependencies: - dequal "^2.0.3" - use-sync-external-store "^1.6.0" - symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" @@ -16631,7 +16602,7 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -16649,15 +16620,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"