Compare commits

...

16 Commits

Author SHA1 Message Date
17e5cbfd29 Update dependency @storybook/react-vite to v10.2.12
Some checks failed
continuous-integration/drone/pr Build is failing
2026-02-26 06:13:42 +00:00
1523f7be2c add unit tests
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 17:55:26 +01:00
b664c99944 fix sonar issues
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 16:27:53 +01:00
3d4a4a5f57 rewrite datepicker, v0.1.18
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is passing
2026-02-24 16:22:12 +01:00
f9864842b5 fix sonar bugs
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 12:18:55 +01:00
dd084369e9 update thresholds
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 12:10:36 +01:00
850eed0766 update ci
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 12:06:43 +01:00
4904bea29c update unit tests coverage
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 11:51:08 +01: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
30 changed files with 4718 additions and 104 deletions

View File

@@ -13,26 +13,47 @@ 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
@@ -46,13 +67,11 @@ trigger:
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.15", "version": "0.1.18",
"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,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",
"@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",
@@ -81,6 +88,10 @@
"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"
},
"dependencies": {
"@types/node": "^25.3.0"
} }
} }

View File

@@ -11,7 +11,7 @@ const meta = {
docs: { docs: {
description: { description: {
component: component:
'Date selection field with InputField-compatible API, supporting date/time/datetime-local values, size/layout variants, and validation state.', 'In-house date/time selection field with InputField-compatible API. Uses a custom popup (not native browser pickers) and supports date, time, and date-time modes.',
}, },
}, },
}, },
@@ -27,10 +27,10 @@ const meta = {
table: { type: { summary: 'string' } }, table: { type: { summary: 'string' } },
}, },
type: { type: {
description: 'Native date input type.', description: 'DatePicker mode.',
options: ['date', 'datetime-local', 'time'], options: ['date', 'date-time', 'time'],
control: 'inline-radio', control: 'inline-radio',
table: { type: { summary: "'date' | 'datetime-local' | 'time'" } }, table: { type: { summary: "'date' | 'date-time' | 'time'" } },
}, },
size: { size: {
description: 'Input size.', description: 'Input size.',
@@ -55,6 +55,22 @@ const meta = {
control: 'text', control: 'text',
table: { type: { summary: 'string' } }, table: { type: { summary: 'string' } },
}, },
format: {
description:
'Optional input/output format. Supported tokens: `dd`, `mm`, `yyyy`, `HH` (for example `dd/mm/yyyy HH:mm`).',
control: 'text',
table: { type: { summary: 'string' } },
},
min: {
description: 'Optional minimum value in the same format as `value`.',
control: 'text',
table: { type: { summary: 'string' } },
},
max: {
description: 'Optional maximum value in the same format as `value`.',
control: 'text',
table: { type: { summary: 'string' } },
},
name: { name: {
description: 'Native input `name` attribute.', description: 'Native input `name` attribute.',
control: 'text', control: 'text',
@@ -108,7 +124,7 @@ const meta = {
}, },
args: { args: {
label: 'Schedule at', label: 'Schedule at',
type: 'datetime-local', type: 'date-time',
value: '', value: '',
size: 'md', size: 'md',
width: 'md', width: 'md',
@@ -124,8 +140,8 @@ export const DateOnly: Story = {
type: 'date', type: 'date',
label: 'Publish date', label: 'Publish date',
}, },
render: (args) => { render: function DateOnlyRender(args) {
const [value, setValue] = useState('2031-05-20'); const [value, setValue] = useState('2031/05/20');
return ( return (
<DatePicker <DatePicker
{...args} {...args}
@@ -141,11 +157,11 @@ export const DateOnly: Story = {
export const DateTime: Story = { export const DateTime: Story = {
args: { args: {
type: 'datetime-local', type: 'date-time',
label: 'Schedule at', label: 'Schedule at',
}, },
render: (args) => { render: function DateTimeRender(args) {
const [value, setValue] = useState('2031-05-20T14:30'); const [value, setValue] = useState('2031/05/20 14:30');
return ( return (
<DatePicker <DatePicker
{...args} {...args}
@@ -167,7 +183,7 @@ export const TimeOnlyInline: Story = {
size: 'sm', size: 'sm',
rightIcon: <CalendarDaysIcon className="h-4 w-4 ui-body-secondary" />, rightIcon: <CalendarDaysIcon className="h-4 w-4 ui-body-secondary" />,
}, },
render: (args) => { render: function TimeOnlyInlineRender(args) {
const [value, setValue] = useState('09:00'); const [value, setValue] = useState('09:00');
return ( return (
<DatePicker <DatePicker
@@ -182,9 +198,10 @@ export const TimeOnlyInline: Story = {
}, },
}; };
export const Error: Story = { export const ErrorState: Story = {
name: 'Error',
args: { args: {
type: 'datetime-local', type: 'date-time',
label: 'Schedule at', label: 'Schedule at',
value: '', value: '',
error: 'Pick a valid future date and time', error: 'Pick a valid future date and time',
@@ -193,20 +210,20 @@ export const Error: Story = {
export const Disabled: Story = { export const Disabled: Story = {
args: { args: {
type: 'datetime-local', type: 'date-time',
label: 'Published at', label: 'Published at',
value: '2031-05-20T14:30', value: '2031/05/20 14:30',
disabled: true, disabled: true,
}, },
}; };
export const SizeMatrix: Story = { export const SizeMatrix: Story = {
args: { args: {
type: 'datetime-local', type: 'date-time',
label: 'Schedule at', label: 'Schedule at',
}, },
render: (args) => { render: function SizeMatrixRender(args) {
const [value, setValue] = useState('2031-05-20T14:30'); const [value, setValue] = useState('2031/05/20 14:30');
return ( return (
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
<DatePicker <DatePicker
@@ -238,3 +255,26 @@ export const SizeMatrix: Story = {
); );
}, },
}; };
export const CustomFormatWithRange: Story = {
args: {
type: 'date-time',
label: 'Starts at',
format: 'dd/mm/yyyy HH:mm',
min: '10/03/2026 09:00',
max: '24/03/2026 18:30',
},
render: function CustomFormatWithRangeRender(args) {
const [value, setValue] = useState('22/03/2026 14:30');
return (
<DatePicker
{...args}
value={value}
onChange={(event) => {
setValue(event.target.value);
args.onChange?.(event);
}}
/>
);
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -99,7 +99,7 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Stacked: Story = { export const Stacked: Story = {
render: (args) => { render: function StackedRender(args) {
const [value, setValue] = useState(args.value); const [value, setValue] = useState(args.value);
return ( return (
<Dropdown <Dropdown
@@ -119,7 +119,7 @@ export const Inline: Story = {
layout: 'inline', layout: 'inline',
size: 'sm', size: 'sm',
}, },
render: (args) => { render: function InlineRender(args) {
const [value, setValue] = useState(args.value); const [value, setValue] = useState(args.value);
return ( return (
<Dropdown <Dropdown
@@ -147,7 +147,7 @@ export const WithError: Story = {
}; };
export const SizeMatrix: Story = { export const SizeMatrix: Story = {
render: (args) => { render: function SizeMatrixRender(args) {
const [value, setValue] = useState(args.value); const [value, setValue] = useState(args.value);
return ( return (
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">

View File

@@ -67,7 +67,7 @@ export const Basic: Story = {
}; };
export const WithActions: Story = { export const WithActions: Story = {
render: (args) => { render: function WithActionsRender(args) {
const [title, setTitle] = useState('Storybook powered CMS'); const [title, setTitle] = useState('Storybook powered CMS');
const [status, setStatus] = useState('draft'); const [status, setStatus] = useState('draft');

View File

@@ -127,7 +127,7 @@ export const Text: Story = {
label: 'Title', label: 'Title',
placeholder: 'Write a title', placeholder: 'Write a title',
}, },
render: (args) => { render: function TextRender(args) {
const [value, setValue] = useState('Storybook integration'); const [value, setValue] = useState('Storybook integration');
return ( return (
<InputField <InputField
@@ -148,7 +148,7 @@ export const PasswordWithToggle: Story = {
label: 'Password', label: 'Password',
placeholder: 'Type a strong password', placeholder: 'Type a strong password',
}, },
render: (args) => { render: function PasswordWithToggleRender(args) {
const [value, setValue] = useState('pa55word'); const [value, setValue] = useState('pa55word');
return ( return (
<InputField <InputField
@@ -171,7 +171,7 @@ export const InlineWithIcon: Story = {
size: 'sm', size: 'sm',
rightIcon: <MagnifyingGlassIcon className="h-4 w-4 ui-body-secondary" />, rightIcon: <MagnifyingGlassIcon className="h-4 w-4 ui-body-secondary" />,
}, },
render: (args) => { render: function InlineWithIconRender(args) {
const [value, setValue] = useState('posts'); const [value, setValue] = useState('posts');
return ( return (
<InputField <InputField
@@ -186,7 +186,8 @@ export const InlineWithIcon: Story = {
}, },
}; };
export const Error: Story = { export const ErrorState: Story = {
name: 'Error',
args: { args: {
type: 'email', type: 'email',
label: 'Email', label: 'Email',
@@ -210,7 +211,7 @@ export const SizeMatrix: Story = {
label: 'Name', label: 'Name',
placeholder: 'Enter value', placeholder: 'Enter value',
}, },
render: (args) => { render: function SizeMatrixRender(args) {
const [value, setValue] = useState('Beatrice'); const [value, setValue] = useState('Beatrice');
return ( return (
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">

View File

@@ -53,7 +53,8 @@ type Story = StoryObj<typeof meta>;
export const Body: Story = {}; export const Body: Story = {};
export const Error: Story = { export const ErrorState: Story = {
name: 'Error',
args: { args: {
variant: 'error', variant: 'error',
children: 'This field is required', children: 'This field is required',

View File

@@ -115,7 +115,7 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Editable: Story = { export const Editable: Story = {
render: (args) => { render: function EditableRender(args) {
const [markdown, setMarkdown] = useState(args.markdown); const [markdown, setMarkdown] = useState(args.markdown);
return ( return (
<div className="w-full max-w-2xl"> <div className="w-full max-w-2xl">

View File

@@ -183,7 +183,7 @@ export const Empty: Story = {
}; };
export const InteractiveSortingAndPagination: Story = { export const InteractiveSortingAndPagination: Story = {
render: () => { render: function InteractiveSortingAndPaginationRender() {
const [sorting, setSorting] = useState<SortState | null>({ const [sorting, setSorting] = useState<SortState | null>({
field: 'name', field: 'name',
direction: 'asc', direction: 'asc',

View File

@@ -10,6 +10,7 @@
--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);
--datepicker-menu-bg: #18181b;
--surface-border: rgba(82, 82, 91, 0.6); --surface-border: rgba(82, 82, 91, 0.6);
--surface-divider: rgba(63, 63, 70, 0.85); --surface-divider: rgba(63, 63, 70, 0.85);
--text-primary: #d5cfdf; --text-primary: #d5cfdf;
@@ -59,6 +60,7 @@
--bg-page: #f7f7fb; --bg-page: #f7f7fb;
--surface-bg: rgba(255, 255, 255, 0.9); --surface-bg: rgba(255, 255, 255, 0.9);
--surface-bg-strong: rgba(255, 255, 255, 0.98); --surface-bg-strong: rgba(255, 255, 255, 0.98);
--datepicker-menu-bg: #ffffff;
--surface-border: rgba(161, 161, 170, 0.45); --surface-border: rgba(161, 161, 170, 0.45);
--surface-divider: rgba(212, 212, 216, 0.9); --surface-divider: rgba(212, 212, 216, 0.9);
--text-primary: #52485c; --text-primary: #52485c;

View File

@@ -30,24 +30,6 @@
-webkit-text-fill-color: var(--field-selection-text); -webkit-text-fill-color: var(--field-selection-text);
} }
.field[type='date']:focus::-webkit-datetime-edit-day-field,
.field[type='date']:focus::-webkit-datetime-edit-month-field,
.field[type='date']:focus::-webkit-datetime-edit-year-field,
.field[type='time']:focus::-webkit-datetime-edit-hour-field,
.field[type='time']:focus::-webkit-datetime-edit-minute-field,
.field[type='time']:focus::-webkit-datetime-edit-ampm-field,
.field[type='datetime-local']:focus::-webkit-datetime-edit-day-field,
.field[type='datetime-local']:focus::-webkit-datetime-edit-month-field,
.field[type='datetime-local']:focus::-webkit-datetime-edit-year-field,
.field[type='datetime-local']:focus::-webkit-datetime-edit-hour-field,
.field[type='datetime-local']:focus::-webkit-datetime-edit-minute-field,
.field[type='datetime-local']:focus::-webkit-datetime-edit-ampm-field {
border-radius: 0.375rem;
background-color: var(--field-selection-bg) !important;
color: var(--field-selection-text) !important;
-webkit-text-fill-color: var(--field-selection-text) !important;
}
.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);
@@ -345,3 +327,190 @@
color: var(--text-secondary); color: var(--text-secondary);
@apply px-4 py-3 text-sm; @apply px-4 py-3 text-sm;
} }
.datepicker-icon-btn {
color: var(--text-muted);
@apply absolute inset-y-0 right-2 my-auto inline-flex h-6 w-6 items-center justify-center rounded-md border border-transparent bg-transparent p-0 transition;
}
.datepicker-icon-btn:hover {
color: var(--text-primary);
background-color: var(--ghost-hover);
}
.datepicker-icon-btn:focus-visible {
outline: none;
border-color: rgb(var(--accent-400));
box-shadow: 0 0 0 2px rgb(var(--accent-400) / 0.3);
}
.datepicker-icon-btn:disabled {
color: var(--ghost-disabled-text);
background-color: transparent;
@apply cursor-not-allowed;
}
.datepicker-popup {
border: 1px solid var(--surface-divider);
background-color: var(--surface-bg-strong);
box-shadow: var(--shadow-glow);
color: var(--text-primary);
backdrop-filter: saturate(145%) blur(var(--auth-glass-blur));
-webkit-backdrop-filter: saturate(145%) blur(var(--auth-glass-blur));
will-change: backdrop-filter;
max-width: min(96vw, 440px);
@apply fixed z-[70] flex flex-col gap-3 rounded-xl p-3;
}
.datepicker-popup-top {
transform-origin: bottom center;
}
.datepicker-popup-bottom {
transform-origin: top center;
}
@screen sm {
.datepicker-popup {
@apply flex-row;
}
}
.datepicker-panel {
@apply min-w-0;
}
.datepicker-calendar-nav {
@apply mb-2 flex items-center justify-between gap-2;
}
.datepicker-nav-btn {
border: 1px solid var(--ghost-border);
background-color: var(--ghost-bg);
color: var(--text-secondary);
@apply inline-flex h-8 w-8 items-center justify-center rounded-lg transition;
}
.datepicker-nav-btn:hover {
background-color: var(--ghost-hover);
color: var(--text-primary);
}
.datepicker-heading-controls {
@apply relative flex items-center gap-2;
}
.datepicker-chooser {
@apply relative;
}
.datepicker-chooser-btn {
border: 1px solid var(--field-border);
background-color: var(--field-bg);
color: var(--text-primary);
@apply inline-flex h-8 items-center rounded-lg px-2.5 text-xs font-semibold;
}
.datepicker-chooser-menu {
border: 1px solid var(--surface-divider);
background-color: var(--datepicker-menu-bg);
box-shadow: var(--shadow-glow);
@apply absolute left-0 top-full z-20 mt-1 max-h-48 min-w-[9rem] overflow-y-auto rounded-lg p-1;
}
.datepicker-chooser-option {
color: var(--text-secondary);
@apply block w-full rounded-md px-2 py-1.5 text-left text-xs font-medium transition;
}
.datepicker-chooser-option:hover {
background-color: var(--ghost-hover);
color: var(--text-primary);
}
.datepicker-chooser-option.is-selected {
background-color: rgb(var(--accent-500) / 0.22);
color: rgb(var(--accent-300));
}
.datepicker-weekdays {
@apply mb-1 grid grid-cols-7 gap-1;
}
.datepicker-weekday {
color: var(--text-muted);
@apply text-center text-[0.65rem] font-semibold uppercase tracking-[0.08em];
}
.datepicker-grid {
@apply grid grid-cols-7 gap-1;
}
.datepicker-day {
color: var(--text-secondary);
@apply inline-flex h-8 w-8 items-center justify-center rounded-lg text-sm transition;
}
.datepicker-day:hover {
background-color: var(--ghost-hover);
color: var(--text-primary);
}
.datepicker-day.is-selected {
background-color: rgb(var(--accent-500));
color: rgb(var(--accent-contrast));
}
.datepicker-day.is-today {
border: 1px solid rgb(var(--accent-400) / 0.65);
}
.datepicker-day.is-outside-month {
color: var(--text-soft);
}
.datepicker-day:disabled {
color: var(--ghost-disabled-text);
background-color: transparent;
@apply cursor-not-allowed opacity-50;
}
.datepicker-time-root {
@apply grid min-w-[180px] grid-cols-2 gap-2;
}
.datepicker-time-column {
@apply flex min-w-0 flex-col gap-1;
}
.datepicker-time-title {
color: var(--text-muted);
@apply text-[0.65rem] font-semibold uppercase tracking-[0.08em];
}
.datepicker-time-list {
border: 1px solid var(--field-border);
background-color: var(--field-bg);
@apply max-h-52 overflow-y-auto rounded-lg p-1;
}
.datepicker-time-option {
color: var(--text-secondary);
@apply block w-full rounded-md px-2 py-1.5 text-left text-xs font-semibold transition;
}
.datepicker-time-option:hover {
background-color: var(--ghost-hover);
color: var(--text-primary);
}
.datepicker-time-option.is-selected {
background-color: rgb(var(--accent-500) / 0.22);
color: rgb(var(--accent-300));
}
.datepicker-time-option:disabled {
color: var(--ghost-disabled-text);
background-color: transparent;
@apply cursor-not-allowed opacity-55;
}

View File

@@ -0,0 +1,77 @@
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('uses explicit variant when provided', () => {
render(<Button label="Danger" type="outlined" variant="important" />);
expect(screen.getByRole('button', { name: 'Danger' })).toHaveClass('btn-important');
});
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,62 @@
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();
});
it('ignores unresolved direct palettes and unknown shades', () => {
const { rerender } = render(<Chip tone="indigo">Palette token</Chip>);
expect(screen.getByText('Palette token').getAttribute('style')).toBeNull();
rerender(<Chip tone="indigo-999">Unknown shade</Chip>);
expect(screen.getByText('Unknown shade').getAttribute('style')).toBeNull();
});
});

View File

@@ -0,0 +1,428 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { __datePickerTestUtils as utils } from '../../src/components/DatePicker';
type GlobalDescriptor = PropertyDescriptor | undefined;
function setGlobalProperty(name: string, descriptor: PropertyDescriptor): GlobalDescriptor {
const key = name as keyof typeof globalThis;
const original = Object.getOwnPropertyDescriptor(globalThis, key);
Object.defineProperty(globalThis, key, descriptor);
return original;
}
function restoreGlobalProperty(name: string, original: GlobalDescriptor): void {
const key = name as keyof typeof globalThis;
if (original) {
Object.defineProperty(globalThis, key, original);
return;
}
Reflect.deleteProperty(globalThis, key);
}
describe('DatePicker logic helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('formats and clamps numeric values', () => {
expect(utils.pad2(3)).toBe('03');
expect(utils.pad4(12)).toBe('0012');
expect(utils.clampNumber(99, 0, 50)).toBe(50);
expect(utils.clampNumber(-1, 0, 50)).toBe(0);
});
it('validates calendar dates strictly', () => {
expect(utils.createValidatedDate(2026, 2, 29)).toBeNull();
expect(utils.createValidatedDate(2028, 2, 29)).toBeInstanceOf(Date);
});
it('resolves locale with and without navigator', () => {
const originalNavigator = setGlobalProperty('navigator', {
configurable: true,
value: undefined,
});
expect(utils.resolveLocale()).toBe('en-US');
restoreGlobalProperty('navigator', originalNavigator);
const locale = utils.resolveLocale();
expect(typeof locale).toBe('string');
expect(locale.length).toBeGreaterThan(0);
});
it('resolves week start across locale and fallback branches', () => {
const originalIntl = setGlobalProperty('Intl', {
configurable: true,
value: undefined,
});
expect(utils.resolveWeekStart('en-US')).toBe(0);
setGlobalProperty('Intl', {
configurable: true,
value: {
Locale: class MockLocale {
weekInfo = { firstDay: 2 };
},
},
});
expect(utils.resolveWeekStart('de-DE')).toBe(2);
setGlobalProperty('Intl', {
configurable: true,
value: {
Locale: class MockLocale {
weekInfo = { firstDay: 7 };
},
},
});
expect(utils.resolveWeekStart('en-US')).toBe(0);
setGlobalProperty('Intl', {
configurable: true,
value: {
Locale: class MockLocale {
weekInfo = { firstDay: 0 };
},
},
});
expect(utils.resolveWeekStart('en-US')).toBe(0);
setGlobalProperty('Intl', {
configurable: true,
value: {
Locale: class MockLocale {
weekInfo = { firstDay: 'x' as unknown as number };
},
},
});
expect(utils.resolveWeekStart('en-US')).toBe(0);
setGlobalProperty('Intl', {
configurable: true,
value: {
Locale: class MockLocale {
constructor() {
throw new Error('boom');
}
},
},
});
expect(utils.resolveWeekStart('en-US')).toBe(0);
restoreGlobalProperty('Intl', originalIntl);
});
it('builds and validates format configuration', () => {
expect(utils.buildFormatConfigOrNull('date-time', '')).toBeNull();
expect(utils.buildFormatConfigOrNull('date-time', 'yyyy/mm/dd HH')).toBeNull();
const config = utils.buildFormatConfigOrNull('date-time', 'dd/mm/yyyy HH:mm');
expect(config).not.toBeNull();
expect(config?.segments).toHaveLength(5);
expect(config?.totalLength).toBe(16);
});
it('falls back to default format when requested format is invalid', () => {
const config = utils.buildFormatConfig('date-time', 'yyyy/mm/dd');
expect(config.format).toBe('yyyy/mm/dd HH:mm');
});
it('parses valid values and rejects invalid input', () => {
const dateTimeConfig = utils.buildFormatConfig('date-time', 'dd/mm/yyyy HH:mm');
expect(utils.parsePickerValueWithFormat('22/02/2026 14:30', dateTimeConfig)).toEqual({
date: utils.createDateAtLocalMidnight(2026, 1, 22),
hour: 14,
minute: 30,
});
expect(utils.parsePickerValueWithFormat('2/2/2026 14:30', dateTimeConfig)).toBeNull();
expect(utils.parsePickerValueWithFormat('22-02-2026 14:30', dateTimeConfig)).toBeNull();
expect(utils.parsePickerValueWithFormat('aa/02/2026 14:30', dateTimeConfig)).toBeNull();
expect(utils.parsePickerValueWithFormat('31/02/2026 14:30', dateTimeConfig)).toBeNull();
expect(utils.parsePickerValueWithFormat('22/02/2026 24:30', dateTimeConfig)).toBeNull();
const timeConfig = utils.buildFormatConfig('time', 'HH:mm');
expect(utils.parsePickerValueWithFormat('09:00', timeConfig)).toEqual({
date: utils.startOfDay(new Date()),
hour: 9,
minute: 0,
});
expect(utils.parsePickerValueWithFormat('09:77', timeConfig)).toBeNull();
});
it('formats picker values with the chosen configuration', () => {
const config = utils.buildFormatConfig('date-time', 'dd/mm/yyyy HH:mm');
const text = utils.formatPickerValueWithFormat(
{
date: utils.createDateAtLocalMidnight(2027, 2, 11),
hour: 6,
minute: 5,
},
config,
);
expect(text).toBe('11/03/2027 06:05');
});
it('compares values by picker type', () => {
const date = utils.comparePickerValue(
{
date: utils.createDateAtLocalMidnight(2026, 1, 1),
hour: 23,
minute: 30,
},
{
date: utils.createDateAtLocalMidnight(2026, 1, 2),
hour: 1,
minute: 0,
},
'date',
);
expect(date).toBeLessThan(0);
const time = utils.comparePickerValue(
{
date: utils.createDateAtLocalMidnight(2026, 1, 1),
hour: 9,
minute: 0,
},
{
date: utils.createDateAtLocalMidnight(2026, 1, 1),
hour: 8,
minute: 59,
},
'time',
);
expect(time).toBeGreaterThan(0);
const dateTime = utils.comparePickerValue(
{
date: utils.createDateAtLocalMidnight(2026, 1, 1),
hour: 1,
minute: 0,
},
{
date: utils.createDateAtLocalMidnight(2026, 1, 1),
hour: 1,
minute: 1,
},
'date-time',
);
expect(dateTime).toBeLessThan(0);
});
it('normalizes and enforces picker ranges', () => {
const min = {
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 9,
minute: 0,
};
const max = {
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 10,
minute: 0,
};
const normalized = utils.normalizeRange(max, min, 'date-time');
expect(normalized.minValue).toEqual(min);
expect(normalized.maxValue).toEqual(max);
const below = utils.clampPickerToRange(
{
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 8,
minute: 0,
},
min,
max,
'date-time',
);
expect(below).toEqual(min);
const above = utils.clampPickerToRange(
{
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 10,
minute: 30,
},
min,
max,
'date-time',
);
expect(above).toEqual(max);
expect(utils.isWithinRange(min, min, max, 'date-time')).toBe(true);
expect(
utils.isWithinRange(
{
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 7,
minute: 59,
},
min,
max,
'date-time',
),
).toBe(false);
expect(
utils.isWithinRange(
{
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 10,
minute: 1,
},
min,
max,
'date-time',
),
).toBe(false);
});
it('applies segment edits across year, month, day, hour and minute', () => {
const base = {
date: utils.createDateAtLocalMidnight(2026, 1, 28),
hour: 14,
minute: 30,
};
expect(utils.applySegmentDigits(base, 'year', '0001').date.getFullYear()).toBe(1);
expect(utils.applySegmentDigits(base, 'month', '13').date.getMonth()).toBe(11);
expect(utils.applySegmentDigits(base, 'day', '99').date.getDate()).toBe(28);
expect(utils.applySegmentDigits(base, 'hour', '88').hour).toBe(23);
expect(utils.applySegmentDigits(base, 'minute', '99').minute).toBe(59);
const unchanged = utils.applySegmentDigits(base, 'day', 'not-a-number');
expect(unchanged).toEqual(base);
});
it('resolves segment index from caret position', () => {
const config = utils.buildFormatConfig('date-time', 'dd/mm/yyyy HH:mm');
expect(utils.findSegmentIndexByCaret([], 3)).toBe(0);
expect(utils.findSegmentIndexByCaret(config.segments, null)).toBe(0);
expect(utils.findSegmentIndexByCaret(config.segments, 0)).toBe(0);
expect(utils.findSegmentIndexByCaret(config.segments, 6)).toBe(2);
expect(utils.findSegmentIndexByCaret(config.segments, 99)).toBe(
config.segments[config.segments.length - 1].segmentIndex,
);
expect(utils.findSegmentIndexByCaret(config.segments, 2)).toBe(0);
const nonStandardSegments = [
{
type: 'segment',
kind: 'day',
token: 'dd',
length: 2,
start: 5,
end: 7,
segmentIndex: 0,
},
] as unknown as Parameters<typeof utils.findSegmentIndexByCaret>[0];
expect(utils.findSegmentIndexByCaret(nonStandardSegments, 1)).toBe(0);
});
it('checks date and hour selectability inside constrained ranges', () => {
const min = {
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 9,
minute: 30,
};
const max = {
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 10,
minute: 15,
};
expect(
utils.isDateSelectableForRange(
utils.createDateAtLocalMidnight(2026, 2, 9),
'date-time',
min,
max,
),
).toBe(false);
expect(
utils.isDateSelectableForRange(
utils.createDateAtLocalMidnight(2026, 2, 10),
'date-time',
min,
max,
),
).toBe(true);
expect(
utils.isDateSelectableForRange(
utils.createDateAtLocalMidnight(2026, 2, 11),
'date-time',
min,
max,
),
).toBe(false);
expect(
utils.isDateSelectableForRange(
utils.createDateAtLocalMidnight(2026, 2, 9),
'date',
{
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 0,
minute: 0,
},
{
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 0,
minute: 0,
},
),
).toBe(false);
expect(
utils.isHourSelectableForRange(
utils.createDateAtLocalMidnight(2026, 2, 10),
8,
'date-time',
min,
max,
),
).toBe(false);
expect(
utils.isHourSelectableForRange(
utils.createDateAtLocalMidnight(2026, 2, 10),
9,
'date-time',
min,
max,
),
).toBe(true);
expect(
utils.isHourSelectableForRange(
utils.createDateAtLocalMidnight(2026, 2, 10),
11,
'date-time',
min,
max,
),
).toBe(false);
});
it('builds month grids and joins class names', () => {
const month = utils.createDateAtLocalMidnight(2026, 2, 10);
const grid = utils.buildMonthGrid(month, 1);
expect(grid).toHaveLength(42);
expect(grid[0]).toBeInstanceOf(Date);
expect(grid[41]).toBeInstanceOf(Date);
expect(utils.joinClassNames('a', false, 'b', undefined, '', null, 'c')).toBe('a b c');
});
it('assigns refs for callback and object refs', () => {
const callback = vi.fn();
const objectRef = { current: null as HTMLInputElement | null };
const node = document.createElement('input');
utils.assignRef(callback, node);
utils.assignRef(objectRef, node);
utils.assignRef(undefined, node);
expect(callback).toHaveBeenCalledWith(node);
expect(objectRef.current).toBe(node);
});
});

View File

@@ -0,0 +1,740 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { createRef, type FocusEvent as ReactFocusEvent, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { DatePicker } from '../../src/components/DatePicker';
type ControlledProps = {
type: 'date' | 'time' | 'date-time';
initialValue: string;
format?: string;
min?: string;
max?: string;
onValueChange?: (value: string) => void;
onBlur?: (event: ReactFocusEvent<HTMLInputElement>) => void;
disabled?: boolean;
};
function ControlledDatePicker({
type,
initialValue,
format,
min,
max,
onValueChange,
onBlur,
disabled = false,
}: Readonly<ControlledProps>) {
const [value, setValue] = useState(initialValue);
return (
<DatePicker
label="Schedule"
type={type}
value={value}
format={format}
min={min}
max={max}
disabled={disabled}
onBlur={onBlur}
onChange={(event) => {
setValue(event.target.value);
onValueChange?.(event.target.value);
}}
/>
);
}
function pickCurrentMonthDay(label: string): void {
const dayButtons = Array.from(document.querySelectorAll('.datepicker-day')) as HTMLButtonElement[];
const targetButton = dayButtons.find(
(button) => button.textContent === label && !button.classList.contains('is-outside-month'),
);
expect(targetButton).toBeDefined();
fireEvent.click(targetButton as HTMLButtonElement);
}
function getCurrentMonthDayButton(label: string): HTMLButtonElement {
const dayButtons = Array.from(document.querySelectorAll('.datepicker-day')) as HTMLButtonElement[];
const targetButton = dayButtons.find(
(button) => button.textContent === label && !button.classList.contains('is-outside-month'),
);
if (!targetButton) {
throw new Error(`Could not find day button for ${label}`);
}
return targetButton;
}
function createPasteEvent(text: string): Event {
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
getData: () => text,
},
});
return event;
}
function createRect(left: number, top: number, width: number, height: number): DOMRect {
return {
x: left,
y: top,
left,
top,
width,
height,
right: left + width,
bottom: top + height,
toJSON: () => ({}),
} as DOMRect;
}
describe('DatePicker', () => {
it('opens popup from icon button and closes with Escape', () => {
render(<DatePicker label="Schedule" type="date-time" value="2031/05/20 14:30" onChange={() => {}} />);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
expect(input.type).toBe('text');
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
expect(
screen.getByRole('dialog', { name: 'Date and time picker popup' }),
).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(
screen.queryByRole('dialog', { name: 'Date and time picker popup' }),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
fireEvent.click(screen.getByRole('button', { name: 'Close date picker' }));
expect(
screen.queryByRole('dialog', { name: 'Date and time picker popup' }),
).not.toBeInTheDocument();
});
it('supports segment-by-segment editing with custom format and auto-advance', async () => {
const onValueChange = vi.fn();
render(
<ControlledDatePicker
type="date-time"
format="dd/mm/yyyy HH:mm"
initialValue="22/02/2026 14:30"
onValueChange={onValueChange}
/>,
);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
fireEvent.focus(input);
await waitFor(() => {
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(2);
});
fireEvent.keyDown(input, { key: '1' });
await waitFor(() => {
expect(input.value).toBe('01/02/2026 14:30');
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(2);
});
fireEvent.keyDown(input, { key: '1' });
await waitFor(() => {
expect(input.value).toBe('11/02/2026 14:30');
expect(input.selectionStart).toBe(3);
expect(input.selectionEnd).toBe(5);
});
fireEvent.keyDown(input, { key: '0' });
fireEvent.keyDown(input, { key: '3' });
await waitFor(() => {
expect(input.value).toBe('11/03/2026 14:30');
expect(input.selectionStart).toBe(6);
expect(input.selectionEnd).toBe(10);
});
fireEvent.keyDown(input, { key: '2' });
fireEvent.keyDown(input, { key: '0' });
fireEvent.keyDown(input, { key: '2' });
fireEvent.keyDown(input, { key: '7' });
await waitFor(() => {
expect(input.value).toBe('11/03/2027 14:30');
expect(input.selectionStart).toBe(11);
expect(input.selectionEnd).toBe(13);
});
fireEvent.keyDown(input, { key: '1' });
fireEvent.keyDown(input, { key: '6' });
await waitFor(() => {
expect(input.value).toBe('11/03/2027 16:30');
expect(input.selectionStart).toBe(14);
expect(input.selectionEnd).toBe(16);
});
fireEvent.keyDown(input, { key: '4' });
fireEvent.keyDown(input, { key: '5' });
await waitFor(() => {
expect(input.value).toBe('11/03/2027 16:45');
});
expect(onValueChange).toHaveBeenCalled();
});
it('preserves year 0000 while editing and does not coerce to 19xx', async () => {
render(
<ControlledDatePicker
type="date-time"
format="dd/mm/yyyy HH:mm"
initialValue="22/02/2026 14:30"
/>,
);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
fireEvent.focus(input);
await waitFor(() => {
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(2);
});
fireEvent.keyDown(input, { key: '/' });
await waitFor(() => {
expect(input.selectionStart).toBe(3);
expect(input.selectionEnd).toBe(5);
});
fireEvent.keyDown(input, { key: '/' });
await waitFor(() => {
expect(input.selectionStart).toBe(6);
expect(input.selectionEnd).toBe(10);
});
fireEvent.keyDown(input, { key: '0' });
await waitFor(() => {
expect(input.value).toBe('22/02/0000 14:30');
expect(input.selectionStart).toBe(6);
expect(input.selectionEnd).toBe(10);
});
fireEvent.keyDown(input, { key: '0' });
fireEvent.keyDown(input, { key: '0' });
fireEvent.keyDown(input, { key: '0' });
await waitFor(() => {
expect(input.value).toBe('22/02/0000 14:30');
expect(input.selectionStart).toBe(11);
expect(input.selectionEnd).toBe(13);
});
});
it('uses separator keys to move between editable segments', async () => {
render(
<ControlledDatePicker
type="date-time"
format="dd/mm/yyyy HH:mm"
initialValue="22/02/2026 14:30"
/>,
);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
fireEvent.focus(input);
await waitFor(() => {
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(2);
});
fireEvent.keyDown(input, { key: '/' });
await waitFor(() => {
expect(input.selectionStart).toBe(3);
expect(input.selectionEnd).toBe(5);
});
fireEvent.keyDown(input, { key: '/' });
await waitFor(() => {
expect(input.selectionStart).toBe(6);
expect(input.selectionEnd).toBe(10);
});
fireEvent.keyDown(input, { key: ' ' });
await waitFor(() => {
expect(input.selectionStart).toBe(11);
expect(input.selectionEnd).toBe(13);
});
fireEvent.keyDown(input, { key: ':' });
await waitFor(() => {
expect(input.selectionStart).toBe(14);
expect(input.selectionEnd).toBe(16);
});
});
it('applies min/max constraints to calendar and time options', () => {
render(
<ControlledDatePicker
type="date-time"
initialValue="2026/03/10 10:00"
min="2026/03/10 09:30"
max="2026/03/10 10:15"
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
expect(getCurrentMonthDayButton('9')).toBeDisabled();
expect(getCurrentMonthDayButton('10')).toBeEnabled();
expect(getCurrentMonthDayButton('11')).toBeDisabled();
const hoursList = screen.getByRole('listbox', { name: 'Hours' });
expect(within(hoursList).getByRole('button', { name: '08' })).toBeDisabled();
expect(within(hoursList).getByRole('button', { name: '09' })).toBeEnabled();
expect(within(hoursList).getByRole('button', { name: '10' })).toBeEnabled();
expect(within(hoursList).getByRole('button', { name: '11' })).toBeDisabled();
const minutesList = screen.getByRole('listbox', { name: 'Minutes' });
expect(within(minutesList).getByRole('button', { name: '15' })).toBeEnabled();
expect(within(minutesList).getByRole('button', { name: '20' })).toBeDisabled();
});
it('renders date mode calendar only and auto-closes after day selection', () => {
const onValueChange = vi.fn();
render(
<ControlledDatePicker
type="date"
initialValue="2031/05/20"
onValueChange={onValueChange}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
expect(screen.getByRole('dialog', { name: 'Date picker popup' })).toBeInTheDocument();
expect(screen.queryByText('Hours')).not.toBeInTheDocument();
expect(screen.queryByText('Minutes')).not.toBeInTheDocument();
pickCurrentMonthDay('15');
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
expect(input.value).toMatch(/^\d{4}\/\d{2}\/15$/);
expect(onValueChange).toHaveBeenCalled();
expect(screen.queryByRole('dialog', { name: 'Date picker popup' })).not.toBeInTheDocument();
});
it('commits calendar date changes in date-time mode without closing the popup', () => {
const onValueChange = vi.fn();
render(
<ControlledDatePicker
type="date-time"
initialValue="2031/05/20 14:30"
onValueChange={onValueChange}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
pickCurrentMonthDay('15');
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
expect(input.value).toMatch(/^\d{4}\/\d{2}\/15 14:30$/);
expect(onValueChange).toHaveBeenCalled();
expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument();
});
it('renders time mode selectors only and keeps popup open after selection', () => {
const onValueChange = vi.fn();
render(<ControlledDatePicker type="time" initialValue="09:00" onValueChange={onValueChange} />);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
expect(screen.getByRole('dialog', { name: 'Time picker popup' })).toBeInTheDocument();
expect(screen.queryByRole('grid')).not.toBeInTheDocument();
const hoursList = screen.getByRole('listbox', { name: 'Hours' });
const minutesList = screen.getByRole('listbox', { name: 'Minutes' });
fireEvent.click(within(hoursList).getByRole('button', { name: '13' }));
fireEvent.click(within(minutesList).getByRole('button', { name: '45' }));
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
expect(input.value).toBe('13:45');
expect(onValueChange).toHaveBeenCalledTimes(2);
expect(screen.getByRole('dialog', { name: 'Time picker popup' })).toBeInTheDocument();
});
it('blocks popup interactions while disabled', () => {
render(<ControlledDatePicker type="date" initialValue="2031/05/20" disabled />);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
const iconButton = screen.getByRole('button', { name: 'Open date picker' });
expect(input).toBeDisabled();
expect(iconButton).toBeDisabled();
fireEvent.click(iconButton);
expect(screen.queryByRole('dialog', { name: 'Date picker popup' })).not.toBeInTheDocument();
});
it('renders right icon and error message', () => {
const { container } = render(
<DatePicker
label="Schedule"
type="date-time"
value="2031/05/20 14:30"
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('forwards inputRef for callback and object refs', () => {
const callbackRef = vi.fn();
const objectRef = createRef<HTMLInputElement>();
const { rerender } = render(
<DatePicker
label="Schedule"
type="date-time"
value="2031/05/20 14:30"
onChange={() => {}}
inputRef={callbackRef}
/>,
);
expect(callbackRef).toHaveBeenCalled();
const callbackNode = callbackRef.mock.calls.find(([node]) => node instanceof HTMLInputElement)?.[0];
expect(callbackNode).toBeInstanceOf(HTMLInputElement);
rerender(
<DatePicker
label="Schedule"
type="date-time"
value="2031/05/20 14:30"
onChange={() => {}}
inputRef={objectRef}
/>,
);
expect(objectRef.current).toBeInstanceOf(HTMLInputElement);
});
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');
});
it('handles outside vs inside pointer interactions for popup close behavior', () => {
render(<DatePicker label="Schedule" type="date-time" value="2031/05/20 14:30" onChange={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
const dialog = screen.getByRole('dialog', { name: 'Date and time picker popup' });
fireEvent.mouseDown(dialog);
expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument();
fireEvent.mouseDown(document.body);
expect(
screen.queryByRole('dialog', { name: 'Date and time picker popup' }),
).not.toBeInTheDocument();
});
it('supports month/year chooser interactions and month navigation buttons', async () => {
render(<DatePicker label="Schedule" type="date" value="2031/05/20" onChange={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
fireEvent.click(screen.getByRole('button', { name: 'Previous month' }));
fireEvent.click(screen.getByRole('button', { name: 'Next month' }));
const chooserButtons = document.querySelectorAll('.datepicker-chooser-btn');
const monthChooserButton = chooserButtons[0] as HTMLButtonElement;
const yearChooserButton = chooserButtons[1] as HTMLButtonElement;
const initialMonthText = monthChooserButton.textContent;
fireEvent.click(monthChooserButton);
const monthList = screen.getByRole('listbox', { name: 'Choose month' });
const monthOptions = within(monthList).getAllByRole('button');
const nextMonth = monthOptions.find((option) => option.textContent !== initialMonthText) ?? monthOptions[0];
fireEvent.click(nextMonth);
await waitFor(() => {
expect(screen.queryByRole('listbox', { name: 'Choose month' })).not.toBeInTheDocument();
});
expect((document.querySelectorAll('.datepicker-chooser-btn')[0] as HTMLButtonElement).textContent).toBe(
nextMonth.textContent,
);
const initialYearText = yearChooserButton.textContent;
fireEvent.click(yearChooserButton);
const yearList = screen.getByRole('listbox', { name: 'Choose year' });
const yearOptions = within(yearList).getAllByRole('button');
const nextYear = yearOptions.find((option) => option.textContent !== initialYearText) ?? yearOptions[0];
fireEvent.click(nextYear);
await waitFor(() => {
expect(screen.queryByRole('listbox', { name: 'Choose year' })).not.toBeInTheDocument();
});
expect((document.querySelectorAll('.datepicker-chooser-btn')[1] as HTMLButtonElement).textContent).toBe(
nextYear.textContent,
);
});
it('guards month navigation at absolute calendar boundaries', () => {
const first = render(
<DatePicker label="Schedule" type="date" value="0000/01/15" onChange={() => {}} />,
);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
fireEvent.click(screen.getByRole('button', { name: 'Previous month' }));
const lowerYearButton = document.querySelectorAll('.datepicker-chooser-btn')[1] as HTMLButtonElement;
expect(lowerYearButton.textContent).toBe('0');
first.unmount();
render(<DatePicker label="Schedule" type="date" value="9999/12/15" onChange={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
fireEvent.click(screen.getByRole('button', { name: 'Next month' }));
const upperYearButton = document.querySelectorAll('.datepicker-chooser-btn')[1] as HTMLButtonElement;
expect(upperYearButton.textContent).toBe('9999');
});
it('supports keyboard navigation commands and segment reset shortcuts', async () => {
const onBlur = vi.fn();
render(
<ControlledDatePicker
type="date-time"
format="dd/mm/yyyy HH:mm"
initialValue="22/02/2026 14:30"
onBlur={onBlur}
/>,
);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
fireEvent.focus(input);
await waitFor(() => {
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(2);
});
fireEvent.keyDown(input, { key: '1' });
fireEvent.keyDown(input, { key: 'ArrowRight' });
await waitFor(() => {
expect(input.value.startsWith('01/')).toBe(true);
expect(input.selectionStart).toBe(3);
});
fireEvent.mouseUp(input);
fireEvent.click(input);
fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument();
fireEvent.keyDown(input, { key: 'ArrowRight' });
await waitFor(() => expect(input.selectionStart).toBe(3));
fireEvent.keyDown(input, { key: 'ArrowLeft' });
await waitFor(() => expect((input.selectionStart ?? 0) <= 3).toBe(true));
fireEvent.keyDown(input, { key: 'Tab' });
await waitFor(() => expect((input.selectionStart ?? 0) >= 0).toBe(true));
fireEvent.keyDown(input, { key: 'Tab', shiftKey: true });
await waitFor(() => expect((input.selectionStart ?? 0) >= 0).toBe(true));
fireEvent.keyDown(input, { key: 'Backspace' });
await waitFor(() => {
expect(input.value.startsWith('01/')).toBe(true);
});
fireEvent.keyDown(input, { key: '/' });
await waitFor(() => expect(input.selectionStart).toBe(3));
fireEvent.keyDown(input, { key: 'Delete' });
await waitFor(() => {
expect(input.value.slice(3, 5)).toBe('01');
});
fireEvent.keyDown(input, { key: '/' });
await waitFor(() => expect(input.selectionStart).toBe(6));
fireEvent.keyDown(input, { key: 'Delete' });
fireEvent.keyDown(input, { key: ' ' });
fireEvent.keyDown(input, { key: 'Delete' });
await waitFor(() => {
expect(/\s00:|:00$/.test(input.value)).toBe(true);
});
fireEvent.keyDown(input, { key: 'Enter' });
fireEvent.keyDown(input, { key: 'x' });
fireEvent.blur(input);
expect(onBlur).toHaveBeenCalledTimes(1);
});
it('handles paste validation and value commits', async () => {
render(
<ControlledDatePicker
type="date-time"
format="dd/mm/yyyy HH:mm"
initialValue="22/02/2026 14:30"
/>,
);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
await act(async () => {
input.dispatchEvent(createPasteEvent('invalid value'));
});
expect(input.value).toBe('22/02/2026 14:30');
await act(async () => {
input.dispatchEvent(createPasteEvent('11/03/2027 16:45'));
});
expect(input.value).toBe('11/03/2027 16:45');
});
it('returns early from interaction handlers when disabled', () => {
render(
<DatePicker
label="Schedule"
type="date-time"
format="dd/mm/yyyy HH:mm"
value="22/02/2026 14:30"
disabled
onChange={() => {}}
/>,
);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
fireEvent.focus(input);
fireEvent.mouseUp(input);
fireEvent.click(input);
fireEvent.keyDown(input, { key: 'ArrowDown' });
const disabledPaste = createPasteEvent('11/03/2027 16:45');
input.dispatchEvent(disabledPaste);
expect(disabledPaste.defaultPrevented).toBe(false);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('falls back to direct assignment and synthetic onChange when native dispatch is stubbed', async () => {
const onChange = vi.fn();
const originalGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;
render(
<DatePicker
label="Schedule"
type="date-time"
format="dd/mm/yyyy HH:mm"
value="22/02/2026 14:30"
onChange={onChange}
/>,
);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
const nativeDispatchEvent = input.dispatchEvent.bind(input);
vi.spyOn(input, 'dispatchEvent').mockImplementation((event: Event) => {
if (event.type === 'change') {
return true;
}
return nativeDispatchEvent(event);
});
vi.spyOn(Object, 'getOwnPropertyDescriptor').mockImplementation((target, property) => {
if (target === HTMLInputElement.prototype && property === 'value') {
return {
configurable: true,
enumerable: true,
get: originalGetOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.get,
};
}
return originalGetOwnPropertyDescriptor(target, property);
});
fireEvent.focus(input);
input.setSelectionRange(0, 2);
fireEvent.keyDown(input, { key: '1' });
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
});
it('safely handles pending segment selection during unmount', () => {
vi.useFakeTimers();
const { unmount } = render(
<DatePicker
label="Schedule"
type="date-time"
format="dd/mm/yyyy HH:mm"
value="22/02/2026 14:30"
onChange={() => {}}
/>,
);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
fireEvent.focus(input);
unmount();
expect(() => {
vi.runAllTimers();
}).not.toThrow();
});
it('recalculates popup position on window resize and scroll', async () => {
const originalInnerWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth');
const originalInnerHeight = Object.getOwnPropertyDescriptor(window, 'innerHeight');
const rectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function mockRect(this: HTMLElement) {
if (this.classList.contains('datepicker-popup')) {
return createRect(0, 0, 300, 200);
}
if (this.classList.contains('relative') && this.querySelector('input')) {
return createRect(500, 80, 120, 40);
}
return createRect(0, 0, 100, 30);
});
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 600 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 480 });
render(<DatePicker label="Schedule" type="date-time" value="2031/05/20 14:30" onChange={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
const dialog = screen.getByRole('dialog', { name: 'Date and time picker popup' });
expect(dialog).toBeInTheDocument();
await waitFor(() => {
expect(dialog.style.left).toBe('292px');
});
fireEvent(window, new Event('resize'));
fireEvent(window, new Event('scroll'));
expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument();
expect(dialog.style.left).toBe('292px');
rectSpy.mockRestore();
if (originalInnerWidth) {
Object.defineProperty(window, 'innerWidth', originalInnerWidth);
}
if (originalInnerHeight) {
Object.defineProperty(window, 'innerHeight', originalInnerHeight);
}
});
});

View File

@@ -0,0 +1,60 @@
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');
});
it('supports rendering without a label', () => {
const { container } = render(<Dropdown value="USER" choices={choices} />);
expect(container.querySelector('label > span')).toBeNull();
});
});

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,97 @@
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');
});
it('supports rendering without a label', () => {
const { container } = render(<InputField type="text" value="" onChange={() => {}} />);
expect(container.querySelector('label > span')).toBeNull();
});
});

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;

18
tests/setup.ts Normal file
View File

@@ -0,0 +1,18 @@
// 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();
const storage = globalThis.window?.localStorage ?? globalThis.localStorage;
if (typeof storage?.clear === 'function') {
storage.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,25 @@ export default defineConfig({
], ],
}, },
}, },
test: {
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.story.{ts,tsx}',
'src/**/*.stories.{ts,tsx}',
'src/index.ts',
'src/styles/**',
'src/components/types.ts',
'src/types/**',
],
thresholds: {
lines: 80,
functions: 75,
branches: 70,
},
},
},
}); });

628
yarn.lock

File diff suppressed because it is too large Load Diff