Compare commits

..

19 Commits

Author SHA1 Message Date
3c7534ef11 Update dependency react-router-dom to v7.13.1
Some checks failed
continuous-integration/drone/pr Build is failing
2026-02-24 10:51:25 +00:00
4fc3738adf add unit tests
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 11:33:37 +01:00
e17c82de2f fix eslint ver
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 11:23:41 +01:00
a527ce27cd add code analysis
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 11:21:26 +01:00
623e45d241 update eslint
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 11:15:26 +01:00
5593746cf4 remove unused css
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 10:43:22 +01:00
b163fdaa62 update styles, v0.1.17
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-02-24 10:37:36 +01:00
ec63a10027 update ci
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-02-24 10:14:08 +01:00
5ada69773c update ci, datepicker, v0.1.16
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 10:06:41 +01:00
370d6e7e0a v0.1.15
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-02-23 23:53:32 +01:00
44dd5d5deb v0.1.14
Some checks failed
continuous-integration/drone/push Build encountered an error
continuous-integration/drone/tag Build encountered an error
2026-02-23 23:51:42 +01:00
8d3ca5a281 fix ci
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-02-23 23:51:16 +01:00
1d5113d209 fix datepicker css, v0.1.13
Some checks failed
continuous-integration/drone/tag Build is failing
continuous-integration/drone/push Build is passing
2026-02-23 23:48:58 +01:00
f71e773a3a update drone
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 23:34:04 +01:00
4921afe296 fix date picker highlighting, v0.1.12
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 23:29:06 +01:00
5cc3e3646c make accent overridable, v0.1.11
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 22:59:57 +01:00
29a4e8c2ee add width, v0.1.10
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 19:57:19 +01:00
3ddd108186 add datepicker, v0.1.9
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 19:21:38 +01:00
836d24943e update eslint
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 15:18:13 +01:00
32 changed files with 1976 additions and 248 deletions

View File

@@ -13,48 +13,65 @@ trigger:
steps: steps:
- name: install - name: install
image: node:22 image: node:25
commands: commands:
- corepack enable
- corepack prepare yarn@1.22.22 --activate
- yarn install --frozen-lockfile - yarn install --frozen-lockfile
- name: lint - name: lint
image: node:22 image: node:25
commands: commands:
- corepack enable
- corepack prepare yarn@1.22.22 --activate
- yarn lint - yarn lint
- name: build - name: build
image: node:22 image: node:25
commands: commands:
- corepack enable
- corepack prepare yarn@1.22.22 --activate
- yarn build - yarn build
- name: unit-tests
image: node:25
environment:
NODE_OPTIONS: --no-webstorage
commands:
- yarn test:coverage
- test -f coverage/lcov.info
- name: code-analysis
when:
event:
- push
image: sonarsource/sonar-scanner-cli:latest
commands:
- |
test -f coverage/lcov.info
SONAR_ARGS="-Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.host.url=$SONAR_INSTANCE_URL -Dsonar.token=$SONAR_LOGIN_KEY -Dsonar.sources=src -Dsonar.tests=tests -Dsonar.test.inclusions=tests/**/*.{test,spec}.{ts,tsx,js,jsx} -Dsonar.javascript.lcov.reportPaths=coverage/lcov.info -Dsonar.working.directory=/tmp/.scannerwork"
sonar-scanner $SONAR_ARGS
environment:
SONAR_USER_HOME: /tmp/.sonar
SONAR_PROJECT_KEY:
from_secret: sonar_project_key
SONAR_INSTANCE_URL:
from_secret: sonar_instance_url
SONAR_LOGIN_KEY:
from_secret: sonar_login_key
--- ---
kind: pipeline kind: pipeline
type: docker type: docker
name: web-ui-publish name: web-ui-publish
trigger: trigger:
branch:
- main
event: event:
- promote - tag
target: ref:
- production - refs/tags/v*
steps: steps:
- name: publish-npm - name: publish-npm
image: node:22 image: node:25
environment: environment:
NEXUS_NPM_TOKEN: NEXUS_NPM_TOKEN:
from_secret: nexus_npm_token from_secret: nexus_npm_token
commands: commands:
- corepack enable
- corepack prepare yarn@1.22.22 --activate
- yarn install --frozen-lockfile - yarn install --frozen-lockfile
- npm config set //nexus.beatrice.wtf/repository/npm-hosted/:_authToken "$NEXUS_NPM_TOKEN" - npm config set //nexus.beatrice.wtf/repository/npm-hosted/:_authToken "$NEXUS_NPM_TOKEN"
- yarn publish:nexus - yarn publish:nexus

View File

@@ -45,4 +45,14 @@ export default tseslint.config(
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
}, },
}, },
{
files: ['tests/**/*.{ts,tsx}'],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
...globals.vitest,
},
},
},
); );

View File

@@ -1,6 +1,6 @@
{ {
"name": "@panic/web-ui", "name": "@panic/web-ui",
"version": "0.1.8", "version": "0.1.17",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"description": "Core components for panic.haus web applications", "description": "Core components for panic.haus web applications",
"type": "module", "type": "module",
@@ -27,6 +27,9 @@
"scripts": { "scripts": {
"clean": "rm -rf dist", "clean": "rm -rf dist",
"build": "yarn clean && vite build && tsc -p tsconfig.build.json && mkdir -p dist/styles && cp src/styles/base.css dist/styles/base.css && tailwindcss -c tailwind.build.config.cjs -i src/styles/components.css -o dist/styles/components.css --minify && tailwindcss -c tailwind.build.config.cjs -i src/styles/utilities.css -o dist/styles/utilities.css --minify && cp tailwind-preset.cjs dist/tailwind-preset.cjs", "build": "yarn clean && vite build && tsc -p tsconfig.build.json && mkdir -p dist/styles && cp src/styles/base.css dist/styles/base.css && tailwindcss -c tailwind.build.config.cjs -i src/styles/components.css -o dist/styles/components.css --minify && tailwindcss -c tailwind.build.config.cjs -i src/styles/utilities.css -o dist/styles/utilities.css --minify && cp tailwind-preset.cjs dist/tailwind-preset.cjs",
"test": "vitest run",
"test:coverage": "vitest run --coverage --coverage.reporter=lcov --coverage.reporter=text-summary",
"test:watch": "vitest",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"format": "prettier . --write", "format": "prettier . --write",
@@ -54,7 +57,7 @@
}, },
"devDependencies": { "devDependencies": {
"@codemirror/language": "^6.11.3", "@codemirror/language": "^6.11.3",
"@eslint/js": "^9", "@eslint/js": "^10",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@lezer/highlight": "^1.2.1", "@lezer/highlight": "^1.2.1",
"@mdxeditor/editor": "^3.52.4", "@mdxeditor/editor": "^3.52.4",
@@ -64,13 +67,17 @@
"@storybook/react": "^10.2.10", "@storybook/react": "^10.2.10",
"@storybook/react-vite": "^10.2.10", "@storybook/react-vite": "^10.2.10",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"eslint": "^9", "@vitest/coverage-v8": "^4.0.18",
"eslint-plugin-react-hooks": "^7.0.1", "eslint": "^10",
"eslint-plugin-react-hooks": "^7.1.0-canary-ab18f33d-20260220",
"eslint-plugin-react-refresh": "^0.5.1", "eslint-plugin-react-refresh": "^0.5.1",
"globals": "^17.3.0", "globals": "^17.3.0",
"jsdom": "^28.1.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"react": "^19.0.0", "react": "^19.0.0",
@@ -81,6 +88,7 @@
"typescript": "^5.6.2", "typescript": "^5.6.2",
"typescript-eslint": "^8.56.0", "typescript-eslint": "^8.56.0",
"vite": "^7.0.0", "vite": "^7.0.0",
"vitest": "^4.0.18",
"yjs": "^13.6.24" "yjs": "^13.6.24"
} }
} }

