Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/silver-cobras-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@launchpad-ui/components": minor
---

added the CopyToClipboard and Snippet components
6 changes: 5 additions & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,17 @@
"@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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be in devDep? I see all the other @react-aria deps are devDeps. I'm not sure why, but best to stay consistent. Better still find out why they are devDep and not dep.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure on that one. The reason being that we had originally had it as a dependency in the dependencies list and I didn't want to stray from that. What'd you think?

https://github.com/launchdarkly/gonfalon/blob/main/packages/forms-experimental/package.json#L6-L28

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"class-variance-authority": "0.7.0",
"prism-themes": "1.9.0"
},
"devDependencies": {
"@react-aria/focus": "3.21.2",
"@react-aria/interactions": "3.25.6",
"@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",
Expand All @@ -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",
Expand Down
36 changes: 36 additions & 0 deletions packages/components/src/CopyToClipboard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<TooltipTrigger>
<PressResponder onPress={handlePress}>{children}</PressResponder>
{showTooltip && <Tooltip placement="bottom">{tooltip}</Tooltip>}
</TooltipTrigger>
);
};
181 changes: 181 additions & 0 deletions packages/components/src/Snippet.tsx
Original file line number Diff line number Diff line change
@@ -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);
//
// <Snippet withCopyButton={true} lang="json">{json}</Snippet>
export function Snippet({
children,
className,
highlightRange,
highlightOffset,
lang,
label,
withHeader,
withLineNumbers,
useDefaultHighlighting = false,
withCopyButton,
trackAnalyticsOnClick,
}: SnippetProps) {
const codeEl = useRef<HTMLElement>(null);

// biome-ignore lint/correctness/useExhaustiveDependencies: children and lang are intentionally included to re-highlight when they change
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the biome-ignore to maintain the existing dependency list so we don't cause any unintended regressions with this migration

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 && (
<div className={styles.header}>
{label && <span>{label}</span>}
{lang && <span>{lang}</span>}
</div>
)}
<div
className={`${styles.snippet} ${className ?? ''} ${withCopyButton ? styles.copyable : ''} ${useDefaultHighlighting ? styles.useDefaultHighlighting : ''}`}
>
<pre
className={withLineNumbers ? styles['line-numbers'] : ''}
data-start={1}
data-line-offset={highlightOffset ? highlightOffset.toString() : ''}
data-line={highlightRange}
>
<code className={`language-${lang}`} ref={codeEl}>
{children}
</code>
{withCopyButton && (
<CopyToClipboard text={children as string} showTooltip={false}>
<IconButton
className={styles.copyButton}
aria-label="Copy code snippet"
variant="minimal"
icon="copy-code"
onPress={trackAnalyticsOnClick}
/>
</CopyToClipboard>
)}
</pre>
</div>
</>
);
}
132 changes: 132 additions & 0 deletions packages/components/src/styles/Snippet.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
@import 'prism-themes/themes/prism-ghcolors.css';

.snippet {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed this from Snippet to 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'] {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed this from Snippet--copyable to copyable

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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed Snippet--useDefaultHighlighting to useDefaultHighlighting

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;
}
Loading
Loading