diff --git a/GEMINI.md b/GEMINI.md index 97fb757..953dac6 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -129,9 +129,12 @@ All packages use the `@ermnvldmr/` namespace: ### TypeScript Guidelines - **Strict Mode**: All TypeScript strict checks must pass -- **Explicit Types**: Avoid `any` and prefer explicit type definitions -- **Interface First**: Use interfaces for component props and public APIs -- **Cross-Package Types**: Export types from packages for service consumption +- **No 'any'**: The use of `any` is strictly prohibited. Use `unknown` if the type is truly unknown, or define proper generic constraints. +- **No 'ts-ignore' or 'ts-expect-error'**: Never suppress compiler errors. If a third-party library has missing types, provide a local declaration file (.d.ts) instead. +- **No Unsafe Assertions**: Avoid `as any` or `as T` assertions. Prefer type guards, `instanceof` checks, or proper schema validation (e.g., Zod) to narrow types. +- **Explicit Types**: Avoid type inference for public APIs; prefer explicit type definitions for exports. +- **Interface First**: Use interfaces for component props and public APIs. +- **Cross-Package Types**: Export types from packages for service consumption. ### TSDoc Documentation Requirements diff --git a/packages/ui/src/components/Container/Container.stories.tsx b/packages/ui/src/components/Container/Container.stories.tsx index 254bcc4..a3ba892 100644 --- a/packages/ui/src/components/Container/Container.stories.tsx +++ b/packages/ui/src/components/Container/Container.stories.tsx @@ -176,3 +176,25 @@ export const Composition: Story = { ), }; + +export const SemanticElements: Story = { + render: () => ( + + + Rendered as <main> + + + Rendered as <article> + + + Rendered as <section> + + + Rendered as <header> + + + Rendered as <footer> + + + ), +}; diff --git a/packages/ui/src/components/Container/Container.test.tsx b/packages/ui/src/components/Container/Container.test.tsx index 527862e..a8b467e 100644 --- a/packages/ui/src/components/Container/Container.test.tsx +++ b/packages/ui/src/components/Container/Container.test.tsx @@ -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(Main Content); + expect(screen.getByText('Main Content').tagName).toBe('MAIN'); + + rerender(Article Content); + expect(screen.getByText('Article Content').tagName).toBe('ARTICLE'); + + rerender(Section Content); + expect(screen.getByText('Section Content').tagName).toBe('SECTION'); + }); }); diff --git a/packages/ui/src/components/Container/Container.tsx b/packages/ui/src/components/Container/Container.tsx index eb5206e..df4f659 100644 --- a/packages/ui/src/components/Container/Container.tsx +++ b/packages/ui/src/components/Container/Container.tsx @@ -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(null); + const ref = React.useRef(null); const { pressProps } = usePress({ onPress, isDisabled: !onPress, @@ -176,7 +179,7 @@ export const Container = memo(function Container({ }; return ( -
{children} -
+ ); }); diff --git a/packages/ui/src/components/Link/Link.stories.tsx b/packages/ui/src/components/Link/Link.stories.tsx index db79aa2..b51cc35 100644 --- a/packages/ui/src/components/Link/Link.stories.tsx +++ b/packages/ui/src/components/Link/Link.stories.tsx @@ -20,7 +20,22 @@ type Story = StoryObj; 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', }, }; diff --git a/packages/ui/src/components/Link/Link.test.tsx b/packages/ui/src/components/Link/Link.test.tsx index 5407ee0..cd69402 100644 --- a/packages/ui/src/components/Link/Link.test.tsx +++ b/packages/ui/src/components/Link/Link.test.tsx @@ -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(External); + 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(Internal); + const link = screen.getByRole('link'); + expect(link).not.toHaveAttribute('target'); + }); + + it('respects explicit isExternal prop', () => { + const { rerender } = render(Force External); + expect(screen.getByRole('link')).toHaveAttribute('target', '_blank'); + + rerender(Force Internal); + expect(screen.getByRole('link')).not.toHaveAttribute('target'); + }); + it('renders as a button/span when no href is provided but has onPress', () => { render( {}}>Click me); expect(screen.getByText('Click me')).toBeInTheDocument(); diff --git a/packages/ui/src/components/Link/Link.tsx b/packages/ui/src/components/Link/Link.tsx index 521b955..1d182fa 100644 --- a/packages/ui/src/components/Link/Link.tsx +++ b/packages/ui/src/components/Link/Link.tsx @@ -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 ( - + {children} ); diff --git a/packages/ui/src/components/Text/Text.tsx b/packages/ui/src/components/Text/Text.tsx index f200ad5..71f0b06 100644 --- a/packages/ui/src/components/Text/Text.tsx +++ b/packages/ui/src/components/Text/Text.tsx @@ -31,7 +31,7 @@ export type TextColor = export interface TextProps extends ClassNameProps, TestIdProps, - Omit, 'as'> { + Omit, 'as' | 'type' | 'size' | 'color'> { /** Content to be rendered */ children: React.ReactNode; /** Typography style type */ diff --git a/packages/ui/src/components/Time/Time.stories.tsx b/packages/ui/src/components/Time/Time.stories.tsx new file mode 100644 index 0000000..11a5765 --- /dev/null +++ b/packages/ui/src/components/Time/Time.stories.tsx @@ -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 = { + 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; + +export const Default: Story = { + args: { + date: new Date(), + }, +}; + +export const CustomFormatting: Story = { + render: () => ( + + + ), +}; + +export const Styled: Story = { + render: () => ( + + + ), +}; + +export const CustomChildren: Story = { + args: { + date: new Date(), + children: 'Today', + }, +}; diff --git a/packages/ui/src/components/Time/Time.test.tsx b/packages/ui/src/components/Time/Time.test.tsx new file mode 100644 index 0000000..ee5a07d --- /dev/null +++ b/packages/ui/src/components/Time/Time.test.tsx @@ -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(