fix: Fix PR issues
This commit is contained in:
20 files changed
+291
-67
No files matched your search
@@ -6,8 +6,8 @@ import { Separator } from '../Separator/Separator';
|
||||
import { Text } from '../Text/Text';
|
||||
import { VStack } from '../VStack/VStack';
|
||||
|
||||
import type { ClassNameProps } from '@ermnvldmr/stl';
|
||||
import type { ContainerProps } from '../Container/Container';
|
||||
import type { ClassNameProps } from '@ermnvldmr/stl';
|
||||
|
||||
/**
|
||||
* Props for the Article component.
|
||||
@@ -21,12 +21,12 @@ export interface ArticleProps extends ClassNameProps {
|
||||
additionalText?: string;
|
||||
/**
|
||||
* Press interaction handler.
|
||||
* @note Cannot be used with `href`.
|
||||
* Cannot be used with `href`.
|
||||
*/
|
||||
onPress?: ContainerProps['onPress'];
|
||||
/**
|
||||
* If provided, the container will be rendered as an `<a>` tag for navigation.
|
||||
* @note Cannot be used with `onPress`.
|
||||
* Cannot be used with `onPress`.
|
||||
*/
|
||||
href?: ContainerProps['href'];
|
||||
/** The main article body content */
|
||||
@@ -54,7 +54,7 @@ export const Article = memo(function Article({
|
||||
className,
|
||||
}: ArticleProps) {
|
||||
return (
|
||||
<Container padding={4} href={href} onPress={onPress} className={className}>
|
||||
<Container className={className} href={href} padding={4} onPress={onPress}>
|
||||
<VStack align="stretch" gap={4}>
|
||||
<Header level={4}>{headline}</Header>
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import { CodeBlock } from './CodeBlock/CodeBlock';
|
||||
// Mock shiki
|
||||
vi.mock('shiki', () => ({
|
||||
createHighlighter: vi.fn().mockResolvedValue({
|
||||
codeToHtml: vi.fn().mockReturnValue('<pre><code><span class="line">highlighted code</span></code></pre>'),
|
||||
codeToHtml: vi
|
||||
.fn()
|
||||
.mockReturnValue('<pre><code><span class="line">highlighted code</span></code></pre>'),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -49,18 +51,18 @@ describe('Code Component', () => {
|
||||
expect(screen.getByText('test.ts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies content on click in inline mode', async () => {
|
||||
it('copies content on click in inline mode', () => {
|
||||
render(<Code copyValue="copied!">Display Text</Code>);
|
||||
const codeElement = screen.getByText('Display Text');
|
||||
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
fireEvent.click(codeElement);
|
||||
});
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('copied!');
|
||||
});
|
||||
|
||||
it('copies content via button in block mode', async () => {
|
||||
it('copies content via button in block mode', () => {
|
||||
render(
|
||||
<CodeBlock copyValue="block copy">
|
||||
<Code>
|
||||
@@ -70,18 +72,18 @@ describe('Code Component', () => {
|
||||
);
|
||||
|
||||
const copyButton = screen.getByLabelText('Copy code');
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
fireEvent.click(copyButton);
|
||||
});
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('block copy');
|
||||
});
|
||||
|
||||
it('extracts text from children if copyValue is not provided', async () => {
|
||||
it('extracts text from children if copyValue is not provided', () => {
|
||||
render(<Code>extracted</Code>);
|
||||
|
||||
const codeElement = screen.getByText('extracted');
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
fireEvent.click(codeElement);
|
||||
});
|
||||
|
||||
@@ -89,7 +91,7 @@ describe('Code Component', () => {
|
||||
});
|
||||
|
||||
it('uses shiki for highlighting when language is provided in block mode', async () => {
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
render(
|
||||
<CodeBlock language="typescript">
|
||||
<Code>const x: number = 1;</Code>
|
||||
|
||||
@@ -5,8 +5,8 @@ import { usePress } from 'react-aria';
|
||||
import { useCodeContext } from './contexts/CodeContext/CodeContext';
|
||||
import { codeHighlighter } from './lib/highlighter/highlighter';
|
||||
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
import type { CodeLanguage } from './lib/highlighter/highlighter';
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
|
||||
export type { CodeLanguage };
|
||||
|
||||
@@ -47,6 +47,7 @@ export const Code = memo(function Code({
|
||||
const context = useCodeContext();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const codeRef = useRef<HTMLElement>(null);
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null);
|
||||
|
||||
const language = propsLanguage ?? context?.language;
|
||||
@@ -54,10 +55,13 @@ export const Code = memo(function Code({
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
let textToCopy = copyValue;
|
||||
if (!textToCopy && codeRef.current) {
|
||||
textToCopy = codeRef.current.innerText;
|
||||
if (!textToCopy) {
|
||||
textToCopy = codeRef.current.textContent ?? undefined;
|
||||
if (!textToCopy) {
|
||||
const ref = codeRef.current ?? divRef.current;
|
||||
if (ref) {
|
||||
textToCopy = ref.innerText;
|
||||
if (!textToCopy) {
|
||||
textToCopy = ref.textContent ?? undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +113,9 @@ export const Code = memo(function Code({
|
||||
if (highlightedHtml) {
|
||||
return (
|
||||
<div
|
||||
ref={codeRef as any}
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
ref={divRef}
|
||||
className={cn(
|
||||
'block min-w-full font-mono text-sm',
|
||||
'[&>pre]:!bg-transparent [&>pre]:!p-0 [&>pre]:!m-0',
|
||||
@@ -131,7 +137,6 @@ export const Code = memo(function Code({
|
||||
],
|
||||
className
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Button } from '../../Button/Button';
|
||||
import { Container } from '../../Container/Container';
|
||||
import { CodeContext, type CodeContextValue } from '../contexts/CodeContext/CodeContext';
|
||||
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
import type { CodeLanguage } from '../lib/highlighter/highlighter';
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
|
||||
/**
|
||||
* Props for the CodeBlock component.
|
||||
|
||||
@@ -46,24 +46,27 @@ export const SUPPORTED_LANGUAGES: CodeLanguage[] = [
|
||||
* A singleton highlighter service for code blocks.
|
||||
*/
|
||||
class CodeHighlighter {
|
||||
private static instance: CodeHighlighter;
|
||||
private static instance: CodeHighlighter | null = null;
|
||||
private highlighter: Highlighter | null = null;
|
||||
private initializingPromise: Promise<Highlighter> | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Gets the singleton instance of the highlighter.
|
||||
* @example
|
||||
* ```typescript
|
||||
* const highlighter = CodeHighlighter.getInstance();
|
||||
* ```
|
||||
*/
|
||||
public static getInstance(): CodeHighlighter {
|
||||
if (!CodeHighlighter.instance) {
|
||||
CodeHighlighter.instance = new CodeHighlighter();
|
||||
}
|
||||
return CodeHighlighter.instance;
|
||||
return (CodeHighlighter.instance ??= new CodeHighlighter());
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the highlighter if it hasn't been already.
|
||||
* @example
|
||||
* ```typescript
|
||||
* const highlighter = await this.getHighlighter();
|
||||
* ```
|
||||
*/
|
||||
private async getHighlighter(): Promise<Highlighter> {
|
||||
if (this.highlighter) {
|
||||
@@ -92,6 +95,10 @@ class CodeHighlighter {
|
||||
* @param code - The raw source code to highlight
|
||||
* @param lang - The language of the code
|
||||
* @returns A promise resolving to the highlighted HTML
|
||||
* @example
|
||||
* ```typescript
|
||||
* const html = await highlighter.highlight('const a = 1;', 'typescript');
|
||||
* ```
|
||||
*/
|
||||
public async highlight(code: string, lang: CodeLanguage): Promise<string> {
|
||||
try {
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('components/Container', () => {
|
||||
</Container>
|
||||
);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'[@ermnvldmr/ui] Container cannot have both `onPress` and `href` props.',
|
||||
'[@ermnvldmr/ui] Container cannot have both `onPress` and `href` props.'
|
||||
);
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -86,12 +86,12 @@ export interface ContainerProps extends ClassNameProps, TestIdProps {
|
||||
border?: boolean;
|
||||
/**
|
||||
* Press interaction handler.
|
||||
* @note Cannot be used with `href`.
|
||||
* Cannot be used with `href`.
|
||||
*/
|
||||
onPress?: PressEvents['onPress'];
|
||||
/**
|
||||
* If provided, the container will be rendered as an `<a>` tag.
|
||||
* @note Cannot be used with `onPress`.
|
||||
* Cannot be used with `onPress`.
|
||||
*/
|
||||
href?: string;
|
||||
/** The HTML element to use for rendering. Overridden to `a` if `href` is provided. */
|
||||
@@ -119,11 +119,10 @@ export const Container = memo(function Container({
|
||||
'data-testid': testId,
|
||||
}: ContainerProps) {
|
||||
if (process.env.NODE_ENV !== 'production' && onPress && href) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[@ermnvldmr/ui] Container cannot have both `onPress` and `href` props.');
|
||||
}
|
||||
|
||||
const isLink = !!href;
|
||||
const isLink = href != null;
|
||||
const isPressable = !!onPress && !isLink;
|
||||
|
||||
const ref = React.useRef<HTMLElement>(null);
|
||||
@@ -202,7 +201,6 @@ export const Container = memo(function Container({
|
||||
<FinalComponent
|
||||
{...interactionProps}
|
||||
ref={ref}
|
||||
href={href}
|
||||
className={cn(
|
||||
'overflow-hidden',
|
||||
bgClasses[bg],
|
||||
@@ -211,11 +209,12 @@ export const Container = memo(function Container({
|
||||
paddingClasses[padding],
|
||||
shadow && 'shadow-md',
|
||||
border && 'border border-[var(--rb-outline)]',
|
||||
(onPress || href) &&
|
||||
(isPressable || isLink) &&
|
||||
'cursor-pointer transition-all duration-200 hover:brightness-95 active:opacity-80',
|
||||
className,
|
||||
className
|
||||
)}
|
||||
data-testid={testId}
|
||||
href={href}
|
||||
>
|
||||
{children}
|
||||
</FinalComponent>
|
||||
|
||||
@@ -161,3 +161,66 @@ export const ListStyles: Story = {
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Showcases code blocks with language-specific syntax highlighting.
|
||||
*/
|
||||
export const CodeLanguages: Story = {
|
||||
render: () => (
|
||||
<div className="max-w-[800px] mx-auto space-y-8">
|
||||
<div>
|
||||
<Markdown.H3 className="mb-4">TypeScript</Markdown.H3>
|
||||
<Markdown.Pre label="example.ts">
|
||||
<Markdown.Code className="language-typescript">
|
||||
{`interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const greet = (user: User) => {
|
||||
console.log(\`Hello, \${user.name}!\`);
|
||||
};`}
|
||||
</Markdown.Code>
|
||||
</Markdown.Pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Markdown.H3 className="mb-4">Rust</Markdown.H3>
|
||||
<Markdown.Pre label="main.rs">
|
||||
<Markdown.Code className="language-rust">
|
||||
{`fn main() {
|
||||
let name = "World";
|
||||
println!("Hello, {}!", name);
|
||||
}`}
|
||||
</Markdown.Code>
|
||||
</Markdown.Pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Markdown.H3 className="mb-4">Python</Markdown.H3>
|
||||
<Markdown.Pre label="script.py">
|
||||
<Markdown.Code className="language-python">
|
||||
{`def greet(name: str):
|
||||
print(f"Hello, {name}!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
greet("World")`}
|
||||
</Markdown.Code>
|
||||
</Markdown.Pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Markdown.H3 className="mb-4">JSON</Markdown.H3>
|
||||
<Markdown.Pre label="data.json">
|
||||
<Markdown.Code className="language-json">
|
||||
{`{
|
||||
"project": "ermnvldmr.com",
|
||||
"version": "1.0.0",
|
||||
"private": true
|
||||
}`}
|
||||
</Markdown.Code>
|
||||
</Markdown.Pre>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -1,10 +1,25 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
|
||||
import { Code } from '../../Code/Code';
|
||||
import { SUPPORTED_LANGUAGES } from '../../Code/lib/highlighter/highlighter';
|
||||
|
||||
import type { CodeProps } from '../../Code/Code';
|
||||
import type { CodeLanguage } from '../../Code/lib/highlighter/highlighter';
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid code language.
|
||||
* @param lang The string to check.
|
||||
* @returns True if the string is a valid code language.
|
||||
* @example
|
||||
* ```typescript
|
||||
* isCodeLanguage('typescript'); // true
|
||||
* isCodeLanguage('foo'); // false
|
||||
* ```
|
||||
*/
|
||||
function isCodeLanguage(lang: string): lang is CodeLanguage {
|
||||
return SUPPORTED_LANGUAGES.some((l) => l === lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* A Markdown code component for inline snippets.
|
||||
*/
|
||||
@@ -15,7 +30,10 @@ export const MarkdownCode = memo(function MarkdownCode(props: CodeProps) {
|
||||
if (className) {
|
||||
const match = /language-(\w+)/.exec(className);
|
||||
if (match) {
|
||||
return match[1] as CodeLanguage;
|
||||
const lang = match[1];
|
||||
if (isCodeLanguage(lang)) {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
|
||||
import { CodeBlock } from '../../Code/CodeBlock/CodeBlock';
|
||||
import { SUPPORTED_LANGUAGES } from '../../Code/lib/highlighter/highlighter';
|
||||
|
||||
import type { CodeBlockProps } from '../../Code/CodeBlock/CodeBlock';
|
||||
import type { CodeLanguage } from '../../Code/lib/highlighter/highlighter';
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid code language.
|
||||
* @param lang The string to check.
|
||||
* @returns True if the string is a valid code language.
|
||||
* @example
|
||||
* ```typescript
|
||||
* isCodeLanguage('typescript'); // true
|
||||
* isCodeLanguage('foo'); // false
|
||||
* ```
|
||||
*/
|
||||
function isCodeLanguage(lang: string): lang is CodeLanguage {
|
||||
return SUPPORTED_LANGUAGES.some((l) => l === lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* A Markdown pre component for code blocks.
|
||||
*
|
||||
@@ -20,9 +35,10 @@ export const MarkdownPre = memo(function MarkdownPre(props: CodeBlockProps) {
|
||||
const className = children.props.className;
|
||||
const match = /language-(\w+)/.exec(className);
|
||||
if (match) {
|
||||
// We cast here because we know it comes from markdown,
|
||||
// shiki will handle it or fallback to plaintext if unknown at runtime.
|
||||
return match[1] as CodeLanguage;
|
||||
const lang = match[1];
|
||||
if (isCodeLanguage(lang)) {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { Meta, StoryObj } from '@storybook/react';
|
||||
* - Multiple color options (black, outline, outline-light) with theme support
|
||||
* - Horizontal and vertical orientations
|
||||
* - Optional thinned gradient fade effect at line ends ('thinned', 'thinned-start', 'thinned-end', 'none')
|
||||
* - 2px stroke width following print design standards
|
||||
* - 1px stroke width following print design standards
|
||||
* - Semantic div elements with proper ARIA separator role for accessibility
|
||||
*/
|
||||
const meta: Meta<typeof Separator> = {
|
||||
@@ -64,7 +64,7 @@ type Story = StoryObj<typeof meta>;
|
||||
|
||||
/**
|
||||
* The default single-line separator with light outline color.
|
||||
* Uses the newspaper-standard 2px stroke with thinned gradient ends.
|
||||
* Uses the newspaper-standard 1px stroke with thinned gradient ends.
|
||||
*/
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
|
||||
@@ -89,8 +89,7 @@ export const Separator = memo(function Separator({
|
||||
];
|
||||
|
||||
// Thinned effect using CSS mask
|
||||
const thinnedClasses =
|
||||
thinned !== 'none' ? [THINNED_TYPE_CLASSES[direction][thinned]] : [];
|
||||
const thinnedClasses = thinned !== 'none' ? [THINNED_TYPE_CLASSES[direction][thinned]] : [];
|
||||
|
||||
// Size classes - fill container by default
|
||||
const sizeClasses =
|
||||
|
||||
Reference in new issue
Block a user