fix: Fix PR issues

This commit is contained in:
deytenit committed 2026-03-07 10:51:54 +03:00
1 parent d2903a3715
commit 6aa3a03319
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>
+10 -8
View File
@@ -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>
+12 -7
View File
@@ -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 =