diff --git a/.changeset/silver-cobras-walk.md b/.changeset/silver-cobras-walk.md new file mode 100644 index 000000000..94f89cac0 --- /dev/null +++ b/.changeset/silver-cobras-walk.md @@ -0,0 +1,5 @@ +--- +"@launchpad-ui/components": minor +--- + +added the CopyToClipboard and Snippet components diff --git a/packages/components/package.json b/packages/components/package.json index 9e33b03c7..11b72bda9 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -33,7 +33,9 @@ "@internationalized/date": "3.10.0", "@launchpad-ui/icons": "workspace:~", "@launchpad-ui/tokens": "workspace:~", - "class-variance-authority": "0.7.0" + "@react-aria/live-announcer": "3.4.4", + "class-variance-authority": "0.7.0", + "prism-themes": "1.9.0" }, "devDependencies": { "@react-aria/focus": "3.21.2", @@ -41,6 +43,7 @@ "@react-aria/utils": "3.31.0", "@react-stately/utils": "3.10.8", "@react-types/shared": "3.32.1", + "@types/prismjs": "1.26.5", "copyfiles": "2.4.1", "react": "19.2.0", "react-aria": "3.44.0", @@ -55,6 +58,7 @@ "@react-aria/utils": "3.31.0", "@react-stately/utils": "3.10.8", "@react-types/shared": "3.32.1", + "prismjs": "1.30.0", "react": "19.2.0", "react-aria": "3.44.0", "react-aria-components": "1.13.0", diff --git a/packages/components/src/CopyToClipboard.tsx b/packages/components/src/CopyToClipboard.tsx new file mode 100644 index 000000000..c2ec44b5e --- /dev/null +++ b/packages/components/src/CopyToClipboard.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react'; + +import { PressResponder } from '@react-aria/interactions'; + +import { Tooltip, TooltipTrigger } from './Tooltip'; +import { copyToClipboard } from './utils'; + +type CopyToClipboardProps = { + onCopy?: () => void; + children: ReactNode; + text: string; + tooltip?: string; + showTooltip?: boolean; +}; + +export const CopyToClipboard = ({ + onCopy, + children, + text, + tooltip = 'Copy to clipboard', + showTooltip = true, +}: CopyToClipboardProps) => { + const handlePress = async () => { + await copyToClipboard(text, 'Copied!'); + if (onCopy) { + onCopy(); + } + }; + + return ( + + {children} + {showTooltip && {tooltip}} + + ); +}; diff --git a/packages/components/src/Snippet.tsx b/packages/components/src/Snippet.tsx new file mode 100644 index 000000000..53119c544 --- /dev/null +++ b/packages/components/src/Snippet.tsx @@ -0,0 +1,181 @@ +import Prism from 'prismjs'; +import { type JSX, useLayoutEffect, useRef } from 'react'; + +import { IconButton } from './IconButton'; + +// Import languages based on what you need +import 'prismjs/components/prism-apex'; +import 'prismjs/components/prism-bash'; +import 'prismjs/components/prism-brightscript'; +import 'prismjs/components/prism-c'; +import 'prismjs/components/prism-clike'; +import 'prismjs/components/prism-cpp'; +import 'prismjs/components/prism-csharp'; +import 'prismjs/components/prism-erlang'; +import 'prismjs/components/prism-go'; +import 'prismjs/components/prism-gradle'; +import 'prismjs/components/prism-haskell'; +import 'prismjs/components/prism-java'; +import 'prismjs/components/prism-javascript'; +import 'prismjs/components/prism-json'; +import 'prismjs/components/prism-jsx'; +import 'prismjs/components/prism-kotlin'; +import 'prismjs/components/prism-lua'; +import 'prismjs/components/prism-makefile'; +import 'prismjs/components/prism-markup'; +import 'prismjs/components/prism-markup-templating'; +import 'prismjs/components/prism-objectivec'; +import 'prismjs/components/prism-php'; +import 'prismjs/components/prism-powershell'; +import 'prismjs/components/prism-python'; +import 'prismjs/components/prism-ruby'; +import 'prismjs/components/prism-rust'; +import 'prismjs/components/prism-sql'; +import 'prismjs/components/prism-swift'; +import 'prismjs/components/prism-tsx'; +import 'prismjs/components/prism-typescript'; +import 'prismjs/components/prism-yaml'; +// Import plugins +import 'prismjs/plugins/keep-markup/prism-keep-markup'; +import 'prismjs/plugins/line-highlight/prism-line-highlight'; +import 'prismjs/plugins/line-numbers/prism-line-numbers'; + +import { CopyToClipboard } from './CopyToClipboard'; + +import 'prismjs/plugins/line-numbers/prism-line-numbers.css'; + +import styles from './styles/Snippet.module.css'; + +export const languages = [ + 'bash', + 'shell', + 'json', + 'html', + 'xml', + 'js', + 'javascript', + 'lua', + 'ts', + 'typescript', + 'php', + 'java', + 'ruby', + 'python', + 'go', + 'csharp', + 'c', + 'cpp', + 'objectivec', + 'swift', + 'makefile', + 'haskell', + 'brightscript', + 'dart', + 'rust', + 'tsx', + 'gradle', + 'powershell', + 'kotlin', + 'erlang', + 'yaml', + 'apex', + // text and empty string are not a recognized languages by prism, we use it here as a default option for when you don't want styling. + 'text', + '', +] as const; + +export type SnippetLang = (typeof languages)[number]; + +type SnippetProps = { + children: string | JSX.Element; + className?: string; + highlightRange?: string; + highlightOffset?: number; + lang: SnippetLang; + label?: string; + withHeader?: boolean; + withLineNumbers?: boolean; + useDefaultHighlighting?: boolean; + withCopyButton?: boolean; + trackAnalyticsOnClick?: () => void; +}; + +// Example usage: +// +// const json = JSON.stringify({ +// 'key': 'test@test.com', +// 'ip': '192.168.0.1', +// 'custom': { +// 'customer_ranking': 10004 +// } +// }, null, 2); +// +// {json} +export function Snippet({ + children, + className, + highlightRange, + highlightOffset, + lang, + label, + withHeader, + withLineNumbers, + useDefaultHighlighting = false, + withCopyButton, + trackAnalyticsOnClick, +}: SnippetProps) { + const codeEl = useRef(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: children and lang are intentionally included to re-highlight when they change + useLayoutEffect(() => { + const element = codeEl.current; + if (!element) { + return; + } + + // Use requestAnimationFrame to ensure that the element is mounted + // before highlighting it. + const frame = requestAnimationFrame(() => { + Prism.highlightElement(element); + }); + + // Cancel the animation frame when the component unmounts. + return () => cancelAnimationFrame(frame); + }, [children, lang]); + + return ( + <> + {withHeader && ( +
+ {label && {label}} + {lang && {lang}} +
+ )} +
+
+					
+						{children}
+					
+					{withCopyButton && (
+						
+							
+						
+					)}
+				
+
+ + ); +} diff --git a/packages/components/src/styles/Snippet.module.css b/packages/components/src/styles/Snippet.module.css new file mode 100644 index 000000000..a9a75e386 --- /dev/null +++ b/packages/components/src/styles/Snippet.module.css @@ -0,0 +1,132 @@ +@import 'prism-themes/themes/prism-ghcolors.css'; + +.snippet { + border: 1px solid var(--lp-color-border-ui-secondary); + border-radius: 3px; + overflow-x: auto; + overflow-y: auto; +} + +.snippet > pre { + position: relative; + white-space: pre-wrap; + word-break: break-word; + width: 100%; +} + +.copyable { + display: flex; + align-items: flex-start; + justify-content: space-between; + position: relative; +} + +.header { + display: flex; + justify-content: space-between; + color: var(--lp-color-text-ui-secondary); + font-size: var(--lp-font-size-200); + line-height: var(--lp-line-height-200); + width: 100%; +} + +.copyable [class*='_CopyToClipboard'] { + position: absolute; + top: var(--lp-spacing-400); + right: var(--lp-spacing-400); + visibility: hidden; +} + +.copyable:hover [class*='_CopyToClipboard'] { + visibility: visible; +} + +.Snippet--inline { + border: none; +} + +.snippet pre[class*='language-'] { + margin: 0; + border: none; + background-color: var(--lp-color-bg-ui-secondary); + padding-right: 2rem; +} + +.snippet pre[class*='language-'] [data-theme='dark'] { + background-color: var(--lp-color-bg-ui-tertiary); + color: var(--lp-color-gray-200); +} + +.snippet pre[class*='language-'], +.snippet code[class*='language-'] { + font-family: var(--lp-font-family-monospace); + line-height: 140%; + color: var(--lp-color-text-code-base); +} + +.snippet code { + padding-left: 0; + background-color: transparent; + z-index: var(--stacking-above-new-context); + white-space: pre-wrap; + word-break: break-word; + border: none; +} + +.snippet:not(.useDefaultHighlighting) .line-highlight { + margin-top: 0; + background: var(--lp-color-brand-yellow-light); + z-index: var(--stacking-new-context); +} + +.snippet:not(.useDefaultHighlighting) .line-highlight [data-theme='dark'] { + background: #3c4200; +} + +.snippet pre[class*='language-'].line-numbers { + line-height: 0.625rem; + padding-bottom: var(--lp-spacing-400); + padding-top: var(--lp-spacing-400); + overflow-y: hidden; + white-space: pre-wrap; + z-index: var(--stacking-above-new-context); +} + +.snippet .line-numbers .line-numbers-rows { + border: none; + left: -3em; + width: 2em; +} + +.snippet .token.operator, +.snippet .token.punctuation { + color: var(--lp-color-text-ui-primary-base); +} + +.snippet .token.function { + color: var(--lp-color-text-code-function); + font-weight: var(--lp-font-weight-regular); +} + +.snippet .token.tag { + color: var(--lp-color-text-code-tag); +} + +.token.attr-value, +.token.string { + color: var(--lp-color-text-code-string); +} + +.snippet .token.property { + color: var(--lp-color-text-code-keyword); +} + +.snippet .token.keyword { + color: var(--lp-color-text-code-keyword); +} + +.copyButton { + position: absolute; + right: 0; + top: 0; +} diff --git a/packages/components/src/utils.tsx b/packages/components/src/utils.tsx index 725fcfa05..cc414bff9 100644 --- a/packages/components/src/utils.tsx +++ b/packages/components/src/utils.tsx @@ -1,13 +1,16 @@ import type { Href } from '@react-types/shared'; -import type { Context, Ref } from 'react'; +import type { Context, ReactNode, Ref } from 'react'; import type { ContextValue, SlotProps } from 'react-aria-components'; +import { announce } from '@react-aria/live-announcer'; import { mergeRefs } from '@react-aria/utils'; import { useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { mergeProps } from 'react-aria'; import { useSlottedContext } from 'react-aria-components'; import { useHref as useRouterHref } from 'react-router'; +import { toastQueue } from './Toast'; + type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; const useMedia = (media: string) => { @@ -91,4 +94,67 @@ const useLPContextProps = ( return [mergedProps, mergedRef]; }; -export { useHref, useImageLoadingStatus, useLPContextProps, useMedia }; +const fallbackCopyToClipboard = (text: string) => + new Promise((resolve, reject) => { + // Using setTimeout to ensure we focus the text area after the DOM is updated + // in cases of dropdown menus + setTimeout(() => { + try { + const textArea = document.createElement('textarea'); + textArea.value = text; + + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + document.body.appendChild(textArea); + + textArea.focus(); + textArea.select(); + + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + + if (successful) { + resolve(true); + } else { + reject(new Error('execCommand failed')); + } + } catch (error) { + reject(error); + } + }, 10); // Small delay to ensure focus operations complete + }); + +// navigator.clipboard is only available in secure contexts (https) +// We need to use document.execCommand for insecure contexts (http) +// for example when testing the Sandbox locally +const copyToClipboard = async ( + text: string, + toastMessage?: string | ReactNode, + errorMessage?: string | ReactNode, +) => { + const MAX_WIDTH = 80; + + try { + if ('clipboard' in navigator) { + await navigator.clipboard.writeText(text); + } else { + await fallbackCopyToClipboard(text); + } + + toastQueue.add({ + title: + toastMessage ?? + `'${text.length > MAX_WIDTH ? `${text.slice(0, MAX_WIDTH)}...` : text}' copied to clipboard.`, + status: 'success', + }); + announce('Copied!', 'polite', 300); + return true; + } catch { + announce('Failed to copy', 'polite', 300); + toastQueue.add({ title: errorMessage ?? 'Unable to copy', status: 'error' }); + return false; + } +}; + +export { copyToClipboard, useHref, useImageLoadingStatus, useLPContextProps, useMedia }; diff --git a/packages/components/stories/Snippet.stories.tsx b/packages/components/stories/Snippet.stories.tsx new file mode 100644 index 000000000..26582bcdb --- /dev/null +++ b/packages/components/stories/Snippet.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Snippet } from '../src/Snippet'; + +const meta: Meta = { + component: Snippet, + title: 'Components/Forms/Snippet', + parameters: { + figma: { + design: + 'https://www.figma.com/design/98HKKXL2dTle29ikJ3tzk7/%F0%9F%9A%80-LaunchPad?node-id=14383-120499&m=dev', + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const json = JSON.stringify( + { + key: 'test@test.com', + ip: '192.168.0.1', + custom: { + customerRanking: 10004, + }, + }, + null, + 2, +); + +export const Example: Story = { + args: { + children: json, + lang: 'json', + withCopyButton: true, + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2b0f9901..d41b6faca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -248,9 +248,18 @@ importers: '@launchpad-ui/tokens': specifier: workspace:~ version: link:../tokens + '@react-aria/live-announcer': + specifier: 3.4.4 + version: 3.4.4 class-variance-authority: specifier: 0.7.0 version: 0.7.0 + prism-themes: + specifier: 1.9.0 + version: 1.9.0 + prismjs: + specifier: 1.30.0 + version: 1.30.0 react-hook-form: specifier: 7.59.0 version: 7.59.0(react@19.2.0) @@ -270,6 +279,9 @@ importers: '@react-types/shared': specifier: 3.32.1 version: 3.32.1(react@19.2.0) + '@types/prismjs': + specifier: 1.26.5 + version: 1.26.5 copyfiles: specifier: 2.4.1 version: 2.4.1 @@ -2583,6 +2595,9 @@ packages: '@types/postcss-modules-scope@3.0.4': resolution: {integrity: sha512-//ygSisVq9kVI0sqx3UPLzWIMCmtSVrzdljtuaAEJtGoGnpjBikZ2sXO5MpH9SnWX9HRfXxHifDAXcQjupWnIQ==} + '@types/prismjs@1.26.5': + resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} + '@types/react-dom@19.2.1': resolution: {integrity: sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==} peerDependencies: @@ -4639,6 +4654,13 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + prism-themes@1.9.0: + resolution: {integrity: sha512-tX2AYsehKDw1EORwBps+WhBFKc2kxfoFpQAjxBndbZKr4fRmMkv47XN0BghC/K1qwodB1otbe4oF23vUTFDokw==} + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -7984,6 +8006,8 @@ snapshots: dependencies: postcss: 8.5.4 + '@types/prismjs@1.26.5': {} + '@types/react-dom@19.2.1(@types/react@19.2.2)': dependencies: '@types/react': 19.2.2 @@ -10228,6 +10252,10 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + prism-themes@1.9.0: {} + + prismjs@1.30.0: {} + process-nextick-args@2.0.1: {} process@0.11.10: {}