View File

@@ -39,6 +39,12 @@ const meta = {
control: 'inline-radio', control: 'inline-radio',
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } }, table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } },
}, },
width: {
description: 'Button width behavior.',
options: ['sm', 'md', 'lg', 'full'],
control: 'inline-radio',
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } },
},
to: { to: {
description: 'Navigation path. When set, the component renders a `<Link>`.', description: 'Navigation path. When set, the component renders a `<Link>`.',
control: 'text', control: 'text',
@@ -80,6 +86,7 @@ const meta = {
type: 'solid', type: 'solid',
variant: 'primary', variant: 'primary',
size: 'md', size: 'md',
width: 'md',
label: 'Save', label: 'Save',
}, },
} satisfies Meta<typeof Button>; } satisfies Meta<typeof Button>;
@@ -132,7 +139,7 @@ export const SizeMatrix: Story = {
<Button {...args} size="sm" label="Small" /> <Button {...args} size="sm" label="Small" />
<Button {...args} size="md" label="Medium" /> <Button {...args} size="md" label="Medium" />
<Button {...args} size="lg" label="Large" /> <Button {...args} size="lg" label="Large" />
<Button {...args} size="full" label="Full width" /> <Button {...args} size="md" width="full" label="Full width" />
</div> </div>
), ),
}; };

View File

@@ -11,6 +11,7 @@ type ButtonProps = {
type: ButtonType; type: ButtonType;
variant?: ButtonVariant; variant?: ButtonVariant;
size?: ComponentSize; size?: ComponentSize;
width?: ComponentSize;
to?: string; to?: string;
htmlType?: NativeButtonType; htmlType?: NativeButtonType;
onClick?: MouseEventHandler<HTMLElement>; onClick?: MouseEventHandler<HTMLElement>;
@@ -24,14 +25,14 @@ const SIZE_CLASS: Record<ComponentSize, string> = {
sm: 'h-8 px-3 text-xs', sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4 text-sm', md: 'h-10 px-4 text-sm',
lg: 'h-12 px-5 text-base', lg: 'h-12 px-5 text-base',
full: 'h-10 w-full px-4 text-sm', full: 'h-10 px-4 text-sm',
}; };
const ICON_ONLY_SIZE_CLASS: Record<ComponentSize, string> = { const ICON_ONLY_SIZE_CLASS: Record<ComponentSize, string> = {
sm: 'h-8 w-8 !p-0', sm: 'h-8 w-8 !p-0',
md: 'h-10 w-10 !p-0', md: 'h-10 w-10 !p-0',
lg: 'h-12 w-12 !p-0', lg: 'h-12 w-12 !p-0',
full: 'h-10 w-full !p-0', full: 'h-10 w-10 !p-0',
}; };
const ICON_CLASS: Record<ComponentSize, string> = { const ICON_CLASS: Record<ComponentSize, string> = {
@@ -48,6 +49,13 @@ const ICON_ONLY_CLASS: Record<ComponentSize, string> = {
full: 'h-5 w-5', full: 'h-5 w-5',
}; };
const WIDTH_CLASS: Record<ComponentSize, string> = {
sm: 'max-w-xs',
md: '',
lg: 'max-w-md',
full: 'w-full max-w-none',
};
const TYPE_CLASS: Record<ButtonType, string> = { const TYPE_CLASS: Record<ButtonType, string> = {
solid: 'btn-solid', solid: 'btn-solid',
outlined: 'btn-outlined', outlined: 'btn-outlined',
@@ -73,6 +81,7 @@ export function Button({
type, type,
variant, variant,
size = 'md', size = 'md',
width = 'md',
to, to,
htmlType = 'button', htmlType = 'button',
onClick, onClick,
@@ -87,6 +96,7 @@ export function Button({
TYPE_CLASS[type], TYPE_CLASS[type],
VARIANT_CLASS[resolvedVariant], VARIANT_CLASS[resolvedVariant],
isIconOnly ? ICON_ONLY_SIZE_CLASS[size] : SIZE_CLASS[size], isIconOnly ? ICON_ONLY_SIZE_CLASS[size] : SIZE_CLASS[size],
WIDTH_CLASS[width],
Icon && label ? 'gap-1.5' : '', Icon && label ? 'gap-1.5' : '',
disabled ? 'pointer-events-none cursor-not-allowed opacity-45 saturate-50' : '', disabled ? 'pointer-events-none cursor-not-allowed opacity-45 saturate-50' : '',
className, className,

View File

@@ -0,0 +1,240 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { CalendarDaysIcon } from '@heroicons/react/24/solid';
import { DatePicker } from './DatePicker';
const meta = {
title: 'Components/DatePicker',
component: DatePicker,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Date selection field with InputField-compatible API, supporting date/time/datetime-local values, size/layout variants, and validation state.',
},
},
},
argTypes: {
label: {
description: 'Label text shown above (stacked) or on the left (inline).',
control: 'text',
table: { type: { summary: 'string' } },
},
placeholder: {
description: 'Input placeholder text.',
control: 'text',
table: { type: { summary: 'string' } },
},
type: {
description: 'Native date input type.',
options: ['date', 'datetime-local', 'time'],
control: 'inline-radio',
table: { type: { summary: "'date' | 'datetime-local' | 'time'" } },
},
size: {
description: 'Input size.',
options: ['sm', 'md', 'lg', 'full'],
control: 'inline-radio',
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } },
},
width: {
description: 'Input width constraint.',
options: ['sm', 'md', 'lg', 'full'],
control: 'inline-radio',
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } },
},
layout: {
description: 'Label/input layout mode.',
options: ['stacked', 'inline'],
control: 'inline-radio',
table: { type: { summary: "'stacked' | 'inline'" } },
},
value: {
description: 'Controlled input value.',
control: 'text',
table: { type: { summary: 'string' } },
},
name: {
description: 'Native input `name` attribute.',
control: 'text',
table: { type: { summary: 'string' } },
},
disabled: {
description: 'Disables the input.',
control: 'boolean',
table: { type: { summary: 'boolean' } },
},
required: {
description: 'Sets the native HTML `required` attribute.',
control: 'boolean',
table: { type: { summary: 'boolean' } },
},
error: {
description: 'Validation message shown below the field.',
control: 'text',
table: { type: { summary: 'string' } },
},
rightIcon: {
description: 'Optional trailing icon node.',
control: false,
table: { type: { summary: 'ReactNode' } },
},
className: {
description: 'Extra CSS classes for the outer wrapper.',
control: 'text',
table: { type: { summary: 'string' } },
},
inputClassName: {
description: 'Extra CSS classes for the `<input>` element.',
control: 'text',
table: { type: { summary: 'string' } },
},
onChange: {
description: 'Change handler callback.',
action: 'changed',
table: { type: { summary: 'ChangeEventHandler<HTMLInputElement>' } },
},
onBlur: {
description: 'Blur handler callback.',
control: false,
table: { type: { summary: 'FocusEventHandler<HTMLInputElement>' } },
},
inputRef: {
description: 'Ref forwarded to the native `<input>` element.',
control: false,
table: { type: { summary: 'Ref<HTMLInputElement>' } },
},
},
args: {
label: 'Schedule at',
type: 'datetime-local',
value: '',
size: 'md',
width: 'md',
layout: 'stacked',
},
} satisfies Meta<typeof DatePicker>;
export default meta;
type Story = StoryObj<typeof meta>;
export const DateOnly: Story = {
args: {
type: 'date',
label: 'Publish date',
},
render: (args) => {
const [value, setValue] = useState('2031-05-20');
return (
<DatePicker
{...args}
value={value}
onChange={(event) => {
setValue(event.target.value);
args.onChange?.(event);
}}
/>
);
},
};
export const DateTime: Story = {
args: {
type: 'datetime-local',
label: 'Schedule at',
},
render: (args) => {
const [value, setValue] = useState('2031-05-20T14:30');
return (
<DatePicker
{...args}
value={value}
onChange={(event) => {
setValue(event.target.value);
args.onChange?.(event);
}}
/>
);
},
};
export const TimeOnlyInline: Story = {
args: {
type: 'time',
label: 'Start time',
layout: 'inline',
size: 'sm',
rightIcon: <CalendarDaysIcon className="h-4 w-4 ui-body-secondary" />,
},
render: (args) => {
const [value, setValue] = useState('09:00');
return (
<DatePicker
{...args}
value={value}
onChange={(event) => {
setValue(event.target.value);
args.onChange?.(event);
}}
/>
);
},
};
export const Error: Story = {
args: {
type: 'datetime-local',
label: 'Schedule at',
value: '',
error: 'Pick a valid future date and time',
},
};
export const Disabled: Story = {
args: {
type: 'datetime-local',
label: 'Published at',
value: '2031-05-20T14:30',
disabled: true,
},
};
export const SizeMatrix: Story = {
args: {
type: 'datetime-local',
label: 'Schedule at',
},
render: (args) => {
const [value, setValue] = useState('2031-05-20T14:30');
return (
<div className="grid grid-cols-1 gap-3">
<DatePicker
{...args}
value={value}
size="sm"
onChange={(event) => setValue(event.target.value)}
/>
<DatePicker
{...args}
value={value}
size="md"
onChange={(event) => setValue(event.target.value)}
/>
<DatePicker
{...args}
value={value}
size="lg"
onChange={(event) => setValue(event.target.value)}
/>
<DatePicker
{...args}
value={value}
size="full"
width="full"
onChange={(event) => setValue(event.target.value)}
/>
</div>
);
},
};

