diff --git a/codecov.yml b/codecov.yml index 5066b81..f0a3e3d 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,30 +2,36 @@ codecov: notify: wait_for_ci: true +ignore: + - 'packages/eslint-config' + - 'packages/rsbuild-config' + - 'packages/storybook-config' + coverage: status: project: default: - target: auto + target: 80% threshold: 1% + range: 10% stl: - target: auto + target: 80% flags: - stl ui: - target: auto + target: 80% flags: - ui ui-storybook: - target: auto + target: 80% flags: - ui-storybook www: - target: auto + target: 80% flags: - www www-storybook: - target: auto + target: 80% flags: - www-storybook diff --git a/packages/rsbuild-config/src/lib/config/rsbuild.ts b/packages/rsbuild-config/src/lib/config/rsbuild.ts index 5f09975..93beeff 100644 --- a/packages/rsbuild-config/src/lib/config/rsbuild.ts +++ b/packages/rsbuild-config/src/lib/config/rsbuild.ts @@ -3,6 +3,8 @@ import { pluginReact } from '@rsbuild/plugin-react'; import { pluginTypeCheck } from '@rsbuild/plugin-type-check'; import { pluginDts } from 'rsbuild-plugin-dts'; +import { mergeConfig } from '../utils/merge.js'; + /** * Defines a standard Rsbuild configuration for services (apps). * @@ -18,7 +20,7 @@ import { pluginDts } from 'rsbuild-plugin-dts'; * ``` */ export function defineServiceConfig(config: RsbuildConfig = {}): RsbuildConfig { - return defineConfig({ + const defaultConfig: RsbuildConfig = { plugins: [pluginReact(), pluginTypeCheck()], output: { distPath: { @@ -40,23 +42,25 @@ export function defineServiceConfig(config: RsbuildConfig = {}): RsbuildConfig { rel: 'preload', as: 'font', type: 'font/woff2', - href: '/static/font/lato-var.woff2', + href: '/static/font/lato-400.woff2', crossorigin: true, }, }, { - tag: 'style', - children: ` - :root { background-color: #f6f6f6; color: #262626; } - .dark { background-color: #262626; color: #f6f6f6; } - body { margin: 0; transition: background-color 0.2s ease; } - `, - head: true, + tag: 'link', + attrs: { + rel: 'preload', + as: 'font', + type: 'font/woff2', + href: '/static/font/lato-700.woff2', + crossorigin: true, + }, }, ], }, - ...config, - }); + }; + + return defineConfig(mergeConfig(defaultConfig, config)); } /** @@ -74,7 +78,7 @@ export function defineServiceConfig(config: RsbuildConfig = {}): RsbuildConfig { * ``` */ export function definePackageConfig(config: RsbuildConfig = {}): RsbuildConfig { - return defineConfig({ + const defaultConfig: RsbuildConfig = { plugins: [pluginReact(), pluginTypeCheck(), pluginDts()], output: { distPath: { @@ -86,6 +90,7 @@ export function definePackageConfig(config: RsbuildConfig = {}): RsbuildConfig { tools: { htmlPlugin: false, }, - ...config, - }); + }; + + return defineConfig(mergeConfig(defaultConfig, config)); } diff --git a/packages/rsbuild-config/src/lib/utils/merge.ts b/packages/rsbuild-config/src/lib/utils/merge.ts new file mode 100644 index 0000000..a10d1cb --- /dev/null +++ b/packages/rsbuild-config/src/lib/utils/merge.ts @@ -0,0 +1,35 @@ +/** + * Minimal deep merge utility for Rsbuild configurations. + * Concatenates arrays and recursively merges objects. + * + * @param source - The source object + * @param target - The target object to merge into source + * @returns The merged object + */ +export function mergeConfig(source: any, target: any): T { + if (target === undefined) return source; + if (source === undefined) return target; + + if (Array.isArray(source) && Array.isArray(target)) { + return [...source, ...target] as any; + } + + if ( + source !== null && + typeof source === 'object' && + !Array.isArray(source) && + target !== null && + typeof target === 'object' && + !Array.isArray(target) + ) { + const result = { ...source }; + for (const key in target) { + if (Object.prototype.hasOwnProperty.call(target, key)) { + result[key] = mergeConfig(source[key], target[key]); + } + } + return result as any; + } + + return target; +} diff --git a/packages/stl/package.json b/packages/stl/package.json index f369603..e17a04b 100644 --- a/packages/stl/package.json +++ b/packages/stl/package.json @@ -8,15 +8,18 @@ "exports": { ".": { "import": "./dist/index.js", - "types": "./dist/index.d.ts" + "types": "./dist/index.d.ts", + "default": "./src/index.ts" }, "./testing": { "import": "./dist/testing.js", - "types": "./dist/testing.d.ts" + "types": "./dist/testing.d.ts", + "default": "./src/testing.ts" }, "./dev": { "import": "./dist/dev.js", - "types": "./dist/dev.d.ts" + "types": "./dist/dev.d.ts", + "default": "./src/dev.ts" } }, "files": [ diff --git a/packages/stl/src/lib/index.ts b/packages/stl/src/lib/index.ts index 2918aa4..9effa37 100644 --- a/packages/stl/src/lib/index.ts +++ b/packages/stl/src/lib/index.ts @@ -1,4 +1,5 @@ export * from './types/index.js'; export * from './functions/index.js'; +export * from './object/index.js'; export * from './window/localStorage.js'; export * from './react/index.js'; diff --git a/packages/stl/src/lib/object/index.ts b/packages/stl/src/lib/object/index.ts new file mode 100644 index 0000000..65afcb3 --- /dev/null +++ b/packages/stl/src/lib/object/index.ts @@ -0,0 +1,6 @@ +/** + * Object utilities for common operations on objects. + */ + +export { merge } from './merge/merge'; +export type { MergeOptions } from './merge/merge'; diff --git a/packages/stl/src/lib/object/merge/merge.test.ts b/packages/stl/src/lib/object/merge/merge.test.ts new file mode 100644 index 0000000..d57f73a --- /dev/null +++ b/packages/stl/src/lib/object/merge/merge.test.ts @@ -0,0 +1,108 @@ +import { merge } from './merge'; + +/** + * Test suite for the merge utility function. + */ +describe('shared/helpers/object/merge', () => { + it('deeply merges two objects, preserving source values by default', () => { + const source = { a: 1, b: { c: 2 } }; + const target = { a: 3, b: { c: 4, d: 5 }, e: 6 }; + const result = merge(source, target); + + expect(result).toEqual({ + a: 1, // force is false, so 1 is preserved + b: { + c: 2, // force is false, so 2 is preserved + d: 5, // added because it was not in source + }, + e: 6, // added because it was not in source + }); + }); + + it('deeply merges two objects and overrides values when force is true', () => { + const source = { a: 1, b: { c: 2 } }; + const target = { a: 3, b: { c: 4, d: 5 }, e: 6 }; + const result = merge(source, target, { force: true }); + + expect(result).toEqual({ + a: 3, + b: { + c: 4, + d: 5, + }, + e: 6, + }); + }); + + it('concatenates arrays when both are present at the same key', () => { + const source = { a: [1, 2] }; + const target = { a: [3, 4] }; + const result = merge(source, target); + + expect(result).toEqual({ a: [1, 2, 3, 4] }); + }); + + it('handles primitive values at the root', () => { + expect(merge(1, 2)).toBe(1); + expect(merge(1, 2, { force: true })).toBe(2); + }); + + it('handles mixed types between source and target', () => { + const source = { a: { b: 1 } }; + const target = { a: [1, 2] }; + + // Source wins because force is false + expect(merge(source, target)).toEqual({ a: { b: 1 } }); + + // Target wins because force is true + expect(merge(source, target, { force: true })).toEqual({ a: [1, 2] }); + }); + + it('handles deep recursion with arrays and objects', () => { + const source = { + users: [ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' }, + ], + config: { + retries: 3, + enabled: true, + }, + }; + + const target = { + users: [{ id: 3, name: 'Doe' }], + config: { + retries: 5, + timeout: 1000, + }, + }; + + const result = merge(source, target); + + expect(result).toEqual({ + users: [ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' }, + { id: 3, name: 'Doe' }, + ], + config: { + retries: 3, + enabled: true, + timeout: 1000, + }, + }); + }); + + it('preserves the source when target has undefined values', () => { + const source = { a: 1 }; + const target = { a: undefined }; + expect(merge(source, target)).toEqual({ a: 1 }); + }); + + it('replaces undefined source with target', () => { + const source = { a: undefined }; + const target = { a: 1 }; + expect(merge(source, target)).toEqual({ a: 1 }); + }); +}); diff --git a/packages/stl/src/lib/object/merge/merge.ts b/packages/stl/src/lib/object/merge/merge.ts new file mode 100644 index 0000000..771b2f6 --- /dev/null +++ b/packages/stl/src/lib/object/merge/merge.ts @@ -0,0 +1,76 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-unnecessary-type-assertion */ +/** + * Options for the merge utility. + */ +export interface MergeOptions { + /** + * Whether to override existing primitive values in the source with values from the target. + * If false, existing primitive values are preserved. + * @default false + */ + force?: boolean; +} + +/** + * Deeply merges two values (objects, arrays, or primitives). + * + * Behavior: + * - If both values are arrays, they are concatenated. + * - If both values are objects, they are merged recursively. + * - If encounter a primitive value, it is overridden only if `force` is set to true. + * - If a key exists in target but not in source, it is added to the result. + * + * @param source - The source value to merge from + * @param target - The target value to merge with + * @param options - Merge configuration options + * @returns The merged result + * + * @example + * ```typescript + * merge({ a: 1 }, { a: 2 }) // { a: 1 } + * merge({ a: 1 }, { a: 2 }, { force: true }) // { a: 2 } + * merge({ a: [1] }, { a: [2] }) // { a: [1, 2] } + * merge({ a: { b: 1 } }, { a: { c: 2 } }) // { a: { b: 1, c: 2 } } + * ``` + */ +export function merge(source: any, target: any, options: MergeOptions = {}): T { + const { force = false } = options; + + // If target is undefined, source wins + if (target === undefined) { + return source; + } + // If source is undefined, target wins + if (source === undefined) { + return target; + } + + const sourceIsArray = Array.isArray(source); + const targetIsArray = Array.isArray(target); + + // If both are arrays, concatenate + if (sourceIsArray && targetIsArray) { + return [...source, ...target] as any; + } + + const sourceIsObject = source !== null && typeof source === 'object' && !sourceIsArray; + const targetIsObject = target !== null && typeof target === 'object' && !targetIsArray; + + // If both are objects, deep merge + if (sourceIsObject && targetIsObject) { + const result = { ...source }; + for (const key in target) { + if (Object.prototype.hasOwnProperty.call(target, key)) { + if (key in source) { + result[key] = merge(source[key], target[key], options); + } else { + result[key] = target[key]; + } + } + } + return result as any; + } + + // If we encounter primitive values or mixed types, override only if force is true + return force ? target : source; +} diff --git a/packages/ui/.config/vitest/setup.ts b/packages/ui/.config/vitest/setup.ts index 3c64eee..8307eae 100644 --- a/packages/ui/.config/vitest/setup.ts +++ b/packages/ui/.config/vitest/setup.ts @@ -8,3 +8,17 @@ class ResizeObserverMock { } global.ResizeObserver = ResizeObserverMock; + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // Deprecated + removeListener: vi.fn(), // Deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); diff --git a/packages/ui/package.json b/packages/ui/package.json index be4319e..214e761 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -8,15 +8,19 @@ "exports": { ".": { "import": "./dist/index.js", - "types": "./dist/ui/src/index.d.ts" + "style": "./dist/styles/*.css", + "types": "./dist/ui/src/index.d.ts", + "default": "./src/index.ts" }, "./testing": { "import": "./dist/testing.js", - "types": "./dist/ui/src/testing.d.ts" + "types": "./dist/ui/src/testing.d.ts", + "default": "./src/testing.ts" }, "./dev": { "import": "./dist/dev.js", - "types": "./dist/ui/src/dev.d.ts" + "types": "./dist/ui/src/dev.d.ts", + "default": "./src/dev.ts" } }, "files": [ diff --git a/packages/ui/src/components/Article/Article.test.tsx b/packages/ui/src/components/Article/Article.test.tsx index 2e1ddf5..372bd79 100644 --- a/packages/ui/src/components/Article/Article.test.tsx +++ b/packages/ui/src/components/Article/Article.test.tsx @@ -31,9 +31,8 @@ describe('components/Article', () => { ); separators = screen.getAllByRole('separator'); - expect(separators).toHaveLength(2); - expect(separators[0].className).not.toContain('mask-image'); - expect(separators[1].className).toContain('mask-image'); + expect(separators).toHaveLength(1); + expect(separators[0].className).toContain('mask-image'); }); it('renders as a link when href is provided', () => { diff --git a/packages/ui/src/components/Article/Article.tsx b/packages/ui/src/components/Article/Article.tsx index 84398df..227be24 100644 --- a/packages/ui/src/components/Article/Article.tsx +++ b/packages/ui/src/components/Article/Article.tsx @@ -56,26 +56,24 @@ export const Article = memo(function Article({ return ( -
{headline}
- - {subHeadline ? ( - <> - - - {subHeadline} - +
{headline}
+ + {subHeadline ? ( + <> + + {subHeadline} + + + + ) : ( - - ) : ( - - )} + )} +
- {children} - {additionalText && ( diff --git a/packages/ui/src/components/Page/PageColumn/PageColumn.test.tsx b/packages/ui/src/components/Page/PageColumn/PageColumn.test.tsx new file mode 100644 index 0000000..9cbdaa6 --- /dev/null +++ b/packages/ui/src/components/Page/PageColumn/PageColumn.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, it, expect } from 'vitest'; + +import { PageColumn } from './PageColumn'; + +describe('PageColumn', () => { + it('renders children correctly', () => { + render( + +
Test Content
+
+ ); + expect(screen.getByTestId('child')).toBeInTheDocument(); + }); + + it('applies size classes', () => { + const { container: small } = render(Content); + expect(small.firstChild).toHaveClass('lg:w-[var(--rb-page-column-small,300px)]'); + + const { container: full } = render(Content); + expect(full.firstChild).toHaveClass('flex-1'); + }); + + it('handles sticky prop', () => { + const { container } = render(Content); + expect(container.firstChild).toHaveClass('lg:sticky'); + expect(container.firstChild).toHaveClass('lg:top-24'); + }); + + it('renders as different element', () => { + render(Content); + expect(screen.getByRole('complementary')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/Page/PageColumn/PageColumn.tsx b/packages/ui/src/components/Page/PageColumn/PageColumn.tsx new file mode 100644 index 0000000..3ddffb8 --- /dev/null +++ b/packages/ui/src/components/Page/PageColumn/PageColumn.tsx @@ -0,0 +1,66 @@ +import { cn } from '@ermnvldmr/stl'; +import React from 'react'; + +/** + * Props for the PageColumn component. + */ +export interface PageColumnProps { + /** The content of the column. */ + children: React.ReactNode; + /** + * The size of the column on desktop. + * - `small`: Fixed width (300px) + * - `full`: Takes up remaining space (flex-1) + * @default 'full' + */ + size?: 'small' | 'full'; + /** + * If true, the column will stick to the top when scrolling on desktop. + * @default false + */ + sticky?: boolean; + /** + * Optional additional class names. + */ + className?: string; + /** + * The HTML element to use for the root of the component. + * @default 'div' + */ + as?: React.ElementType; +} + +/** + /** + * A single column unit within the PageColumns host. + * + * @param props - The component props. + * @returns A PageColumn component. + * @example + * ```tsx + * Sidebar + * ``` + */ +export const PageColumn = ({ + children, + size = 'full', + sticky = false, + className, + as: Component = 'div', +}: PageColumnProps) => { + return ( + + {children} + + ); +}; diff --git a/packages/ui/src/components/Page/PageColumns/PageColumns.stories.tsx b/packages/ui/src/components/Page/PageColumns/PageColumns.stories.tsx new file mode 100644 index 0000000..8dc7fb3 --- /dev/null +++ b/packages/ui/src/components/Page/PageColumns/PageColumns.stories.tsx @@ -0,0 +1,137 @@ +import React from 'react'; + +import { PageColumns } from './PageColumns'; +import { PageColumn } from '../PageColumn/PageColumn'; + +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: 'components/Page/PageColumns', + component: PageColumns, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const Box = ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+); + +/** + * A standard two-column layout: small sidebar and full main area. + */ +export const TwoColumnsSidebarLeft: Story = { + args: { + gap: 8, + children: ( + <> + + + Small Sidebar + + + + Main Full Content + + + ), + }, +}; + +/** + * A standard two-column layout with sidebar on the right. + */ +export const TwoColumnsSidebarRight: Story = { + args: { + gap: 8, + children: ( + <> + + Main Full Content + + + + Small Sidebar + + + + ), + }, +}; + +/** + * Three columns: small sidebar on both sides. + */ +export const ThreeColumns: Story = { + args: { + gap: 6, + children: ( + <> + + Left Sidebar + + + Primary Content + + + Right Sidebar + + + ), + }, +}; + +/** + * Two full-width columns sharing space equally. + */ +export const TwoFullColumns: Story = { + args: { + gap: 8, + children: ( + <> + + Column A (full) + + + Column B (full) + + + ), + }, +}; + +/** + * A sidebar that sticks to the top when scrolling. + */ +export const StickySidebar: Story = { + render: (args) => ( +
+

Scroll down to see the sidebar stick

+ + +
+ {Array.from({ length: 15 }).map((_, i) => ( + // eslint-disable-next-line react/no-array-index-key + + Main Content Block {i + 1} + + ))} +
+
+ + + I am a Sticky Sidebar! + + +
+
+ ), +}; diff --git a/packages/ui/src/components/Page/PageColumns/PageColumns.test.tsx b/packages/ui/src/components/Page/PageColumns/PageColumns.test.tsx new file mode 100644 index 0000000..d659a2b --- /dev/null +++ b/packages/ui/src/components/Page/PageColumns/PageColumns.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, it, expect } from 'vitest'; + +import { PageColumns } from './PageColumns'; + +describe('PageColumns', () => { + it('renders children correctly', () => { + render( + +
Test Content
+
+ ); + expect(screen.getByTestId('child')).toBeInTheDocument(); + }); + + it('applies gap class', () => { + const { container: gap8 } = render(Content); + expect(gap8.firstChild).toHaveClass('gap-8'); + + const { container: gapCustom } = render(Content); + expect(gapCustom.firstChild).toHaveClass('gap-[20px]'); + }); + + it('has base responsive classes', () => { + const { container } = render(Content); + expect(container.firstChild).toHaveClass('flex'); + expect(container.firstChild).toHaveClass('flex-col'); + expect(container.firstChild).toHaveClass('lg:flex-row'); + }); +}); diff --git a/packages/ui/src/components/Page/PageColumns/PageColumns.tsx b/packages/ui/src/components/Page/PageColumns/PageColumns.tsx new file mode 100644 index 0000000..4c9d3b4 --- /dev/null +++ b/packages/ui/src/components/Page/PageColumns/PageColumns.tsx @@ -0,0 +1,51 @@ +import { cn } from '@ermnvldmr/stl'; +import React from 'react'; + +/** + * Props for the PageColumns component. + */ +export interface PageColumnsProps { + /** The columns to be displayed within the grid. */ + children: React.ReactNode; + /** + * Spacing between columns. + * @default 6 + */ + gap?: number | string; + /** + * Optional additional class names. + */ + className?: string; +} + +/** + /** + * Host component for a responsive column system. + * It handles the flex/grid layout for page columns. + * + * @param props - The component props. + * @returns A PageColumns component. + * @example + * ```tsx + * + * Sidebar + * Main Content + * + * ``` + */ +export const PageColumns = ({ children, gap = 6, className }: PageColumnsProps) => { + return ( +
+ {children} +
+ ); +}; diff --git a/packages/ui/src/components/Page/PageContainer/PageContainer.stories.tsx b/packages/ui/src/components/Page/PageContainer/PageContainer.stories.tsx new file mode 100644 index 0000000..fc59230 --- /dev/null +++ b/packages/ui/src/components/Page/PageContainer/PageContainer.stories.tsx @@ -0,0 +1,120 @@ +import React from 'react'; + +import { PageContainer } from './PageContainer'; + +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: 'components/Page/PageContainer', + component: PageContainer, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], + argTypes: { + width: { + control: 'select', + options: ['slim', 'default', 'wide', 'full'], + }, + paddingX: { + control: 'select', + options: ['none', 'small', 'medium', 'large'], + }, + paddingY: { + control: 'select', + options: ['none', 'small', 'medium', 'large'], + }, + centerVertically: { + control: 'boolean', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const Box = ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+); + +/** + * Standard container with default width and padding. + */ +export const Default: Story = { + args: { + children: Default Container Content, + width: 'default', + paddingY: 'medium', + }, +}; + +/** + * Narrower slim container, ideal for articles. + */ +export const Slim: Story = { + args: { + children: Slim Container Content (1100px Max-Width), + width: 'slim', + paddingY: 'medium', + }, +}; + +/** + * Wider wide container, useful for dashboards. + */ +export const Wide: Story = { + args: { + children: Wide Container Content (1920px Max-Width), + width: 'wide', + paddingY: 'medium', + }, +}; + +/** + * Vertically centered container. + */ +export const VerticallyCentered: Story = { + render: (args) => ( +
+ + I am vertically centered! + +
+ ), +}; + +/** + * Demonstration of different horizontal padding scales. + */ +export const PaddingScales: Story = { + render: () => ( +
+
+

paddingX: small

+ + Small Padding Content + +
+
+

paddingX: medium (Default)

+ + + Medium Padding Content + + +
+
+

paddingX: large

+ + + Large Padding Content + + +
+
+ ), +}; diff --git a/packages/ui/src/components/Page/PageContainer/PageContainer.test.tsx b/packages/ui/src/components/Page/PageContainer/PageContainer.test.tsx new file mode 100644 index 0000000..97b9d92 --- /dev/null +++ b/packages/ui/src/components/Page/PageContainer/PageContainer.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, it, expect } from 'vitest'; + +import { PageContainer } from './PageContainer'; + +describe('PageContainer', () => { + it('renders children correctly', () => { + render( + +
Test Content
+
+ ); + expect(screen.getByTestId('child')).toBeInTheDocument(); + }); + + it('applies correct width classes', () => { + const { container: slim } = render(Content); + expect(slim.firstChild).toHaveClass('max-w-[var(--rb-page-width-slim,1100px)]'); + + const { container: def } = render(Content); + expect(def.firstChild).toHaveClass('max-w-[var(--rb-page-width-default,1600px)]'); + + const { container: wide } = render(Content); + expect(wide.firstChild).toHaveClass('max-w-[var(--rb-page-width-wide,1920px)]'); + + const { container: full } = render(Content); + expect(full.firstChild).toHaveClass('max-w-full'); + }); + + it('applies padding classes', () => { + const { container: small } = render( + + Content + + ); + expect(small.firstChild).toHaveClass('px-2'); + expect(small.firstChild).toHaveClass('py-4'); + + const { container: large } = render( + + Content + + ); + expect(large.firstChild).toHaveClass('px-6'); + expect(large.firstChild).toHaveClass('py-12'); + }); + + it('handles centerVertically prop', () => { + const { container } = render(Content); + expect(container.firstChild).toHaveClass('flex-1'); + expect(container.firstChild).toHaveClass('flex-col'); + expect(container.firstChild).toHaveClass('justify-center'); + }); + + it('renders as different element', () => { + render(Content); + expect(screen.getByRole('main')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/Page/PageContainer/PageContainer.tsx b/packages/ui/src/components/Page/PageContainer/PageContainer.tsx new file mode 100644 index 0000000..869c356 --- /dev/null +++ b/packages/ui/src/components/Page/PageContainer/PageContainer.tsx @@ -0,0 +1,119 @@ +import { cn } from '@ermnvldmr/stl'; +import React from 'react'; + +/** + * Supported width configurations for the PageContainer. + * - `slim`: 1100px + * - `default`: 1600px + * - `wide`: 1920px + * - `full`: 100% + */ +export type PageWidth = 'slim' | 'default' | 'wide' | 'full'; + +/** + * Supported padding scales for the PageContainer. + * - `none`: No padding. + * - `small`: Standard small padding. + * - `medium`: Standard medium padding. + * - `large`: Standard large padding. + */ +export type PagePadding = 'none' | 'small' | 'medium' | 'large'; + +/** + * Props for the PageContainer. + */ +export interface PageContainerProps { + /** The content of the container. */ + children: React.ReactNode; + /** + * The maximum width of the container on desktop. + * @default 'default' + */ + width?: PageWidth; + /** + * Horizontal padding scale. + * @default 'medium' + */ + paddingX?: PagePadding; + /** + * Vertical padding scale. + * @default 'none' + */ + paddingY?: PagePadding; + /** + * If true, vertically centers the content on the page. + * Has no effect if the content is taller than the viewport. + * @default false + */ + centerVertically?: boolean; + /** + * Optional additional class names. + */ + className?: string; + /** + * The HTML element to use for the root of the component. + * @default 'div' + */ + as?: React.ElementType; +} + +/** + /** + * Manages the horizontal and vertical constraints for page content. + * + * @param props - The component props. + * @returns A PageContainer component. + * @example + * ```tsx + * + * Main Content + * + * ``` + */ +export const PageContainer = ({ + children, + width = 'default', + paddingX = 'medium', + paddingY = 'none', + centerVertically = false, + className, + as: Component = 'div', +}: PageContainerProps) => { + const widthClasses = { + slim: 'max-w-[var(--rb-page-width-slim,1100px)]', + default: 'max-w-[var(--rb-page-width-default,1600px)]', + wide: 'max-w-[var(--rb-page-width-wide,1920px)]', + full: 'max-w-full', + }; + + const pxClasses = { + none: 'px-0', + small: 'px-2 sm:px-4', + medium: 'px-4 sm:px-6 lg:px-8', + large: 'px-6 sm:px-8 lg:px-12', + }; + + const pyClasses = { + none: 'py-0', + small: 'py-4 sm:py-6', + medium: 'py-8 sm:py-12', + large: 'py-12 sm:py-24', + }; + + return ( + + {children} + + ); +}; diff --git a/packages/ui/src/components/PageHead/PageHead.stories.tsx b/packages/ui/src/components/Page/PageHead/PageHead.stories.tsx similarity index 95% rename from packages/ui/src/components/PageHead/PageHead.stories.tsx rename to packages/ui/src/components/Page/PageHead/PageHead.stories.tsx index 8f27972..50f6a7c 100644 --- a/packages/ui/src/components/PageHead/PageHead.stories.tsx +++ b/packages/ui/src/components/Page/PageHead/PageHead.stories.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { PageHead } from './PageHead'; -import { Breadcrumbs } from '../Breadcrumbs/Breadcrumbs'; -import { Button } from '../Button/Button'; +import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs'; +import { Button } from '../../Button/Button'; import type { Meta, StoryObj } from '@storybook/react'; diff --git a/packages/ui/src/components/PageHead/PageHead.test.tsx b/packages/ui/src/components/Page/PageHead/PageHead.test.tsx similarity index 100% rename from packages/ui/src/components/PageHead/PageHead.test.tsx rename to packages/ui/src/components/Page/PageHead/PageHead.test.tsx diff --git a/packages/ui/src/components/PageHead/PageHead.tsx b/packages/ui/src/components/Page/PageHead/PageHead.tsx similarity index 56% rename from packages/ui/src/components/PageHead/PageHead.tsx rename to packages/ui/src/components/Page/PageHead/PageHead.tsx index 079447c..66da92b 100644 --- a/packages/ui/src/components/PageHead/PageHead.tsx +++ b/packages/ui/src/components/Page/PageHead/PageHead.tsx @@ -1,10 +1,11 @@ import { cn, useScroll, useResizeObserver } from '@ermnvldmr/stl'; import React, { type ReactNode, useRef, useEffect, useState } from 'react'; -import { Header } from '../Header/Header'; -import { HStack } from '../HStack/HStack'; -import { Text } from '../Text/Text'; -import { VStack } from '../VStack/VStack'; +import { Header } from '../../Header/Header'; +import { HStack } from '../../HStack/HStack'; +import { Text } from '../../Text/Text'; +import { VStack } from '../../VStack/VStack'; +import { PageContainer } from '../PageContainer/PageContainer'; /** * Defines the positioning and behavior of the PageHead component. @@ -28,7 +29,7 @@ export interface PageHeadProps { /** The main heading text. */ heading: string; /** The caption text displayed below the heading. */ - caption?: string; + caption?: ReactNode; /** The breadcrumbs navigation component. */ breadcrumbs?: ReactNode; /** A component to be displayed on the left side of the header. */ @@ -77,55 +78,69 @@ export const PageHead = ({ }, [scrollY, strategy]); const rootClasses = cn( - 'page-head w-full transition-all duration-200 bg-[var(--rb-background)] fixed top-0 z-10', + 'page-head w-full transition-all duration-200 bg-[var(--rb-background)] fixed top-0 z-10 border-b border-[var(--rb-outline)]/10', { - 'px-6 py-4': !isCollapsed, - 'px-4 py-2': isCollapsed, - 'shadow-md': isCollapsed, + 'shadow-md bg-[var(--rb-background)]/90 backdrop-blur-md': isCollapsed, }, className ); + const expandedContent = ( + + + + + {addonLeft &&
{addonLeft}
} + {breadcrumbs &&
{breadcrumbs}
} +
+ {addonRight &&
{addonRight}
} +
+ +
{heading}
+ {typeof caption === 'string' ? ( + + {caption} + + ) : ( + caption + )} +
+
+
+ ); + + const collapsedContent = ( + + + + {addonLeft &&
{addonLeft}
} +
+ {heading} +
+
+ {addonRight &&
{addonRight}
} +
+
+ ); + return ( <>
- - - - {addonLeft &&
{addonLeft}
} - {breadcrumbs &&
{breadcrumbs}
} -
- {addonRight &&
{addonRight}
} -
- -
{heading}
- {caption && ( - - {caption} - - )} -
-
+ {expandedContent}
- - - {addonLeft &&
{addonLeft}
} -
{heading}
-
- {addonRight &&
{addonRight}
} -
+ {collapsedContent}
diff --git a/packages/ui/src/components/Page/PageRoot/PageRoot.stories.tsx b/packages/ui/src/components/Page/PageRoot/PageRoot.stories.tsx new file mode 100644 index 0000000..af3e3e0 --- /dev/null +++ b/packages/ui/src/components/Page/PageRoot/PageRoot.stories.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +import { PageRoot } from './PageRoot'; + +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: 'components/Page/PageRoot', + component: PageRoot, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +/** + * The default PageRoot story showing the base technical container. + */ +export const Default: Story = { + args: { + children: ( +
+

Page Root Content

+

+ This is the base technical container that handles theme initialization and core + background/text colors. +

+
+ ), + }, +}; + +/** + * PageRoot with custom background classes. + */ +export const CustomStyling: Story = { + args: { + className: 'bg-slate-100 dark:bg-slate-900', + children: ( +
+

Custom Styled Root

+

+ PageRoot accepts a className prop to override or extend base styles. +

+
+ ), + }, +}; diff --git a/packages/ui/src/components/Page/PageRoot/PageRoot.test.tsx b/packages/ui/src/components/Page/PageRoot/PageRoot.test.tsx new file mode 100644 index 0000000..713cd30 --- /dev/null +++ b/packages/ui/src/components/Page/PageRoot/PageRoot.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, it, expect } from 'vitest'; + +import { PageRoot } from './PageRoot'; + +describe('PageRoot', () => { + it('renders children correctly', () => { + render( + +
Test Content
+
+ ); + expect(screen.getByTestId('child')).toBeInTheDocument(); + expect(screen.getByText('Test Content')).toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render( + +
Content
+
+ ); + expect(container.firstChild).toHaveClass('custom-class'); + }); + + it('has base technical classes', () => { + const { container } = render( + +
Content
+
+ ); + expect(container.firstChild).toHaveClass('w-full'); + }); +}); diff --git a/packages/ui/src/components/Page/PageRoot/PageRoot.tsx b/packages/ui/src/components/Page/PageRoot/PageRoot.tsx new file mode 100644 index 0000000..d9d7469 --- /dev/null +++ b/packages/ui/src/components/Page/PageRoot/PageRoot.tsx @@ -0,0 +1,34 @@ +import { cn } from '@ermnvldmr/stl'; +import React from 'react'; + +import { useTheme } from '../../../lib/theme'; + +/** + * Props for the PageRoot component. + */ +export interface PageRootProps { + /** The main content slot. */ + children: React.ReactNode; + /** Additional CSS classes for the root container. */ + className?: string; +} + +/** + /** + * The technical bootstrap layer for all layouts. + * It handles theme initialization and base background/text colors. + * + * @param props - The component props. + * @returns A PageRoot component. + * @example + * ```tsx + * + * Content + * + * ``` + */ +export const PageRoot = ({ children, className }: PageRootProps) => { + useTheme(); + + return
{children}
; +}; diff --git a/packages/ui/src/components/Page/index.ts b/packages/ui/src/components/Page/index.ts new file mode 100644 index 0000000..5b6bcae --- /dev/null +++ b/packages/ui/src/components/Page/index.ts @@ -0,0 +1,5 @@ +export * from './PageRoot/PageRoot'; +export * from './PageContainer/PageContainer'; +export * from './PageColumns/PageColumns'; +export * from './PageColumn/PageColumn'; +export * from './PageHead/PageHead'; diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index eb0d34f..e2b212d 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -11,7 +11,7 @@ export * from './Link/Link'; export * from './List/List'; export * from './Markdown/Markdown'; export * from './Paragraph/Paragraph'; -export * from './PageHead/PageHead'; +export * from './Page/index'; export * from './Separator/Separator'; export * from './Stack/Stack'; export * from './Stub/Stub'; diff --git a/packages/ui/src/lib/dev.ts b/packages/ui/src/lib/dev.ts index cb0ff5c..0d012d4 100644 --- a/packages/ui/src/lib/dev.ts +++ b/packages/ui/src/lib/dev.ts @@ -1 +1 @@ -export {}; +export * from './theme/dev'; diff --git a/packages/ui/src/lib/theme/constants.ts b/packages/ui/src/lib/theme/constants.ts index 48dbb17..9a0ae20 100644 --- a/packages/ui/src/lib/theme/constants.ts +++ b/packages/ui/src/lib/theme/constants.ts @@ -4,3 +4,13 @@ * correct theme class before the rest of the page hydrates. */ export const THEME_INIT_SCRIPT = `(function(){try{var k='ermnvldmr/ui/lib/theme';var p=JSON.parse(localStorage.getItem(k)||'"system"');var d=p==='dark'||(p==='system'&&window.matchMedia('(prefers-color-scheme:dark)').matches);document.documentElement.classList.toggle('dark',d)}catch(e){}})();`; + +/** + * Initial inline styles to prevent FOUC before the main CSS is loaded. + * These align with the main palette in styles.css. + */ +export const THEME_INIT_STYLES = ` + :root { background-color: #fafafa; color: #262626; } + .dark { background-color: #141414; color: #f6f6f6; } + body { margin: 0; transition: background-color 0.2s ease; } +`; diff --git a/packages/ui/src/lib/theme/dev.ts b/packages/ui/src/lib/theme/dev.ts new file mode 100644 index 0000000..346f018 --- /dev/null +++ b/packages/ui/src/lib/theme/dev.ts @@ -0,0 +1 @@ +export { THEME_INIT_SCRIPT, THEME_INIT_STYLES } from './constants'; diff --git a/packages/ui/src/lib/theme/useTheme.ts b/packages/ui/src/lib/theme/useTheme.ts index d03af72..988fa2b 100644 --- a/packages/ui/src/lib/theme/useTheme.ts +++ b/packages/ui/src/lib/theme/useTheme.ts @@ -30,11 +30,7 @@ export interface UseThemeResult { * ``` */ export function useTheme(): UseThemeResult { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const [preference, setPreference] = useLocalStorage(themeStorage) as [ - ThemePreference, - (val: ThemePreference) => void, - ]; + const [preference, setPreference] = useLocalStorage(themeStorage); const resolveTheme = useCallback((pref: ThemePreference): 'light' | 'dark' => { if (pref !== 'system') return pref; diff --git a/packages/ui/src/static/styles.css b/packages/ui/src/static/styles.css index a5d4630..9db2e39 100644 --- a/packages/ui/src/static/styles.css +++ b/packages/ui/src/static/styles.css @@ -169,6 +169,7 @@ --rb-color-violet-900: #3b2c83; --rb-color-violet-950: #231a4c; + --rb-color-neutral-25: #fafafa; --rb-color-neutral-50: #f6f6f6; --rb-color-neutral-100: #e7e7e7; --rb-color-neutral-200: #d1d1d1; @@ -180,12 +181,13 @@ --rb-color-neutral-800: #454545; --rb-color-neutral-900: #3d3d3d; --rb-color-neutral-950: #262626; + --rb-color-neutral-975: #141414; } :root { /* Core palette */ --rb-base: var(--rb-color-neutral-50); - --rb-background: var(--rb-base); + --rb-background: var(--rb-color-neutral-25); --rb-outline: var(--rb-color-neutral-500); --rb-input: var(--rb-color-neutral-800); --rb-text: var(--rb-color-neutral-950); @@ -218,7 +220,7 @@ .dark { /* Core palette flips */ --rb-base: var(--rb-color-neutral-950); - --rb-background: var(--rb-base); + --rb-background: var(--rb-color-neutral-975); --rb-text: var(--rb-color-neutral-50); --rb-outline: var(--rb-color-neutral-500); --rb-input: var(--rb-color-neutral-300); @@ -248,13 +250,6 @@ --rb-chart-5: var(--rb-color-red-400); } -@layer base { - body { - background-color: var(--rb-base); - color: var(--rb-text); - } -} - @keyframes shimmer { 0% { background-position: -200% 0; diff --git a/services/static/rsbuild.config.ts b/services/static/rsbuild.config.ts index dcc94ff..bb37af0 100644 --- a/services/static/rsbuild.config.ts +++ b/services/static/rsbuild.config.ts @@ -1,4 +1,5 @@ import { defineServiceConfig, discoverEntries } from '@ermnvldmr/rsbuild-config/dev'; +import { THEME_INIT_SCRIPT, THEME_INIT_STYLES } from '@ermnvldmr/ui/dev'; export default defineServiceConfig({ source: { @@ -6,5 +7,17 @@ export default defineServiceConfig({ }, html: { title: 'Static Content', + tags: [ + { + tag: 'script', + children: THEME_INIT_SCRIPT, + head: true, + }, + { + tag: 'style', + children: THEME_INIT_STYLES, + head: true, + }, + ], }, }); diff --git a/services/static/src/components/layouts/IndexLayout/IndexLayout.tsx b/services/static/src/components/layouts/IndexLayout/IndexLayout.tsx index a5f9757..99f6776 100644 --- a/services/static/src/components/layouts/IndexLayout/IndexLayout.tsx +++ b/services/static/src/components/layouts/IndexLayout/IndexLayout.tsx @@ -1,4 +1,4 @@ -import { Container, HStack, ThemeSwitch, useTheme, VStack, Text, Separator } from '@ermnvldmr/ui'; +import { Container, HStack, ThemeSwitch, VStack, Text, Separator, PageRoot } from '@ermnvldmr/ui'; import React from 'react'; /** @@ -13,7 +13,6 @@ export interface IndexLayoutProps { * The root layout for the static service. * * @param props - The component props. - * @param props.children - Main content slot. * @returns A React element representing the index layout. * @example * ```tsx @@ -21,19 +20,11 @@ export interface IndexLayoutProps { * ``` */ export function IndexLayout({ children }: IndexLayoutProps): React.JSX.Element { - // Initialize theme side-effects - useTheme(); - const currentYear = new Date().getFullYear(); return ( - - - {children} - + +
{children}
@@ -47,6 +38,6 @@ export function IndexLayout({ children }: IndexLayoutProps): React.JSX.Element { -
+ ); } diff --git a/services/static/src/static/global.css b/services/static/src/static/global.css index 1c1bab8..7ce02dc 100644 --- a/services/static/src/static/global.css +++ b/services/static/src/static/global.css @@ -1,2 +1,2 @@ @import '../../../../packages/ui/src/static/styles.css'; -@source "../../../../packages/ui/src/components"; +@source '../../../../packages/ui/src/components'; diff --git a/services/static/src/static/index.html b/services/static/src/static/index.html index 1adb0aa..6c7e3a4 100644 --- a/services/static/src/static/index.html +++ b/services/static/src/static/index.html @@ -1,18 +1,6 @@ - Vladimir Eremin diff --git a/services/www/rsbuild.config.ts b/services/www/rsbuild.config.ts index 0339889..3f816d0 100644 --- a/services/www/rsbuild.config.ts +++ b/services/www/rsbuild.config.ts @@ -1,4 +1,5 @@ import { defineServiceConfig, discoverEntries } from '@ermnvldmr/rsbuild-config/dev'; +import { THEME_INIT_SCRIPT, THEME_INIT_STYLES } from '@ermnvldmr/ui/dev'; import { pluginMdx } from '@rsbuild/plugin-mdx'; import remarkFrontmatter from 'remark-frontmatter'; import remarkMdxFrontmatter from 'remark-mdx-frontmatter'; @@ -14,4 +15,18 @@ export default defineServiceConfig({ source: { entry: discoverEntries(import.meta.dirname, './src/app/**/*.tsx'), }, + html: { + tags: [ + { + tag: 'script', + children: THEME_INIT_SCRIPT, + head: true, + }, + { + tag: 'style', + children: THEME_INIT_STYLES, + head: true, + }, + ], + }, }); diff --git a/services/www/src/app/articles/2026/matrix-server-deployment-guide.tsx b/services/www/src/app/articles/2026/matrix-server-deployment-guide.tsx index 29c4e6d..16017ef 100644 --- a/services/www/src/app/articles/2026/matrix-server-deployment-guide.tsx +++ b/services/www/src/app/articles/2026/matrix-server-deployment-guide.tsx @@ -2,7 +2,7 @@ import { MARKDOWN_COMPONENTS } from '@ermnvldmr/ui'; import React from 'react'; import { articles } from '../../../../content/registry'; -import { ArticleLayout } from '../../../components/layouts/ArticleLayout/ArticleLayout'; +import { ArticleLayout } from '../../../components/Layout/ArticleLayout'; import { createPage } from '../../../lib/core/createPage'; const article = articles['matrix-server-deployment-guide']; diff --git a/services/www/src/app/articles/2026/test-article.tsx b/services/www/src/app/articles/2026/test-article.tsx index bfb5235..2bb1569 100644 --- a/services/www/src/app/articles/2026/test-article.tsx +++ b/services/www/src/app/articles/2026/test-article.tsx @@ -2,7 +2,7 @@ import { MARKDOWN_COMPONENTS } from '@ermnvldmr/ui'; import React from 'react'; import { articles } from '../../../../content/registry'; -import { ArticleLayout } from '../../../components/layouts/ArticleLayout/ArticleLayout'; +import { ArticleLayout } from '../../../components/Layout/ArticleLayout'; import { createPage } from '../../../lib/core/createPage'; const article = articles['test-article']; diff --git a/services/www/src/app/articles/index.tsx b/services/www/src/app/articles/index.tsx index d05b249..5490ae0 100644 --- a/services/www/src/app/articles/index.tsx +++ b/services/www/src/app/articles/index.tsx @@ -1,17 +1,31 @@ -import { Header, Separator, Article, VStack } from '@ermnvldmr/ui'; +import { Separator, Article, VStack, Breadcrumbs } from '@ermnvldmr/ui'; import React from 'react'; import { sortedArticles } from '../../../content/registry'; -import { IndexLayout } from '../../components/layouts/IndexLayout/IndexLayout'; +import { DefaultLayout } from '../../components/Layout/DefaultLayout'; import { createPage } from '../../lib/core/createPage'; import { SITE_TITLE } from '../../lib/shared/constants'; createPage( function ArticlesList(): React.JSX.Element { + const breadcrumbs = ( + + + Home + + + Articles + + ); + return ( - + -
Articles
{sortedArticles.map((article, index) => ( @@ -37,7 +51,7 @@ createPage( ))}
-
+ ); }, { diff --git a/services/www/src/app/index.tsx b/services/www/src/app/index.tsx index 9a6b17d..e41e219 100644 --- a/services/www/src/app/index.tsx +++ b/services/www/src/app/index.tsx @@ -1,14 +1,14 @@ -import { Header, Paragraph, Link, VStack } from '@ermnvldmr/ui'; +import { Paragraph, Link, VStack, Header } from '@ermnvldmr/ui'; import React from 'react'; -import { IndexLayout } from '../components/layouts/IndexLayout/IndexLayout'; +import { DefaultLayout } from '../components/Layout/DefaultLayout'; import { createPage } from '../lib/core/createPage'; import { SITE_DESCRIPTION, SITE_TITLE } from '../lib/shared/constants'; createPage( function HomePage(): React.JSX.Element { return ( - + {SITE_TITLE}}>
Welcome to my page!
@@ -28,7 +28,7 @@ createPage( Thank you for visiting!
-
+ ); }, { title: SITE_TITLE, description: SITE_DESCRIPTION } diff --git a/services/www/src/components/Footer/Footer.tsx b/services/www/src/components/Footer/Footer.tsx new file mode 100644 index 0000000..a3d240f --- /dev/null +++ b/services/www/src/components/Footer/Footer.tsx @@ -0,0 +1,42 @@ +import { Container, HStack, Link, ThemeSwitch, VStack, Text } from '@ermnvldmr/ui'; +import React from 'react'; + +import { RainbowSeparator } from '../RainbowSeparator'; + +/** + /** + * The standard footer for the www service. + * Displays copyright information, license, and theme switcher. + * + * @returns A Footer component. + * @example + * ```tsx + *