Compare commits
17 Commits
v0.1.9
...
6bc156284b
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bc156284b | |||
| 4fc3738adf | |||
| e17c82de2f | |||
| a527ce27cd | |||
| 623e45d241 | |||
| 5593746cf4 | |||
| b163fdaa62 | |||
| ec63a10027 | |||
| 5ada69773c | |||
| 370d6e7e0a | |||
| 44dd5d5deb | |||
| 8d3ca5a281 | |||
| 1d5113d209 | |||
| f71e773a3a | |||
| 4921afe296 | |||
| 5cc3e3646c | |||
| 29a4e8c2ee |
51
.drone.yml
51
.drone.yml
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@panic/web-ui",
|
"name": "@panic/web-ui",
|
||||||
"version": "0.1.9",
|
"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",
|
||||||
@@ -64,23 +67,28 @@
|
|||||||
"@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",
|
||||||
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"eslint": "^10",
|
"eslint": "^10",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"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",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0",
|
"react-router-dom": "^7.0.0",
|
||||||
"storybook": "^10.2.10",
|
"storybook": "^10.2.10",
|
||||||
"tailwindcss": "^3.4.16",
|
"tailwindcss": "^4.0.0",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'],
|
||||||
@@ -105,6 +111,7 @@ const meta = {
|
|||||||
type: 'datetime-local',
|
type: 'datetime-local',
|
||||||
value: '',
|
value: '',
|
||||||
size: 'md',
|
size: 'md',
|
||||||
|
width: 'md',
|
||||||
layout: 'stacked',
|
layout: 'stacked',
|
||||||
},
|
},
|
||||||
} satisfies Meta<typeof DatePicker>;
|
} satisfies Meta<typeof DatePicker>;
|
||||||
@@ -224,6 +231,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>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type DatePickerProps = {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
type: DatePickerKind;
|
type: DatePickerKind;
|
||||||
size?: ComponentSize;
|
size?: ComponentSize;
|
||||||
|
width?: ComponentSize;
|
||||||
layout?: Layout;
|
layout?: Layout;
|
||||||
value: string;
|
value: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -28,6 +29,7 @@ export function DatePicker({
|
|||||||
placeholder = '',
|
placeholder = '',
|
||||||
type,
|
type,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
|
width = 'md',
|
||||||
layout = 'stacked',
|
layout = 'stacked',
|
||||||
value,
|
value,
|
||||||
name,
|
name,
|
||||||
@@ -41,12 +43,12 @@ export function DatePicker({
|
|||||||
className = '',
|
className = '',
|
||||||
inputClassName = '',
|
inputClassName = '',
|
||||||
}: Readonly<DatePickerProps>) {
|
}: Readonly<DatePickerProps>) {
|
||||||
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',
|
||||||
@@ -63,7 +65,7 @@ export function DatePicker({
|
|||||||
|
|
||||||
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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
71
tests/components/Button.test.tsx
Normal file
71
tests/components/Button.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
54
tests/components/Chip.test.tsx
Normal file
54
tests/components/Chip.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
62
tests/components/DatePicker.test.tsx
Normal file
62
tests/components/DatePicker.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
55
tests/components/Dropdown.test.tsx
Normal file
55
tests/components/Dropdown.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
28
tests/components/Form.test.tsx
Normal file
28
tests/components/Form.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
92
tests/components/InputField.test.tsx
Normal file
92
tests/components/InputField.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
25
tests/components/Label.test.tsx
Normal file
25
tests/components/Label.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
82
tests/components/MDXEditorField.test.tsx
Normal file
82
tests/components/MDXEditorField.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
55
tests/components/SidebarNavItem.test.tsx
Normal file
55
tests/components/SidebarNavItem.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
199
tests/components/Table.test.tsx
Normal file
199
tests/components/Table.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
12
tests/helpers/renderWithRouter.tsx
Normal file
12
tests/helpers/renderWithRouter.tsx
Normal 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
16
tests/index.test.ts
Normal 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
74
tests/mocks/mdxeditor.tsx
Normal 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
15
tests/setup.ts
Normal 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();
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user