View File

@@ -0,0 +1,97 @@
import type { ChangeEventHandler, FocusEventHandler, ReactNode, Ref } from 'react';
import type { ComponentSize } from './types';
type DatePickerKind = 'date' | 'datetime-local' | 'time';
type Layout = 'stacked' | 'inline';
export type DatePickerProps = {
label?: string;
placeholder?: string;
type: DatePickerKind;
size?: ComponentSize;
width?: ComponentSize;
layout?: Layout;
value: string;
name?: string;
onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
inputRef?: Ref<HTMLInputElement>;
disabled?: boolean;
required?: boolean;
error?: string;
rightIcon?: ReactNode;
className?: string;
inputClassName?: string;
};
export function DatePicker({
label,
placeholder = '',
type,
size = 'md',
width = 'md',
layout = 'stacked',
value,
name,
onChange,
onBlur,
inputRef,
disabled = false,
required = false,
error,
rightIcon,
className = '',
inputClassName = '',
}: Readonly<DatePickerProps>) {
const containerWidthClass = {
sm: 'max-w-xs',
md: 'max-w-sm',
lg: 'max-w-md',
full: 'max-w-none',
}[width];
const inputSizeClass = {
sm: 'h-8 !text-xs',
md: 'h-10 text-sm',
lg: 'h-12 text-sm',
full: 'h-10 text-sm',
}[size];
const wrapperClass =
layout === 'inline' ? 'inline-flex w-auto items-center gap-2' : 'block w-full gap-1';
const labelClass = layout === 'inline' ? 'text-xs ui-body-secondary' : '';
const hasTrailingIcon = Boolean(rightIcon);
const inputWrapperClass = layout === 'inline' ? 'relative' : 'relative mt-1';
return (
<label
className={`${wrapperClass} text-sm font-medium ${disabled ? 'ui-label-disabled' : 'ui-label'} ${containerWidthClass} ${className}`.trim()}
>
{label ? <span className={labelClass}>{label}</span> : null}
<div className={inputWrapperClass}>
<input
type={type}
value={value}
name={name}
onChange={onChange}
onBlur={onBlur}
ref={inputRef}
placeholder={placeholder}
disabled={disabled}
required={required}
className={`field w-full ${hasTrailingIcon ? 'pr-10' : ''} ${inputSizeClass} ${error ? 'border-red-400/70 focus:border-red-400 focus:ring-red-400/30' : ''} ${inputClassName}`.trim()}
/>
{rightIcon ? (
<span className="pointer-events-none absolute inset-y-0 right-2 inline-flex items-center justify-center px-1">
{rightIcon}
</span>
) : null}
</div>
{error ? (
<span className="mt-1 block text-xs" style={{ color: 'var(--error-text)' }}>
{error}
</span>
) : null}
</label>
);
}

View File

@@ -42,6 +42,12 @@ const meta = {
control: 'inline-radio', control: 'inline-radio',
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } }, table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } },
}, },
width: {
description: 'Control width constraint.',
options: ['sm', 'md', 'lg', 'full'],
control: 'inline-radio',
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } },
},
layout: { layout: {
description: 'Label/control layout mode.', description: 'Label/control layout mode.',
options: ['stacked', 'inline'], options: ['stacked', 'inline'],
@@ -84,6 +90,7 @@ const meta = {
value: 'draft', value: 'draft',
choices, choices,
size: 'md', size: 'md',
width: 'md',
layout: 'stacked', layout: 'stacked',
}, },
} satisfies Meta<typeof Dropdown>; } satisfies Meta<typeof Dropdown>;
@@ -147,7 +154,14 @@ export const SizeMatrix: Story = {
<Dropdown {...args} value={value} size="sm" label="Small" onChange={setValue} /> <Dropdown {...args} value={value} size="sm" label="Small" onChange={setValue} />
<Dropdown {...args} value={value} size="md" label="Medium" onChange={setValue} /> <Dropdown {...args} value={value} size="md" label="Medium" onChange={setValue} />
<Dropdown {...args} value={value} size="lg" label="Large" onChange={setValue} /> <Dropdown {...args} value={value} size="lg" label="Large" onChange={setValue} />
<Dropdown {...args} value={value} size="full" label="Full" onChange={setValue} /> <Dropdown
{...args}
value={value}
size="full"
width="full"
label="Full"
onChange={setValue}
/>
</div> </div>
); );
}, },

View File

