Compare commits

...

33 Commits

Author SHA1 Message Date
514b643566 Update dependency react-router-dom to v7.13.1
All checks were successful
continuous-integration/drone/pr Build is passing
2026-02-24 22:10:52 +00:00
1523f7be2c add unit tests
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 17:55:26 +01:00
b664c99944 fix sonar issues
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 16:27:53 +01:00
3d4a4a5f57 rewrite datepicker, v0.1.18
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is passing
2026-02-24 16:22:12 +01:00
f9864842b5 fix sonar bugs
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 12:18:55 +01:00
dd084369e9 update thresholds
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 12:10:36 +01:00
850eed0766 update ci
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 12:06:43 +01:00
4904bea29c update unit tests coverage
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 11:51:08 +01:00
4fc3738adf add unit tests
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 11:33:37 +01:00
e17c82de2f fix eslint ver
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 11:23:41 +01:00
a527ce27cd add code analysis
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 11:21:26 +01:00
623e45d241 update eslint
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 11:15:26 +01:00
5593746cf4 remove unused css
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 10:43:22 +01:00
b163fdaa62 update styles, v0.1.17
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-02-24 10:37:36 +01:00
ec63a10027 update ci
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-02-24 10:14:08 +01:00
5ada69773c update ci, datepicker, v0.1.16
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 10:06:41 +01:00
370d6e7e0a v0.1.15
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-02-23 23:53:32 +01:00
44dd5d5deb v0.1.14
Some checks failed
continuous-integration/drone/push Build encountered an error
continuous-integration/drone/tag Build encountered an error
2026-02-23 23:51:42 +01:00
8d3ca5a281 fix ci
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-02-23 23:51:16 +01:00
1d5113d209 fix datepicker css, v0.1.13
Some checks failed
continuous-integration/drone/tag Build is failing
continuous-integration/drone/push Build is passing
2026-02-23 23:48:58 +01:00
f71e773a3a update drone
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 23:34:04 +01:00
4921afe296 fix date picker highlighting, v0.1.12
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 23:29:06 +01:00
5cc3e3646c make accent overridable, v0.1.11
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 22:59:57 +01:00
29a4e8c2ee add width, v0.1.10
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 19:57:19 +01:00
3ddd108186 add datepicker, v0.1.9
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 19:21:38 +01:00
836d24943e update eslint
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 15:18:13 +01:00
a312141c21 v0.1.8
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 14:50:55 +01:00
457962ede2 update css
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 14:50:12 +01:00
f1c7e245aa update prettier
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 14:23:37 +01:00
c2e370f0a8 add eslint / prettier
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 14:19:16 +01:00
01b00b5717 update package.json
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 14:01:34 +01:00
f9a9c89e4f v0.1.7
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 13:57:36 +01:00
6ba98fa6b6 fix build
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 13:56:52 +01:00
57 changed files with 7876 additions and 2098 deletions

View File

@@ -13,41 +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: build
image: node:22
- name: lint
image: node:25
commands:
- yarn lint
- name: build
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

5
.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
dist
coverage
storybook-static
node_modules
yarn.lock

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"singleQuote": true,
"semi": true,
"printWidth": 100,
"tabWidth": 4
}

View File

@@ -2,18 +2,14 @@ import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx|mdx)'],
addons: [
'@storybook/addon-docs',
'@storybook/addon-a11y',
'@storybook/addon-themes'
],
addons: ['@storybook/addon-docs', '@storybook/addon-a11y', '@storybook/addon-themes'],
framework: {
name: '@storybook/react-vite',
options: {}
options: {},
},
docs: {
autodocs: 'tag'
}
autodocs: 'tag',
},
};
export default config;

View File

@@ -11,17 +11,17 @@ const preview: Preview = {
<MemoryRouter>
<Story />
</MemoryRouter>
)
),
],
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i
}
date: /Date$/i,
},
},
layout: 'centered',
},
layout: 'centered'
}
};
export default preview;

58
eslint.config.mjs Normal file
View File

@@ -0,0 +1,58 @@
import js from '@eslint/js';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: [
'dist',
'coverage',
'storybook-static',
'node_modules',
'*.config.cjs',
'vite.config.js',
'vite.config.d.ts',
'tailwind-preset.cjs',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
},
},
rules: {
'no-empty': ['error', { allowEmptyCatch: true }],
},
},
{
files: ['src/**/*.{ts,tsx}'],
ignores: ['src/**/*.stories.ts', 'src/**/*.stories.tsx'],
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
{
files: ['tests/**/*.{ts,tsx}'],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
...globals.vitest,
},
},
},
);

View File

@@ -1,5 +1,4 @@
GNU Affero General Public License
=================================
# GNU Affero General Public License
_Version 3, 19 November 2007_
_Copyright © 2007 Free Software Foundation, Inc. &lt;<http://fsf.org/>&gt;_
@@ -201,20 +200,20 @@ You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
* **a)** The work must carry prominent notices stating that you modified
- **a)** The work must carry prominent notices stating that you modified
it, and giving a relevant date.
* **b)** The work must carry prominent notices stating that it is
- **b)** The work must carry prominent notices stating that it is
released under this License and any conditions added under section 7.
This requirement modifies the requirement in section 4 to
“keep intact all notices”.
* **c)** You must license the entire work, as a whole, under this
- **c)** You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
* **d)** If the work has interactive user interfaces, each must display
- **d)** If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
@@ -236,11 +235,11 @@ of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
* **a)** Convey the object code in, or embodied in, a physical product
- **a)** Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
* **b)** Convey the object code in, or embodied in, a physical product
- **b)** Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
@@ -251,12 +250,12 @@ in one of these ways:
more than your reasonable cost of physically performing this
conveying of source, or **(2)** access to copy the
Corresponding Source from a network server at no charge.
* **c)** Convey individual copies of the object code with a copy of the
- **c)** Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
* **d)** Convey the object code by offering access from a designated
- **d)** Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
@@ -268,7 +267,7 @@ in one of these ways:
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
* **e)** Convey the object code using peer-to-peer transmission, provided
- **e)** Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
@@ -345,19 +344,19 @@ Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
* **a)** Disclaiming warranty or limiting liability differently from the
- **a)** Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
* **b)** Requiring preservation of specified reasonable legal notices or
- **b)** Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
* **c)** Prohibiting misrepresentation of the origin of that material, or
- **c)** Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
* **d)** Limiting the use for publicity purposes of names of licensors or
- **d)** Limiting the use for publicity purposes of names of licensors or
authors of the material; or
* **e)** Declining to grant rights under trademark law for use of some
- **e)** Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
* **f)** Requiring indemnification of licensors and authors of that
- **f)** Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on

View File

@@ -1,6 +1,6 @@
{
"name": "@panic/web-ui",
"version": "0.1.6",
"version": "0.1.18",
"license": "AGPL-3.0-only",
"description": "Core components for panic.haus web applications",
"type": "module",
@@ -27,6 +27,13 @@
"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",
"format:check": "prettier . --check",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"prepublishOnly": "yarn build",
@@ -49,27 +56,42 @@
}
},
"devDependencies": {
"@codemirror/language": "^6.11.3",
"@eslint/js": "^10",
"@heroicons/react": "^2.2.0",
"@lezer/highlight": "^1.2.1",
"@mdxeditor/editor": "^3.52.4",
"@storybook/addon-a11y": "^10.2.10",
"@storybook/addon-docs": "^10.2.10",
"@storybook/addon-themes": "^10.2.10",
"@storybook/react": "^10.2.10",
"@storybook/react-vite": "^10.2.10",
"@codemirror/language": "^6.11.3",
"@heroicons/react": "^2.2.0",
"@lezer/highlight": "^1.2.1",
"@mdxeditor/editor": "^3.52.4",
"@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.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": "^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"
}
}

View File

@@ -1,7 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {
config: './tailwind.storybook.config.cjs'
}
}
config: './tailwind.storybook.config.cjs',
},
},
};

View File

