From fb99e44eb8a100a59e9ea095b3e6f24f58b0c4ad Mon Sep 17 00:00:00 2001 From: Jamie Henson Date: Wed, 17 Jun 2026 14:19:50 +0100 Subject: [PATCH 1/3] refactor(docs): localise Code + CodeSnippet (+ Tooltip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendor the Code and CodeSnippet subsystem (ApiKeySelector, CopyButton, LanguageSelector, PlainCodeView, TooltipButton, languages) plus the transitive Tooltip into src/components/ui/ (DX-1128). Wired to the local Icon / cn / colors, the local syntax-highlighter (PR D), and the local SegmentedControl / Badge / Code from earlier stack PRs. CodeSnippet receives apiKeys as a prop from MDXWrapper, so the SessionData API-key seam stays in MDXWrapper (the kept infra) — no scripts/SessionData import lands in the vendored component. Codemod: CodeSnippet + CodeSnippet/ languages consumers repointed; Code is internal to CodeSnippet (no direct consumer). eslint clean. Completes the docs visual-surface lift: after this, @ably/ui is imported only for insights + core/scripts (SessionData) infra. Refs: DX-1128 --- src/components/Layout/MDXWrapper.test.tsx | 4 +- src/components/Layout/MDXWrapper.tsx | 4 +- src/components/ui/Code.tsx | 121 ++++ src/components/ui/CodeSnippet.tsx | 597 ++++++++++++++++++ .../ui/CodeSnippet/ApiKeySelector.tsx | 125 ++++ src/components/ui/CodeSnippet/CopyButton.tsx | 45 ++ .../ui/CodeSnippet/LanguageSelector.tsx | 108 ++++ .../ui/CodeSnippet/PlainCodeView.tsx | 58 ++ .../ui/CodeSnippet/TooltipButton.tsx | 99 +++ .../ui/CodeSnippet/languages.test.ts | 107 ++++ src/components/ui/CodeSnippet/languages.ts | 159 +++++ src/components/ui/Tooltip.tsx | 67 ++ src/contexts/layout-context.tsx | 2 +- 13 files changed, 1491 insertions(+), 5 deletions(-) create mode 100644 src/components/ui/Code.tsx create mode 100644 src/components/ui/CodeSnippet.tsx create mode 100644 src/components/ui/CodeSnippet/ApiKeySelector.tsx create mode 100644 src/components/ui/CodeSnippet/CopyButton.tsx create mode 100644 src/components/ui/CodeSnippet/LanguageSelector.tsx create mode 100644 src/components/ui/CodeSnippet/PlainCodeView.tsx create mode 100644 src/components/ui/CodeSnippet/TooltipButton.tsx create mode 100644 src/components/ui/CodeSnippet/languages.test.ts create mode 100644 src/components/ui/CodeSnippet/languages.ts create mode 100644 src/components/ui/Tooltip.tsx diff --git a/src/components/Layout/MDXWrapper.test.tsx b/src/components/Layout/MDXWrapper.test.tsx index e4cb87b438..8b947d6947 100644 --- a/src/components/Layout/MDXWrapper.test.tsx +++ b/src/components/Layout/MDXWrapper.test.tsx @@ -3,7 +3,7 @@ import { WindowLocation } from '@reach/router'; import { render, screen, waitFor } from '@testing-library/react'; import { Helmet } from 'react-helmet'; import If from './mdx/If'; -import CodeSnippet from '@ably/ui/core/CodeSnippet'; +import CodeSnippet from 'src/components/ui/CodeSnippet'; import UserContext from 'src/contexts/user-context'; import MDXWrapper from './MDXWrapper'; @@ -54,7 +54,7 @@ jest.mock('src/components/Icon', () => { }); // Mock Code component used by CodeSnippet -jest.mock('@ably/ui/core/Code', () => { +jest.mock('src/components/ui/Code', () => { return { __esModule: true, default: ({ language, snippet }: any) => ( diff --git a/src/components/Layout/MDXWrapper.tsx b/src/components/Layout/MDXWrapper.tsx index 32af50478e..72bd5a5c6b 100644 --- a/src/components/Layout/MDXWrapper.tsx +++ b/src/components/Layout/MDXWrapper.tsx @@ -9,8 +9,8 @@ import React, { ReactElement, } from 'react'; import { navigate, PageProps } from 'gatsby'; -import CodeSnippet from '@ably/ui/core/CodeSnippet'; -import type { CodeSnippetProps, SDKType } from '@ably/ui/core/CodeSnippet'; +import CodeSnippet from 'src/components/ui/CodeSnippet'; +import type { CodeSnippetProps, SDKType } from 'src/components/ui/CodeSnippet'; import cn from 'src/utilities/cn'; import { getRandomChannelName } from '../../utilities/get-random-channel-name'; diff --git a/src/components/ui/Code.tsx b/src/components/ui/Code.tsx new file mode 100644 index 0000000000..66244118a8 --- /dev/null +++ b/src/components/ui/Code.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { + highlightSnippet, + LINE_HIGHLIGHT_CLASSES, + registerDefaultLanguages, + splitHtmlLines, +} from 'src/utilities/syntax-highlighter'; +import languagesRegistry from 'src/utilities/syntax-highlighter-registry'; +import cn from 'src/utilities/cn'; + +registerDefaultLanguages(languagesRegistry); + +export type LineHighlightType = 'addition' | 'removal' | 'highlight'; + +type CodeProps = { + language: string; + snippet: string; + textSize?: string; + padding?: string; + additionalCSS?: string; + showLines?: boolean; + lineCSS?: string; + wrap?: boolean; + lineHighlights?: Record; +}; + +const Code = ({ + language, + snippet, + textSize = 'ui-text-code', + padding = 'p-8', + additionalCSS = '', + showLines, + lineCSS, + wrap = false, + lineHighlights, +}: CodeProps) => { + // Trim the snippet and remove trailing empty lines + const trimmedSnippet = snippet.trimEnd(); + const HTMLraw = highlightSnippet(language, trimmedSnippet) ?? ''; + const className = `language-${language} ${textSize}`; + + // Calculate line count after removing trailing empty lines + const lines = trimmedSnippet.split(/\r\n|\r|\n/); + const lineCount = lines.length; + + const hasHighlights = lineHighlights && Object.keys(lineHighlights).length > 0; + + // Per-line rendering when highlights are present + if (hasHighlights) { + const htmlLines = splitHtmlLines(HTMLraw); + + return ( +
+
+          
+            {htmlLines.map((lineHtml, i) => {
+              const lineNum = i + 1;
+              const highlightType = lineHighlights[lineNum];
+              const highlightClass = highlightType ? LINE_HIGHLIGHT_CLASSES[highlightType] : undefined;
+
+              return (
+                
+                  {showLines && (
+                    
+                      {lineNum}
+                    
+                  )}
+                  
+                
+              );
+            })}
+          
+        
+
+ ); + } + + // Default: single-block rendering (no highlights) + return ( +
+ {showLines ? ( +
+ {[...Array(lineCount)].map((_, i) => ( +

+ {i + 1} +

+ ))} +
+ ) : null} +
+        
+      
+
+ ); +}; + +export default Code; diff --git a/src/components/ui/CodeSnippet.tsx b/src/components/ui/CodeSnippet.tsx new file mode 100644 index 0000000000..5ae3714999 --- /dev/null +++ b/src/components/ui/CodeSnippet.tsx @@ -0,0 +1,597 @@ +import React, { useState, useEffect, Children, isValidElement, useRef, useCallback, useMemo } from 'react'; +import Code from 'src/components/ui/Code'; +import type { LineHighlightType } from 'src/components/ui/Code'; +import cn from 'src/utilities/cn'; +import { parseLineHighlights } from 'src/utilities/syntax-highlighter'; +import Icon from 'src/components/Icon'; +import { getLanguageInfo, stripSdkType, SDK_PREFIXES, SDKType } from './CodeSnippet/languages'; +import LanguageSelector from './CodeSnippet/LanguageSelector'; +import ApiKeySelector from './CodeSnippet/ApiKeySelector'; +import PlainCodeView from './CodeSnippet/PlainCodeView'; +import CopyButton from './CodeSnippet/CopyButton'; +import SegmentedControl from 'src/components/ui/SegmentedControl'; + +// Re-export SDKType for consumers +export type { SDKType }; + +// Define API key types +export type ApiKeysItem = { + app: string; + keys: { name: string; key: string }[]; +}; + +export type CodeSnippetProps = { + /** + * If true, hides the language selector row completely + */ + fixed?: boolean; + /** + * If true, renders a macOS-style window header with buttons and title + */ + headerRow?: boolean; + /** + * Title to display in the header row (when headerRow is true) + */ + title?: string; + /** + * Children elements with lang attribute + */ + children: React.ReactNode; + /** + * Additional CSS classes + */ + className?: string; + /** + * Default language to display. If not found in available languages, first available is used. + * If found in languages but no matching snippet exists, a message is displayed. + */ + lang: string | null; + /** + * Callback fired when the active language changes + */ + onChange?: (language: string, sdk?: SDKType) => void; + /** + * List of API keys to display in a dropdown + */ + apiKeys?: ApiKeysItem[]; + /** + * Default SDK type to use for the code snippet + */ + sdk?: SDKType; + /** + * Whether to show line numbers in code snippets + */ + showCodeLines?: boolean; + /** + * Defines the order in which languages should be displayed. + * Languages not in this array will be shown after those that are included. + */ + languageOrdering?: string[]; + /** + * Whether to wrap code content instead of scrolling + */ + wrapCode?: boolean; +}; + +// Substitution function for API key placeholders +const substituteApiKey = (content: string, apiKey: string, mask = true): string => { + return content.replace(/\{\{API_KEY\}\}/g, mask ? `${apiKey.split(':')[0]}:*****` : apiKey); +}; + +/** + * CodeSnippet component that displays code with language switching capability + */ +const CodeSnippet: React.FC = ({ + fixed = false, + headerRow = false, + title = 'Code', + children, + className, + lang, + onChange, + apiKeys, + sdk, + showCodeLines = true, + languageOrdering, + wrapCode = false, +}) => { + const codeRef = useRef(null); + + const [selectedApiKey, setSelectedApiKey] = useState(() => apiKeys?.[0]?.keys?.[0]?.key ?? ''); + const [prevApiKeys, setPrevApiKeys] = useState(apiKeys); + if (prevApiKeys !== apiKeys) { + setPrevApiKeys(apiKeys); + if (!selectedApiKey && apiKeys && apiKeys.length > 0) { + setSelectedApiKey(apiKeys[0].keys?.[0]?.key ?? ''); + } + } + + useEffect(() => { + const element = codeRef.current; + if (!element) { + return; + } + + // Detects the key masking via substituteApiKey (i.e. "abcde:*****") and replaces it with the actual API key + const unmaskRenderedApiKey = (content: string, apiKey: string): string => { + return content.replace(/(['"]?)([^:'"]+):\*{5}\1/g, `$1${apiKey}$1`); + }; + + const handleCopy = (event: ClipboardEvent) => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + + const selectedText = selection.toString(); + if (!selectedText) { + return; + } + + // Check if the selection is within our element + const range = selection.getRangeAt(0); + if (!element.contains(range.commonAncestorContainer)) { + return; + } + + const modifiedText = unmaskRenderedApiKey(selectedText, selectedApiKey); + + event.clipboardData?.setData('text/plain', modifiedText); + event.preventDefault(); + }; + + document.addEventListener('copy', handleCopy); + + return () => { + document.removeEventListener('copy', handleCopy); + }; + }, [selectedApiKey]); + + const extractLanguageFromCode = useCallback((codeElement: React.ReactElement | null): string | null => { + if (!codeElement || !codeElement.props.className) { + return null; + } + + const classNames = codeElement.props.className.split(' '); + const langClass = classNames.find((cls: string) => cls.startsWith('language-')); + if (!langClass) { + return null; + } + + return langClass.substring(9); // Remove "language-" prefix + }, []); + + // Helper to find the code element within pre's children (handles both single element and array) + const findCodeElement = useCallback((preChildren: React.ReactNode): React.ReactElement | null => { + if (isValidElement(preChildren)) { + return preChildren; + } + if (Array.isArray(preChildren)) { + const codeEl = preChildren.find((c) => isValidElement(c)); + return codeEl && isValidElement(codeEl) ? codeEl : null; + } + return null; + }, []); + + const { codeData, languages, sdkTypes, isSinglePlainCommand } = useMemo(() => { + const childrenArray = Children.toArray(children); + const languages: string[] = []; + const sdkTypes = new Set(); + const codeData: { + language: string; + content: string; + lineHighlights: Record; + }[] = []; + + const isSinglePlainCommand = + childrenArray.length === 1 && + ['language-shell', 'language-text'].some((lang) => { + if (!isValidElement(childrenArray[0])) { + return false; + } + const codeEl = findCodeElement(childrenArray[0].props.children); + return codeEl?.props.className?.includes(lang); + }); + + childrenArray.forEach((child) => { + if (!isValidElement(child)) { + return; + } + + const preElement = child; + const codeElement = findCodeElement(preElement.props.children); + + if (!codeElement) { + return; + } + + const rawLanguage = extractLanguageFromCode(codeElement); + + if (!rawLanguage) { + return; + } + + const meta: string | undefined = codeElement.props?.['data-meta']; + const { lang: codeLanguage, highlights: lineHighlights } = parseLineHighlights(rawLanguage, meta); + + for (const prefix of SDK_PREFIXES) { + if (codeLanguage.startsWith(`${prefix}_`)) { + sdkTypes.add(prefix); + break; + } + } + + if (!languages.includes(codeLanguage)) { + languages.push(codeLanguage); + } + + const codeContent = codeElement.props.children; + codeData.push({ + language: codeLanguage, + content: codeContent, + lineHighlights, + }); + }); + + return { + codeData, + languages, + sdkTypes, + isSinglePlainCommand, + }; + }, [children, extractLanguageFromCode, findCodeElement]); + + // Resolve which SDK type to filter by. If the snippet only contains one SDK type + // and it doesn't match the current selector, fall back to the only available type. + // Returns undefined when no SDK prop is provided (e.g. plain code blocks without SDK prefixes). + const resolvedSdk: SDKType | undefined = useMemo(() => { + if (sdkTypes.size === 1 && sdk && !sdkTypes.has(sdk)) { + return Array.from(sdkTypes)[0]; + } + return sdk; + }, [sdk, sdkTypes]); + + // Only show SDK selector for realtime/rest types, not for client/agent (which are controlled by page-level selector) + const showSDKSelector = sdkTypes.has('realtime') || sdkTypes.has('rest'); + + const filteredLanguages = useMemo(() => { + const filtered = + !resolvedSdk || !showSDKSelector + ? [...languages] + : languages.filter((lang) => lang.startsWith(`${resolvedSdk}_`)); + + // Apply custom ordering if provided + if (languageOrdering && languageOrdering.length > 0) { + filtered.sort((a, b) => { + const aBase = stripSdkType(a); + const bBase = stripSdkType(b); + + const aIndex = languageOrdering.indexOf(aBase); + const bIndex = languageOrdering.indexOf(bBase); + + if (aIndex !== -1 && bIndex !== -1) { + return aIndex - bIndex; + } + if (aIndex !== -1) { + return -1; + } + if (bIndex !== -1) { + return 1; + } + return 0; + }); + } + + return filtered; + }, [resolvedSdk, showSDKSelector, languages, languageOrdering]); + + const activeLanguage = useMemo(() => { + // For client/agent SDK types (controlled by page-level selector), construct the full language + if (resolvedSdk === 'client' || resolvedSdk === 'agent') { + const fullLang = `${resolvedSdk}_${lang}`; + // Verify this language exists in available languages + if (languages.includes(fullLang)) { + return fullLang; + } + // Fall back to first language with this prefix + const prefixMatch = languages.find((l) => l.startsWith(`${resolvedSdk}_`)); + if (prefixMatch) { + return prefixMatch; + } + } + + // For realtime/rest SDK types + if (resolvedSdk && sdkTypes.has(resolvedSdk)) { + return `${resolvedSdk}_${lang}`; + } + + if (lang) { + return lang; + } + + if (filteredLanguages.length > 0) { + return filteredLanguages[0]; + } + + return languages[0]; + }, [resolvedSdk, sdkTypes, lang, filteredLanguages, languages]); + + const requiresApiKeySubstitution = useMemo(() => { + const containsPlaceholder = codeData.some((code) => code?.content.includes('{{API_KEY}}')); + + return containsPlaceholder && !!apiKeys && apiKeys.length > 0 && !!selectedApiKey; + }, [codeData, apiKeys, selectedApiKey]); + + const [isHovering, setIsHovering] = useState(false); + + const hasOnlyJsonSnippet = useMemo(() => languages.length === 1 && languages[0] === 'json', [languages]); + + const processedChildren = useMemo(() => { + if (!activeLanguage) { + return []; + } + + const targetLanguage = hasOnlyJsonSnippet ? 'json' : activeLanguage; + + return codeData + .filter((code) => { + return code?.language === targetLanguage; + }) + .map((code) => { + if (!code) { + return null; + } + + const cleanLang = hasOnlyJsonSnippet ? 'json' : code.language; + const langInfo = getLanguageInfo(cleanLang ?? ''); + + if (typeof code.content === 'string' || typeof code.content === 'number' || typeof code.content === 'boolean') { + // Apply API key substitution if apiKeys are provided + let processedContent = String(code.content); + if (requiresApiKeySubstitution) { + processedContent = substituteApiKey(processedContent, selectedApiKey); + } + + if (!langInfo.syntaxHighlighterKey || !cleanLang) { + return null; + } + + return ( + 0 ? code.lineHighlights : undefined} + /> + ); + } + + return null; + }); + }, [ + activeLanguage, + hasOnlyJsonSnippet, + codeData, + requiresApiKeySubstitution, + showCodeLines, + wrapCode, + selectedApiKey, + ]); + + const hasSnippetForActiveLanguage = useMemo(() => { + if (!activeLanguage) { + return false; + } + if (hasOnlyJsonSnippet) { + return true; + } + + return codeData.some((code) => { + return code?.language === activeLanguage; + }); + }, [activeLanguage, hasOnlyJsonSnippet, codeData]); + + const handleSDKTypeChange = useCallback( + (type: SDKType) => { + const nextLang = stripSdkType( + languages.find((l) => l === `${type}_${stripSdkType(activeLanguage)}`) ?? + languages.find((l) => l.startsWith(`${type}_`)) ?? + activeLanguage, + ); + + if (onChange && nextLang) { + onChange(stripSdkType(activeLanguage), type); + } + }, + [activeLanguage, languages, onChange], + ); + + const handleLanguageChange = useCallback( + (language: string) => { + if (onChange) { + onChange(stripSdkType(language), resolvedSdk); + } + }, + [onChange, resolvedSdk], + ); + + const noSnippetMessage = useMemo(() => { + if (!activeLanguage) { + return null; + } + + const activeLanguageInfo = getLanguageInfo(activeLanguage); + + return ( +
+ +

