Compare commits
21 Commits
d1266f24a3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1523f7be2c | |||
| b664c99944 | |||
| 3d4a4a5f57 | |||
| f9864842b5 | |||
| dd084369e9 | |||
| 850eed0766 | |||
| 4904bea29c | |||
| 4fc3738adf | |||
| e17c82de2f | |||
| a527ce27cd | |||
| 623e45d241 | |||
| 5593746cf4 | |||
| b163fdaa62 | |||
| ec63a10027 | |||
| 5ada69773c | |||
| 370d6e7e0a | |||
| 44dd5d5deb | |||
| 8d3ca5a281 | |||
| 1d5113d209 | |||
| f71e773a3a | |||
| 4921afe296 |
51
.drone.yml
51
.drone.yml
@@ -13,48 +13,65 @@ trigger:
|
||||
|
||||
steps:
|
||||
- name: install
|
||||
image: node:22
|
||||
image: node:25
|
||||
commands:
|
||||
- corepack enable
|
||||
- corepack prepare yarn@1.22.22 --activate
|
||||
- yarn install --frozen-lockfile
|
||||
|
||||
- name: lint
|
||||
image: node:22
|
||||
image: node:25
|
||||
commands:
|
||||
- corepack enable
|
||||
- corepack prepare yarn@1.22.22 --activate
|
||||
- yarn lint
|
||||
|
||||
- name: build
|
||||
image: node:22
|
||||
image: node:25
|
||||
commands:
|
||||
- corepack enable
|
||||
- corepack prepare yarn@1.22.22 --activate
|
||||
- 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
|
||||
type: docker
|
||||
name: web-ui-publish
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
event:
|
||||
- promote
|
||||
target:
|
||||
- production
|
||||
- tag
|
||||
ref:
|
||||
- refs/tags/v*
|
||||
|
||||
steps:
|
||||
- name: publish-npm
|
||||
image: node:22
|
||||
image: node:25
|
||||
environment:
|
||||
NEXUS_NPM_TOKEN:
|
||||
from_secret: nexus_npm_token
|
||||
commands:
|
||||
- corepack enable
|
||||
- corepack prepare yarn@1.22.22 --activate
|
||||
- yarn install --frozen-lockfile
|
||||
- npm config set //nexus.beatrice.wtf/repository/npm-hosted/:_authToken "$NEXUS_NPM_TOKEN"
|
||||
- yarn publish:nexus
|
||||
|
||||
@@ -45,4 +45,14 @@ export default tseslint.config(
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['tests/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
...globals.vitest,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
17
package.json
17
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@panic/web-ui",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.18",
|
||||
"license": "AGPL-3.0-only",
|
||||
"description": "Core components for panic.haus web applications",
|
||||
"type": "module",
|
||||
@@ -27,6 +27,9 @@
|
||||
"scripts": {
|
||||
"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",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage --coverage.reporter=lcov --coverage.reporter=text-summary",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier . --write",
|
||||
@@ -64,23 +67,31 @@
|
||||
"@storybook/react": "^10.2.10",
|
||||
"@storybook/react-vite": "^10.2.10",
|
||||
"@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-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"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",
|
||||
"globals": "^17.3.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.8.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"storybook": "^10.2.10",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.6.2",
|
||||
"typescript-eslint": "^8.56.0",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^4.0.18",
|
||||
"yjs": "^13.6.24"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "^25.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
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' } },
|
||||
},
|
||||
type: {
|
||||
description: 'Native date input type.',
|
||||
options: ['date', 'datetime-local', 'time'],
|
||||
description: 'DatePicker mode.',
|
||||
options: ['date', 'date-time', 'time'],
|
||||
control: 'inline-radio',
|
||||
table: { type: { summary: "'date' | 'datetime-local' | 'time'" } },
|
||||
table: { type: { summary: "'date' | 'date-time' | 'time'" } },
|
||||
},
|
||||
size: {
|
||||
description: 'Input size.',
|
||||
@@ -55,6 +55,22 @@ const meta = {
|
||||
control: 'text',
|
||||
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: {
|
||||
description: 'Native input `name` attribute.',
|
||||
control: 'text',
|
||||
@@ -108,7 +124,7 @@ const meta = {
|
||||
},
|
||||
args: {
|
||||
label: 'Schedule at',
|
||||
type: 'datetime-local',
|
||||
type: 'date-time',
|
||||
value: '',
|
||||
size: 'md',
|
||||
width: 'md',
|
||||
@@ -124,8 +140,8 @@ export const DateOnly: Story = {
|
||||
type: 'date',
|
||||
label: 'Publish date',
|
||||
},
|
||||
render: (args) => {
|
||||
const [value, setValue] = useState('2031-05-20');
|
||||
render: function DateOnlyRender(args) {
|
||||
const [value, setValue] = useState('2031/05/20');
|
||||
return (
|
||||
<DatePicker
|
||||
{...args}
|
||||
@@ -141,11 +157,11 @@ export const DateOnly: Story = {
|
||||
|
||||
export const DateTime: Story = {
|
||||
args: {
|
||||
type: 'datetime-local',
|
||||
type: 'date-time',
|
||||
label: 'Schedule at',
|
||||
},
|
||||
render: (args) => {
|
||||
const [value, setValue] = useState('2031-05-20T14:30');
|
||||
render: function DateTimeRender(args) {
|
||||
const [value, setValue] = useState('2031/05/20 14:30');
|
||||
return (
|
||||
<DatePicker
|
||||
{...args}
|
||||
@@ -167,7 +183,7 @@ export const TimeOnlyInline: Story = {
|
||||
size: 'sm',
|
||||
rightIcon: <CalendarDaysIcon className="h-4 w-4 ui-body-secondary" />,
|
||||
},
|
||||
render: (args) => {
|
||||
render: function TimeOnlyInlineRender(args) {
|
||||
const [value, setValue] = useState('09:00');
|
||||
return (
|
||||
<DatePicker
|
||||
@@ -182,9 +198,10 @@ export const TimeOnlyInline: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
export const ErrorState: Story = {
|
||||
name: 'Error',
|
||||
args: {
|
||||
type: 'datetime-local',
|
||||
type: 'date-time',
|
||||
label: 'Schedule at',
|
||||
value: '',
|
||||
error: 'Pick a valid future date and time',
|
||||
@@ -193,20 +210,20 @@ export const Error: Story = {
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
type: 'datetime-local',
|
||||
type: 'date-time',
|
||||
label: 'Published at',
|
||||
value: '2031-05-20T14:30',
|
||||
value: '2031/05/20 14:30',
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const SizeMatrix: Story = {
|
||||
args: {
|
||||
type: 'datetime-local',
|
||||
type: 'date-time',
|
||||
label: 'Schedule at',
|
||||
},
|
||||
render: (args) => {
|
||||
const [value, setValue] = useState('2031-05-20T14:30');
|
||||
render: function SizeMatrixRender(args) {
|
||||
const [value, setValue] = useState('2031/05/20 14:30');
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<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
@@ -99,7 +99,7 @@ export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Stacked: Story = {
|
||||
render: (args) => {
|
||||
render: function StackedRender(args) {
|
||||
const [value, setValue] = useState(args.value);
|
||||
return (
|
||||
<Dropdown
|
||||
@@ -119,7 +119,7 @@ export const Inline: Story = {
|
||||
layout: 'inline',
|
||||
size: 'sm',
|
||||
},
|
||||
render: (args) => {
|
||||
render: function InlineRender(args) {
|
||||
const [value, setValue] = useState(args.value);
|
||||
return (
|
||||
<Dropdown
|
||||
@@ -147,7 +147,7 @@ export const WithError: Story = {
|
||||
};
|
||||
|
||||
export const SizeMatrix: Story = {
|
||||
render: (args) => {
|
||||
render: function SizeMatrixRender(args) {
|
||||
const [value, setValue] = useState(args.value);
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
|
||||
@@ -67,7 +67,7 @@ export const Basic: Story = {
|
||||
};
|
||||
|
||||
export const WithActions: Story = {
|
||||
render: (args) => {
|
||||
render: function WithActionsRender(args) {
|
||||
const [title, setTitle] = useState('Storybook powered CMS');
|
||||
const [status, setStatus] = useState('draft');
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ export const Text: Story = {
|
||||
label: 'Title',
|
||||
placeholder: 'Write a title',
|
||||
},
|
||||
render: (args) => {
|
||||
render: function TextRender(args) {
|
||||
const [value, setValue] = useState('Storybook integration');
|
||||
return (
|
||||
<InputField
|
||||
@@ -148,7 +148,7 @@ export const PasswordWithToggle: Story = {
|
||||
label: 'Password',
|
||||
placeholder: 'Type a strong password',
|
||||
},
|
||||
render: (args) => {
|
||||
render: function PasswordWithToggleRender(args) {
|
||||
const [value, setValue] = useState('pa55word');
|
||||
return (
|
||||
<InputField
|
||||
@@ -171,7 +171,7 @@ export const InlineWithIcon: Story = {
|
||||
size: 'sm',
|
||||
rightIcon: <MagnifyingGlassIcon className="h-4 w-4 ui-body-secondary" />,
|
||||
},
|
||||
render: (args) => {
|
||||
render: function InlineWithIconRender(args) {
|
||||
const [value, setValue] = useState('posts');
|
||||
return (
|
||||
<InputField
|
||||
@@ -186,7 +186,8 @@ export const InlineWithIcon: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
export const ErrorState: Story = {
|
||||
name: 'Error',
|
||||
args: {
|
||||
type: 'email',
|
||||
label: 'Email',
|
||||
@@ -210,7 +211,7 @@ export const SizeMatrix: Story = {
|
||||
label: 'Name',
|
||||
placeholder: 'Enter value',
|
||||
},
|
||||
render: (args) => {
|
||||
render: function SizeMatrixRender(args) {
|
||||
const [value, setValue] = useState('Beatrice');
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
|
||||
@@ -53,7 +53,8 @@ type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Body: Story = {};
|
||||
|
||||
export const Error: Story = {
|
||||
export const ErrorState: Story = {
|
||||
name: 'Error',
|
||||
args: {
|
||||
variant: 'error',
|
||||
children: 'This field is required',
|
||||
|
||||
@@ -115,7 +115,7 @@ export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Editable: Story = {
|
||||
render: (args) => {
|
||||
render: function EditableRender(args) {
|
||||
const [markdown, setMarkdown] = useState(args.markdown);
|
||||
return (
|
||||
<div className="w-full max-w-2xl">
|
||||
|
||||
@@ -183,7 +183,7 @@ export const Empty: Story = {
|
||||
};
|
||||
|
||||
export const InteractiveSortingAndPagination: Story = {
|
||||
render: () => {
|
||||
render: function InteractiveSortingAndPaginationRender() {
|
||||
const [sorting, setSorting] = useState<SortState | null>({
|
||||
field: 'name',
|
||||
direction: 'asc',
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
--bg-page: #16121a;
|
||||
--surface-bg: rgba(24, 24, 27, 0.45);
|
||||
--surface-bg-strong: rgba(24, 24, 27, 0.62);
|
||||
--datepicker-menu-bg: #18181b;
|
||||
--surface-border: rgba(82, 82, 91, 0.6);
|
||||
--surface-divider: rgba(63, 63, 70, 0.85);
|
||||
--text-primary: #d5cfdf;
|
||||
@@ -22,6 +23,8 @@
|
||||
--field-disabled-border: #3f3f46;
|
||||
--field-disabled-text: #bbb6c3;
|
||||
--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-border: #3f3f46;
|
||||
--ghost-hover: rgba(39, 39, 42, 0.7);
|
||||
@@ -57,6 +60,7 @@
|
||||
--bg-page: #f7f7fb;
|
||||
--surface-bg: rgba(255, 255, 255, 0.9);
|
||||
--surface-bg-strong: rgba(255, 255, 255, 0.98);
|
||||
--datepicker-menu-bg: #ffffff;
|
||||
--surface-border: rgba(161, 161, 170, 0.45);
|
||||
--surface-divider: rgba(212, 212, 216, 0.9);
|
||||
--text-primary: #52485c;
|
||||
@@ -69,6 +73,8 @@
|
||||
--field-disabled-border: #d7d7d7;
|
||||
--field-disabled-text: #71717a;
|
||||
--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-border: #d4d4d8;
|
||||
--ghost-hover: #f4f4f5;
|
||||
|
||||
@@ -24,6 +24,12 @@
|
||||
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 {
|
||||
border-color: var(--field-disabled-border);
|
||||
background-color: var(--field-disabled-bg);
|
||||
@@ -321,3 +327,190 @@
|
||||
color: var(--text-secondary);
|
||||
@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;
|
||||
}
|
||||
|
||||
77
tests/components/Button.test.tsx
Normal file
77
tests/components/Button.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
62
tests/components/Chip.test.tsx
Normal file
62
tests/components/Chip.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
428
tests/components/DatePicker.logic.test.ts
Normal file
428
tests/components/DatePicker.logic.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
740
tests/components/DatePicker.test.tsx
Normal file
740
tests/components/DatePicker.test.tsx
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
60
tests/components/Dropdown.test.tsx
Normal file
60
tests/components/Dropdown.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
97
tests/components/InputField.test.tsx
Normal file
97
tests/components/InputField.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
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;
|
||||
18
tests/setup.ts
Normal file
18
tests/setup.ts
Normal 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();
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user