@@ -9,77 +9,86 @@ const meta = {
parameters: {
docs: {
description: {
component: 'Polymorphic button component: renders a native `<button>` or a React Router `<Link>` when `to` is provided.'
}
}
component:
'Polymorphic button component: renders a native `<button>` or a React Router `<Link>` when `to` is provided.',
},
},
},
argTypes: {
label: {
description: 'Visible button label text.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
type: {
description: 'Base visual style.',
options: ['solid', 'outlined', 'noborder'],
control: 'inline-radio',
table: { type: { summary: "'solid' | 'outlined' | 'noborder'" } }
table: { type: { summary: "'solid' | 'outlined' | 'noborder'" } },
},
variant: {
description: 'Color variant. If omitted: `primary` for `solid`, `secondary` for the other types.',
description:
'Color variant. If omitted: `primary` for `solid`, `secondary` for the other types.',
options: ['primary', 'secondary', 'important'],
control: 'inline-radio',
table: { type: { summary: "'primary' | 'secondary' | 'important'" } }
table: { type: { summary: "'primary' | 'secondary' | 'important'" } },
},
size: {
description: 'Button size.',
options: ['sm', 'md', 'lg', 'full'],
control: 'inline-radio',
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } }
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } },
},
width: {
description: 'Button width behavior.',
options: ['sm', 'md', 'lg', 'full'],
control: 'inline-radio',
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } },
},
to: {
description: 'Navigation path. When set, the component renders a `<Link>`.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
htmlType: {
description: 'HTML button type used when rendering a native `<button>`.',
options: ['button', 'submit', 'reset'],
control: 'inline-radio',
table: { type: { summary: "'button' | 'submit' | 'reset'" } }
table: { type: { summary: "'button' | 'submit' | 'reset'" } },
},
disabled: {
description: 'Disables interaction and hover/click states.',
control: 'boolean',
table: { type: { summary: 'boolean' } }
table: { type: { summary: 'boolean' } },
},
icon: {
description: 'Optional icon component (for example Heroicons).',
control: false,
table: { type: { summary: 'ElementType' } }
table: { type: { summary: 'ElementType' } },
},
ariaLabel: {
description: 'Accessible label. Falls back to `label` when not provided.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
className: {
description: 'Extra CSS classes for the root element.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
onClick: {
description: 'Click handler callback.',
action: 'clicked',
table: { type: { summary: 'MouseEventHandler<HTMLElement>' } }
}
table: { type: { summary: 'MouseEventHandler<HTMLElement>' } },
},
},
args: {
type: 'solid',
variant: 'primary',
size: 'md',
label: 'Save'
}
width: 'md',
label: 'Save',
},
} satisfies Meta<typeof Button>;
export default meta;
@@ -91,37 +100,37 @@ export const SolidImportant: Story = {
args: {
type: 'solid',
variant: 'important',
label: 'Delete'
}
label: 'Delete',
},
};
export const OutlinedSecondary: Story = {
args: {
type: 'outlined',
variant: 'secondary',
label: 'Cancel'
}
label: 'Cancel',
},
};
export const IconOnly: Story = {
args: {
icon: PlusIcon,
label: undefined,
ariaLabel: 'Add item'
}
ariaLabel: 'Add item',
},
};
export const LinkButton: Story = {
args: {
to: '/demo',
label: 'Go to demo'
}
label: 'Go to demo',
},
};
export const Disabled: Story = {
args: {
disabled: true
}
disabled: true,
},
};
export const SizeMatrix: Story = {
@@ -130,7 +139,7 @@ export const SizeMatrix: Story = {
<Button {...args} size="sm" label="Small" />
<Button {...args} size="md" label="Medium" />
<Button {...args} size="lg" label="Large" />
<Button {...args} size="full" label="Full width" />
<Button {...args} size="md" width="full" label="Full width" />
</div>
)
),
};

View File

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

View File

@@ -8,42 +8,45 @@ const meta = {
parameters: {
docs: {
description: {
component: 'Compact badge/chip component with solid or outlined style. `tone` accepts a Tailwind color token (for example `cyan-700`) and resolves it to the correct border/background/text color at runtime.'
}
}
component:
'Compact badge/chip component with solid or outlined style. `tone` accepts a Tailwind color token (for example `cyan-700`) and resolves it to the correct border/background/text color at runtime.',
},
},
},
argTypes: {
variant: {
description: 'Chip visual style.',
options: ['solid', 'outlined'],
control: 'inline-radio',
table: { type: { summary: "'solid' | 'outlined'" } }
table: { type: { summary: "'solid' | 'outlined'" } },
},
tone: {
description: 'Tailwind color token (format: `<color>-<shade>`, for example `cyan-700`, `indigo-600`, `rose-500`).',
description:
'Tailwind color token (format: `<color>-<shade>`, for example `cyan-700`, `indigo-600`, `rose-500`).',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
as: {
description: "Root tag or component to render (for example `'span'`, `'a'`, `'button'`).",
description:
"Root tag or component to render (for example `'span'`, `'a'`, `'button'`).",
control: false,
table: { type: { summary: 'ElementType' } }
table: { type: { summary: 'ElementType' } },
},
className: {
description: 'Extra CSS classes for the root element.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
children: {
description: 'Text or React node rendered inside the chip.',
control: 'text',
table: { type: { summary: 'ReactNode' } }
}
table: { type: { summary: 'ReactNode' } },
},
},
args: {
children: 'Published',
variant: 'solid'
}
variant: 'solid',
},
} satisfies Meta<typeof Chip>;
export default meta;
@@ -55,29 +58,41 @@ export const OutlinedIndigo: Story = {
args: {
variant: 'outlined',
tone: 'indigo-700',
children: 'Draft'
}
children: 'Draft',
},
};
export const OutlinedCyan: Story = {
args: {
variant: 'outlined',
tone: 'cyan-700',
children: 'Archived'
}
children: 'Archived',
},
};
export const ToneMatrix: Story = {
render: () => (
<div className="flex flex-wrap items-center gap-2">
<Chip variant="solid">Default</Chip>
<Chip variant="solid" tone="indigo-700">Indigo</Chip>
<Chip variant="solid" tone="cyan-700">Cyan</Chip>
<Chip variant="solid" tone="rose-600">Rose</Chip>
<Chip variant="solid" tone="indigo-700">
Indigo
</Chip>
<Chip variant="solid" tone="cyan-700">
Cyan
</Chip>
<Chip variant="solid" tone="rose-600">
Rose
</Chip>
<Chip variant="outlined">Default</Chip>
<Chip variant="outlined" tone="indigo-700">Indigo</Chip>
<Chip variant="outlined" tone="cyan-700">Cyan</Chip>
<Chip variant="outlined" tone="rose-600">Rose</Chip>
<Chip variant="outlined" tone="indigo-700">
Indigo
</Chip>
<Chip variant="outlined" tone="cyan-700">
Cyan
</Chip>
<Chip variant="outlined" tone="rose-600">
Rose
</Chip>
</div>
)
),
};

View File