@@ -14,6 +14,7 @@ type DropdownProps = {
value: string; value: string;
choices: DropdownChoice[]; choices: DropdownChoice[];
size?: ComponentSize; size?: ComponentSize;
width?: ComponentSize;
layout?: DropdownLayout; layout?: DropdownLayout;
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
@@ -28,6 +29,7 @@ export function Dropdown({
value, value,
choices, choices,
size = 'md', size = 'md',
width = 'md',
layout = 'stacked', layout = 'stacked',
disabled = false, disabled = false,
required = false, required = false,
@@ -36,12 +38,12 @@ export function Dropdown({
className = '', className = '',
selectClassName = '', selectClassName = '',
}: Readonly<DropdownProps>) { }: Readonly<DropdownProps>) {
const containerSizeClass = { const containerWidthClass = {
sm: 'max-w-xs', sm: 'max-w-xs',
md: 'max-w-sm', md: 'max-w-sm',
lg: 'max-w-md', lg: 'max-w-md',
full: 'max-w-none', full: 'max-w-none',
}[size]; }[width];
const selectSizeClass = { const selectSizeClass = {
sm: 'h-8 !text-xs', sm: 'h-8 !text-xs',
@@ -62,7 +64,7 @@ export function Dropdown({
return ( return (
<label <label
className={`${wrapperClass} text-sm font-medium ${disabled ? 'ui-label-disabled' : 'ui-label'} ${containerSizeClass} ${className}`.trim()} className={`${wrapperClass} text-sm font-medium ${disabled ? 'ui-label-disabled' : 'ui-label'} ${containerWidthClass} ${className}`.trim()}
> >
{label ? <span className={labelClass}>{label}</span> : null} {label ? <span className={labelClass}>{label}</span> : null}
<div className={selectWrapperClass}> <div className={selectWrapperClass}>

View File

@@ -80,6 +80,7 @@ export const WithActions: Story = {
value={title} value={title}
onChange={(event) => setTitle(event.target.value)} onChange={(event) => setTitle(event.target.value)}
size="full" size="full"
width="full"
/> />
</div> </div>
<Dropdown <Dropdown

View File

@@ -38,6 +38,12 @@ const meta = {
control: 'inline-radio', control: 'inline-radio',
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } }, table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } },
}, },
width: {
description: 'Input width constraint.',
options: ['sm', 'md', 'lg', 'full'],
control: 'inline-radio',
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } },
},
layout: { layout: {
description: 'Label/input layout mode.', description: 'Label/input layout mode.',
options: ['stacked', 'inline'], options: ['stacked', 'inline'],
@@ -107,6 +113,7 @@ const meta = {
placeholder: 'name@example.com', placeholder: 'name@example.com',
value: '', value: '',
size: 'md', size: 'md',
width: 'md',
layout: 'stacked', layout: 'stacked',
}, },
} satisfies Meta<typeof InputField>; } satisfies Meta<typeof InputField>;
@@ -229,6 +236,7 @@ export const SizeMatrix: Story = {
{...args} {...args}
value={value} value={value}
size="full" size="full"
width="full"
onChange={(event) => setValue(event.target.value)} onChange={(event) => setValue(event.target.value)}
/> />
</div> </div>

View File

@@ -12,6 +12,7 @@ type InputFieldProps = {
placeholder?: string; placeholder?: string;
type: InputKind; type: InputKind;
size?: ComponentSize; size?: ComponentSize;
width?: ComponentSize;
layout?: Layout; layout?: Layout;
value: string; value: string;
name?: string; name?: string;
@@ -31,6 +32,7 @@ export function InputField({
placeholder = '', placeholder = '',
type, type,
size = 'md', size = 'md',
width = 'md',
layout = 'stacked', layout = 'stacked',
value, value,
name, name,
@@ -45,12 +47,12 @@ export function InputField({
inputClassName = '', inputClassName = '',
}: Readonly<InputFieldProps>) { }: Readonly<InputFieldProps>) {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const containerSizeClass = { const containerWidthClass = {
sm: 'max-w-xs', sm: 'max-w-xs',
md: 'max-w-sm', md: 'max-w-sm',
lg: 'max-w-md', lg: 'max-w-md',
full: 'max-w-none', full: 'max-w-none',
}[size]; }[width];
const inputSizeClass = { const inputSizeClass = {
sm: 'h-8 !text-xs', sm: 'h-8 !text-xs',
@@ -69,7 +71,7 @@ export function InputField({
return ( return (
<label <label
className={`${wrapperClass} text-sm font-medium ${disabled ? 'ui-label-disabled' : 'ui-label'} ${containerSizeClass} ${className}`.trim()} className={`${wrapperClass} text-sm font-medium ${disabled ? 'ui-label-disabled' : 'ui-label'} ${containerWidthClass} ${className}`.trim()}
> >
{label ? <span className={labelClass}>{label}</span> : null} {label ? <span className={labelClass}>{label}</span> : null}
<div className={inputWrapperClass}> <div className={inputWrapperClass}>

View File

@@ -28,7 +28,7 @@ export function SidebarNavItem({
onClick={onClick} onClick={onClick}
className={({ isActive }) => className={({ isActive }) =>
`inline-flex h-8 items-center rounded-lg text-sm font-medium transition ${layoutClass} ${ `inline-flex h-8 items-center rounded-lg text-sm font-medium transition ${layoutClass} ${
isActive ? 'bg-accent-500 text-white' : 'ui-body-secondary hover:bg-zinc-500/15' isActive ? 'ui-accent-active' : 'ui-body-secondary hover:bg-zinc-500/15'
}` }`
} }
> >

View File

@@ -1,5 +1,6 @@
export { Button } from './components/Button'; export { Button } from './components/Button';
export { Chip } from './components/Chip'; export { Chip } from './components/Chip';
export { DatePicker } from './components/DatePicker';
export { Dropdown } from './components/Dropdown'; export { Dropdown } from './components/Dropdown';
export { Form } from './components/Form'; export { Form } from './components/Form';
export { InputField } from './components/InputField'; export { InputField } from './components/InputField';
@@ -8,5 +9,6 @@ export { SidebarNavItem } from './components/SidebarNavItem';
export { Table } from './components/Table'; export { Table } from './components/Table';
export type { TableHeader } from './components/Table'; export type { TableHeader } from './components/Table';
export type { DatePickerProps } from './components/DatePicker';
export type { ComponentSize } from './components/types'; export type { ComponentSize } from './components/types';
export type { SortDirection, SortState } from './types/sort'; export type { SortDirection, SortState } from './types/sort';

View File

@@ -1,6 +1,12 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
:root { :root {
/* Consumer projects can override these accent tokens after importing @panic/web-ui styles. */
--accent-300: 168 155 191;
--accent-400: 149 135 173;
--accent-500: 125 111 152;
--accent-600: 106 93 132;
--accent-contrast: 255 255 255;
--bg-page: #16121a; --bg-page: #16121a;
--surface-bg: rgba(24, 24, 27, 0.45); --surface-bg: rgba(24, 24, 27, 0.45);
--surface-bg-strong: rgba(24, 24, 27, 0.62); --surface-bg-strong: rgba(24, 24, 27, 0.62);
@@ -16,6 +22,8 @@
--field-disabled-border: #3f3f46; --field-disabled-border: #3f3f46;
--field-disabled-text: #bbb6c3; --field-disabled-text: #bbb6c3;
--field-disabled-placeholder: #71717a; --field-disabled-placeholder: #71717a;
--field-selection-bg: rgb(var(--accent-500) / 0.42);
--field-selection-text: var(--text-primary);
--ghost-bg: rgba(24, 24, 27, 0.5); --ghost-bg: rgba(24, 24, 27, 0.5);
--ghost-border: #3f3f46; --ghost-border: #3f3f46;
--ghost-hover: rgba(39, 39, 42, 0.7); --ghost-hover: rgba(39, 39, 42, 0.7);
@@ -33,8 +41,8 @@
--error-border: rgba(252, 165, 165, 0.3); --error-border: rgba(252, 165, 165, 0.3);
--error-bg: rgba(239, 68, 68, 0.1); --error-bg: rgba(239, 68, 68, 0.1);
--error-text: #fecaca; --error-text: #fecaca;
--mdx-link: #7d6f98; --mdx-link: rgb(var(--accent-500));
--mdx-link-hover: #9587ad; --mdx-link-hover: rgb(var(--accent-400));
--mdx-inline-code-bg: #27272a; --mdx-inline-code-bg: #27272a;
--mdx-inline-code-border: #3f3f46; --mdx-inline-code-border: #3f3f46;
--mdx-codeblock-bg: #18181b; --mdx-codeblock-bg: #18181b;
@@ -42,8 +50,8 @@
--mdx-codeblock-text: #e4e4e7; --mdx-codeblock-text: #e4e4e7;
--mdx-codeblock-gutter: #a1a1aa; --mdx-codeblock-gutter: #a1a1aa;
--mdx-codeblock-active: #27272a; --mdx-codeblock-active: #27272a;
--mdx-codeblock-selection: rgba(125, 111, 152, 0.35); --mdx-codeblock-selection: rgb(var(--accent-500) / 0.35);
--mdx-codeblock-bracket: rgba(125, 111, 152, 0.45); --mdx-codeblock-bracket: rgb(var(--accent-500) / 0.45);
--shadow-glow: 0 0 0 1px rgba(63, 63, 70, 0.65), 0 18px 44px rgba(0, 0, 0, 0.45); --shadow-glow: 0 0 0 1px rgba(63, 63, 70, 0.65), 0 18px 44px rgba(0, 0, 0, 0.45);
} }
@@ -63,6 +71,8 @@
--field-disabled-border: #d7d7d7; --field-disabled-border: #d7d7d7;
--field-disabled-text: #71717a; --field-disabled-text: #71717a;
--field-disabled-placeholder: #a1a1aa; --field-disabled-placeholder: #a1a1aa;
--field-selection-bg: rgb(var(--accent-500) / 0.24);
--field-selection-text: var(--text-primary);
--ghost-bg: rgba(255, 255, 255, 0.88); --ghost-bg: rgba(255, 255, 255, 0.88);
--ghost-border: #d4d4d8; --ghost-border: #d4d4d8;
--ghost-hover: #f4f4f5; --ghost-hover: #f4f4f5;
@@ -78,8 +88,8 @@
--error-border: rgba(248, 113, 113, 0.35); --error-border: rgba(248, 113, 113, 0.35);
--error-bg: rgba(254, 226, 226, 0.8); --error-bg: rgba(254, 226, 226, 0.8);
--error-text: #991b1b; --error-text: #991b1b;
--mdx-link: #7d6f98; --mdx-link: rgb(var(--accent-500));
--mdx-link-hover: #6a5d84; --mdx-link-hover: rgb(var(--accent-600));
--mdx-inline-code-bg: #f4f4f5; --mdx-inline-code-bg: #f4f4f5;
--mdx-inline-code-border: #d4d4d8; --mdx-inline-code-border: #d4d4d8;
--mdx-codeblock-bg: #ffffff; --mdx-codeblock-bg: #ffffff;
@@ -87,7 +97,7 @@
--mdx-codeblock-text: #18181b; --mdx-codeblock-text: #18181b;
--mdx-codeblock-gutter: #71717a; --mdx-codeblock-gutter: #71717a;
--mdx-codeblock-active: #f4f4f5; --mdx-codeblock-active: #f4f4f5;
--mdx-codeblock-selection: rgba(125, 111, 152, 0.22); --mdx-codeblock-selection: rgb(var(--accent-500) / 0.22);
--mdx-codeblock-bracket: rgba(125, 111, 152, 0.32); --mdx-codeblock-bracket: rgb(var(--accent-500) / 0.32);
--shadow-glow: 0 0 0 1px rgba(212, 212, 216, 0.9), 0 18px 36px rgba(15, 23, 42, 0.08); --shadow-glow: 0 0 0 1px rgba(212, 212, 216, 0.9), 0 18px 36px rgba(15, 23, 42, 0.08);
} }

View File

@@ -12,13 +12,24 @@
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
-webkit-text-fill-color: var(--text-primary); -webkit-text-fill-color: var(--text-primary);
@apply w-full rounded-xl px-3 py-2 text-sm outline-none transition focus:border-accent-400 focus:ring-2 focus:ring-accent-400/30; @apply w-full rounded-xl px-3 py-2 text-sm outline-none transition;
}
.field:focus {
border-color: rgb(var(--accent-400));
box-shadow: 0 0 0 2px rgb(var(--accent-400) / 0.3);
} }
.field::placeholder { .field::placeholder {
color: var(--text-soft); color: var(--text-soft);
} }
.field::selection {
background-color: var(--field-selection-bg);
color: var(--field-selection-text);
-webkit-text-fill-color: var(--field-selection-text);
}
.field:disabled { .field:disabled {
border-color: var(--field-disabled-border); border-color: var(--field-disabled-border);
background-color: var(--field-disabled-bg); background-color: var(--field-disabled-bg);
@@ -55,7 +66,13 @@
} }
.btn-solid.btn-primary { .btn-solid.btn-primary {
@apply bg-accent-500 text-white hover:bg-accent-400 disabled:opacity-100; background-color: rgb(var(--accent-500));
color: rgb(var(--accent-contrast));
@apply disabled:opacity-100;
}
.btn-solid.btn-primary:hover {
background-color: rgb(var(--accent-400));
} }
.btn-solid.btn-primary:disabled { .btn-solid.btn-primary:disabled {
@@ -107,16 +124,19 @@
} }
.btn-outlined.btn-primary { .btn-outlined.btn-primary {
@apply border-accent-500 text-accent-300; border-color: rgb(var(--accent-500));
color: rgb(var(--accent-300));
background-color: transparent; background-color: transparent;
} }
.btn-outlined.btn-primary:hover { .btn-outlined.btn-primary:hover {
@apply bg-accent-500/15 text-accent-300; background-color: rgb(var(--accent-500) / 0.15);
color: rgb(var(--accent-300));
} }
.btn-outlined.btn-primary:disabled { .btn-outlined.btn-primary:disabled {
@apply border-accent-500/40 text-accent-300/60; border-color: rgb(var(--accent-500) / 0.4);
color: rgb(var(--accent-300) / 0.6);
background-color: transparent; background-color: transparent;
} }
@@ -149,16 +169,17 @@
} }
.btn-noborder.btn-primary { .btn-noborder.btn-primary {
@apply text-accent-300; color: rgb(var(--accent-300));
background-color: transparent; background-color: transparent;
} }
.btn-noborder.btn-primary:hover { .btn-noborder.btn-primary:hover {
@apply bg-accent-500/15 text-accent-300; background-color: rgb(var(--accent-500) / 0.15);
color: rgb(var(--accent-300));
} }
.btn-noborder.btn-primary:disabled { .btn-noborder.btn-primary:disabled {
@apply text-accent-300/60; color: rgb(var(--accent-300) / 0.6);
background-color: transparent; background-color: transparent;
} }
@@ -228,6 +249,15 @@
color: var(--error-text); color: var(--error-text);
} }
.ui-accent-active {
background-color: rgb(var(--accent-500));
color: rgb(var(--accent-contrast));
}
.ui-accent-active:hover {
background-color: rgb(var(--accent-400));
}
.chip-root { .chip-root {
@apply inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold leading-none; @apply inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold leading-none;
} }

View File

@@ -0,0 +1,71 @@
import { HomeIcon } from '@heroicons/react/24/solid';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Button } from '../../src/components/Button';
import { renderWithRouter } from '../helpers/renderWithRouter';
describe('Button', () => {
it('renders native button with expected type and disabled state', () => {
render(<Button label="Save" type="solid" htmlType="submit" disabled />);
const button = screen.getByRole('button', { name: 'Save' });
expect(button.tagName).toBe('BUTTON');
expect(button).toHaveAttribute('type', 'submit');
expect(button).toBeDisabled();
expect(button).toHaveClass('btn-solid');
expect(button).toHaveClass('btn-primary');
});
it('defaults non-solid button variants to secondary', () => {
render(<Button label="Details" type="noborder" />);
expect(screen.getByRole('button', { name: 'Details' })).toHaveClass('btn-secondary');
});
it('renders icon-only button and custom aria label', () => {
render(<Button type="solid" icon={HomeIcon} ariaLabel="Open home" />);
const button = screen.getByRole('button', { name: 'Open home' });
expect(button).toBeInTheDocument();
expect(button).toHaveClass('!p-0');
expect(button.textContent).toBe('');
});
it('renders link button and prevents click when disabled', () => {
const onClick = vi.fn();
renderWithRouter(
<Button label="Go home" type="outlined" to="/home" onClick={onClick} disabled />,
);
const link = screen.getByRole('link', { name: 'Go home' });
fireEvent.click(link);
expect(link).toHaveAttribute('aria-disabled', 'true');
expect(link).toHaveAttribute('tabindex', '-1');
expect(onClick).not.toHaveBeenCalled();
});
it('calls onClick for enabled link buttons', () => {
const onClick = vi.fn();
renderWithRouter(<Button label="Profile" type="solid" to="/profile" onClick={onClick} />);
fireEvent.click(screen.getByRole('link', { name: 'Profile' }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('renders icon+label spacing and custom class names', () => {
render(
<Button
label="Home"
type="outlined"
icon={HomeIcon}
className="custom-button"
width="full"
/>,
);
const button = screen.getByRole('button', { name: 'Home' });
expect(button).toHaveClass('gap-1.5');
expect(button).toHaveClass('custom-button');
expect(button).toHaveClass('w-full');
});
});

View File

@@ -0,0 +1,54 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Chip } from '../../src/components/Chip';
describe('Chip', () => {
it('renders default span with solid classes', () => {
render(<Chip>Default</Chip>);
const chip = screen.getByText('Default');
expect(chip.tagName).toBe('SPAN');
expect(chip.className).toContain('chip-solid');
});
it('supports outlined variant with valid tone', () => {
render(
<Chip as="div" variant="outlined" tone="indigo-700">
Custom
</Chip>,
);
const chip = screen.getByText('Custom');
expect(chip.tagName).toBe('DIV');
expect(chip.className).toContain('chip-outlined');
expect(chip).toHaveStyle({
borderColor: 'rgb(67, 56, 202)',
color: 'rgb(67, 56, 202)',
});
});
it('supports direct tone tokens without shades for solid variant', () => {
render(<Chip tone="white">Solid</Chip>);
expect(screen.getByText('Solid')).toHaveStyle({
borderColor: 'rgb(255, 255, 255)',
backgroundColor: 'rgb(255, 255, 255)',
color: 'rgb(255, 255, 255)',
});
});
it('ignores invalid/empty tones and keeps className', () => {
const { rerender } = render(
<Chip tone="not-a-token" className="chip-custom">
Invalid
</Chip>,
);
const invalid = screen.getByText('Invalid');
expect(invalid).toHaveClass('chip-custom');
expect(invalid.getAttribute('style')).toBeNull();
rerender(<Chip tone=" ">Blank</Chip>);
expect(screen.getByText('Blank').getAttribute('style')).toBeNull();
});
});

View File

@@ -0,0 +1,62 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { DatePicker } from '../../src/components/DatePicker';
describe('DatePicker', () => {
it('supports datetime-local type and change callback', () => {
const onChange = vi.fn();
render(<DatePicker label="Schedule" type="datetime-local" value="" onChange={onChange} />);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
expect(input.type).toBe('datetime-local');
fireEvent.change(input, { target: { value: '2031-05-20T14:30' } });
expect(onChange).toHaveBeenCalledTimes(1);
});
it('supports date type and disabled state', () => {
render(
<DatePicker
label="Publish date"
type="date"
value="2031-05-20"
onChange={() => {}}
disabled
/>,
);
const input = screen.getByLabelText('Publish date') as HTMLInputElement;
expect(input.type).toBe('date');
expect(input).toBeDisabled();
});
it('renders right icon and error message', () => {
const { container } = render(
<DatePicker
label="Schedule"
type="datetime-local"
value=""
onChange={() => {}}
rightIcon={<span data-testid="right-icon">R</span>}
error="Invalid date"
inputClassName="custom-input"
/>,
);
const input = container.querySelector('input');
expect(input).toBeInstanceOf(HTMLInputElement);
expect(screen.getByTestId('right-icon')).toBeInTheDocument();
expect(input).toHaveClass('pr-10');
expect(input).toHaveClass('custom-input');
expect(screen.getByText('Invalid date')).toBeInTheDocument();
});
it('supports inline layout', () => {
const { container } = render(
<DatePicker label="Start time" type="time" value="09:00" onChange={() => {}} layout="inline" />,
);
expect(container.querySelector('label')).toHaveClass('inline-flex');
expect(container.querySelector('label > div')).not.toHaveClass('mt-1');
});
});

View File

@@ -0,0 +1,55 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Dropdown } from '../../src/components/Dropdown';
const choices = [
{ id: 'USER', label: 'User' },
{ id: 'ADMIN', label: 'Admin' },
];
describe('Dropdown', () => {
it('calls onChange with selected value', () => {
const onChange = vi.fn();
render(<Dropdown label="Role" value="USER" choices={choices} onChange={onChange} />);
fireEvent.change(screen.getByLabelText('Role'), { target: { value: 'ADMIN' } });
expect(onChange).toHaveBeenCalledWith('ADMIN');
});
it('supports inline layout and disabled/required state', () => {
const { container } = render(
<Dropdown
label="Rows"
value="10"
choices={[{ id: '10', label: '10' }]}
layout="inline"
disabled
required
/>,
);
const select = screen.getByLabelText('Rows');
expect(select).toBeDisabled();
expect(select).toBeRequired();
expect(container.querySelector('label')).toHaveClass('inline-flex');
});
it('renders error and custom class names', () => {
const { container } = render(
<Dropdown
label="Role"
value="USER"
choices={choices}
error="Role is invalid"
className="custom-wrapper"
selectClassName="custom-select"
/>,
);
const select = container.querySelector('select');
expect(select).toBeInstanceOf(HTMLSelectElement);
expect(screen.getByText('Role is invalid')).toBeInTheDocument();
expect(select).toHaveClass('custom-select');
expect(screen.getByText('Role').closest('label')).toHaveClass('custom-wrapper');
});
});

View File

@@ -0,0 +1,28 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Form } from '../../src/components/Form';
describe('Form', () => {
it('renders title, title actions and children', () => {
render(
<Form title="User Details" titleBarRight={<button type="button">Action</button>}>
<div>Form child</div>
</Form>,
);
expect(screen.getByText('User Details')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
expect(screen.getByText('Form child')).toBeInTheDocument();
});
it('supports custom class names and optional title actions', () => {
const { container } = render(
<Form title="No Actions" className="form-custom">
<div>Child</div>
</Form>,
);
expect(container.firstElementChild).toHaveClass('form-custom');
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,92 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { createRef } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { InputField } from '../../src/components/InputField';
describe('InputField', () => {
it('supports email type and emits change/blur callbacks', () => {
const onChange = vi.fn();
const onBlur = vi.fn();
render(
<InputField
label="Email"
type="email"
value=""
onChange={onChange}
onBlur={onBlur}
required
/>,
);
const input = screen.getByLabelText('Email') as HTMLInputElement;
expect(input.type).toBe('email');
expect(input).toBeRequired();
fireEvent.change(input, { target: { value: 'new@example.com' } });
fireEvent.blur(input);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onBlur).toHaveBeenCalledTimes(1);
});
it('passes refs to input element', () => {
const inputRef = createRef<HTMLInputElement>();
render(<InputField label="Username" type="text" value="john" onChange={() => {}} inputRef={inputRef} />);
expect(inputRef.current).toBeInstanceOf(HTMLInputElement);
expect(inputRef.current?.value).toBe('john');
});
it('toggles password visibility', () => {
render(<InputField label="Password" type="password" value="abc" onChange={() => {}} />);
expect((screen.getByLabelText('Password') as HTMLInputElement).type).toBe('password');
fireEvent.click(screen.getByRole('button', { name: 'Show password' }));
expect((screen.getByLabelText('Password') as HTMLInputElement).type).toBe('text');
fireEvent.click(screen.getByRole('button', { name: 'Hide password' }));
expect((screen.getByLabelText('Password') as HTMLInputElement).type).toBe('password');
});
it('renders rightIcon for non-password input and displays errors', () => {
const { container } = render(
<InputField
label="Username"
type="text"
value="john"
onChange={() => {}}
rightIcon={<span data-testid="right-icon">R</span>}
error="Invalid username"
inputClassName="custom-input"
/>,
);
const input = container.querySelector('input');
expect(input).toBeInstanceOf(HTMLInputElement);
expect(input).toHaveClass('pr-10');
expect(input).toHaveClass('custom-input');
expect(screen.getByTestId('right-icon')).toBeInTheDocument();
expect(screen.getByText('Invalid username')).toBeInTheDocument();
});
it('disables password toggle when input is disabled', () => {
render(
<InputField
label="Password"
type="password"
value="secret"
onChange={() => {}}
disabled
/>,
);
expect(screen.getByRole('button', { name: 'Show password' })).toBeDisabled();
});
it('supports inline layout classes', () => {
const { container } = render(
<InputField label="Username" type="text" value="" onChange={() => {}} layout="inline" />,
);
expect(container.querySelector('label')).toHaveClass('inline-flex');
expect(container.querySelector('label > div')).not.toHaveClass('mt-1');
});
});

View File

@@ -0,0 +1,25 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Label } from '../../src/components/Label';
describe('Label', () => {
it('uses default and variant-specific tags/classes', () => {
const { rerender } = render(<Label>Body</Label>);
expect(screen.getByText('Body').tagName).toBe('P');
expect(screen.getByText('Body')).toHaveClass('ui-body-primary');
rerender(<Label variant="h1">Title</Label>);
expect(screen.getByText('Title').tagName).toBe('H1');
expect(screen.getByText('Title')).toHaveClass('ui-title');
rerender(<Label variant="h4">Section</Label>);
expect(screen.getByText('Section').tagName).toBe('H3');
rerender(
<Label variant="h2" as="span">
Custom
</Label>,
);
expect(screen.getByText('Custom').tagName).toBe('SPAN');
});
});

View File

@@ -0,0 +1,82 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { MDXEditorField } from '../../src/components/MDXEditorField';
describe('MDXEditorField', () => {
it('renders label and change handler in editable mode', () => {
const onChange = vi.fn();
render(
<MDXEditorField
label="Content"
markdown=""
onChange={onChange}
themeClassName="light-theme"
editorClassName="extra-editor"
plugins={[]}
/>,
);
expect(screen.getByText('Content')).toHaveClass('ui-label');
fireEvent.change(screen.getByLabelText('Markdown Editor'), {
target: { value: '# Hello' },
});
expect(onChange).toHaveBeenCalledWith('# Hello');
expect(screen.getByLabelText('Markdown Editor')).toHaveAttribute(
'data-class-name',
'light-theme extra-editor',
);
});
it('renders preview and disabled classes when disabled', () => {
render(
<MDXEditorField
label="Content"
markdown="Disabled content"
disabled
themeClassName="dark-theme"
plugins={[]}
/>,
);
const label = screen.getByText('Content');
expect(label).toHaveClass('ui-label-disabled');
expect(screen.getByTestId('md-preview')).toHaveTextContent('Disabled content');
expect(screen.getByTestId('md-preview')).toHaveAttribute('data-class-name', 'dark-theme');
expect(screen.queryByLabelText('Markdown Editor')).not.toBeInTheDocument();
expect(document.querySelector('.post-mdx-editor--disabled')).toBeTruthy();
});
it('renders read-only preview without label when label is omitted', () => {
render(
<MDXEditorField
markdown="Read only content"
readOnly
themeClassName="dark-theme"
plugins={[]}
/>,
);
expect(screen.queryByText('Content')).not.toBeInTheDocument();
expect(screen.getByTestId('md-preview')).toHaveTextContent('Read only content');
});
it('supports wrapper style/class overrides and error rendering', () => {
render(
<MDXEditorField
label="Content"
markdown=""
themeClassName="light-theme"
plugins={[]}
editorWrapperClassName="editor-wrapper"
editorWrapperStyle={{ borderWidth: '2px' }}
error="Content is required"
/>,
);
const wrapper = document.querySelector('.editor-wrapper');
expect(wrapper).toHaveClass('post-mdx-editor--enabled');
expect(wrapper).toHaveStyle({ borderWidth: '2px' });
expect(screen.getByText('Content is required')).toHaveClass('ui-error');
});
});

View File

@@ -0,0 +1,55 @@
import { UserCircleIcon } from '@heroicons/react/24/outline';
import { fireEvent, screen } from '@testing-library/react';
import { Route, Routes } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { SidebarNavItem } from '../../src/components/SidebarNavItem';
import { renderWithRouter } from '../helpers/renderWithRouter';
describe('SidebarNavItem', () => {
it('renders active style and collapsed label behavior', () => {
renderWithRouter(
<Routes>
<Route
path="/users"
element={<SidebarNavItem to="/users" label="Users" icon={UserCircleIcon} collapsed />}
/>
</Routes>,
{ route: '/users' },
);
const link = screen.getByRole('link', { name: 'Users' });
expect(link.className).toContain('ui-accent-active');
expect(link.className).toContain('mx-auto');
expect(screen.getByText('Users').className).toContain('lg:hidden');
});
it('renders inactive style and triggers onClick', () => {
const onClick = vi.fn();
renderWithRouter(
<Routes>
<Route path="/users" element={<div>Users page</div>} />
<Route
path="/profile"
element={
<SidebarNavItem
to="/users"
label="Users"
icon={UserCircleIcon}
collapsed={false}
onClick={onClick}
/>
}
/>
</Routes>,
{ route: '/profile' },
);
const link = screen.getByRole('link', { name: 'Users' });
expect(link.className).toContain('hover:bg-zinc-500/15');
expect(link.className).toContain('lg:w-full');
expect(screen.getByText('Users').className).toContain('truncate');
fireEvent.click(link);
expect(onClick).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,199 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Table, type TableHeader } from '../../src/components/Table';
import type { SortState } from '../../src/types/sort';
type Row = {
id: string;
name: string;
email: string;
};
const headers: TableHeader<Row>[] = [
{ label: 'Name', id: 'name', value: (row) => row.name, headerClassName: 'head-name' },
{ label: 'Email', id: 'email', value: (row) => row.email, cellClassName: 'cell-email' },
{ label: 'Static', id: 'static', value: 'Always', sortable: false },
];
const data: Row[] = [{ id: '1', name: 'Jane', email: 'jane@example.com' }];
describe('Table', () => {
it('renders loading state', () => {
render(<Table headers={headers} data={[]} isLoading rowKey={(row) => row.id} />);
expect(document.querySelector('.animate-spin')).toBeTruthy();
});
it('renders empty state message when no rows are available', () => {
render(
<Table
headers={headers}
data={[]}
emptyMessage="Nothing here"
rowKey={(row) => row.id}
/>,
);
expect(screen.getByText('Nothing here')).toBeInTheDocument();
});
it('renders row values from function and static header values', () => {
const rowKey = vi.fn((row: Row) => row.id);
render(<Table headers={headers} data={data} rowKey={rowKey} />);
expect(screen.getByText('Jane')).toBeInTheDocument();
expect(screen.getByText('jane@example.com')).toBeInTheDocument();
expect(screen.getByText('Always')).toBeInTheDocument();
expect(screen.getByText('Name').closest('th')).toHaveClass('head-name');
expect(screen.getByText('jane@example.com').closest('td')).toHaveClass('cell-email');
expect(rowKey).toHaveBeenCalledWith(data[0], 0);
});
it('shows sortable buttons only when sort config is complete', () => {
const onSortChange = vi.fn();
const sortableHeaders: TableHeader<Row>[] = [
{ label: 'No field', id: 'a', sortable: true, value: (row) => row.name },
{ label: 'Empty field', id: 'b', sortable: true, sortField: '', value: (row) => row.name },
{ label: 'Name', id: 'c', sortable: true, sortField: 'name', value: (row) => row.name },
];
const { rerender } = render(
<Table headers={sortableHeaders} data={data} rowKey={(row) => row.id} onSortChange={onSortChange} />,
);
expect(screen.queryByRole('button', { name: 'Sort by No field' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Sort by Empty field' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Sort by Name' })).toBeInTheDocument();
rerender(<Table headers={sortableHeaders} data={data} rowKey={(row) => row.id} />);
expect(screen.queryByRole('button', { name: 'Sort by Name' })).not.toBeInTheDocument();
});
it('renders sort states and notifies callback on header click', () => {
const onSortChange = vi.fn();
const sortableHeaders: TableHeader<Row>[] = [
{ label: 'Name', id: 'name', sortable: true, sortField: 'name', value: (row) => row.name },
{ label: 'Email', id: 'email', sortable: true, sortField: 'email', value: (row) => row.email },
];
const sorting: SortState = { field: 'name', direction: 'asc' };
const { rerender } = render(
<Table
headers={sortableHeaders}
data={data}
rowKey={(row) => row.id}
sorting={sorting}
onSortChange={onSortChange}
/>,
);
const nameSort = screen.getByRole('button', { name: 'Sort by Name' });
const emailSort = screen.getByRole('button', { name: 'Sort by Email' });
expect(nameSort.querySelector('[data-sort-state]')).toHaveAttribute('data-sort-state', 'asc');
expect(emailSort.querySelector('[data-sort-state]')).toHaveAttribute('data-sort-state', 'none');
fireEvent.click(nameSort);
expect(onSortChange).toHaveBeenCalledWith('name');
rerender(
<Table
headers={sortableHeaders}
data={data}
rowKey={(row) => row.id}
sorting={{ field: 'name', direction: 'desc' }}
onSortChange={onSortChange}
/>,
);
expect(nameSort.querySelector('[data-sort-state]')).toHaveAttribute('data-sort-state', 'desc');
});
it('supports pagination controls and page-size changes', () => {
const onPageChange = vi.fn();
const onPageSizeChange = vi.fn();
render(
<Table
headers={headers}
data={data}
rowKey={(row) => row.id}
pagination={{
page: 2,
pageSize: 10,
total: 21,
totalPages: 3,
onPageChange,
onPageSizeChange,
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Previous page' }));
fireEvent.click(screen.getByRole('button', { name: 'Next page' }));
fireEvent.change(screen.getByLabelText('Rows'), { target: { value: '20' } });
expect(onPageChange).toHaveBeenNthCalledWith(1, 1);
expect(onPageChange).toHaveBeenNthCalledWith(2, 3);
expect(onPageSizeChange).toHaveBeenCalledWith(20);
});
it('hides rows selector when onPageSizeChange is absent and clamps page count display', () => {
render(
<Table
headers={headers}
data={data}
rowKey={(row) => row.id}
pagination={{
page: 1,
pageSize: 10,
total: 1,
totalPages: 0,
onPageChange: vi.fn(),
}}
/>,
);
expect(screen.queryByLabelText('Rows')).not.toBeInTheDocument();
expect(screen.getByText('Page 1 of 1')).toBeInTheDocument();
});
it('disables prev/next at bounds or while loading', () => {
const { rerender } = render(
<Table
headers={headers}
data={data}
rowKey={(row) => row.id}
pagination={{
page: 1,
pageSize: 10,
total: 10,
totalPages: 1,
onPageChange: vi.fn(),
}}
/>,
);
expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
rerender(
<Table
headers={headers}
data={data}
rowKey={(row) => row.id}
isLoading
pagination={{
page: 2,
pageSize: 10,
total: 100,
totalPages: 10,
onPageChange: vi.fn(),
onPageSizeChange: vi.fn(),
}}
/>,
);
expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
expect(screen.getByLabelText('Rows')).toBeDisabled();
});
});

View File

@@ -0,0 +1,12 @@
import type { ReactElement } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { render } from '@testing-library/react';
type RenderWithRouterOptions = {
route?: string;
};
export function renderWithRouter(ui: ReactElement, options: RenderWithRouterOptions = {}) {
const { route = '/' } = options;
return render(<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>);
}

16
tests/index.test.ts Normal file
View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import * as webUi from '../src/index';
describe('index exports', () => {
it('exposes runtime component exports', () => {
expect(typeof webUi.Button).toBe('function');
expect(typeof webUi.Chip).toBe('function');
expect(typeof webUi.DatePicker).toBe('function');
expect(typeof webUi.Dropdown).toBe('function');
expect(typeof webUi.Form).toBe('function');
expect(typeof webUi.InputField).toBe('function');
expect(typeof webUi.Label).toBe('function');
expect(typeof webUi.SidebarNavItem).toBe('function');
expect(typeof webUi.Table).toBe('function');
});
});

74
tests/mocks/mdxeditor.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { forwardRef, useImperativeHandle, type ReactNode } from 'react';
export type MDXEditorMethods = {
setMarkdown: (value: string) => void;
};
export type MDXEditorProps = {
markdown: string;
onChange?: (value: string) => void;
readOnly?: boolean;
className?: string;
contentEditableClassName?: string;
plugins?: unknown[];
};
export const MDXEditor = forwardRef<MDXEditorMethods, MDXEditorProps>(function MDXEditor(
{ markdown, onChange, readOnly, className },
ref,
) {
useImperativeHandle(
ref,
() => ({
setMarkdown: () => undefined,
}),
[],
);
if (readOnly) {
return (
<div data-testid="md-preview" data-class-name={className}>
{markdown}
</div>
);
}
return (
<textarea
aria-label="Markdown Editor"
value={markdown}
data-class-name={className}
onChange={(event) => onChange?.(event.target.value)}
/>
);
});
function Control(): ReactNode {
return <span />;
}
function plugin(): Record<string, never> {
return {};
}
export const BlockTypeSelect = Control;
export const BoldItalicUnderlineToggles = Control;
export const CodeToggle = Control;
export const CreateLink = Control;
export const InsertCodeBlock = Control;
export const InsertTable = Control;
export const ListsToggle = Control;
export const Separator = Control;
export const UndoRedo = Control;
export const codeBlockPlugin = plugin;
export const codeMirrorPlugin = plugin;
export const headingsPlugin = plugin;
export const linkDialogPlugin = plugin;
export const linkPlugin = plugin;
export const listsPlugin = plugin;
export const markdownShortcutPlugin = plugin;
export const quotePlugin = plugin;
export const tablePlugin = plugin;
export const thematicBreakPlugin = plugin;
export const toolbarPlugin = plugin;

15
tests/setup.ts Normal file
View File

@@ -0,0 +1,15 @@
// Required by React to silence act(...) warnings in jsdom tests.
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
vi.mock('@mdxeditor/editor', () => import('./mocks/mdxeditor'));
afterEach(() => {
cleanup();
localStorage.clear();
vi.restoreAllMocks();
vi.useRealTimers();
});

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
@@ -27,4 +27,23 @@ export default defineConfig({
], ],
}, },
}, },
test: {
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.stories.{ts,tsx}',
'src/styles/**',
'src/components/types.ts',
'src/types/**',
],
thresholds: {
lines: 95,
functions: 95,
branches: 90,
},
},
},
}); });

809
yarn.lock

File diff suppressed because it is too large Load Diff