TRIVIAL: Add xray-core-subnet-mimicry article (#4)
This commit is contained in:
1 parent
49b545f1b4
commit
ea35a3d9f5
21 files changed
+785
-79
No files matched your search
@@ -54,7 +54,7 @@ export const Article = memo(function Article({
|
||||
className,
|
||||
}: ArticleProps) {
|
||||
return (
|
||||
<Container className={className} href={href} padding={4} onPress={onPress}>
|
||||
<Container border className={className} href={href} padding={4} rounded="md" onPress={onPress}>
|
||||
<VStack align="stretch" gap={4}>
|
||||
<VStack align="stretch" gap={2}>
|
||||
<Header level={4}>{headline}</Header>
|
||||
|
||||
@@ -33,6 +33,18 @@ export const Default: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
render: () => (
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Separator variant="arrow" />
|
||||
<Breadcrumbs.Item onClick={() => alert('Clicked!')}>Click Me</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Separator variant="arrow" />
|
||||
<Breadcrumbs.Item isCurrent>Current Page</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithArrows: Story = {
|
||||
render: () => (
|
||||
<Breadcrumbs>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Breadcrumbs } from './Breadcrumbs';
|
||||
import { render, screen } from '../../lib/testing';
|
||||
import { render, screen, fireEvent } from '../../lib/testing';
|
||||
import { Link } from '../Link/Link';
|
||||
|
||||
describe('components/Breadcrumbs', () => {
|
||||
@@ -12,14 +12,58 @@ describe('components/Breadcrumbs', () => {
|
||||
<Link href="/">Home</Link>
|
||||
</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Separator variant="slash" />
|
||||
<Breadcrumbs.Item>Current</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Item isCurrent>Current</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('navigation')).toHaveAttribute('aria-label', 'Breadcrumb');
|
||||
expect(screen.getByRole('link', { name: 'Home' })).toBeInTheDocument();
|
||||
expect(screen.getByText('/')).toBeInTheDocument();
|
||||
expect(screen.getByText('Current')).toBeInTheDocument();
|
||||
|
||||
const currentItem = screen.getByText('Current').parentElement;
|
||||
expect(currentItem).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('supports isCurrent state', () => {
|
||||
render(
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item isCurrent>Current Page</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
const item = screen.getByText('Current Page').parentElement;
|
||||
expect(item).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('supports href directly on Item', () => {
|
||||
render(
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item href="/test-url">Test Link</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
expect(link).toHaveAttribute('href', '/test-url');
|
||||
});
|
||||
|
||||
it('supports onClick on Item', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item onClick={handleClick}>Clickable</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
const button = screen.getByRole('button', { name: 'Clickable' });
|
||||
fireEvent.click(button);
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders as a non-submit button when onClick is provided', () => {
|
||||
render(
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item onClick={vi.fn()}>Clickable</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
const button = screen.getByRole('button', { name: 'Clickable' });
|
||||
expect(button).toHaveAttribute('type', 'button');
|
||||
});
|
||||
|
||||
it('supports different separator variants', () => {
|
||||
|
||||
@@ -29,20 +29,57 @@ const BreadcrumbsRoot = memo(function Breadcrumbs({ children, ...props }: Breadc
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Props for the individual Breadcrumbs item.
|
||||
*/
|
||||
export interface BreadcrumbsItemProps extends TestIdProps {
|
||||
/** Content to be rendered inside the item */
|
||||
children: React.ReactNode;
|
||||
/** Optional className for the list item */
|
||||
className?: string;
|
||||
/** Whether this is the current page */
|
||||
isCurrent?: boolean;
|
||||
/** Optional href for the item (renders as a link) */
|
||||
href?: string;
|
||||
/** Optional click handler for the item */
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual step in the breadcrumb trail.
|
||||
*/
|
||||
const BreadcrumbsItem = memo(function BreadcrumbsItem({
|
||||
children,
|
||||
className,
|
||||
isCurrent,
|
||||
href,
|
||||
onClick,
|
||||
'data-testid': testId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
} & TestIdProps) {
|
||||
}: BreadcrumbsItemProps) {
|
||||
const isClickable = !!(href ?? onClick);
|
||||
|
||||
return (
|
||||
<li className={className} data-testid={testId}>
|
||||
<Text color="muted" size="m" type="label">
|
||||
<li
|
||||
aria-current={isCurrent ? 'page' : undefined}
|
||||
className={cn(
|
||||
'group flex items-center transition-all duration-200',
|
||||
!isCurrent && isClickable && 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<Text
|
||||
as={href ? 'a' : onClick ? 'button' : 'span'}
|
||||
className={cn(
|
||||
'transition-all duration-200',
|
||||
!isCurrent && 'group-hover:text-[var(--rb-text)] group-hover:underline underline-offset-4'
|
||||
)}
|
||||
color={isCurrent ? 'default' : 'muted'}
|
||||
href={href}
|
||||
size="m"
|
||||
type="label"
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</li>
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import React from 'react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { Link } from './Link';
|
||||
import { render, screen } from '../../lib/testing';
|
||||
|
||||
describe('components/Link', () => {
|
||||
beforeEach(() => {
|
||||
// Mock IntersectionObserver to avoid errors in Text component
|
||||
class MockIntersectionObserver {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly as a link when href is provided', () => {
|
||||
render(<Link href="https://example.com">Click me</Link>);
|
||||
const link = screen.getByRole('link', { name: 'Click me' });
|
||||
@@ -44,4 +58,14 @@ describe('components/Link', () => {
|
||||
render(<Link onPress={() => {}}>Click me</Link>);
|
||||
expect(screen.getByText('Click me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes delay prop to the underlying Text component', () => {
|
||||
render(
|
||||
<Link delay={500} href="/test">
|
||||
Delayed Link
|
||||
</Link>
|
||||
);
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveClass('opacity-0');
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,8 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps {
|
||||
size?: TextSize;
|
||||
/** Text color variant */
|
||||
color?: TextColor;
|
||||
/** Delay in milliseconds before the link appears after entering the viewport. */
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +35,7 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps {
|
||||
* It uses standard design system colors (--rb-ring) and provides responsive hover/focus states.
|
||||
*/
|
||||
export const Link = memo(function Link(props: LinkProps) {
|
||||
const { children, className, href, isExternal, type, size, color, ...otherProps } = props;
|
||||
const { children, className, href, isExternal, type, size, color, delay, ...otherProps } = props;
|
||||
|
||||
// We use a specific ref type that useLink expects.
|
||||
// Since Link can be an 'a' or a 'span', we use HTMLElement as common denominator.
|
||||
@@ -59,6 +61,7 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
as="a"
|
||||
className={sharedClasses}
|
||||
color={color}
|
||||
delay={delay}
|
||||
href={href}
|
||||
size={size}
|
||||
type={type}
|
||||
@@ -75,6 +78,7 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
as="span"
|
||||
className={sharedClasses}
|
||||
color={color}
|
||||
delay={delay}
|
||||
size={size}
|
||||
type={type}
|
||||
>
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('Markdown Components', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText('Heading 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('This is a paragraph.')).toBeInTheDocument();
|
||||
expect(screen.getByText('This is a paragraph.')).toHaveClass('mb-4');
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://example.com');
|
||||
expect(screen.getByRole('list')).toBeInTheDocument();
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { List } from '../../List/List';
|
||||
import { MarkdownP } from '../MarkdownP/MarkdownP';
|
||||
|
||||
import type { ListItemProps } from '../../List/List';
|
||||
|
||||
/**
|
||||
* A Markdown list item component.
|
||||
*/
|
||||
export const MarkdownLI = memo(function MarkdownLI(props: ListItemProps) {
|
||||
return <List.Item {...props} />;
|
||||
export const MarkdownLI = memo(function MarkdownLI({ children, ...props }: ListItemProps) {
|
||||
return (
|
||||
<List.Item {...props}>
|
||||
<MarkdownP>{children}</MarkdownP>
|
||||
</List.Item>
|
||||
);
|
||||
});
|
||||
@@ -1,14 +1,90 @@
|
||||
import React from 'react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { Text } from './Text';
|
||||
import { render, screen } from '../../lib/testing';
|
||||
import { render, screen, act } from '../../lib/testing';
|
||||
|
||||
describe('components/Text', () => {
|
||||
let intersectionCallback: (entries: any[]) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Mock IntersectionObserver
|
||||
class MockIntersectionObserver {
|
||||
constructor(public callback: (entries: any[]) => void) {
|
||||
intersectionCallback = callback;
|
||||
}
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders children correctly', () => {
|
||||
render(<Text>Test content</Text>);
|
||||
expect(screen.getByText('Test content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is immediately visible when no delay is provided', () => {
|
||||
render(<Text>No delay</Text>);
|
||||
const el = screen.getByText('No delay');
|
||||
expect(el).not.toHaveClass('opacity-0');
|
||||
expect(el).not.toHaveClass('animate-in');
|
||||
});
|
||||
|
||||
it('is initially hidden when delay is provided', () => {
|
||||
render(<Text delay={500}>With delay</Text>);
|
||||
const el = screen.getByText('With delay');
|
||||
expect(el).toHaveClass('opacity-0');
|
||||
});
|
||||
|
||||
it('becomes visible after intersection and specified delay', () => {
|
||||
render(<Text delay={500}>Delayed visible</Text>);
|
||||
const el = screen.getByText('Delayed visible');
|
||||
|
||||
expect(el).toHaveClass('opacity-0');
|
||||
|
||||
// Simulate intersection
|
||||
act(() => {
|
||||
intersectionCallback([{ isIntersecting: true }]);
|
||||
});
|
||||
|
||||
// Should still be hidden before delay
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
expect(el).toHaveClass('opacity-0');
|
||||
|
||||
// Should be visible after delay
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
expect(el).not.toHaveClass('opacity-0');
|
||||
expect(el).toHaveClass('animate-in');
|
||||
expect(el).toHaveClass('fade-in');
|
||||
});
|
||||
|
||||
it('becomes visible immediately after intersection if delay is 0', () => {
|
||||
render(<Text delay={0}>Zero delay</Text>);
|
||||
const el = screen.getByText('Zero delay');
|
||||
|
||||
expect(el).toHaveClass('opacity-0');
|
||||
|
||||
// Simulate intersection
|
||||
act(() => {
|
||||
intersectionCallback([{ isIntersecting: true }]);
|
||||
});
|
||||
|
||||
expect(el).not.toHaveClass('opacity-0');
|
||||
expect(el).toHaveClass('animate-in');
|
||||
});
|
||||
|
||||
it('applies alignment classes', () => {
|
||||
render(<Text align="center">Centered</Text>);
|
||||
expect(screen.getByText('Centered')).toHaveClass('text-center');
|
||||
@@ -39,4 +115,18 @@ describe('components/Text', () => {
|
||||
expect(el).toHaveClass('line-clamp-2');
|
||||
expect(el).not.toHaveClass('truncate');
|
||||
});
|
||||
|
||||
it('applies type="button" by default when as="button"', () => {
|
||||
render(<Text as="button">Button Text</Text>);
|
||||
expect(screen.getByRole('button')).toHaveAttribute('type', 'button');
|
||||
});
|
||||
|
||||
it('allows overriding type attribute with htmlType', () => {
|
||||
render(
|
||||
<Text as="button" htmlType="submit">
|
||||
Submit Text
|
||||
</Text>
|
||||
);
|
||||
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import React, { forwardRef } from 'react';
|
||||
import { cn, genericMemo, useIntersectionObserver, castMutableRef } from '@ermnvldmr/stl';
|
||||
import React, { forwardRef, useState, useEffect, useRef } from 'react';
|
||||
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
|
||||
@@ -60,6 +60,10 @@ export interface TextProps
|
||||
overflow?: 'ellipsis' | 'clip';
|
||||
/** Maximum number of lines to show (truncates with ellipsis) */
|
||||
maxLines?: number;
|
||||
/** Delay in milliseconds before the text appears after entering the viewport. */
|
||||
delay?: number;
|
||||
/** Underlying element's HTML type attribute (e.g. "button", "submit") */
|
||||
htmlType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,13 +86,45 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
|
||||
wrap,
|
||||
overflow,
|
||||
maxLines,
|
||||
delay,
|
||||
as: Component = 'span',
|
||||
htmlType,
|
||||
className,
|
||||
'data-testid': testId,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const [isVisible, setIsVisible] = useState(delay === undefined);
|
||||
const internalRef = useRef<HTMLElement>(null);
|
||||
|
||||
// Sync internalRef with forwarded ref
|
||||
useEffect(() => {
|
||||
if (!ref) return;
|
||||
if (typeof ref === 'function') {
|
||||
ref(internalRef.current);
|
||||
} else {
|
||||
castMutableRef<HTMLElement>(ref).current = internalRef.current;
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
const isIntersecting = useIntersectionObserver(internalRef, {
|
||||
threshold: 0.1,
|
||||
once: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (delay !== undefined && isIntersecting) {
|
||||
if (delay > 0) {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setIsVisible(true);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}, [delay, isIntersecting]);
|
||||
|
||||
// Base typography styles mapping
|
||||
const typeStyles: Record<TextType, Record<TextSize, string>> = {
|
||||
display: {
|
||||
@@ -152,7 +188,7 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
ref={castRef<HTMLElement>(ref)}
|
||||
ref={internalRef}
|
||||
className={cn(
|
||||
typeStyles[type][size],
|
||||
colorClasses[color],
|
||||
@@ -163,9 +199,14 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
|
||||
align && alignClasses[align],
|
||||
wrap && wrapClasses[wrap],
|
||||
truncationClass,
|
||||
delay !== undefined &&
|
||||
(isVisible
|
||||
? 'animate-in fade-in slide-in-from-bottom-2 duration-1000 ease-out'
|
||||
: 'opacity-0'),
|
||||
className
|
||||
)}
|
||||
data-testid={testId}
|
||||
type={htmlType ?? (Component === 'button' ? 'button' : undefined)}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
|
||||
Reference in new issue
Block a user