@@ -14,7 +14,7 @@ type ChipProps<T extends ElementType> = {
const variantClassMap: Record<ChipVariant, string> = {
solid: 'chip-solid',
outlined: 'chip-outlined'
outlined: 'chip-outlined',
};
type TailwindPalette = Record<string, string>;
@@ -25,7 +25,7 @@ function resolveTailwindToneColor(tone: string | undefined): string | null {
return null;
}
const colorSource = tailwindColors as Record<string, unknown>;
const colorSource = tailwindColors as unknown as Record<string, unknown>;
const lastDashIndex = normalizedTone.lastIndexOf('-');
if (lastDashIndex === -1) {
@@ -50,16 +50,21 @@ export function Chip<T extends ElementType = 'span'>({
tone,
as,
className = '',
children
children,
}: Readonly<ChipProps<T>>) {
const Component = as ?? 'span' as ElementType;
const Component = as ?? ('span' as ElementType);
const toneColor = resolveTailwindToneColor(tone);
const toneStyle: CSSProperties | undefined = toneColor == null
const toneStyle: CSSProperties | undefined =
toneColor == null
? undefined
: variant === 'solid'
? { borderColor: toneColor, backgroundColor: toneColor, color: '#ffffff' }
: { borderColor: toneColor, color: toneColor };
const classes = `chip-root ${variantClassMap[variant]} ${className}`.trim();
return <Component className={classes} style={toneStyle}>{children}</Component>;
return (
<Component className={classes} style={toneStyle}>
{children}
</Component>
);
}

View File

@@ -0,0 +1,280 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { CalendarDaysIcon } from '@heroicons/react/24/solid';
import { DatePicker } from './DatePicker';
const meta = {
title: 'Components/DatePicker',
component: DatePicker,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'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.',
},
},
},
argTypes: {
label: {
description: 'Label text shown above (stacked) or on the left (inline).',
control: 'text',
table: { type: { summary: 'string' } },
},
placeholder: {
description: 'Input placeholder text.',
control: 'text',
table: { type: { summary: 'string' } },
},
type: {
description: 'DatePicker mode.',
options: ['date', 'date-time', 'time'],
control: 'inline-radio',
table: { type: { summary: "'date' | 'date-time' | 'time'" } },
},
size: {
description: 'Input size.',
options: ['sm', 'md', 'lg', 'full'],
control: 'inline-radio',
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } },
},
width: {
description: 'Input width constraint.',
options: ['sm', 'md', 'lg', 'full'],
control: 'inline-radio',
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } },
},
layout: {
description: 'Label/input layout mode.',
options: ['stacked', 'inline'],
control: 'inline-radio',
table: { type: { summary: "'stacked' | 'inline'" } },
},
value: {
description: 'Controlled input value.',
control: 'text',
table: { type: { summary: 'string' } },
},
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',
table: { type: { summary: 'string' } },
},
disabled: {
description: 'Disables the input.',
control: 'boolean',
table: { type: { summary: 'boolean' } },
},
required: {
description: 'Sets the native HTML `required` attribute.',
control: 'boolean',
table: { type: { summary: 'boolean' } },
},
error: {
description: 'Validation message shown below the field.',
control: 'text',
table: { type: { summary: 'string' } },
},
rightIcon: {
description: 'Optional trailing icon node.',
control: false,
table: { type: { summary: 'ReactNode' } },
},
className: {
description: 'Extra CSS classes for the outer wrapper.',
control: 'text',
table: { type: { summary: 'string' } },
},
inputClassName: {
description: 'Extra CSS classes for the `<input>` element.',
control: 'text',
table: { type: { summary: 'string' } },
},
onChange: {
description: 'Change handler callback.',
action: 'changed',
table: { type: { summary: 'ChangeEventHandler<HTMLInputElement>' } },
},
onBlur: {
description: 'Blur handler callback.',
control: false,
table: { type: { summary: 'FocusEventHandler<HTMLInputElement>' } },
},
inputRef: {
description: 'Ref forwarded to the native `<input>` element.',
control: false,
table: { type: { summary: 'Ref<HTMLInputElement>' } },
},
},
args: {
label: 'Schedule at',
type: 'date-time',
value: '',
size: 'md',
width: 'md',
layout: 'stacked',
},
} satisfies Meta<typeof DatePicker>;
export default meta;
type Story = StoryObj<typeof meta>;
export const DateOnly: Story = {
args: {
type: 'date',
label: 'Publish date',
},
render: function DateOnlyRender(args) {
const [value, setValue] = useState('2031/05/20');
return (
<DatePicker
{...args}
value={value}
onChange={(event) => {
setValue(event.target.value);
args.onChange?.(event);
}}
/>
);
},
};
export const DateTime: Story = {
args: {
type: 'date-time',
label: 'Schedule at',
},
render: function DateTimeRender(args) {
const [value, setValue] = useState('2031/05/20 14:30');
return (
<DatePicker
{...args}
value={value}
onChange={(event) => {
setValue(event.target.value);
args.onChange?.(event);
}}
/>
);
},
};
export const TimeOnlyInline: Story = {
args: {
type: 'time',
label: 'Start time',
layout: 'inline',
size: 'sm',
rightIcon: <CalendarDaysIcon className="h-4 w-4 ui-body-secondary" />,
},
render: function TimeOnlyInlineRender(args) {
const [value, setValue] = useState('09:00');
return (
<DatePicker
{...args}
value={value}
onChange={(event) => {
setValue(event.target.value);
args.onChange?.(event);
}}
/>
);
},
};
export const ErrorState: Story = {
name: 'Error',
args: {
type: 'date-time',
label: 'Schedule at',
value: '',
error: 'Pick a valid future date and time',
},
};
export const Disabled: Story = {
args: {
type: 'date-time',
label: 'Published at',
value: '2031/05/20 14:30',
disabled: true,
},
};
export const SizeMatrix: Story = {
args: {
type: 'date-time',
label: 'Schedule at',
},
render: function SizeMatrixRender(args) {
const [value, setValue] = useState('2031/05/20 14:30');
return (
<div className="grid grid-cols-1 gap-3">
<DatePicker
{...args}
value={value}
size="sm"
onChange={(event) => setValue(event.target.value)}
/>
<DatePicker
{...args}
value={value}
size="md"
onChange={(event) => setValue(event.target.value)}
/>
<DatePicker
{...args}
value={value}
size="lg"
onChange={(event) => setValue(event.target.value)}
/>
<DatePicker
{...args}
value={value}
size="full"
width="full"
onChange={(event) => setValue(event.target.value)}
/>
</div>
);
},
};
export const CustomFormatWithRange: Story = {
args: {
type: 'date-time',
label: 'Starts at',
format: 'dd/mm/yyyy HH:mm',
min: '10/03/2026 09:00',
max: '24/03/2026 18:30',
},
render: function CustomFormatWithRangeRender(args) {
const [value, setValue] = useState('22/03/2026 14:30');
return (
<DatePicker
{...args}
value={value}
onChange={(event) => {
setValue(event.target.value);
args.onChange?.(event);
}}
/>
);
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ import { Dropdown } from './Dropdown';
const choices = [
{ id: 'draft', label: 'Draft' },
{ id: 'review', label: 'In review' },
{ id: 'published', label: 'Published' }
{ id: 'published', label: 'Published' },
];
const meta = {
@@ -15,83 +15,91 @@ const meta = {
parameters: {
docs: {
description: {
component: 'Styled select component with label, error state, stacked/inline layout, and multiple sizes.'
}
}
component:
'Styled select component with label, error state, stacked/inline layout, and multiple sizes.',
},
},
},
argTypes: {
label: {
description: 'Label text shown above (stacked) or on the left (inline).',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
value: {
description: 'Current selected value (must match one `choices[].id`).',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
choices: {
description: "Options list in `{ id: string; label: string }` format.",
description: 'Options list in `{ id: string; label: string }` format.',
control: 'object',
table: { type: { summary: 'Array<{ id: string; label: string }>' } }
table: { type: { summary: 'Array<{ id: string; label: string }>' } },
},
size: {
description: 'Control size.',
options: ['sm', 'md', 'lg', 'full'],
control: 'inline-radio',
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } }
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } },
},
width: {
description: 'Control width constraint.',
options: ['sm', 'md', 'lg', 'full'],
control: 'inline-radio',
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } },
},
layout: {
description: 'Label/control layout mode.',
options: ['stacked', 'inline'],
control: 'inline-radio',
table: { type: { summary: "'stacked' | 'inline'" } }
table: { type: { summary: "'stacked' | 'inline'" } },
},
disabled: {
description: 'Disables the field.',
control: 'boolean',
table: { type: { summary: 'boolean' } }
table: { type: { summary: 'boolean' } },
},
required: {
description: 'Sets the native HTML `required` attribute.',
control: 'boolean',
table: { type: { summary: 'boolean' } }
table: { type: { summary: 'boolean' } },
},
error: {
description: 'Error message shown below the field.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
className: {
description: 'Extra CSS classes for the wrapper.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
selectClassName: {
description: 'Extra CSS classes for the `<select>` element.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
onChange: {
description: 'Callback fired with the newly selected value.',
action: 'changed',
table: { type: { summary: '(value: string) => void' } }
}
table: { type: { summary: '(value: string) => void' } },
},
},
args: {
label: 'Status',
value: 'draft',
choices,
size: 'md',
layout: 'stacked'
}
width: 'md',
layout: 'stacked',
},
} satisfies Meta<typeof Dropdown>;
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
@@ -103,15 +111,15 @@ export const Stacked: Story = {
}}
/>
);
}
},
};
export const Inline: Story = {
args: {
layout: 'inline',
size: 'sm'
size: 'sm',
},
render: (args) => {
render: function InlineRender(args) {
const [value, setValue] = useState(args.value);
return (
<Dropdown
@@ -123,31 +131,38 @@ export const Inline: Story = {
}}
/>
);
}
},
};
export const Disabled: Story = {
args: {
disabled: true
}
disabled: true,
},
};
export const WithError: Story = {
args: {
error: 'Please choose a valid status'
}
error: 'Please choose a valid status',
},
};
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">
<Dropdown {...args} value={value} size="sm" label="Small" onChange={setValue} />
<Dropdown {...args} value={value} size="md" label="Medium" onChange={setValue} />
<Dropdown {...args} value={value} size="lg" label="Large" onChange={setValue} />
<Dropdown {...args} value={value} size="full" label="Full" onChange={setValue} />
<Dropdown
{...args}
value={value}
size="full"
width="full"
label="Full"
onChange={setValue}
/>
</div>
);
}
},
};

