feat: Migrate www to the ui components
This commit is contained in:
19 files changed
+426
-142
No files matched your search
@@ -176,3 +176,25 @@ export const Composition: Story = {
|
||||
</Container>
|
||||
),
|
||||
};
|
||||
|
||||
export const SemanticElements: Story = {
|
||||
render: () => (
|
||||
<VStack gap={4}>
|
||||
<Container border as="main" bg="base" padding={4} rounded="md">
|
||||
Rendered as <main>
|
||||
</Container>
|
||||
<Container border as="article" bg="base" padding={4} rounded="md">
|
||||
Rendered as <article>
|
||||
</Container>
|
||||
<Container border as="section" bg="base" padding={4} rounded="md">
|
||||
Rendered as <section>
|
||||
</Container>
|
||||
<Container border as="header" bg="base" padding={4} rounded="md">
|
||||
Rendered as <header>
|
||||
</Container>
|
||||
<Container border as="footer" bg="base" padding={4} rounded="md">
|
||||
Rendered as <footer>
|
||||
</Container>
|
||||
</VStack>
|
||||
),
|
||||
};
|
||||
@@ -25,4 +25,15 @@ describe('components/Container', () => {
|
||||
expect(container).toHaveClass('cursor-pointer');
|
||||
expect(container).toHaveClass('active:opacity-80');
|
||||
});
|
||||
|
||||
it('renders as different HTML elements using the "as" prop', () => {
|
||||
const { rerender } = render(<Container as="main">Main Content</Container>);
|
||||
expect(screen.getByText('Main Content').tagName).toBe('MAIN');
|
||||
|
||||
rerender(<Container as="article">Article Content</Container>);
|
||||
expect(screen.getByText('Article Content').tagName).toBe('ARTICLE');
|
||||
|
||||
rerender(<Container as="section">Section Content</Container>);
|
||||
expect(screen.getByText('Section Content').tagName).toBe('SECTION');
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,8 @@ export interface ContainerProps extends ClassNameProps, TestIdProps {
|
||||
border?: boolean;
|
||||
/** Press interaction handler */
|
||||
onPress?: PressEvents['onPress'];
|
||||
/** The HTML element to use for rendering */
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,10 +105,11 @@ export const Container = memo(function Container({
|
||||
shadow = false,
|
||||
border = false,
|
||||
onPress,
|
||||
as: Component = 'div',
|
||||
className,
|
||||
'data-testid': testId,
|
||||
}: ContainerProps) {
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const ref = React.useRef<HTMLElement>(null);
|
||||
const { pressProps } = usePress({
|
||||
onPress,
|
||||
isDisabled: !onPress,
|
||||
@@ -176,7 +179,7 @@ export const Container = memo(function Container({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
<Component
|
||||
{...pressProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
@@ -193,6 +196,6 @@ export const Container = memo(function Container({
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
});
|
||||
@@ -20,7 +20,22 @@ type Story = StoryObj<typeof Link>;
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
href: 'https://example.com',
|
||||
children: 'Standard Link',
|
||||
children: 'Standard External Link',
|
||||
},
|
||||
};
|
||||
|
||||
export const Internal: Story = {
|
||||
args: {
|
||||
href: '/internal-page',
|
||||
children: 'Internal Link',
|
||||
},
|
||||
};
|
||||
|
||||
export const ForcedExternal: Story = {
|
||||
args: {
|
||||
href: '/forced-external',
|
||||
isExternal: true,
|
||||
children: 'Forced External Link',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,27 @@ describe('components/Link', () => {
|
||||
expect(link).toHaveClass('text-[var(--rb-ring)]');
|
||||
});
|
||||
|
||||
it('automatically adds target="_blank" and rel for external links', () => {
|
||||
render(<Link href="https://example.com">External</Link>);
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('does not add target="_blank" for internal links by default', () => {
|
||||
render(<Link href="/articles">Internal</Link>);
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).not.toHaveAttribute('target');
|
||||
});
|
||||
|
||||
it('respects explicit isExternal prop', () => {
|
||||
const { rerender } = render(<Link isExternal href="/internal">Force External</Link>);
|
||||
expect(screen.getByRole('link')).toHaveAttribute('target', '_blank');
|
||||
|
||||
rerender(<Link href="https://external.com" isExternal={false}>Force Internal</Link>);
|
||||
expect(screen.getByRole('link')).not.toHaveAttribute('target');
|
||||
});
|
||||
|
||||
it('renders as a button/span when no href is provided but has onPress', () => {
|
||||
render(<Link onPress={() => {}}>Click me</Link>);
|
||||
expect(screen.getByText('Click me')).toBeInTheDocument();
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps {
|
||||
href?: string;
|
||||
/** Link content */
|
||||
children: React.ReactNode;
|
||||
/** Whether the link is external (opens in new tab) */
|
||||
isExternal?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,7 +26,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, ...otherProps } = props;
|
||||
const { children, className, href, isExternal, ...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.
|
||||
@@ -38,8 +40,20 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
);
|
||||
|
||||
if (href) {
|
||||
const isAutoExternal = isExternal ?? href.startsWith('http');
|
||||
const externalProps = isAutoExternal
|
||||
? { target: '_blank', rel: 'noopener noreferrer' }
|
||||
: {};
|
||||
|
||||
return (
|
||||
<Text {...linkProps} ref={ref} as="a" className={sharedClasses} href={href}>
|
||||
<Text
|
||||
{...linkProps}
|
||||
{...externalProps}
|
||||
ref={ref}
|
||||
as="a"
|
||||
className={sharedClasses}
|
||||
href={href}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ export type TextColor =
|
||||
export interface TextProps
|
||||
extends ClassNameProps,
|
||||
TestIdProps,
|
||||
Omit<React.HTMLAttributes<HTMLElement>, 'as'> {
|
||||
Omit<React.AllHTMLAttributes<HTMLElement>, 'as' | 'type' | 'size' | 'color'> {
|
||||
/** Content to be rendered */
|
||||
children: React.ReactNode;
|
||||
/** Typography style type */
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Time } from './Time';
|
||||
import { VStack } from '../VStack/VStack';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
const meta: Meta<typeof Time> = {
|
||||
title: 'components/Time',
|
||||
component: Time,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
color: {
|
||||
control: 'select',
|
||||
options: ['default', 'primary', 'secondary', 'tertiary', 'error', 'muted', 'inherit'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['s', 'm', 'l'],
|
||||
},
|
||||
type: {
|
||||
control: 'select',
|
||||
options: ['display', 'headline', 'title', 'body', 'label'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Time>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
date: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomFormatting: Story = {
|
||||
render: () => (
|
||||
<VStack gap={4}>
|
||||
<Time date={new Date()} formatOptions={{ dateStyle: 'full' }} />
|
||||
<Time date={new Date()} formatOptions={{ dateStyle: 'long' }} />
|
||||
<Time date={new Date()} formatOptions={{ dateStyle: 'medium' }} />
|
||||
<Time date={new Date()} formatOptions={{ dateStyle: 'short' }} />
|
||||
</VStack>
|
||||
),
|
||||
};
|
||||
|
||||
export const Styled: Story = {
|
||||
render: () => (
|
||||
<VStack gap={4}>
|
||||
<Time bold color="primary" date={new Date()} size="l" type="headline" />
|
||||
<Time italic color="muted" date={new Date()} size="s" type="label" />
|
||||
</VStack>
|
||||
),
|
||||
};
|
||||
|
||||
export const CustomChildren: Story = {
|
||||
args: {
|
||||
date: new Date(),
|
||||
children: 'Today',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Time } from './Time';
|
||||
import { render, screen } from '../../test-utils';
|
||||
|
||||
describe('components/Time', () => {
|
||||
it('renders correctly with a Date object', () => {
|
||||
const date = new Date('2026-02-10T00:00:00Z');
|
||||
render(<Time date={date} />);
|
||||
|
||||
const timeElement = screen.getByText(/February 10, 2026/i);
|
||||
expect(timeElement.tagName).toBe('TIME');
|
||||
expect(timeElement).toHaveAttribute('datetime', date.toISOString());
|
||||
});
|
||||
|
||||
it('renders correctly with an ISO string', () => {
|
||||
const dateString = '2026-02-10T00:00:00Z';
|
||||
render(<Time date={dateString} />);
|
||||
|
||||
const timeElement = screen.getByText(/February 10, 2026/i);
|
||||
expect(timeElement.tagName).toBe('TIME');
|
||||
expect(timeElement).toHaveAttribute('datetime', new Date(dateString).toISOString());
|
||||
});
|
||||
|
||||
it('respects custom children', () => {
|
||||
const date = new Date('2026-02-10T00:00:00Z');
|
||||
render(<Time date={date}>Custom Date Display</Time>);
|
||||
|
||||
const timeElement = screen.getByText('Custom Date Display');
|
||||
expect(timeElement).toHaveAttribute('datetime', date.toISOString());
|
||||
});
|
||||
|
||||
it('applies custom formatting options', () => {
|
||||
const date = new Date('2026-02-10T00:00:00Z');
|
||||
render(
|
||||
<Time
|
||||
date={date}
|
||||
formatOptions={{ year: '2-digit', month: 'short', day: 'numeric' }}
|
||||
/>
|
||||
);
|
||||
|
||||
// Depending on locale, "Feb 10, 26" or similar
|
||||
expect(screen.getByText(/Feb/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/10/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/26/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes text props correctly', () => {
|
||||
const date = new Date('2026-02-10T00:00:00Z');
|
||||
render(<Time bold color="primary" date={date} size="l" />);
|
||||
|
||||
const timeElement = screen.getByText(/February 10, 2026/i);
|
||||
expect(timeElement).toHaveClass('font-bold');
|
||||
expect(timeElement).toHaveClass('text-[var(--rb-primary-text)]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import React, { forwardRef, useMemo } from 'react';
|
||||
|
||||
import { Text } from '../Text/Text';
|
||||
|
||||
import type { TextProps } from '../Text/Text';
|
||||
|
||||
/**
|
||||
* Props for the Time component.
|
||||
*/
|
||||
export interface TimeProps extends Omit<TextProps, 'as' | 'children'> {
|
||||
/** The date to display */
|
||||
date: Date | string;
|
||||
/** Intl.DateTimeFormatOptions for formatting */
|
||||
formatOptions?: Intl.DateTimeFormatOptions;
|
||||
/** Locale for formatting (defaults to system/en-US) */
|
||||
locale?: string;
|
||||
/** Content to be rendered instead of formatted date */
|
||||
children?: React.ReactNode;
|
||||
/** Semantic ISO date string for the HTML time element */
|
||||
dateTime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A semantic time component that wraps the Text component.
|
||||
*
|
||||
* Time provides a consistent way to display formatted dates while
|
||||
* maintaining semantic integrity using the HTML <time> element.
|
||||
*/
|
||||
export const Time = genericMemo(
|
||||
forwardRef<HTMLElement, TimeProps>(function Time(
|
||||
{ date, formatOptions, locale = 'en-US', children, dateTime, ...props },
|
||||
ref
|
||||
) {
|
||||
const dateObj = useMemo(() => (typeof date === 'string' ? new Date(date) : date), [date]);
|
||||
const isoString = useMemo(() => dateTime ?? dateObj.toISOString(), [dateTime, dateObj]);
|
||||
|
||||
const formattedDate = useMemo(() => {
|
||||
if (children) return children;
|
||||
|
||||
// If formatOptions contains dateStyle or timeStyle, we must not provide
|
||||
// individual components like year/month/day as it will throw an error.
|
||||
const hasStyle =
|
||||
formatOptions && ('dateStyle' in formatOptions || 'timeStyle' in formatOptions);
|
||||
const options: Intl.DateTimeFormatOptions = hasStyle
|
||||
? { ...formatOptions }
|
||||
: {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
...formatOptions,
|
||||
};
|
||||
|
||||
return dateObj.toLocaleDateString(locale, options);
|
||||
}, [children, dateObj, locale, formatOptions]);
|
||||
|
||||
return (
|
||||
<Text
|
||||
ref={castRef<HTMLElement>(ref)}
|
||||
as="time"
|
||||
dateTime={isoString}
|
||||
{...props}
|
||||
>
|
||||
{formattedDate}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
);
|
||||
@@ -14,6 +14,7 @@ export * from './Code';
|
||||
export * from './Blockquote';
|
||||
export * from './Stub/Stub';
|
||||
export * from './Image/Image';
|
||||
export * from './Time/Time';
|
||||
|
||||
export * from './Table';
|
||||
|
||||
@@ -33,6 +34,7 @@ export type { CodeProps, CodeBlockProps } from './Code';
|
||||
export type { BlockquoteProps, BlockquoteCitationProps } from './Blockquote';
|
||||
export type { StubProps } from './Stub/Stub';
|
||||
export type { ImageProps } from './Image/Image';
|
||||
export type { TimeProps } from './Time/Time';
|
||||
export type {
|
||||
TableProps,
|
||||
TableDensity,
|
||||
|
||||
Reference in new issue
Block a user