Compare commits

...

26 Commits

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

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

View File

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

View File

@@ -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,76 +15,84 @@ 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;
@@ -103,13 +111,13 @@ export const Stacked: Story = {
}}
/>
);
}
},
};
export const Inline: Story = {
args: {
layout: 'inline',
size: 'sm'
size: 'sm',
},
render: (args) => {
const [value, setValue] = useState(args.value);
@@ -123,19 +131,19 @@ 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 = {
@@ -146,8 +154,15 @@ export const SizeMatrix: Story = {
<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,13 +57,13 @@ 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 = {
@@ -71,10 +72,7 @@ export const WithActions: Story = {
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,7 +125,7 @@ export const Text: Story = {
args: {
type: 'text',
label: 'Title',
placeholder: 'Write a title'
placeholder: 'Write a title',
},
render: (args) => {
const [value, setValue] = useState('Storybook integration');
@@ -130,14 +139,14 @@ 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) => {
const [value, setValue] = useState('pa55word');
@@ -151,7 +160,7 @@ export const PasswordWithToggle: Story = {
}}
/>
);
}
},
};
export const InlineWithIcon: Story = {
@@ -160,7 +169,7 @@ 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) => {
const [value, setValue] = useState('posts');
@@ -174,7 +183,7 @@ export const InlineWithIcon: Story = {
}}
/>
);
}
},
};
export const Error: Story = {
@@ -182,8 +191,8 @@ export const Error: Story = {
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 +200,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) => {
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;
@@ -49,15 +56,15 @@ export const Body: Story = {};
export const Error: Story = {
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 +80,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,88 +25,90 @@ 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;
@@ -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: () => {
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,6 +1,12 @@
@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);
@@ -16,6 +22,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 +41,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 +50,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);
}
@@ -63,6 +71,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 +88,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 +97,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;
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

15
tests/setup.ts Normal file
View File

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

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'vite';
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { 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,27 @@ 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/**/*.stories.{ts,tsx}',
'src/styles/**',
'src/components/types.ts',
'src/types/**',
],
thresholds: {
lines: 95,
functions: 95,
branches: 90,
},
},
},
});

1143
yarn.lock

File diff suppressed because it is too large Load Diff