View File

@@ -14,6 +14,7 @@ type DropdownProps = {
value: string;
choices: DropdownChoice[];
size?: ComponentSize;
width?: ComponentSize;
layout?: DropdownLayout;
disabled?: boolean;
required?: boolean;
@@ -28,41 +29,43 @@ export function Dropdown({
value,
choices,
size = 'md',
width = 'md',
layout = 'stacked',
disabled = false,
required = false,
onChange,
error,
className = '',
selectClassName = ''
selectClassName = '',
}: Readonly<DropdownProps>) {
const containerSizeClass = {
const containerWidthClass = {
sm: 'max-w-xs',
md: 'max-w-sm',
lg: 'max-w-md',
full: 'max-w-none'
}[size];
full: 'max-w-none',
}[width];
const selectSizeClass = {
sm: 'h-8 !text-xs',
md: 'h-10 text-sm',
lg: 'h-12 text-sm',
full: 'h-10 text-sm'
full: 'h-10 text-sm',
}[size];
const handleChange: ChangeEventHandler<HTMLSelectElement> = (event) => {
onChange?.(event.target.value);
};
const wrapperClass = layout === 'inline'
? 'inline-flex w-auto items-center gap-2'
: 'block w-full gap-1';
const wrapperClass =
layout === 'inline' ? 'inline-flex w-auto items-center gap-2' : 'block w-full gap-1';
const selectWrapperClass = layout === 'inline' ? 'relative' : 'relative mt-1';
const labelClass = layout === 'inline' ? 'text-xs ui-body-secondary' : '';
return (
<label className={`${wrapperClass} text-sm font-medium ${disabled ? 'ui-label-disabled' : 'ui-label'} ${containerSizeClass} ${className}`.trim()}>
<label
className={`${wrapperClass} text-sm font-medium ${disabled ? 'ui-label-disabled' : 'ui-label'} ${containerWidthClass} ${className}`.trim()}
>
{label ? <span className={labelClass}>{label}</span> : null}
<div className={selectWrapperClass}>
<select
@@ -78,11 +81,17 @@ export function Dropdown({
</option>
))}
</select>
<span className={`pointer-events-none absolute inset-y-0 right-3 flex items-center ${disabled ? 'ui-label-disabled' : 'ui-body-secondary'}`}>
<span
className={`pointer-events-none absolute inset-y-0 right-3 flex items-center ${disabled ? 'ui-label-disabled' : 'ui-body-secondary'}`}
>
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
</span>
</div>
{error ? <span className="mt-1 block text-xs" style={{ color: 'var(--error-text)' }}>{error}</span> : null}
{error ? (
<span className="mt-1 block text-xs" style={{ color: 'var(--error-text)' }}>
{error}
</span>
) : null}
</label>
);
}

View File