+ You're currently viewing the {activeLanguageInfo.label} docs. There either isn't a{' '} + {activeLanguageInfo.label} code sample for this example, or this feature isn't supported in{' '} + {activeLanguageInfo.label}. Switch language to view this example in a different language, or check which SDKs + support this feature. +

+
+ ); + }, [activeLanguage]); + + const showLanguageSelector = !fixed && filteredLanguages.length > 0; + const showFullSelector = filteredLanguages.length > 1; + // Show a read-only language label when fixed (controlled by external selector) + const showFixedLanguageLabel = fixed && activeLanguage; + + const renderLanguageLabel = (langKey: string, onClick?: () => void) => ( +
+
+ + + {getLanguageInfo(langKey).label} + +
+
+ ); + + const renderContent = useMemo(() => { + if (!activeLanguage) { + return null; + } + + if (hasSnippetForActiveLanguage) { + return processedChildren; + } + + return noSnippetMessage; + }, [activeLanguage, hasSnippetForActiveLanguage, processedChildren, noSnippetMessage]); + + // Render special case for plain commands (shell or text) + if (isSinglePlainCommand) { + const plainChild = codeData[0]; + if (plainChild) { + const codeContent = plainChild.content; + const language = plainChild.language; + + if (!language || !codeContent) { + return null; + } + + // Apply API key substitution if apiKeys are provided + let processedContent = String(codeContent); + if (requiresApiKeySubstitution) { + processedContent = substituteApiKey(processedContent, selectedApiKey); + } + + return ( + + ); + } + } + + return ( +
+ {headerRow && ( +
+
+
+
+
+
+ +
{title}
+ +
+
+ )} + + {showSDKSelector && ( +
+
+ {['realtime', 'rest'].map( + (type) => + sdkTypes.has(type as SDKType) && ( + handleSDKTypeChange(type as SDKType)} + size="xs" + active={resolvedSdk === type} + className={cn( + 'text-[11px] font-semibold px-2 py-1 h-auto', + sdkTypes.size === 1 && + 'pointer-events-none bg-neutral-100 dark:bg-neutral-1200 !text-neutral-800 !dark:text-neutral-500', + sdkTypes.size > 1 && + resolvedSdk !== type && + 'bg-neutral-100 dark:bg-neutral-1200 hover:bg-neutral-200 dark:hover:bg-neutral-1100 active:bg-neutral-400 dark:active:bg-neutral-900', + sdkTypes.size > 1 && resolvedSdk === type && 'bg-neutral-000 dark:bg-neutral-1100', + )} + > + {type === 'realtime' ? 'Realtime' : 'REST'} + + ), + )} +
+
+ )} + + {showFixedLanguageLabel && renderLanguageLabel(activeLanguage)} + + {showLanguageSelector && + (showFullSelector ? ( + + ) : ( + renderLanguageLabel(filteredLanguages[0], () => handleLanguageChange(filteredLanguages[0])) + ))} +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + onFocus={() => setIsHovering(true)} + onBlur={() => setIsHovering(false)} + > + {renderContent} + {isHovering && activeLanguage && hasSnippetForActiveLanguage && ( + { + const text = codeData.find((code) => code.language === activeLanguage)?.content; + if (text) { + navigator.clipboard.writeText(substituteApiKey(text, selectedApiKey, false)); + } + }} + /> + )} +
+ {requiresApiKeySubstitution && ( + + )} +
+ ); +}; + +export default CodeSnippet; diff --git a/src/components/ui/CodeSnippet/ApiKeySelector.tsx b/src/components/ui/CodeSnippet/ApiKeySelector.tsx new file mode 100644 index 0000000000..50bba0a8ca --- /dev/null +++ b/src/components/ui/CodeSnippet/ApiKeySelector.tsx @@ -0,0 +1,125 @@ +import React, { useMemo } from 'react'; +import * as Select from '@radix-ui/react-select'; +import Badge from 'src/components/ui/Badge'; +import Icon from 'src/components/Icon'; +import Tooltip from 'src/components/ui/Tooltip'; +import type { ApiKeysItem } from '../CodeSnippet'; + +type ApiKeySelectorProps = { + apiKeys?: ApiKeysItem[]; + selectedApiKey: string; + onApiKeyChange: (apiKey: string) => void; +}; + +const ApiKeySelector = ({ apiKeys, selectedApiKey, onApiKeyChange }: ApiKeySelectorProps) => { + const isDemoMode = useMemo(() => apiKeys?.length === 1 && apiKeys[0].app === 'demo', [apiKeys]); + + const renderDemoMode = useMemo( + () => ( +
+ DEMO ONLY + + + +
+ } + > + This code example uses a temporary key that is rate limited and expires in 4 hrs. Sign in to Ably to use your + API keys instead. + + + ), + [], + ); + + const renderApiKeyDropdown = useMemo(() => { + if (isDemoMode) { + return renderDemoMode; + } + + if (!apiKeys?.length) { + return null; + } + + return ( + + + + + + + + + + + + + + + + {apiKeys.map((apiKeyItem) => ( + + {apiKeys.length > 1 && ( + + {apiKeyItem.app} + + )} + {apiKeyItem.keys.map(({ name, key }) => ( + + + {key.length > 10 ? `${key.substring(0, 10)}...` : key} + {name && ` - ${name}`} + + + + + + ))} + + ))} + + + + + + + + + ); + }, [apiKeys, isDemoMode, selectedApiKey, onApiKeyChange, renderDemoMode]); + + return ( +
+ API key: + {renderApiKeyDropdown} +
+ ); +}; + +export default ApiKeySelector; diff --git a/src/components/ui/CodeSnippet/CopyButton.tsx b/src/components/ui/CodeSnippet/CopyButton.tsx new file mode 100644 index 0000000000..0e33645a06 --- /dev/null +++ b/src/components/ui/CodeSnippet/CopyButton.tsx @@ -0,0 +1,45 @@ +import React, { useState } from 'react'; +import Icon from 'src/components/Icon'; +import TooltipButton from './TooltipButton'; + +type CopyButtonProps = { + onCopy: () => void; + tooltip?: string; +}; + +const CopyButton = ({ onCopy, tooltip = 'Copy' }: CopyButtonProps) => { + const [isCopied, setIsCopied] = useState(false); + const [isHovering, setIsHovering] = useState(false); + + return ( +
setIsHovering(true)} + onMouseLeave={() => { + setIsHovering(false); + + setTimeout(() => { + setIsCopied(false); + }, 250); + }} + > + { + onCopy(); + setIsCopied(true); + }} + tooltipRootProps={{ + open: isHovering, + }} + variant="icon-button" + > + + +
+ ); +}; + +export default CopyButton; diff --git a/src/components/ui/CodeSnippet/LanguageSelector.tsx b/src/components/ui/CodeSnippet/LanguageSelector.tsx new file mode 100644 index 0000000000..4a89d0f455 --- /dev/null +++ b/src/components/ui/CodeSnippet/LanguageSelector.tsx @@ -0,0 +1,108 @@ +import React, { useMemo } from 'react'; +import * as Select from '@radix-ui/react-select'; +import Icon from 'src/components/Icon'; +import TooltipButton from './TooltipButton'; +import { getLanguageInfo } from './languages'; + +type LanguageSelectorProps = { + languages: string[]; + activeLanguage: string; + onLanguageChange: (language: string) => void; +}; + +const LanguageSelector = ({ languages, activeLanguage, onLanguageChange }: LanguageSelectorProps) => { + const desktopLanguageElements = useMemo( + () => + languages.map((lang) => { + const active = activeLanguage === lang; + const displayName = getLanguageInfo(lang).label; + + return ( + onLanguageChange(lang)} + icon={getLanguageInfo(lang).icon} + variant="segmented" + size="xs" + > + {displayName} + + ); + }), + [languages, activeLanguage, onLanguageChange], + ); + + const mobileLanguageElements = useMemo( + () => + languages.map((lang) => ( + + +
+ + {getLanguageInfo(lang).label} +
+
+ + + +
+ )), + [languages], + ); + + const mobileSelectValue = useMemo( + () => + activeLanguage ? ( +
+ + {getLanguageInfo(activeLanguage).label} +
+ ) : null, + [activeLanguage], + ); + + return ( +
+
{desktopLanguageElements}
+ +
+ + + {mobileSelectValue} + + + + + + + + + + + + {mobileLanguageElements} + + + + + + + +
+
+ ); +}; + +export default LanguageSelector; diff --git a/src/components/ui/CodeSnippet/PlainCodeView.tsx b/src/components/ui/CodeSnippet/PlainCodeView.tsx new file mode 100644 index 0000000000..147dc966b8 --- /dev/null +++ b/src/components/ui/CodeSnippet/PlainCodeView.tsx @@ -0,0 +1,58 @@ +import React, { useRef, useState } from 'react'; +import Icon from 'src/components/Icon'; +import Code from 'src/components/ui/Code'; +import cn from 'src/utilities/cn'; +import CopyButton from './CopyButton'; +import { IconName } from 'src/components/Icon/types'; + +type PlainCodeViewProps = { + content: string; + language: string; + icon: IconName | null; + className?: string; +}; + +const PlainCodeView: React.FC = ({ content, className, language, icon }) => { + const codeRef = useRef(null); + const [isHovering, setIsHovering] = useState(false); + + return ( +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + onFocus={() => setIsHovering(true)} + onBlur={() => setIsHovering(false)} + tabIndex={0} + role="button" + aria-label="Focusable code view area" + ref={codeRef} + > + {icon && ( +
+
+ +
+
+ )} + + + + {isHovering && navigator.clipboard.writeText(content)} />} +
+ ); +}; + +export default PlainCodeView; diff --git a/src/components/ui/CodeSnippet/TooltipButton.tsx b/src/components/ui/CodeSnippet/TooltipButton.tsx new file mode 100644 index 0000000000..8170d7b522 --- /dev/null +++ b/src/components/ui/CodeSnippet/TooltipButton.tsx @@ -0,0 +1,99 @@ +import React, { useMemo } from 'react'; +import Tooltip from 'src/components/ui/Tooltip'; +import SegmentedControl, { SegmentedControlSize } from 'src/components/ui/SegmentedControl'; +import cn from 'src/utilities/cn'; +import { IconName } from 'src/components/Icon/types'; +import type { TooltipProps } from '@radix-ui/react-tooltip'; + +type TooltipButtonProps = { + tooltip: string | React.ReactNode; + active?: boolean; + onClick: () => void; + icon?: IconName; + className?: string; + children?: React.ReactNode; + variant?: 'segmented' | 'icon-button'; + size?: SegmentedControlSize; + alwaysShowLabel?: boolean; + tooltipRootProps?: TooltipProps; +}; + +const TooltipButton = ({ + tooltip, + active = false, + onClick, + icon, + className, + children, + variant = 'segmented', + size = 'sm', + alwaysShowLabel = false, + tooltipRootProps, +}: TooltipButtonProps) => { + const showTooltip = (variant === 'segmented' && !active) || variant === 'icon-button'; + + const showChildren = active || alwaysShowLabel; + + // Create the button element based on variant + const buttonElement = useMemo(() => { + if (variant === 'segmented') { + return ( + + {showChildren ? children : null} + + ); + } + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick?.(); + } + }} + tabIndex={0} + > + {children} +
+ ); + }, [variant, size, active, onClick, icon, className, showChildren, children]); + + if (showTooltip) { + return ( + + {tooltip} + + ); + } + + return buttonElement; +}; + +export default TooltipButton; diff --git a/src/components/ui/CodeSnippet/languages.test.ts b/src/components/ui/CodeSnippet/languages.test.ts new file mode 100644 index 0000000000..26d127c686 --- /dev/null +++ b/src/components/ui/CodeSnippet/languages.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { stripSdkType, getLanguageInfo, SDK_PREFIXES, SDKType } from './languages'; + +describe('SDK_PREFIXES', () => { + it('contains the expected SDK types', () => { + expect(SDK_PREFIXES).toEqual(['realtime', 'rest', 'client', 'agent']); + }); + + it('derives SDKType correctly', () => { + // Verify the type system works by assigning each prefix + const types: SDKType[] = [...SDK_PREFIXES]; + expect(types).toHaveLength(4); + }); +}); + +describe('stripSdkType', () => { + it('strips realtime_ prefix', () => { + expect(stripSdkType('realtime_javascript')).toBe('javascript'); + }); + + it('strips rest_ prefix', () => { + expect(stripSdkType('rest_python')).toBe('python'); + }); + + it('strips client_ prefix', () => { + expect(stripSdkType('client_javascript')).toBe('javascript'); + }); + + it('strips agent_ prefix', () => { + expect(stripSdkType('agent_python')).toBe('python'); + }); + + it('returns the language unchanged when no prefix', () => { + expect(stripSdkType('javascript')).toBe('javascript'); + }); + + it('handles languages with underscores after the prefix', () => { + expect(stripSdkType('client_objective_c')).toBe('objective_c'); + }); + + it('does not strip unknown prefixes', () => { + expect(stripSdkType('unknown_javascript')).toBe('unknown_javascript'); + }); + + it('handles empty string', () => { + expect(stripSdkType('')).toBe(''); + }); + + it('does not strip prefix without underscore', () => { + expect(stripSdkType('realtimejavascript')).toBe('realtimejavascript'); + }); +}); + +describe('getLanguageInfo', () => { + it('returns info for known languages', () => { + const info = getLanguageInfo('javascript'); + expect(info.label).toBe('JavaScript'); + expect(info.icon).toBe('icon-tech-javascript'); + expect(info.syntaxHighlighterKey).toBe('javascript'); + }); + + it('returns info for prefixed languages by stripping prefix first', () => { + const info = getLanguageInfo('realtime_javascript'); + expect(info.label).toBe('JavaScript'); + expect(info.icon).toBe('icon-tech-javascript'); + }); + + it('returns info for client_ prefixed languages', () => { + const info = getLanguageInfo('client_python'); + expect(info.label).toBe('Python'); + expect(info.icon).toBe('icon-tech-python'); + }); + + it('returns info for agent_ prefixed languages', () => { + const info = getLanguageInfo('agent_nodejs'); + expect(info.label).toBe('Node.js'); + expect(info.icon).toBe('icon-tech-nodejs'); + }); + + it('handles case-insensitive lookup', () => { + const info = getLanguageInfo('JavaScript'); + expect(info.label).toBe('JavaScript'); + }); + + it('returns fallback for unknown languages', () => { + const info = getLanguageInfo('brainfuck'); + expect(info.label).toBe('brainfuck'); + expect(info.icon).toBe('icon-tech-web'); + expect(info.syntaxHighlighterKey).toBe('brainfuck'); + }); + + it('returns correct info for new language entries', () => { + expect(getLanguageInfo('cpp').label).toBe('C++'); + expect(getLanguageInfo('dart').label).toBe('Dart'); + expect(getLanguageInfo('objc').label).toBe('Objective-C'); + expect(getLanguageInfo('android').label).toBe('Android'); + expect(getLanguageInfo('flutter').label).toBe('Flutter'); + }); + + it('maps languages to correct syntax highlighter keys', () => { + expect(getLanguageInfo('nodejs').syntaxHighlighterKey).toBe('javascript'); + expect(getLanguageInfo('react').syntaxHighlighterKey).toBe('javascript'); + expect(getLanguageInfo('flutter').syntaxHighlighterKey).toBe('dart'); + expect(getLanguageInfo('android').syntaxHighlighterKey).toBe('kotlin'); + expect(getLanguageInfo('laravel').syntaxHighlighterKey).toBe('php'); + }); +}); diff --git a/src/components/ui/CodeSnippet/languages.ts b/src/components/ui/CodeSnippet/languages.ts new file mode 100644 index 0000000000..b432438ef5 --- /dev/null +++ b/src/components/ui/CodeSnippet/languages.ts @@ -0,0 +1,159 @@ +import { IconName } from 'src/components/Icon/types'; + +export interface LanguageInfo { + label: string; + icon: IconName; + syntaxHighlighterKey?: string; +} + +export type LanguageMap = Record; + +export const SDK_PREFIXES = ['realtime', 'rest', 'client', 'agent'] as const; +export type SDKType = (typeof SDK_PREFIXES)[number]; + +const languages: LanguageMap = { + javascript: { + label: 'JavaScript', + icon: 'icon-tech-javascript', + syntaxHighlighterKey: 'javascript', + }, + typescript: { + label: 'TypeScript', + icon: 'icon-tech-typescript', + syntaxHighlighterKey: 'typescript', + }, + java: { + label: 'Java', + icon: 'icon-tech-java', + syntaxHighlighterKey: 'java', + }, + kotlin: { + label: 'Kotlin', + icon: 'icon-tech-kotlin', + syntaxHighlighterKey: 'kotlin', + }, + python: { + label: 'Python', + icon: 'icon-tech-python', + syntaxHighlighterKey: 'python', + }, + csharp: { + label: 'C#', + icon: 'icon-tech-csharp', + syntaxHighlighterKey: 'csharp', + }, + go: { + label: 'Go', + icon: 'icon-tech-go', + syntaxHighlighterKey: 'go', + }, + ruby: { + label: 'Ruby', + icon: 'icon-tech-ruby', + syntaxHighlighterKey: 'ruby', + }, + php: { + label: 'PHP', + icon: 'icon-tech-php', + syntaxHighlighterKey: 'php', + }, + nodejs: { + label: 'Node.js', + icon: 'icon-tech-nodejs', + syntaxHighlighterKey: 'javascript', + }, + react: { + label: 'React', + icon: 'icon-tech-react', + syntaxHighlighterKey: 'javascript', + }, + html: { + label: 'HTML', + icon: 'icon-tech-web', + syntaxHighlighterKey: 'xml', + }, + shell: { + label: 'Shell', + icon: 'icon-tech-web', + syntaxHighlighterKey: 'bash', + }, + json: { + label: 'JSON', + icon: 'icon-tech-json', + syntaxHighlighterKey: 'json', + }, + laravel: { + label: 'Laravel', + icon: 'icon-tech-laravel-broadcast', + syntaxHighlighterKey: 'php', + }, + xml: { + label: 'XML', + icon: 'icon-tech-web', + syntaxHighlighterKey: 'xml', + }, + sql: { + label: 'SQL', + icon: 'icon-tech-postgres', + syntaxHighlighterKey: 'sql', + }, + swift: { + label: 'Swift', + icon: 'icon-tech-swift', + syntaxHighlighterKey: 'swift', + }, + // New entries from languageInfo.ts + cpp: { + label: 'C++', + icon: 'icon-tech-web', + syntaxHighlighterKey: 'cpp', + }, + dart: { + label: 'Dart', + icon: 'icon-tech-web', + syntaxHighlighterKey: 'dart', + }, + objc: { + label: 'Objective-C', + icon: 'icon-tech-objectivec', + syntaxHighlighterKey: 'objc', + }, + android: { + label: 'Android', + icon: 'icon-tech-android-full', + syntaxHighlighterKey: 'kotlin', + }, + flutter: { + label: 'Flutter', + icon: 'icon-tech-flutter', + syntaxHighlighterKey: 'dart', + }, +}; + +export const stripSdkType = (lang: string) => { + for (const prefix of SDK_PREFIXES) { + const withUnderscore = `${prefix}_`; + if (lang.startsWith(withUnderscore)) { + return lang.slice(withUnderscore.length); + } + } + return lang; +}; + +// Fallback function to handle languages not in the map +export const getLanguageInfo = (langKey: string): LanguageInfo => { + const key = stripSdkType(langKey).toLowerCase(); + + if (languages[key]) { + return languages[key]; + } + + // Fallback for unknown languages + return { + label: langKey, + icon: 'icon-tech-web', + syntaxHighlighterKey: langKey, + }; +}; + +export default languages; diff --git a/src/components/ui/Tooltip.tsx b/src/components/ui/Tooltip.tsx new file mode 100644 index 0000000000..d9df1e0e71 --- /dev/null +++ b/src/components/ui/Tooltip.tsx @@ -0,0 +1,67 @@ +import React, { ButtonHTMLAttributes, HTMLAttributes, PropsWithChildren, ReactNode } from 'react'; +import * as RadixTooltip from '@radix-ui/react-tooltip'; +import Icon from 'src/components/Icon'; +import cn from 'src/utilities/cn'; +import { IconSize } from 'src/components/Icon/types'; + +type TooltipProps = { + triggerElement?: ReactNode; + triggerProps?: ButtonHTMLAttributes; + contentProps?: RadixTooltip.TooltipContentProps & HTMLAttributes; + rootProps?: RadixTooltip.TooltipProps; + interactive?: boolean; + iconSize?: IconSize; +} & HTMLAttributes; + +const Tooltip = ({ + children, + triggerElement, + triggerProps, + contentProps, + rootProps, + interactive = false, + iconSize = '1rem', + ...rest +}: PropsWithChildren) => { + return ( +
+ + + + + + + +
{children}
+
+
+
+
+
+ ); +}; + +export default Tooltip; diff --git a/src/contexts/layout-context.tsx b/src/contexts/layout-context.tsx index e7ca3ab7a1..8107314649 100644 --- a/src/contexts/layout-context.tsx +++ b/src/contexts/layout-context.tsx @@ -1,6 +1,6 @@ import React, { createContext, PropsWithChildren, useContext, useMemo } from 'react'; import { useLocation } from '@reach/router'; -import { stripSdkType } from '@ably/ui/core/CodeSnippet/languages'; +import { stripSdkType } from 'src/components/ui/CodeSnippet/languages'; import { ActivePage, determineActivePage, PageTemplate } from 'src/components/Layout/utils/nav'; import { productData } from 'src/data'; import { LanguageKey } from 'src/data/languages/types'; From f9d87a8d2390d0cd426a06002bfc11c369ad7f41 Mon Sep 17 00:00:00 2001 From: Jamie Henson Date: Thu, 18 Jun 2026 14:20:57 +0100 Subject: [PATCH 2/3] fix(docs): make vendored languages.test runnable under Jest + repoint setupTests mocks - Drop the vitest import from CodeSnippet/languages.test.ts (describe/it/expect are Jest globals); it was failing module resolution. - Repoint the three jest.mock() calls in setupTests.js off @ably/ui/core/* to local src/utilities/syntax-highlighter* + src/components/ui/Code. Refs: DX-1128 --- setupTests.js | 6 +++--- src/components/ui/CodeSnippet/languages.test.ts | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/setupTests.js b/setupTests.js index 6838e230f6..4f8ce05462 100644 --- a/setupTests.js +++ b/setupTests.js @@ -11,7 +11,7 @@ window.ResizeObserver = ResizeObserver; Element.prototype.scrollIntoView = jest.fn(); -jest.mock('@ably/ui/core/utils/syntax-highlighter', () => ({ +jest.mock('src/utilities/syntax-highlighter', () => ({ highlightSnippet: jest.fn, LINE_HIGHLIGHT_CLASSES: { addition: 'code-line-addition', @@ -23,12 +23,12 @@ jest.mock('@ably/ui/core/utils/syntax-highlighter', () => ({ splitHtmlLines: (html) => html.split('\n'), })); -jest.mock('@ably/ui/core/Code', () => ({ +jest.mock('src/components/ui/Code', () => ({ __esModule: true, default: () => null, })); -jest.mock('@ably/ui/core/utils/syntax-highlighter-registry', () => ({ +jest.mock('src/utilities/syntax-highlighter-registry', () => ({ __esModule: true, default: [], })); diff --git a/src/components/ui/CodeSnippet/languages.test.ts b/src/components/ui/CodeSnippet/languages.test.ts index 26d127c686..58906d8528 100644 --- a/src/components/ui/CodeSnippet/languages.test.ts +++ b/src/components/ui/CodeSnippet/languages.test.ts @@ -1,4 +1,3 @@ -import { describe, expect, it } from 'vitest'; import { stripSdkType, getLanguageInfo, SDK_PREFIXES, SDKType } from './languages'; describe('SDK_PREFIXES', () => { From 0dd3b596fc03f38808898625a22e351862ed9d2a Mon Sep 17 00:00:00 2001 From: Jamie Henson Date: Thu, 18 Jun 2026 16:36:51 +0100 Subject: [PATCH 3/3] perf(docs): prune dead vendored CSS + drop @ably/ui content scan Every visual component is now local, so apply the end-state optimizations the earlier stack PRs deliberately deferred to stay self-sufficient/deployable: - drop the ./node_modules/@ably/ui content glob (no @ably/ui visual components remain to scan; the residual @ably/ui imports are infra with no Tailwind classes) - delete 5 dead vendored CSS files (legacy-buttons, dropdowns, layout, Slider, Flash component CSS) - zero references - prune shadows.css to the 2 used tokens (sm-soft, lg-medium) and forms.css to the checkbox block (the only forms classes docs consumes) Compiled global.css 8457 -> 6755 lines. Refs: DX-1128 --- src/styles/global.css | 2 - src/styles/ui/core/Flash/component.css | 28 ----- src/styles/ui/core/Slider/component.css | 40 ------- src/styles/ui/core/styles.components.css | 3 - src/styles/ui/core/styles/dropdowns.css | 49 -------- src/styles/ui/core/styles/forms.css | 93 --------------- src/styles/ui/core/styles/layout.css | 29 ----- src/styles/ui/core/styles/legacy-buttons.css | 95 --------------- src/styles/ui/core/styles/shadows.css | 117 ------------------- tailwind.config.js | 7 +- 10 files changed, 1 insertion(+), 462 deletions(-) delete mode 100644 src/styles/ui/core/Flash/component.css delete mode 100644 src/styles/ui/core/Slider/component.css delete mode 100644 src/styles/ui/core/styles/dropdowns.css delete mode 100644 src/styles/ui/core/styles/layout.css delete mode 100644 src/styles/ui/core/styles/legacy-buttons.css diff --git a/src/styles/global.css b/src/styles/global.css index f17c58f5fa..e11afbeb08 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -4,9 +4,7 @@ @import './ui/reset/styles.css'; @import './ui/core/styles.css'; -@import './ui/core/Slider/component.css'; @import './ui/core/Code/component.css'; -@import './ui/core/Flash/component.css'; @import './ui/core/utils/syntax-highlighter.css'; :root { diff --git a/src/styles/ui/core/Flash/component.css b/src/styles/ui/core/Flash/component.css deleted file mode 100644 index 0900ddf571..0000000000 --- a/src/styles/ui/core/Flash/component.css +++ /dev/null @@ -1,28 +0,0 @@ -@layer components { - .ui-flash { - @apply w-full fixed; - top: 5.5rem; - z-index: calc(var(--stacking-context-page-meganav) - 10); - transition: margin-top 200ms; - } - - .ui-flash-message { - @apply font-sans font-light antialiased max-w-screen-xl mx-auto mt-2 opacity-0 relative; - transition: - opacity 200ms, - transform 200ms, - height 200ms 200ms, - margin-top 200ms 200ms; - transform: translateY(-200%) rotateX(-90deg); - } - - /* dynamic content inside flash, can't add classes */ - .ui-flash-text a { - @apply underline; - } - - .ui-flash-message-enter { - @apply opacity-100; - transform: translateY(0) rotateX(0); - } -} diff --git a/src/styles/ui/core/Slider/component.css b/src/styles/ui/core/Slider/component.css deleted file mode 100644 index 165c8ebbbe..0000000000 --- a/src/styles/ui/core/Slider/component.css +++ /dev/null @@ -1,40 +0,0 @@ -@layer components { - .ui-slider-marker { - font-size: 0.5rem; - top: -1px; - - @apply leading-none px-1 relative; - } - - @keyframes fillAnimation { - 0% { - width: 0%; - } - 100% { - width: 100%; - } - } - - .ui-icon-cta { - @apply cursor-pointer overflow-hidden; - @apply rounded border-2 border-mid-grey hover:border-active-orange; - transition: all 0.4s; - } - - @screen sm { - .ui-icon-cta-left:hover .ui-icon-cta-holder { - transform: translateX(-100%); - } - .ui-icon-cta-right .ui-icon-cta-holder { - transform: translateX(-100%); - } - .ui-icon-cta-right:hover .ui-icon-cta-holder { - transform: translateX(0%); - } - } - - .ui-icon-cta-holder { - @apply w-full h-full; - transition: all 0.4s; - } -} diff --git a/src/styles/ui/core/styles.components.css b/src/styles/ui/core/styles.components.css index a5157c8043..d1a00a160d 100644 --- a/src/styles/ui/core/styles.components.css +++ b/src/styles/ui/core/styles.components.css @@ -1,9 +1,6 @@ @import "./styles/utils.css"; @import "./styles/buttons.css"; -@import "./styles/legacy-buttons.css"; -@import "./styles/dropdowns.css"; @import "./styles/forms.css"; -@import "./styles/layout.css"; @import "./styles/shadows.css"; @import "./styles/text.css"; diff --git a/src/styles/ui/core/styles/dropdowns.css b/src/styles/ui/core/styles/dropdowns.css deleted file mode 100644 index 85b835d48f..0000000000 --- a/src/styles/ui/core/styles/dropdowns.css +++ /dev/null @@ -1,49 +0,0 @@ -@layer components { - .ui-dropdown-base { - @apply rounded-lg border border-neutral-400 hover:border-neutral-600 active:border-neutral-500 pl-4 pr-14 appearance-none select-none focus-base; - @apply bg-no-repeat text-p2 flex font-medium bg-neutral-100 transition-colors; - background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KCTxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjNjY3MDg1IiBkPSJNMTIuOTA0NiAxMkw5LjAwNjcxIDE2LjMzMUwxMC40OTMzIDE3LjY2OUwxNC45OTMzIDEyLjY2OUwxNC45OTMzIDExLjMzMUwxMC40OTMzIDYuMzMxMDVMOS4wMDY3MSA3LjY2ODk4TDEyLjkwNDYgMTJaIiB0cmFuc2Zvcm09InJvdGF0ZSg5MCAxMiAxMikiLz4KPC9zdmc+Cg=="); - background-position: center right 16px; - } - - .ui-theme-dark .ui-dropdown-base { - @apply text-neutral-000 bg-neutral-1200 border-neutral-900 hover:border-neutral-700 active:border-neutral-800; - } - - .ui-dropdown-base option:disabled { - @apply text-neutral-800; - } - - .ui-dropdown { - @apply ui-dropdown-base py-3; - } - - .ui-dropdown-small { - @apply ui-dropdown-base py-2.5 text-[14px]; - } - - /* Special wrapper styles for overriding default Select2 functionality */ - .ui-dropdown-select2-wrapper .select2 { - @apply !w-full; - } - - .ui-dropdown-select2-wrapper .select2-selection { - @apply ui-dropdown-small; - } - - .ui-dropdown-select2-wrapper .select2-selection--single { - @apply !h-auto; - } - - .ui-dropdown-select2-wrapper .select2-selection__rendered { - @apply !leading-relaxed !px-0; - } - - .ui-dropdown-select2-wrapper .select2-selection__arrow { - @apply hidden; - } - - .ui-dropdown-dark { - @apply text-neutral-300 bg-neutral-1200 border-neutral-800; - } -} diff --git a/src/styles/ui/core/styles/forms.css b/src/styles/ui/core/styles/forms.css index c661005b1a..afefbdb9d2 100644 --- a/src/styles/ui/core/styles/forms.css +++ b/src/styles/ui/core/styles/forms.css @@ -65,97 +65,4 @@ .ui-theme-dark .ui-checkbox-input:checked + .ui-checkbox-styled { @apply bg-active-orange border-gui-unavailable-dark; } - - .ui-textarea { - @apply font-sans font-medium text-cool-black text-p1; - @apply p-input mb-4; - @apply bg-light-grey border-mid-grey transition-input border rounded block w-full; - @apply hover:bg-white hover:shadow-input hover:border-transparent; - @apply focus:bg-white focus:shadow-input focus:border-transparent focus:outline-none focus-visible:outline-gui-focus; - } - - .ui-textarea::placeholder { - /* CSS vars don't work in ::placeholder in Webkit :( */ - /* color: var(--text-dark-grey); */ - color: #76767c; - } - - .ui-toggle { - @apply h-8 w-14 rounded-full relative inline-block; - } - - .ui-toggle:has(:disabled) { - @apply pointer-events-none; - } - - .ui-toggle input { - @apply w-0 h-0 opacity-0; - } - - .ui-toggle-slider { - @apply absolute cursor-pointer inset-0 transition-all bg-neutral-600 rounded-full; - } - - .ui-theme-dark .ui-toggle-slider { - @apply bg-neutral-700; - } - - .ui-toggle-slider:before { - @apply absolute h-7 w-7 left-0.5 bottom-0.5 bg-white rounded-full transition-transform drop-shadow-toggle; - content: ""; - } - - .ui-toggle input:checked + .ui-toggle-slider { - @apply bg-orange-600; - } - - .ui-toggle input:disabled + .ui-toggle-slider { - @apply bg-gui-unavailable; - } - - .ui-toggle input:checked + .ui-toggle-slider:before { - @apply translate-x-6; - } - - .ui-toggle input:disabled + .ui-toggle-slider:before { - @apply bg-neutral-500; - } - - .ui-input { - @apply ui-text-p2 font-medium bg-neutral-100 rounded p-input w-full leading-none appearance-none border border-neutral-400 transition-colors placeholder-neutral-600; - @apply hover:border-neutral-700 focus:bg-white focus-base; - @apply max-w-screen-sm invalid:border-gui-error-red; - } - - .ui-theme-dark .ui-input { - @apply bg-neutral-1200 hover:bg-neutral-1000 focus:bg-neutral-1000 text-neutral-300 border-neutral-900 placeholder-neutral-700 invalid:border-gui-error-red; - } - - .ui-input:disabled { - @apply bg-gui-unavailable placeholder-neutral-500 text-neutral-500; - } - - .ui-theme-dark .ui-input:disabled { - @apply bg-gui-unavailable-dark text-neutral-800 placeholder-neutral-800; - } - - .ui-radio { - @apply border border-neutral-600 w-5 h-5 appearance-none cursor-pointer rounded-full focus-base; - } - - .ui-radio:checked { - @apply bg-orange-600 border-orange-600 border; - } - - .ui-radio:checked::after { - @apply content-[''] mt-[0.3125rem] w-2 h-2 block bg-white rounded-full m-auto; - } - - .ui-radio:disabled { - @apply bg-neutral-300; - } - - .ui-theme-dark .ui-radio:disabled { - @apply bg-neutral-1000; - } } diff --git a/src/styles/ui/core/styles/layout.css b/src/styles/ui/core/styles/layout.css deleted file mode 100644 index d21bccbed7..0000000000 --- a/src/styles/ui/core/styles/layout.css +++ /dev/null @@ -1,29 +0,0 @@ -@layer components { - .ui-standard-container { - @apply w-full max-w-screen-xl mx-auto px-6 sm:px-8 md:px-10 lg:px-16; - } - - .ui-grid-gap { - @apply gap-2 sm:gap-4 md:gap-6 xl:gap-8; - } - - .ui-grid-gap-x { - @apply gap-x-2 sm:gap-x-4 md:gap-x-6 xl:gap-x-8; - } - - .ui-grid-px { - @apply px-6 sm:px-8 md:px-10 lg:px-16; - } - - .ui-grid-mx { - @apply mx-6 sm:mx-8 md:mx-10 lg:mx-16; - } - - .ui-full-borderless-container-override { - @apply w-[625rem] ml-[calc(50%-5000px)]; - } - - .ui-full-container-override { - @apply ml-[calc(-50vw+50%)] !px-0 w-screen; - } -} diff --git a/src/styles/ui/core/styles/legacy-buttons.css b/src/styles/ui/core/styles/legacy-buttons.css deleted file mode 100644 index 295187c8fc..0000000000 --- a/src/styles/ui/core/styles/legacy-buttons.css +++ /dev/null @@ -1,95 +0,0 @@ -@layer components { - .ui-btn { - @apply text-white bg-cool-black text-label2 font-sans font-bold inline-block rounded p-btn; - @apply hover:text-white hover:bg-neutral-1100; - @apply active:text-white active:bg-neutral-1200; - @apply focus:text-white focus:bg-cool-black focus-base; - @apply disabled:text-mid-grey disabled:bg-gui-unavailable disabled:cursor-not-allowed; - @apply transition-colors; - @apply inline-flex items-center justify-center; - } - - .ui-btn-alt { - transition: background-position 0.2s; - background: linear-gradient( - 61.2deg, - var(--color-active-orange) 5%, - #fe5215 19%, - #fc4a13 27%, - #f73c10 33%, - #f1280a 39%, - #e90f04 44%, - var(--color-red-orange) 50% - ); - background-size: 200% 100%; - background-position: 0% 0%; - - @apply text-white text-label2 font-sans font-bold inline-block rounded p-btn; - @apply focus-base; - @apply inline-flex items-center justify-center; - } - - .ui-btn-alt:hover, - .ui-btn-alt:focus { - background-position: 100% 0%; - } - - .ui-btn-alt:disabled { - background: linear-gradient(var(--gradient-transparent)); - @apply bg-gui-unavailable text-mid-grey cursor-not-allowed; - } - - .ui-btn-invert { - @apply text-cool-black bg-white text-label2 font-sans font-bold inline-block rounded p-btn; - @apply hover:text-white hover:bg-active-orange; - @apply active:text-white active:bg-red-orange; - @apply focus:text-white focus:bg-cool-black focus-base; - @apply disabled:text-mid-grey disabled:bg-gui-unavailable disabled:cursor-not-allowed; - @apply transition-colors; - @apply inline-flex items-center justify-center; - } - - .ui-btn-secondary { - @apply text-cool-black bg-white text-label2 font-sans font-bold inline-block border-btn border-cool-black rounded p-btn; - @apply hover:text-cool-black hover:border-active-orange hover:bg-white; - @apply active:border-red-orange active:bg-white; - @apply focus:border-cool-black focus:bg-white focus-base; - @apply disabled:text-gui-unavailable disabled:border-gui-unavailable disabled:bg-white disabled:cursor-not-allowed; - @apply transition-colors; - @apply inline-flex items-center justify-center; - } - - .ui-btn-secondary-invert { - @apply text-white bg-cool-black text-label2 font-sans font-bold inline-block border-btn border-mid-grey rounded p-btn; - @apply hover:text-white hover:border-active-orange; - @apply active:border-red-orange; - @apply focus-base; - @apply disabled:text-gui-unavailable disabled:border-gui-unavailable disabled:cursor-not-allowed; - @apply transition-colors; - @apply inline-flex items-center justify-center; - } - - .ui-btn-icon { - @apply w-6 h-6 mr-4; - } - - .ui-btn-icon-small { - @apply w-5 h-5 mr-3; - } - - .ui-btn-icon-xsmall { - @apply w-4 h-4 mr-2; - } - .ui-btn.ui-btn-disabled, - .ui-btn-invert.ui-btn-disabled { - @apply text-mid-grey bg-gui-unavailable cursor-not-allowed pointer-events-none select-none; - } - - .ui-btn-secondary.ui-btn-disabled { - @apply text-gui-unavailable border-gui-unavailable bg-white cursor-not-allowed pointer-events-none select-none; - } - - .ui-btn-secondary-invert.ui-btn-disabled { - @apply text-gui-unavailable border-gui-unavailable cursor-not-allowed pointer-events-none select-none; - } -} diff --git a/src/styles/ui/core/styles/shadows.css b/src/styles/ui/core/styles/shadows.css index cb9da2c69c..f265469887 100644 --- a/src/styles/ui/core/styles/shadows.css +++ b/src/styles/ui/core/styles/shadows.css @@ -1,13 +1,4 @@ @layer components { - .ui-shadow-xs-soft { - box-shadow: - 0px 13.699px 3.805px 0px rgba(3, 2, 13, 0), - 0px 9.133px 3.805px 0px rgba(3, 2, 13, 0.01), - 0px 5.327px 3.044px 0px rgba(3, 2, 13, 0.03), - 0px 2.283px 2.283px 0px rgba(3, 2, 13, 0.05), - 0px 0.761px 1.522px 0px rgba(3, 2, 13, 0.06); - } - .ui-shadow-sm-soft { box-shadow: 0px 32.725px 9.133px 0px rgba(3, 2, 13, 0), @@ -17,60 +8,6 @@ 0px 1.522px 3.044px 0px rgba(3, 2, 13, 0.06); } - .ui-shadow-md-soft { - box-shadow: - 0px 105.024px 29.681px 0px rgba(3, 2, 13, 0), - 0px 66.972px 26.637px 0px rgba(3, 2, 13, 0.01), - 0px 38.052px 22.831px 0px rgba(3, 2, 13, 0.02), - 0px 16.743px 16.743px 0px rgba(3, 2, 13, 0.03), - 0px 4.566px 9.133px 0px rgba(3, 2, 13, 0.04); - } - - .ui-shadow-lg-soft { - box-shadow: - 0px 200.915px 56.317px 0px rgba(20, 25, 36, 0), - 0px 128.616px 51.751px 0px rgba(20, 25, 36, 0.01), - 0px 72.299px 43.379px 0px rgba(20, 25, 36, 0.02), - 0px 31.964px 31.964px 0px rgba(20, 25, 36, 0.03), - 0px 8.371px 17.504px 0px rgba(20, 25, 36, 0.04); - } - - .ui-shadow-xl-soft { - box-shadow: - 0px 210.048px 58.6px 0px rgba(20, 25, 36, 0), - 0px 134.705px 54.034px 0px rgba(20, 25, 36, 0.01), - 0px 75.343px 45.663px 0px rgba(20, 25, 36, 0.03), - 0px 33.486px 33.486px 0px rgba(20, 25, 36, 0.04), - 0px 8.371px 18.265px 0px rgba(20, 25, 36, 0.05); - } - - .ui-shadow-xs-medium { - box-shadow: - 0px 14px 4px 0px rgba(3, 2, 13, 0), - 0px 9px 4px 0px rgba(3, 2, 13, 0.02), - 0px 5px 3px 0px rgba(3, 2, 13, 0.08), - 0px 2px 2px 0px rgba(3, 2, 13, 0.14), - 0px 1px 1px 0px rgba(3, 2, 13, 0.16); - } - - .ui-shadow-sm-medium { - box-shadow: - 0px 33px 9px 0px rgba(3, 2, 13, 0), - 0px 21px 8px 0px rgba(3, 2, 13, 0.02), - 0px 12px 7px 0px rgba(3, 2, 13, 0.08), - 0px 5px 5px 0px rgba(3, 2, 13, 0.14), - 0px 1px 3px 0px rgba(3, 2, 13, 0.16); - } - - .ui-shadow-md-medium { - box-shadow: - 0px 105px 29px 0px rgba(3, 2, 13, 0), - 0px 67px 27px 0px rgba(3, 2, 13, 0.02), - 0px 38px 23px 0px rgba(3, 2, 13, 0.06), - 0px 17px 17px 0px rgba(3, 2, 13, 0.1), - 0px 4px 9px 0px rgba(3, 2, 13, 0.12); - } - .ui-shadow-lg-medium { box-shadow: 0px 201px 56px 0px rgba(20, 25, 36, 0), @@ -79,58 +16,4 @@ 0px 32px 32px 0px rgba(20, 25, 36, 0.1), 0px 8px 18px 0px rgba(20, 25, 36, 0.12); } - - .ui-shadow-xl-medium { - box-shadow: - 0px 210px 59px 0px rgba(20, 25, 36, 0), - 0px 134px 54px 0px rgba(20, 25, 36, 0.02), - 0px 76px 45px 0px rgba(20, 25, 36, 0.08), - 0px 34px 34px 0px rgba(20, 25, 36, 0.13), - 0px 8px 18px 0px rgba(20, 25, 36, 0.15); - } - - .ui-shadow-xs-strong { - box-shadow: - 0px 14px 4px 0px rgba(3, 2, 13, 0.01), - 0px 9px 4px 0px rgba(3, 2, 13, 0.09), - 0px 5px 3px 0px rgba(3, 2, 13, 0.3), - 0px 2px 2px 0px rgba(3, 2, 13, 0.51), - 0px 1px 1px 0px rgba(3, 2, 13, 0.59); - } - - .ui-shadow-sm-strong { - box-shadow: - 0px 49px 14px 0px rgba(3, 2, 13, 0.01), - 0px 32px 13px 0px rgba(3, 2, 13, 0.09), - 0px 18px 11px 0px rgba(3, 2, 13, 0.3), - 0px 8px 8px 0px rgba(3, 2, 13, 0.51), - 0px 2px 4px 0px rgba(3, 2, 13, 0.59); - } - - .ui-shadow-md-strong { - box-shadow: - 0px 105px 29px 0px rgba(3, 2, 13, 0.01), - 0px 67px 27px 0px rgba(3, 2, 13, 0.09), - 0px 38px 23px 0px rgba(3, 2, 13, 0.3), - 0px 17px 17px 0px rgba(3, 2, 13, 0.51), - 0px 4px 9px 0px rgba(3, 2, 13, 0.59); - } - - .ui-shadow-lg-strong { - box-shadow: - 0px 201px 56px 0px rgba(0, 0, 0, 0.01), - 0px 129px 51px 0px rgba(0, 0, 0, 0.08), - 0px 72px 43px 0px rgba(0, 0, 0, 0.28), - 0px 32px 32px 0px rgba(0, 0, 0, 0.47), - 0px 8px 18px 0px rgba(0, 0, 0, 0.54); - } - - .ui-shadow-xl-strong { - box-shadow: - 0px 210px 59px 0px rgba(0, 0, 0, 0.01), - 0px 134px 54px 0px rgba(0, 0, 0, 0.09), - 0px 76px 45px 0px rgba(0, 0, 0, 0.3), - 0px 34px 34px 0px rgba(0, 0, 0, 0.51), - 0px 8px 18px 0px rgba(0, 0, 0, 0.59); - } } diff --git a/tailwind.config.js b/tailwind.config.js index 31320c0db9..9cb5896926 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,12 +1,7 @@ // docs design tokens — originally @ably/ui's Tailwind theme, now owned locally // (DX-1128). gui-disabled-* alias gui-unavailable for the disabled-state classes. module.exports = { - content: [ - './src/pages/docs/*.{ts,tsx}', - './src/components/**/*.{ts,tsx}', - './src/templates/**/*.{ts,tsx}', - './node_modules/@ably/ui/core/**/*.{js,json}', - ], + content: ['./src/pages/docs/*.{ts,tsx}', './src/components/**/*.{ts,tsx}', './src/templates/**/*.{ts,tsx}'], safelist: [{ pattern: /^hljs.*/ }], darkMode: ['selector', '.ui-theme-dark'], theme: {