@@ -12,35 +12,36 @@ const meta = {
parameters: {
docs: {
description: {
component: 'Surface container with a title bar and a responsive content grid, intended for CMS forms.'
}
}
component:
'Surface container with a title bar and a responsive content grid, intended for CMS forms.',
},
},
},
argTypes: {
title: {
description: 'Form title displayed in the header bar.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
titleBarRight: {
description: 'Optional node rendered on the right side of the title bar.',
control: false,
table: { type: { summary: 'ReactNode' } }
table: { type: { summary: 'ReactNode' } },
},
children: {
description: 'Form content rendered inside the responsive grid.',
control: false,
table: { type: { summary: 'ReactNode' } }
table: { type: { summary: 'ReactNode' } },
},
className: {
description: 'Extra CSS classes for the root container.',
control: 'text',
table: { type: { summary: 'string' } }
}
table: { type: { summary: 'string' } },
},
},
args: {
title: 'Post details'
}
title: 'Post details',
},
} satisfies Meta<typeof Form>;
export default meta;
@@ -56,25 +57,22 @@ export const Basic: Story = {
value="draft"
choices={[
{ id: 'draft', label: 'Draft' },
{ id: 'published', label: 'Published' }
{ id: 'published', label: 'Published' },
]}
/>
<InputField label="Slug" type="text" value="a-short-post-title" />
</>
)
}
),
},
};
export const WithActions: Story = {
render: (args) => {
render: function WithActionsRender(args) {
const [title, setTitle] = useState('Storybook powered CMS');
const [status, setStatus] = useState('draft');
return (
<Form
{...args}
titleBarRight={<Button type="solid" size="sm" label="Save" />}
>
<Form {...args} titleBarRight={<Button type="solid" size="sm" label="Save" />}>
<div className="col-span-2">
<InputField
label="Title"
@@ -82,6 +80,7 @@ export const WithActions: Story = {
value={title}
onChange={(event) => setTitle(event.target.value)}
size="full"
width="full"
/>
</div>
<Dropdown
@@ -91,11 +90,11 @@ export const WithActions: Story = {
choices={[
{ id: 'draft', label: 'Draft' },
{ id: 'review', label: 'In review' },
{ id: 'published', label: 'Published' }
{ id: 'published', label: 'Published' },
]}
/>
<InputField label="Slug" type="text" value="storybook-powered-cms" />
</Form>
);
}
},
};

View File

@@ -11,14 +11,15 @@ type FormProps = {
export function Form({ title, titleBarRight, children, className = '' }: Readonly<FormProps>) {
return (
<div className={`surface overflow-hidden rounded-xl ${className}`.trim()}>
<div className="flex items-center justify-between border-b px-4 py-3 sm:px-5" style={{ borderColor: 'var(--surface-divider)' }}>
<div
className="flex items-center justify-between border-b px-4 py-3 sm:px-5"
style={{ borderColor: 'var(--surface-divider)' }}
>
<Label variant="h4">{title}</Label>
{titleBarRight ? <div>{titleBarRight}</div> : null}
</div>
<div className="grid grid-cols-1 gap-4 p-4 sm:p-5 lg:grid-cols-3">
{children}
</div>
<div className="grid grid-cols-1 gap-4 p-4 sm:p-5 lg:grid-cols-3">{children}</div>
</div>
);
}

View File

@@ -10,94 +10,102 @@ const meta = {
parameters: {
docs: {
description: {
component: 'Text input field with optional label, validation state, size/layout variants, and password visibility toggle.'
}
}
component:
'Text input field with optional label, validation state, size/layout variants, and password visibility toggle.',
},
},
},
argTypes: {
label: {
description: 'Label text shown above (stacked) or on the left (inline).',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
placeholder: {
description: 'Input placeholder text.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
type: {
description: 'Native input type.',
options: ['text', 'password', 'email'],
control: 'inline-radio',
table: { type: { summary: "'text' | 'password' | 'email'" } }
table: { type: { summary: "'text' | 'password' | 'email'" } },
},
size: {
description: 'Input size.',
options: ['sm', 'md', 'lg', 'full'],
control: 'inline-radio',
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } }
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } },
},
width: {
description: 'Input width constraint.',
options: ['sm', 'md', 'lg', 'full'],
control: 'inline-radio',
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } },
},
layout: {
description: 'Label/input layout mode.',
options: ['stacked', 'inline'],
control: 'inline-radio',
table: { type: { summary: "'stacked' | 'inline'" } }
table: { type: { summary: "'stacked' | 'inline'" } },
},
value: {
description: 'Controlled input value.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
name: {
description: 'Native input `name` attribute.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
disabled: {
description: 'Disables the input.',
control: 'boolean',
table: { type: { summary: 'boolean' } }
table: { type: { summary: 'boolean' } },
},
required: {
description: 'Sets the native HTML `required` attribute.',
control: 'boolean',
table: { type: { summary: 'boolean' } }
table: { type: { summary: 'boolean' } },
},
error: {
description: 'Validation message shown below the field.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
rightIcon: {
description: 'Optional trailing icon node (ignored for password type because toggle icon is used).',
description:
'Optional trailing icon node (ignored for password type because toggle icon is used).',
control: false,
table: { type: { summary: 'ReactNode' } }
table: { type: { summary: 'ReactNode' } },
},
className: {
description: 'Extra CSS classes for the outer wrapper.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
inputClassName: {
description: 'Extra CSS classes for the `<input>` element.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
onChange: {
description: 'Change handler callback.',
action: 'changed',
table: { type: { summary: 'ChangeEventHandler<HTMLInputElement>' } }
table: { type: { summary: 'ChangeEventHandler<HTMLInputElement>' } },
},
onBlur: {
description: 'Blur handler callback.',
control: false,
table: { type: { summary: 'FocusEventHandler<HTMLInputElement>' } }
table: { type: { summary: 'FocusEventHandler<HTMLInputElement>' } },
},
inputRef: {
description: 'Ref forwarded to the native `<input>` element.',
control: false,
table: { type: { summary: 'Ref<HTMLInputElement>' } }
}
table: { type: { summary: 'Ref<HTMLInputElement>' } },
},
},
args: {
label: 'Email',
@@ -105,8 +113,9 @@ const meta = {
placeholder: 'name@example.com',
value: '',
size: 'md',
layout: 'stacked'
}
width: 'md',
layout: 'stacked',
},
} satisfies Meta<typeof InputField>;
export default meta;
@@ -116,9 +125,9 @@ export const Text: Story = {
args: {
type: 'text',
label: 'Title',
placeholder: 'Write a title'
placeholder: 'Write a title',
},
render: (args) => {
render: function TextRender(args) {
const [value, setValue] = useState('Storybook integration');
return (
<InputField
@@ -130,16 +139,16 @@ export const Text: Story = {
}}
/>
);
}
},
};
export const PasswordWithToggle: Story = {
args: {
type: 'password',
label: 'Password',
placeholder: 'Type a strong password'
placeholder: 'Type a strong password',
},
render: (args) => {
render: function PasswordWithToggleRender(args) {
const [value, setValue] = useState('pa55word');
return (
<InputField
@@ -151,7 +160,7 @@ export const PasswordWithToggle: Story = {
}}
/>
);
}
},
};
export const InlineWithIcon: Story = {
@@ -160,9 +169,9 @@ export const InlineWithIcon: Story = {
label: 'Search',
layout: 'inline',
size: 'sm',
rightIcon: <MagnifyingGlassIcon className="h-4 w-4 ui-body-secondary" />
rightIcon: <MagnifyingGlassIcon className="h-4 w-4 ui-body-secondary" />,
},
render: (args) => {
render: function InlineWithIconRender(args) {
const [value, setValue] = useState('posts');
return (
<InputField
@@ -174,16 +183,17 @@ export const InlineWithIcon: Story = {
}}
/>
);
}
},
};
export const Error: Story = {
export const ErrorState: Story = {
name: 'Error',
args: {
type: 'email',
label: 'Email',
value: 'invalid.mail',
error: 'Enter a valid email address'
}
error: 'Enter a valid email address',
},
};
export const Disabled: Story = {
@@ -191,25 +201,46 @@ export const Disabled: Story = {
type: 'text',
label: 'Read only field',
value: 'Locked content',
disabled: true
}
disabled: true,
},
};
export const SizeMatrix: Story = {
args: {
type: 'text',
label: 'Name',
placeholder: 'Enter value'
placeholder: 'Enter value',
},
render: (args) => {
render: function SizeMatrixRender(args) {
const [value, setValue] = useState('Beatrice');
return (
<div className="grid grid-cols-1 gap-3">
<InputField {...args} value={value} size="sm" onChange={(event) => setValue(event.target.value)} />
<InputField {...args} value={value} size="md" onChange={(event) => setValue(event.target.value)} />
<InputField {...args} value={value} size="lg" onChange={(event) => setValue(event.target.value)} />
<InputField {...args} value={value} size="full" onChange={(event) => setValue(event.target.value)} />
<InputField
{...args}
value={value}
size="sm"
onChange={(event) => setValue(event.target.value)}
/>
<InputField
{...args}
value={value}
size="md"
onChange={(event) => setValue(event.target.value)}
/>
<InputField
{...args}
value={value}
size="lg"
onChange={(event) => setValue(event.target.value)}
/>
<InputField
{...args}
value={value}
size="full"
width="full"
onChange={(event) => setValue(event.target.value)}
/>
</div>
);
}
},
};

View File

@@ -12,6 +12,7 @@ type InputFieldProps = {
placeholder?: string;
type: InputKind;
size?: ComponentSize;
width?: ComponentSize;
layout?: Layout;
value: string;
name?: string;
@@ -31,6 +32,7 @@ export function InputField({
placeholder = '',
type,
size = 'md',
width = 'md',
layout = 'stacked',
value,
name,
@@ -42,26 +44,25 @@ export function InputField({
error,
rightIcon,
className = '',
inputClassName = ''
inputClassName = '',
}: Readonly<InputFieldProps>) {
const [showPassword, setShowPassword] = useState(false);
const containerSizeClass = {
const containerWidthClass = {
sm: 'max-w-xs',
md: 'max-w-sm',
lg: 'max-w-md',
full: 'max-w-none'
}[size];
full: 'max-w-none',
}[width];
const inputSizeClass = {
sm: 'h-8 !text-xs',
md: 'h-10 text-sm',
lg: 'h-12 text-sm',
full: 'h-10 text-sm'
full: 'h-10 text-sm',
}[size];
const wrapperClass = layout === 'inline'
? 'inline-flex w-auto items-center gap-2'
: 'block w-full gap-1';
const wrapperClass =
layout === 'inline' ? 'inline-flex w-auto items-center gap-2' : 'block w-full gap-1';
const labelClass = layout === 'inline' ? 'text-xs ui-body-secondary' : '';
const isPasswordType = type === 'password';
const resolvedType: InputKind = isPasswordType && showPassword ? 'text' : type;
@@ -69,7 +70,9 @@ export function InputField({
const inputWrapperClass = layout === 'inline' ? 'relative' : 'relative mt-1';
return (
<label className={`${wrapperClass} text-sm font-medium ${disabled ? 'ui-label-disabled' : 'ui-label'} ${containerSizeClass} ${className}`.trim()}>
<label
className={`${wrapperClass} text-sm font-medium ${disabled ? 'ui-label-disabled' : 'ui-label'} ${containerWidthClass} ${className}`.trim()}
>
{label ? <span className={labelClass}>{label}</span> : null}
<div className={inputWrapperClass}>
<input
@@ -100,7 +103,11 @@ export function InputField({
</span>
) : null}
</div>
{error ? <span className="mt-1 block text-xs" style={{ color: 'var(--error-text)' }}>{error}</span> : null}
{error ? (
<span className="mt-1 block text-xs" style={{ color: 'var(--error-text)' }}>
{error}
</span>
) : null}
</label>
);
}

View File

@@ -8,37 +8,44 @@ const meta = {
parameters: {
docs: {
description: {
component: 'Typography helper component for headings, body text, caption, error text, and inline code styles.'
}
}
component:
'Typography helper component for headings, body text, caption, error text, and inline code styles.',
},
},
},
argTypes: {
variant: {
description: 'Typography style preset.',
options: ['h1', 'h2', 'h3', 'h4', 'body', 'body2', 'caption', 'error', 'code'],
control: 'select',
table: { type: { summary: "'h1' | 'h2' | 'h3' | 'h4' | 'body' | 'body2' | 'caption' | 'error' | 'code'" } }
table: {
type: {
summary:
"'h1' | 'h2' | 'h3' | 'h4' | 'body' | 'body2' | 'caption' | 'error' | 'code'",
},
},
},
as: {
description: "Override rendered HTML tag or component (for example `'p'`, `'span'`, `'h2'`).",
description:
"Override rendered HTML tag or component (for example `'p'`, `'span'`, `'h2'`).",
control: false,
table: { type: { summary: 'ElementType' } }
table: { type: { summary: 'ElementType' } },
},
className: {
description: 'Extra CSS classes.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
children: {
description: 'Label content.',
control: 'text',
table: { type: { summary: 'ReactNode' } }
}
table: { type: { summary: 'ReactNode' } },
},
},
args: {
variant: 'body',
children: 'Label text'
}
children: 'Label text',
},
} satisfies Meta<typeof Label>;
export default meta;
@@ -46,18 +53,19 @@ 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'
}
children: 'This field is required',
},
};
export const Code: Story = {
args: {
variant: 'code',
children: 'const isPublished = true;'
}
children: 'const isPublished = true;',
},
};
export const VariantScale: Story = {
@@ -73,5 +81,5 @@ export const VariantScale: Story = {
<Label variant="error">Error copy</Label>
<Label variant="code">npm run build</Label>
</div>
)
),
};

View File

@@ -1,15 +1,6 @@
import type { ElementType, ReactNode } from 'react';
type LabelVariant =
| 'h1'
| 'h2'
| 'h3'
| 'h4'
| 'body'
| 'body2'
| 'caption'
| 'error'
| 'code';
type LabelVariant = 'h1' | 'h2' | 'h3' | 'h4' | 'body' | 'body2' | 'caption' | 'error' | 'code';
type LabelProps<T extends ElementType> = {
variant?: LabelVariant;
@@ -27,7 +18,7 @@ const variantClassMap: Record<LabelVariant, string> = {
body2: 'ui-body-secondary text-sm',
caption: 'ui-kicker text-xs font-semibold uppercase tracking-[0.12em]',
error: 'ui-error text-sm',
code: 'ui-code text-sm font-mono'
code: 'ui-code text-sm font-mono',
};
const variantTagMap: Record<LabelVariant, ElementType> = {
@@ -39,14 +30,14 @@ const variantTagMap: Record<LabelVariant, ElementType> = {
body2: 'p',
caption: 'p',
error: 'p',
code: 'code'
code: 'code',
};
export function Label<T extends ElementType = 'p'>({
variant = 'body',
as,
className = '',
children
children,
}: Readonly<LabelProps<T>>) {
const Component = as ?? variantTagMap[variant];
const classes = `${variantClassMap[variant]} ${className}`.trim();

View File

@@ -1,14 +1,14 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { headingsPlugin, listsPlugin, markdownShortcutPlugin, quotePlugin } from '@mdxeditor/editor';
import {
headingsPlugin,
listsPlugin,
markdownShortcutPlugin,
quotePlugin,
} from '@mdxeditor/editor';
import { MDXEditorField } from './MDXEditorField';
const basePlugins = [
headingsPlugin(),
listsPlugin(),
quotePlugin(),
markdownShortcutPlugin()
];
const basePlugins = [headingsPlugin(), listsPlugin(), quotePlugin(), markdownShortcutPlugin()];
const sampleMarkdown = `# Hello from MDXEditor
@@ -25,95 +25,97 @@ const meta = {
parameters: {
docs: {
description: {
component: 'MDX editor wrapper with label, editable/read-only/disabled modes, theme class support, and error rendering.'
}
}
component:
'MDX editor wrapper with label, editable/read-only/disabled modes, theme class support, and error rendering.',
},
},
},
argTypes: {
label: {
description: 'Field label shown above the editor.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
markdown: {
description: 'Controlled markdown content value.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
readOnly: {
description: 'Enables read-only mode.',
control: 'boolean',
table: { type: { summary: 'boolean' } }
table: { type: { summary: 'boolean' } },
},
disabled: {
description: 'Disables editing and applies disabled visuals.',
control: 'boolean',
table: { type: { summary: 'boolean' } }
table: { type: { summary: 'boolean' } },
},
themeClassName: {
description: 'Theme class applied to MDXEditor (for example `light-theme` or `dark-theme`).',
description:
'Theme class applied to MDXEditor (for example `light-theme` or `dark-theme`).',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
plugins: {
description: 'MDXEditor plugins array.',
control: false,
table: { type: { summary: 'MDXEditorProps["plugins"]' } }
table: { type: { summary: 'MDXEditorProps["plugins"]' } },
},
contentEditableClassName: {
description: 'CSS class used on the content editable area.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
className: {
description: 'Extra CSS classes for the outer wrapper.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
editorWrapperClassName: {
description: 'Extra CSS classes for the editor shell element.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
editorWrapperStyle: {
description: 'Inline style object for the editor shell.',
control: 'object',
table: { type: { summary: 'CSSProperties' } }
table: { type: { summary: 'CSSProperties' } },
},
editorClassName: {
description: 'Extra CSS classes for the MDXEditor instance.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
error: {
description: 'Error message shown below the editor.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
onChange: {
description: 'Callback fired when markdown changes in editable mode.',
action: 'changed',
table: { type: { summary: '(markdown: string) => void' } }
table: { type: { summary: '(markdown: string) => void' } },
},
editorRef: {
description: 'Ref to MDXEditor methods.',
control: false,
table: { type: { summary: 'Ref<MDXEditorMethods | null>' } }
}
table: { type: { summary: 'Ref<MDXEditorMethods | null>' } },
},
},
args: {
label: 'Content',
markdown: sampleMarkdown,
plugins: basePlugins,
themeClassName: ''
}
themeClassName: '',
},
} satisfies Meta<typeof MDXEditorField>;
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">
@@ -128,12 +130,12 @@ export const Editable: Story = {
/>
</div>
);
}
},
};
export const ReadOnly: Story = {
args: {
readOnly: true
readOnly: true,
},
render: (args) => (
<div className="w-full max-w-2xl">
@@ -142,13 +144,13 @@ export const ReadOnly: Story = {
editorWrapperClassName="mt-2 overflow-hidden rounded-xl border"
/>
</div>
)
),
};
export const DisabledWithError: Story = {
args: {
disabled: true,
error: 'Editor is currently disabled'
error: 'Editor is currently disabled',
},
render: (args) => (
<div className="w-full max-w-2xl">
@@ -157,5 +159,5 @@ export const DisabledWithError: Story = {
editorWrapperClassName="mt-2 overflow-hidden rounded-xl border"
/>
</div>
)
),
};

View File

@@ -33,20 +33,28 @@ export function MDXEditorField({
editorWrapperClassName = 'post-mdx-editor mt-2 overflow-hidden rounded-xl border',
editorWrapperStyle,
editorClassName = '',
error
error,
}: Readonly<MDXEditorFieldProps>) {
const resolvedEditorClassName = `${themeClassName} ${editorClassName}`.trim();
const editorModeKey = disabled || readOnly ? 'read-only' : 'editable';
const resolvedEditorWrapperClassName = `${editorWrapperClassName} ${disabled ? 'post-mdx-editor--disabled' : 'post-mdx-editor--enabled'}`.trim();
const resolvedEditorWrapperClassName =
`${editorWrapperClassName} ${disabled ? 'post-mdx-editor--disabled' : 'post-mdx-editor--enabled'}`.trim();
const resolvedEditorWrapperStyle: CSSProperties = {
backgroundColor: disabled ? 'var(--field-disabled-bg)' : 'var(--field-bg)',
borderColor: disabled ? 'var(--field-disabled-border)' : 'var(--field-border)',
...editorWrapperStyle
...editorWrapperStyle,
};
return (
<div className={className}>
{label ? <Label variant="body" className={`font-medium ${disabled ? 'ui-label-disabled' : 'ui-label'}`}>{label}</Label> : null}
{label ? (
<Label
variant="body"
className={`font-medium ${disabled ? 'ui-label-disabled' : 'ui-label'}`}
>
{label}
</Label>
) : null}
<div className={resolvedEditorWrapperClassName} style={resolvedEditorWrapperStyle}>
<MDXEditor
key={editorModeKey}
@@ -59,7 +67,11 @@ export function MDXEditorField({
plugins={plugins}
/>
</div>
{error ? <Label variant="error" className="mt-2 ui-error">{error}</Label> : null}
{error ? (
<Label variant="error" className="mt-2 ui-error">
{error}
</Label>
) : null}
</div>
);
}

View File

@@ -10,43 +10,44 @@ const meta = {
layout: 'padded',
docs: {
description: {
component: 'Sidebar navigation link with active state styling and collapsed/expanded rendering mode.'
}
}
component:
'Sidebar navigation link with active state styling and collapsed/expanded rendering mode.',
},
},
},
argTypes: {
to: {
description: 'Destination route path.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
label: {
description: 'Navigation item label.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
icon: {
description: 'Icon component rendered before the label.',
control: false,
table: { type: { summary: 'ComponentType<SVGProps<SVGSVGElement>>' } }
table: { type: { summary: 'ComponentType<SVGProps<SVGSVGElement>>' } },
},
collapsed: {
description: 'Collapsed state. When true, desktop view shows icon-only rail style.',
control: 'boolean',
table: { type: { summary: 'boolean' } }
table: { type: { summary: 'boolean' } },
},
onClick: {
description: 'Optional click callback (for example to close mobile drawer).',
action: 'clicked',
table: { type: { summary: '() => void' } }
}
table: { type: { summary: '() => void' } },
},
},
args: {
to: '/',
label: 'Dashboard',
icon: HomeIcon,
collapsed: false
}
collapsed: false,
},
} satisfies Meta<typeof SidebarNavItem>;
export default meta;
@@ -57,14 +58,19 @@ export const Expanded: Story = {
<nav className="flex w-56 flex-col gap-1">
<SidebarNavItem {...args} />
<SidebarNavItem to="/users" label="Users" icon={UsersIcon} collapsed={args.collapsed} />
<SidebarNavItem to="/profile" label="Profile" icon={UserCircleIcon} collapsed={args.collapsed} />
<SidebarNavItem
to="/profile"
label="Profile"
icon={UserCircleIcon}
collapsed={args.collapsed}
/>
</nav>
)
),
};
export const Collapsed: Story = {
args: {
collapsed: true
collapsed: true,
},
render: (args) => (
<nav className="flex w-14 flex-col gap-1">
@@ -72,5 +78,5 @@ export const Collapsed: Story = {
<SidebarNavItem to="/users" label="Users" icon={UsersIcon} collapsed />
<SidebarNavItem to="/profile" label="Profile" icon={UserCircleIcon} collapsed />
</nav>
)
),
};

View File

@@ -11,7 +11,13 @@ type SidebarNavItemProps = {
onClick?: () => void;
};
export function SidebarNavItem({ to, label, icon: Icon, collapsed, onClick }: Readonly<SidebarNavItemProps>) {
export function SidebarNavItem({
to,
label,
icon: Icon,
collapsed,
onClick,
}: Readonly<SidebarNavItemProps>) {
const layoutClass = collapsed
? 'mx-auto w-8 justify-center px-0'
: 'px-2 lg:w-full lg:justify-start';
@@ -20,14 +26,18 @@ export function SidebarNavItem({ to, label, icon: Icon, collapsed, onClick }: Re
<NavLink
to={to}
onClick={onClick}
className={({ isActive }) => (
className={({ isActive }) =>
`inline-flex h-8 items-center rounded-lg text-sm font-medium transition ${layoutClass} ${
isActive ? 'bg-accent-500 text-white' : 'ui-body-secondary hover:bg-zinc-500/15'
isActive ? 'ui-accent-active' : 'ui-body-secondary hover:bg-zinc-500/15'
}`
)}
}
>
<Icon className="h-4 w-4 shrink-0" />
{!collapsed ? <span className="ml-2 truncate leading-none">{label}</span> : <span className="ml-2 lg:hidden">{label}</span>}
{!collapsed ? (
<span className="ml-2 truncate leading-none">{label}</span>
) : (
<span className="ml-2 lg:hidden">{label}</span>
)}
</NavLink>
);
}

View File

@@ -20,7 +20,7 @@ const rows: UserRow[] = [
{ id: '5', name: 'Andrea Pini', role: 'EDITOR', status: 'Pending', posts: 9 },
{ id: '6', name: 'Sofia Denti', role: 'AUTHOR', status: 'Active', posts: 7 },
{ id: '7', name: 'Marco Serra', role: 'AUTHOR', status: 'Active', posts: 18 },
{ id: '8', name: 'Elena Neri', role: 'EDITOR', status: 'Active', posts: 31 }
{ id: '8', name: 'Elena Neri', role: 'EDITOR', status: 'Active', posts: 31 },
];
const headers: TableHeader<UserRow>[] = [
@@ -30,14 +30,14 @@ const headers: TableHeader<UserRow>[] = [
value: (row) => row.name,
sortable: true,
sortField: 'name',
cellClassName: 'table-cell-primary'
cellClassName: 'table-cell-primary',
},
{
id: 'role',
label: 'Role',
value: (row) => row.role,
sortable: true,
sortField: 'role'
sortField: 'role',
},
{
id: 'status',
@@ -46,15 +46,15 @@ const headers: TableHeader<UserRow>[] = [
<Chip variant="outlined" tone={row.status === 'Active' ? 'indigo-700' : 'cyan-700'}>
{row.status}
</Chip>
)
),
},
{
id: 'posts',
label: 'Posts',
value: (row) => row.posts,
sortable: true,
sortField: 'posts'
}
sortField: 'posts',
},
];
type UsersTableProps = {
@@ -117,49 +117,51 @@ const meta = {
parameters: {
docs: {
description: {
component: 'Generic data table with loading/empty states, optional sorting controls, and optional pagination footer.'
}
}
component:
'Generic data table with loading/empty states, optional sorting controls, and optional pagination footer.',
},
},
},
argTypes: {
data: {
description: 'Rows rendered in the table body.',
control: 'object',
table: { type: { summary: 'UserRow[]' } }
table: { type: { summary: 'UserRow[]' } },
},
isLoading: {
description: 'When true, shows the loading indicator row.',
control: 'boolean',
table: { type: { summary: 'boolean' } }
table: { type: { summary: 'boolean' } },
},
emptyMessage: {
description: 'Message shown when `data` is empty and `isLoading` is false.',
control: 'text',
table: { type: { summary: 'string' } }
table: { type: { summary: 'string' } },
},
sorting: {
description: "Current sort state object. Use `null` for no active sorting.",
description: 'Current sort state object. Use `null` for no active sorting.',
control: 'object',
table: { type: { summary: "{ field: string; direction: 'asc' | 'desc' } | null" } }
table: { type: { summary: "{ field: string; direction: 'asc' | 'desc' } | null" } },
},
onSortChange: {
description: 'Callback fired when a sortable header is clicked.',
action: 'sort changed',
table: { type: { summary: '(field: string) => void' } }
table: { type: { summary: '(field: string) => void' } },
},
pagination: {
description: 'Pagination config object. When omitted, pagination footer is hidden.',
control: 'object',
table: {
type: {
summary: '{ page; pageSize; total; totalPages; onPageChange; onPageSizeChange? }'
}
}
}
summary:
'{ page; pageSize; total; totalPages; onPageChange; onPageSizeChange? }',
},
},
},
},
args: {
data: rows
}
data: rows,
},
} satisfies Meta<typeof UsersTable>;
export default meta;
@@ -169,22 +171,22 @@ export const WithRows: Story = {};
export const Loading: Story = {
args: {
isLoading: true
}
isLoading: true,
},
};
export const Empty: Story = {
args: {
data: [],
emptyMessage: 'No users found'
}
emptyMessage: 'No users found',
},
};
export const InteractiveSortingAndPagination: Story = {
render: () => {
render: function InteractiveSortingAndPaginationRender() {
const [sorting, setSorting] = useState<SortState | null>({
field: 'name',
direction: 'asc'
direction: 'asc',
});
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(5);
@@ -220,9 +222,9 @@ export const InteractiveSortingAndPagination: Story = {
onPageSizeChange: (next) => {
setPage(1);
setPageSize(next);
}
},
}}
/>
);
}
},
};

View File

@@ -1,5 +1,11 @@
import type { ReactNode } from 'react';
import { ArrowPathIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon } from '@heroicons/react/24/solid';
import {
ArrowPathIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronUpIcon,
} from '@heroicons/react/24/solid';
import { ArrowsUpDownIcon } from '@heroicons/react/24/outline';
import { Button } from './Button';
import { Dropdown } from './Dropdown';
@@ -46,7 +52,7 @@ export function Table<T>({
className = '',
sorting = null,
onSortChange,
pagination
pagination,
}: Readonly<TableProps<T>>) {
const canGoPrev = pagination != null && pagination.page > 1;
const canGoNext = pagination != null && pagination.page < pagination.totalPages;
@@ -58,24 +64,34 @@ export function Table<T>({
<thead className="table-head">
<tr>
{headers.map((header) => {
const canSort = header.sortable === true
&& typeof onSortChange === 'function'
&& typeof header.sortField === 'string'
&& header.sortField.length > 0;
const canSort =
header.sortable === true &&
typeof onSortChange === 'function' &&
typeof header.sortField === 'string' &&
header.sortField.length > 0;
const isActiveSort = canSort && sorting?.field === header.sortField;
const sortDirection = isActiveSort ? sorting?.direction : null;
return (
<th key={header.id} className={`table-head-cell ${header.headerClassName ?? ''}`.trim()}>
<th
key={header.id}
className={`table-head-cell ${header.headerClassName ?? ''}`.trim()}
>
{canSort ? (
<button
type="button"
className="table-sort-button"
onClick={() => onSortChange(header.sortField as string)}
onClick={() =>
onSortChange(header.sortField as string)
}
aria-label={`Sort by ${header.label}`}
>
<span>{header.label}</span>
<span className="table-sort-icon" aria-hidden="true" data-sort-state={sortDirection ?? 'none'}>
<span
className="table-sort-icon"
aria-hidden="true"
data-sort-state={sortDirection ?? 'none'}
>
{sortDirection === 'asc' ? (
<ChevronUpIcon className="h-4 w-4" />
) : null}
@@ -99,8 +115,15 @@ export function Table<T>({
{isLoading ? (
<tr className="table-body-row">
<td colSpan={headers.length} className="px-4 py-6 text-center">
<Label as="span" variant="body2" className="inline-flex items-center justify-center ui-loading">
<ArrowPathIcon className="h-5 w-5 animate-spin" aria-hidden="true" />
<Label
as="span"
variant="body2"
className="inline-flex items-center justify-center ui-loading"
>
<ArrowPathIcon
className="h-5 w-5 animate-spin"
aria-hidden="true"
/>
</Label>
</td>
</tr>
@@ -114,14 +137,19 @@ export function Table<T>({
</td>
</tr>
) : null}
{!isLoading && data.map((row, index) => (
{!isLoading &&
data.map((row, index) => (
<tr key={rowKey(row, index)} className="table-body-row">
{headers.map((header) => {
const content = typeof header.value === 'function'
const content =
typeof header.value === 'function'
? (header.value as (item: T) => ReactNode)(row)
: header.value;
return (
<td key={`${header.id}-${index}`} className={`table-cell-secondary ${header.cellClassName ?? ''}`.trim()}>
<td
key={`${header.id}-${index}`}
className={`table-cell-secondary ${header.cellClassName ?? ''}`.trim()}
>
{content}
</td>
);
@@ -133,9 +161,7 @@ export function Table<T>({
</div>
{pagination ? (
<div className="flex flex-col gap-3 border-t border-zinc-500/20 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<Label variant="body2">
{pagination.total} results
</Label>
<Label variant="body2">{pagination.total} results</Label>
<div className="flex flex-wrap items-center gap-2">
{pagination.onPageSizeChange ? (
<Dropdown
@@ -143,7 +169,7 @@ export function Table<T>({
value={String(pagination.pageSize)}
choices={[5, 10, 20, 50, 100].map((size) => ({
id: String(size),
label: String(size)
label: String(size),
}))}
size="sm"
layout="inline"

View File

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

View File

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

View File

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

View File

@@ -7,15 +7,15 @@ module.exports = {
300: '#a89bbf',
400: '#9587ad',
500: '#7d6f98',
600: '#6a5d84'
}
600: '#6a5d84',
},
},
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif']
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
},
boxShadow: {
glow: '0 0 0 1px rgba(63,63,70,0.65), 0 18px 44px rgba(0,0,0,0.45)'
}
}
}
glow: '0 0 0 1px rgba(63,63,70,0.65), 0 18px 44px rgba(0,0,0,0.45)',
},
},
},
};

View File

@@ -3,14 +3,12 @@ const webUiPreset = require('./tailwind-preset.cjs');
/** @type {import('tailwindcss').Config} */
module.exports = {
presets: [webUiPreset],
content: [
'./src/**/*.{ts,tsx,js,jsx}'
],
content: ['./src/**/*.{ts,tsx,js,jsx}'],
corePlugins: {
preflight: false
preflight: false,
},
theme: {
extend: {}
extend: {},
},
plugins: []
plugins: [],
};

View File

@@ -3,12 +3,9 @@ const webUiPreset = require('./tailwind-preset.cjs');
/** @type {import('tailwindcss').Config} */
module.exports = {
presets: [webUiPreset],
content: [
'./src/**/*.{ts,tsx,js,jsx,mdx}',
'./.storybook/**/*.{ts,tsx,js,jsx,mdx}'
],
content: ['./src/**/*.{ts,tsx,js,jsx,mdx}', './.storybook/**/*.{ts,tsx,js,jsx,mdx}'],
theme: {
extend: {}
extend: {},
},
plugins: []
plugins: [],
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

18
tests/setup.ts Normal file
View File

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

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'vite';
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { resolve } from 'node:path';
@@ -8,11 +8,14 @@ export default defineConfig({
lib: {
entry: {
index: resolve(__dirname, 'src/index.ts'),
'components/MDXEditorField': resolve(__dirname, 'src/components/MDXEditorField.tsx')
'components/MDXEditorField': resolve(
__dirname,
'src/components/MDXEditorField.tsx',
),
},
name: 'PanicWebUi',
formats: ['es'],
fileName: (_format, entryName) => `${entryName}.js`
fileName: (_format, entryName) => `${entryName}.js`,
},
rollupOptions: {
external: [
@@ -20,8 +23,29 @@ export default defineConfig({
'react-dom',
'react-router-dom',
'@heroicons/react',
'@mdxeditor/editor'
]
}
}
'@mdxeditor/editor',
],
},
},
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,
},
},
},
});

1155
yarn.lock

File diff suppressed because it is too large Load Diff