Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1523f7be2c | |||
| b664c99944 | |||
| 3d4a4a5f57 | |||
| f9864842b5 | |||
| dd084369e9 | |||
| 850eed0766 | |||
| 4904bea29c | |||
| 4fc3738adf | |||
| e17c82de2f | |||
| a527ce27cd | |||
| 623e45d241 | |||
| 5593746cf4 | |||
| b163fdaa62 | |||
| ec63a10027 | |||
| 5ada69773c | |||
| 370d6e7e0a | |||
| 44dd5d5deb | |||
| 8d3ca5a281 | |||
| 1d5113d209 | |||
| f71e773a3a | |||
| 4921afe296 | |||
| 5cc3e3646c | |||
| 29a4e8c2ee | |||
| 3ddd108186 | |||
| 836d24943e | |||
| a312141c21 | |||
| 457962ede2 | |||
| f1c7e245aa | |||
| c2e370f0a8 | |||
| 01b00b5717 | |||
| f9a9c89e4f | |||
| 6ba98fa6b6 |
94
.drone.yml
94
.drone.yml
@@ -4,27 +4,55 @@ type: docker
|
|||||||
name: web-ui-ci
|
name: web-ui-ci
|
||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
branch:
|
branch:
|
||||||
- main
|
- main
|
||||||
- develop
|
- develop
|
||||||
event:
|
event:
|
||||||
- push
|
- push
|
||||||
- pull_request
|
- pull_request
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: install
|
- name: install
|
||||||
image: node:22
|
image: node:25
|
||||||
commands:
|
commands:
|
||||||
- corepack enable
|
- yarn install --frozen-lockfile
|
||||||
- corepack prepare yarn@1.22.22 --activate
|
|
||||||
- yarn install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: build
|
- name: lint
|
||||||
image: node:22
|
image: node:25
|
||||||
commands:
|
commands:
|
||||||
- corepack enable
|
- yarn lint
|
||||||
- corepack prepare yarn@1.22.22 --activate
|
|
||||||
- yarn build
|
- name: build
|
||||||
|
image: node:25
|
||||||
|
commands:
|
||||||
|
- yarn build
|
||||||
|
|
||||||
|
- name: unit-tests
|
||||||
|
image: node:25
|
||||||
|
environment:
|
||||||
|
NODE_OPTIONS: --no-webstorage
|
||||||
|
commands:
|
||||||
|
- yarn test:coverage
|
||||||
|
- test -f coverage/lcov.info
|
||||||
|
|
||||||
|
- name: code-analysis
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
image: sonarsource/sonar-scanner-cli:latest
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
test -f coverage/lcov.info
|
||||||
|
SONAR_ARGS="-Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.host.url=$SONAR_INSTANCE_URL -Dsonar.token=$SONAR_LOGIN_KEY -Dsonar.sources=src -Dsonar.tests=tests -Dsonar.test.inclusions=tests/**/*.{test,spec}.{ts,tsx,js,jsx} -Dsonar.javascript.lcov.reportPaths=coverage/lcov.info -Dsonar.working.directory=/tmp/.scannerwork"
|
||||||
|
sonar-scanner $SONAR_ARGS
|
||||||
|
environment:
|
||||||
|
SONAR_USER_HOME: /tmp/.sonar
|
||||||
|
SONAR_PROJECT_KEY:
|
||||||
|
from_secret: sonar_project_key
|
||||||
|
SONAR_INSTANCE_URL:
|
||||||
|
from_secret: sonar_instance_url
|
||||||
|
SONAR_LOGIN_KEY:
|
||||||
|
from_secret: sonar_login_key
|
||||||
|
|
||||||
---
|
---
|
||||||
kind: pipeline
|
kind: pipeline
|
||||||
@@ -32,22 +60,18 @@ type: docker
|
|||||||
name: web-ui-publish
|
name: web-ui-publish
|
||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
branch:
|
event:
|
||||||
- main
|
- tag
|
||||||
event:
|
ref:
|
||||||
- promote
|
- refs/tags/v*
|
||||||
target:
|
|
||||||
- production
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: publish-npm
|
- name: publish-npm
|
||||||
image: node:22
|
image: node:25
|
||||||
environment:
|
environment:
|
||||||
NEXUS_NPM_TOKEN:
|
NEXUS_NPM_TOKEN:
|
||||||
from_secret: nexus_npm_token
|
from_secret: nexus_npm_token
|
||||||
commands:
|
commands:
|
||||||
- corepack enable
|
- yarn install --frozen-lockfile
|
||||||
- corepack prepare yarn@1.22.22 --activate
|
- npm config set //nexus.beatrice.wtf/repository/npm-hosted/:_authToken "$NEXUS_NPM_TOKEN"
|
||||||
- yarn install --frozen-lockfile
|
- yarn publish:nexus
|
||||||
- npm config set //nexus.beatrice.wtf/repository/npm-hosted/:_authToken "$NEXUS_NPM_TOKEN"
|
|
||||||
- yarn publish:nexus
|
|
||||||
|
|||||||
5
.prettierignore
Normal file
5
.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
dist
|
||||||
|
coverage
|
||||||
|
storybook-static
|
||||||
|
node_modules
|
||||||
|
yarn.lock
|
||||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 4
|
||||||
|
}
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
import type { StorybookConfig } from '@storybook/react-vite';
|
import type { StorybookConfig } from '@storybook/react-vite';
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: ['../src/**/*.stories.@(ts|tsx|mdx)'],
|
stories: ['../src/**/*.stories.@(ts|tsx|mdx)'],
|
||||||
addons: [
|
addons: ['@storybook/addon-docs', '@storybook/addon-a11y', '@storybook/addon-themes'],
|
||||||
'@storybook/addon-docs',
|
framework: {
|
||||||
'@storybook/addon-a11y',
|
name: '@storybook/react-vite',
|
||||||
'@storybook/addon-themes'
|
options: {},
|
||||||
],
|
},
|
||||||
framework: {
|
docs: {
|
||||||
name: '@storybook/react-vite',
|
autodocs: 'tag',
|
||||||
options: {}
|
},
|
||||||
},
|
|
||||||
docs: {
|
|
||||||
autodocs: 'tag'
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--bg-page);
|
background-color: var(--bg-page);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,22 @@ import '../src/styles/base.css';
|
|||||||
import '../src/styles/components.css';
|
import '../src/styles/components.css';
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
decorators: [
|
decorators: [
|
||||||
(Story) => (
|
(Story) => (
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<Story />
|
<Story />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
parameters: {
|
parameters: {
|
||||||
controls: {
|
controls: {
|
||||||
matchers: {
|
matchers: {
|
||||||
color: /(background|color)$/i,
|
color: /(background|color)$/i,
|
||||||
date: /Date$/i
|
date: /Date$/i,
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
layout: 'centered',
|
||||||
},
|
},
|
||||||
layout: 'centered'
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default preview;
|
export default preview;
|
||||||
|
|||||||
58
eslint.config.mjs
Normal file
58
eslint.config.mjs
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
169
license.md
169
license.md
@@ -1,5 +1,4 @@
|
|||||||
GNU Affero General Public License
|
# GNU Affero General Public License
|
||||||
=================================
|
|
||||||
|
|
||||||
_Version 3, 19 November 2007_
|
_Version 3, 19 November 2007_
|
||||||
_Copyright © 2007 Free Software Foundation, Inc. <<http://fsf.org/>>_
|
_Copyright © 2007 Free Software Foundation, Inc. <<http://fsf.org/>>_
|
||||||
@@ -14,13 +13,13 @@ software and other kinds of works, specifically designed to ensure
|
|||||||
cooperation with the community in the case of network server software.
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
The licenses for most software and other practical works are designed
|
||||||
to take away your freedom to share and change the works. By contrast,
|
to take away your freedom to share and change the works. By contrast,
|
||||||
our General Public Licenses are intended to guarantee your freedom to
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
share and change all versions of a program--to make sure it remains free
|
share and change all versions of a program--to make sure it remains free
|
||||||
software for all its users.
|
software for all its users.
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
When we speak of free software, we are referring to freedom, not
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
have the freedom to distribute copies of free software (and charge for
|
have the freedom to distribute copies of free software (and charge for
|
||||||
them if you wish), that you receive source code or can get it if you
|
them if you wish), that you receive source code or can get it if you
|
||||||
want it, that you can change the software or use pieces of it in new
|
want it, that you can change the software or use pieces of it in new
|
||||||
@@ -34,8 +33,8 @@ and/or modify the software.
|
|||||||
A secondary benefit of defending all users' freedom is that
|
A secondary benefit of defending all users' freedom is that
|
||||||
improvements made in alternate versions of the program, if they
|
improvements made in alternate versions of the program, if they
|
||||||
receive widespread use, become available for other developers to
|
receive widespread use, become available for other developers to
|
||||||
incorporate. Many developers of free software are heartened and
|
incorporate. Many developers of free software are heartened and
|
||||||
encouraged by the resulting cooperation. However, in the case of
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
software used on network servers, this result may fail to come about.
|
software used on network servers, this result may fail to come about.
|
||||||
The GNU General Public License permits making a modified version and
|
The GNU General Public License permits making a modified version and
|
||||||
letting the public access it on a server without ever releasing its
|
letting the public access it on a server without ever releasing its
|
||||||
@@ -43,14 +42,14 @@ source code to the public.
|
|||||||
|
|
||||||
The GNU Affero General Public License is designed specifically to
|
The GNU Affero General Public License is designed specifically to
|
||||||
ensure that, in such cases, the modified source code becomes available
|
ensure that, in such cases, the modified source code becomes available
|
||||||
to the community. It requires the operator of a network server to
|
to the community. It requires the operator of a network server to
|
||||||
provide the source code of the modified version running there to the
|
provide the source code of the modified version running there to the
|
||||||
users of that server. Therefore, public use of a modified version, on
|
users of that server. Therefore, public use of a modified version, on
|
||||||
a publicly accessible server, gives the public access to the source
|
a publicly accessible server, gives the public access to the source
|
||||||
code of the modified version.
|
code of the modified version.
|
||||||
|
|
||||||
An older license, called the Affero General Public License and
|
An older license, called the Affero General Public License and
|
||||||
published by Affero, was designed to accomplish similar goals. This is
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
a different license, not a version of the Affero GPL, but Affero has
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
released a new version of the Affero GPL which permits relicensing under
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
this license.
|
this license.
|
||||||
@@ -68,12 +67,12 @@ modification follow.
|
|||||||
works, such as semiconductor masks.
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
“The Program” refers to any copyrightable work licensed under this
|
“The Program” refers to any copyrightable work licensed under this
|
||||||
License. Each licensee is addressed as “you”. “Licensees” and
|
License. Each licensee is addressed as “you”. “Licensees” and
|
||||||
“recipients” may be individuals or organizations.
|
“recipients” may be individuals or organizations.
|
||||||
|
|
||||||
To “modify” a work means to copy from or adapt all or part of the work
|
To “modify” a work means to copy from or adapt all or part of the work
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
exact copy. The resulting work is called a “modified version” of the
|
exact copy. The resulting work is called a “modified version” of the
|
||||||
earlier work or a work “based on” the earlier work.
|
earlier work or a work “based on” the earlier work.
|
||||||
|
|
||||||
A “covered work” means either the unmodified Program or a work based
|
A “covered work” means either the unmodified Program or a work based
|
||||||
@@ -82,12 +81,12 @@ on the Program.
|
|||||||
To “propagate” a work means to do anything with it that, without
|
To “propagate” a work means to do anything with it that, without
|
||||||
permission, would make you directly or secondarily liable for
|
permission, would make you directly or secondarily liable for
|
||||||
infringement under applicable copyright law, except executing it on a
|
infringement under applicable copyright law, except executing it on a
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
distribution (with or without modification), making available to the
|
distribution (with or without modification), making available to the
|
||||||
public, and in some countries other activities as well.
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
To “convey” a work means any kind of propagation that enables other
|
To “convey” a work means any kind of propagation that enables other
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
An interactive user interface displays “Appropriate Legal Notices”
|
An interactive user interface displays “Appropriate Legal Notices”
|
||||||
@@ -95,14 +94,14 @@ to the extent that it includes a convenient and prominently visible
|
|||||||
feature that **(1)** displays an appropriate copyright notice, and **(2)**
|
feature that **(1)** displays an appropriate copyright notice, and **(2)**
|
||||||
tells the user that there is no warranty for the work (except to the
|
tells the user that there is no warranty for the work (except to the
|
||||||
extent that warranties are provided), that licensees may convey the
|
extent that warranties are provided), that licensees may convey the
|
||||||
work under this License, and how to view a copy of this License. If
|
work under this License, and how to view a copy of this License. If
|
||||||
the interface presents a list of user commands or options, such as a
|
the interface presents a list of user commands or options, such as a
|
||||||
menu, a prominent item in the list meets this criterion.
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
### 1. Source Code
|
### 1. Source Code
|
||||||
|
|
||||||
The “source code” for a work means the preferred form of the work
|
The “source code” for a work means the preferred form of the work
|
||||||
for making modifications to it. “Object code” means any non-source
|
for making modifications to it. “Object code” means any non-source
|
||||||
form of a work.
|
form of a work.
|
||||||
|
|
||||||
A “Standard Interface” means an interface that either is an official
|
A “Standard Interface” means an interface that either is an official
|
||||||
@@ -115,7 +114,7 @@ than the work as a whole, that **(a)** is included in the normal form of
|
|||||||
packaging a Major Component, but which is not part of that Major
|
packaging a Major Component, but which is not part of that Major
|
||||||
Component, and **(b)** serves only to enable use of the work with that
|
Component, and **(b)** serves only to enable use of the work with that
|
||||||
Major Component, or to implement a Standard Interface for which an
|
Major Component, or to implement a Standard Interface for which an
|
||||||
implementation is available to the public in source code form. A
|
implementation is available to the public in source code form. A
|
||||||
“Major Component”, in this context, means a major essential component
|
“Major Component”, in this context, means a major essential component
|
||||||
(kernel, window system, and so on) of the specific operating system
|
(kernel, window system, and so on) of the specific operating system
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
@@ -124,10 +123,10 @@ produce the work, or an object code interpreter used to run it.
|
|||||||
The “Corresponding Source” for a work in object code form means all
|
The “Corresponding Source” for a work in object code form means all
|
||||||
the source code needed to generate, install, and (for an executable
|
the source code needed to generate, install, and (for an executable
|
||||||
work) run the object code and to modify the work, including scripts to
|
work) run the object code and to modify the work, including scripts to
|
||||||
control those activities. However, it does not include the work's
|
control those activities. However, it does not include the work's
|
||||||
System Libraries, or general-purpose tools or generally available free
|
System Libraries, or general-purpose tools or generally available free
|
||||||
programs which are used unmodified in performing those activities but
|
programs which are used unmodified in performing those activities but
|
||||||
which are not part of the work. For example, Corresponding Source
|
which are not part of the work. For example, Corresponding Source
|
||||||
includes interface definition files associated with source files for
|
includes interface definition files associated with source files for
|
||||||
the work, and the source code for shared libraries and dynamically
|
the work, and the source code for shared libraries and dynamically
|
||||||
linked subprograms that the work is specifically designed to require,
|
linked subprograms that the work is specifically designed to require,
|
||||||
@@ -145,25 +144,25 @@ same work.
|
|||||||
|
|
||||||
All rights granted under this License are granted for the term of
|
All rights granted under this License are granted for the term of
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
permission to run the unmodified Program. The output from running a
|
permission to run the unmodified Program. The output from running a
|
||||||
covered work is covered by this License only if the output, given its
|
covered work is covered by this License only if the output, given its
|
||||||
content, constitutes a covered work. This License acknowledges your
|
content, constitutes a covered work. This License acknowledges your
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not
|
You may make, run and propagate covered works that you do not
|
||||||
convey, without conditions so long as your license otherwise remains
|
convey, without conditions so long as your license otherwise remains
|
||||||
in force. You may convey covered works to others for the sole purpose
|
in force. You may convey covered works to others for the sole purpose
|
||||||
of having them make modifications exclusively for you, or provide you
|
of having them make modifications exclusively for you, or provide you
|
||||||
with facilities for running those works, provided that you comply with
|
with facilities for running those works, provided that you comply with
|
||||||
the terms of this License in conveying all material for which you do
|
the terms of this License in conveying all material for which you do
|
||||||
not control copyright. Those thus making or running the covered works
|
not control copyright. Those thus making or running the covered works
|
||||||
for you must do so exclusively on your behalf, under your direction
|
for you must do so exclusively on your behalf, under your direction
|
||||||
and control, on terms that prohibit them from making any copies of
|
and control, on terms that prohibit them from making any copies of
|
||||||
your copyrighted material outside their relationship with you.
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under
|
Conveying under any other circumstances is permitted solely under
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
makes it unnecessary.
|
makes it unnecessary.
|
||||||
|
|
||||||
### 3. Protecting Users' Legal Rights From Anti-Circumvention Law
|
### 3. Protecting Users' Legal Rights From Anti-Circumvention Law
|
||||||
@@ -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
|
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:
|
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.
|
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.
|
released under this License and any conditions added under section 7.
|
||||||
This requirement modifies the requirement in section 4 to
|
This requirement modifies the requirement in section 4 to
|
||||||
“keep intact all notices”.
|
“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 to anyone who comes into possession of a copy. This
|
||||||
License will therefore apply, along with any applicable section 7
|
License will therefore apply, along with any applicable section 7
|
||||||
additional terms, to the whole of the work, and all its parts,
|
additional terms, to the whole of the work, and all its parts,
|
||||||
regardless of how they are packaged. This License gives no
|
regardless of how they are packaged. This License gives no
|
||||||
permission to license the work in any other way, but it does not
|
permission to license the work in any other way, but it does not
|
||||||
invalidate such permission if you have separately received it.
|
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
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
work need not make them do so.
|
work need not make them do so.
|
||||||
@@ -225,7 +224,7 @@ and which are not combined with it such as to form a larger program,
|
|||||||
in or on a volume of a storage or distribution medium, is called an
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
“aggregate” if the compilation and its resulting copyright are not
|
“aggregate” if the compilation and its resulting copyright are not
|
||||||
used to limit the access or legal rights of the compilation's users
|
used to limit the access or legal rights of the compilation's users
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
in an aggregate does not cause this License to apply to the other
|
in an aggregate does not cause this License to apply to the other
|
||||||
parts of the aggregate.
|
parts of the aggregate.
|
||||||
|
|
||||||
@@ -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,
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
in one of these ways:
|
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
|
(including a physical distribution medium), accompanied by the
|
||||||
Corresponding Source fixed on a durable physical medium
|
Corresponding Source fixed on a durable physical medium
|
||||||
customarily used for software interchange.
|
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
|
(including a physical distribution medium), accompanied by a
|
||||||
written offer, valid for at least three years and valid for as
|
written offer, valid for at least three years and valid for as
|
||||||
long as you offer spare parts or customer support for that product
|
long as you offer spare parts or customer support for that product
|
||||||
@@ -251,24 +250,24 @@ in one of these ways:
|
|||||||
more than your reasonable cost of physically performing this
|
more than your reasonable cost of physically performing this
|
||||||
conveying of source, or **(2)** access to copy the
|
conveying of source, or **(2)** access to copy the
|
||||||
Corresponding Source from a network server at no charge.
|
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
|
written offer to provide the Corresponding Source. This
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
only if you received the object code with such an offer, in accord
|
only if you received the object code with such an offer, in accord
|
||||||
with subsection 6b.
|
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
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
Corresponding Source in the same way through the same place at no
|
Corresponding Source in the same way through the same place at no
|
||||||
further charge. You need not require recipients to copy the
|
further charge. You need not require recipients to copy the
|
||||||
Corresponding Source along with the object code. If the place to
|
Corresponding Source along with the object code. If the place to
|
||||||
copy the object code is a network server, the Corresponding Source
|
copy the object code is a network server, the Corresponding Source
|
||||||
may be on a different server (operated by you or a third party)
|
may be on a different server (operated by you or a third party)
|
||||||
that supports equivalent copying facilities, provided you maintain
|
that supports equivalent copying facilities, provided you maintain
|
||||||
clear directions next to the object code saying where to find the
|
clear directions next to the object code saying where to find the
|
||||||
Corresponding Source. Regardless of what server hosts the
|
Corresponding Source. Regardless of what server hosts the
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
available for as long as needed to satisfy these requirements.
|
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
|
you inform other peers where the object code and Corresponding
|
||||||
Source of the work are being offered to the general public at no
|
Source of the work are being offered to the general public at no
|
||||||
charge under subsection 6d.
|
charge under subsection 6d.
|
||||||
@@ -280,12 +279,12 @@ included in conveying the object code work.
|
|||||||
A “User Product” is either **(1)** a “consumer product”, which means any
|
A “User Product” is either **(1)** a “consumer product”, which means any
|
||||||
tangible personal property which is normally used for personal, family,
|
tangible personal property which is normally used for personal, family,
|
||||||
or household purposes, or **(2)** anything designed or sold for incorporation
|
or household purposes, or **(2)** anything designed or sold for incorporation
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
product received by a particular user, “normally used” refers to a
|
product received by a particular user, “normally used” refers to a
|
||||||
typical or common use of that class of product, regardless of the status
|
typical or common use of that class of product, regardless of the status
|
||||||
of the particular user or of the way in which the particular user
|
of the particular user or of the way in which the particular user
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
is a consumer product regardless of whether the product has substantial
|
is a consumer product regardless of whether the product has substantial
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
the only significant mode of use of the product.
|
the only significant mode of use of the product.
|
||||||
@@ -293,7 +292,7 @@ the only significant mode of use of the product.
|
|||||||
“Installation Information” for a User Product means any methods,
|
“Installation Information” for a User Product means any methods,
|
||||||
procedures, authorization keys, or other information required to install
|
procedures, authorization keys, or other information required to install
|
||||||
and execute modified versions of a covered work in that User Product from
|
and execute modified versions of a covered work in that User Product from
|
||||||
a modified version of its Corresponding Source. The information must
|
a modified version of its Corresponding Source. The information must
|
||||||
suffice to ensure that the continued functioning of the modified object
|
suffice to ensure that the continued functioning of the modified object
|
||||||
code is in no case prevented or interfered with solely because
|
code is in no case prevented or interfered with solely because
|
||||||
modification has been made.
|
modification has been made.
|
||||||
@@ -304,7 +303,7 @@ part of a transaction in which the right of possession and use of the
|
|||||||
User Product is transferred to the recipient in perpetuity or for a
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
by the Installation Information. But this requirement does not apply
|
by the Installation Information. But this requirement does not apply
|
||||||
if neither you nor any third party retains the ability to install
|
if neither you nor any third party retains the ability to install
|
||||||
modified object code on the User Product (for example, the work has
|
modified object code on the User Product (for example, the work has
|
||||||
been installed in ROM).
|
been installed in ROM).
|
||||||
@@ -312,7 +311,7 @@ been installed in ROM).
|
|||||||
The requirement to provide Installation Information does not include a
|
The requirement to provide Installation Information does not include a
|
||||||
requirement to continue to provide support service, warranty, or updates
|
requirement to continue to provide support service, warranty, or updates
|
||||||
for a work that has been modified or installed by the recipient, or for
|
for a work that has been modified or installed by the recipient, or for
|
||||||
the User Product in which it has been modified or installed. Access to a
|
the User Product in which it has been modified or installed. Access to a
|
||||||
network may be denied when the modification itself materially and
|
network may be denied when the modification itself materially and
|
||||||
adversely affects the operation of the network or violates the rules and
|
adversely affects the operation of the network or violates the rules and
|
||||||
protocols for communication across the network.
|
protocols for communication across the network.
|
||||||
@@ -329,15 +328,15 @@ unpacking, reading or copying.
|
|||||||
License by making exceptions from one or more of its conditions.
|
License by making exceptions from one or more of its conditions.
|
||||||
Additional permissions that are applicable to the entire Program shall
|
Additional permissions that are applicable to the entire Program shall
|
||||||
be treated as though they were included in this License, to the extent
|
be treated as though they were included in this License, to the extent
|
||||||
that they are valid under applicable law. If additional permissions
|
that they are valid under applicable law. If additional permissions
|
||||||
apply only to part of the Program, that part may be used separately
|
apply only to part of the Program, that part may be used separately
|
||||||
under those permissions, but the entire Program remains governed by
|
under those permissions, but the entire Program remains governed by
|
||||||
this License without regard to the additional permissions.
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
When you convey a copy of a covered work, you may at your option
|
||||||
remove any additional permissions from that copy, or from any part of
|
remove any additional permissions from that copy, or from any part of
|
||||||
it. (Additional permissions may be written to require their own
|
it. (Additional permissions may be written to require their own
|
||||||
removal in certain cases when you modify the work.) You may place
|
removal in certain cases when you modify the work.) You may place
|
||||||
additional permissions on material, added by you to a covered work,
|
additional permissions on material, added by you to a covered work,
|
||||||
for which you have or can give appropriate copyright permission.
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
@@ -345,29 +344,29 @@ Notwithstanding any other provision of this License, for material you
|
|||||||
add to a covered work, you may (if authorized by the copyright holders of
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
that material) supplement the terms of this License with terms:
|
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
|
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
|
author attributions in that material or in the Appropriate Legal
|
||||||
Notices displayed by works containing it; or
|
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
|
requiring that modified versions of such material be marked in
|
||||||
reasonable ways as different from the original version; or
|
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
|
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
|
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
|
material by anyone who conveys the material (or modified versions of
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
any liability that these contractual assumptions directly impose on
|
any liability that these contractual assumptions directly impose on
|
||||||
those licensors and authors.
|
those licensors and authors.
|
||||||
|
|
||||||
All other non-permissive additional terms are considered “further
|
All other non-permissive additional terms are considered “further
|
||||||
restrictions” within the meaning of section 10. If the Program as you
|
restrictions” within the meaning of section 10. If the Program as you
|
||||||
received it, or any part of it, contains a notice stating that it is
|
received it, or any part of it, contains a notice stating that it is
|
||||||
governed by this License along with a term that is a further
|
governed by this License along with a term that is a further
|
||||||
restriction, you may remove that term. If a license document contains
|
restriction, you may remove that term. If a license document contains
|
||||||
a further restriction but permits relicensing or conveying under this
|
a further restriction but permits relicensing or conveying under this
|
||||||
License, you may add to a covered work material governed by the terms
|
License, you may add to a covered work material governed by the terms
|
||||||
of that license document, provided that the further restriction does
|
of that license document, provided that the further restriction does
|
||||||
@@ -385,7 +384,7 @@ the above requirements apply either way.
|
|||||||
### 8. Termination
|
### 8. Termination
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
You may not propagate or modify a covered work except as expressly
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
modify it is void, and will automatically terminate your rights under
|
modify it is void, and will automatically terminate your rights under
|
||||||
this License (including any patent licenses granted under the third
|
this License (including any patent licenses granted under the third
|
||||||
paragraph of section 11).
|
paragraph of section 11).
|
||||||
@@ -406,31 +405,31 @@ your receipt of the notice.
|
|||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
Termination of your rights under this section does not terminate the
|
||||||
licenses of parties who have received copies or rights from you under
|
licenses of parties who have received copies or rights from you under
|
||||||
this License. If your rights have been terminated and not permanently
|
this License. If your rights have been terminated and not permanently
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
material under section 10.
|
material under section 10.
|
||||||
|
|
||||||
### 9. Acceptance Not Required for Having Copies
|
### 9. Acceptance Not Required for Having Copies
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
You are not required to accept this License in order to receive or
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
to receive a copy likewise does not require acceptance. However,
|
to receive a copy likewise does not require acceptance. However,
|
||||||
nothing other than this License grants you permission to propagate or
|
nothing other than this License grants you permission to propagate or
|
||||||
modify any covered work. These actions infringe copyright if you do
|
modify any covered work. These actions infringe copyright if you do
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
### 10. Automatic Licensing of Downstream Recipients
|
### 10. Automatic Licensing of Downstream Recipients
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
Each time you convey a covered work, the recipient automatically
|
||||||
receives a license from the original licensors, to run, modify and
|
receives a license from the original licensors, to run, modify and
|
||||||
propagate that work, subject to this License. You are not responsible
|
propagate that work, subject to this License. You are not responsible
|
||||||
for enforcing compliance by third parties with this License.
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
An “entity transaction” is a transaction transferring control of an
|
An “entity transaction” is a transaction transferring control of an
|
||||||
organization, or substantially all assets of one, or subdividing an
|
organization, or substantially all assets of one, or subdividing an
|
||||||
organization, or merging organizations. If propagation of a covered
|
organization, or merging organizations. If propagation of a covered
|
||||||
work results from an entity transaction, each party to that
|
work results from an entity transaction, each party to that
|
||||||
transaction who receives a copy of the work also receives whatever
|
transaction who receives a copy of the work also receives whatever
|
||||||
licenses to the work the party's predecessor in interest had or could
|
licenses to the work the party's predecessor in interest had or could
|
||||||
@@ -439,7 +438,7 @@ Corresponding Source of the work from the predecessor in interest, if
|
|||||||
the predecessor has it or can get it with reasonable efforts.
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
You may not impose any further restrictions on the exercise of the
|
||||||
rights granted or affirmed under this License. For example, you may
|
rights granted or affirmed under this License. For example, you may
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
rights granted under this License, and you may not initiate litigation
|
rights granted under this License, and you may not initiate litigation
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
@@ -449,7 +448,7 @@ sale, or importing the Program or any portion of it.
|
|||||||
### 11. Patents
|
### 11. Patents
|
||||||
|
|
||||||
A “contributor” is a copyright holder who authorizes use under this
|
A “contributor” is a copyright holder who authorizes use under this
|
||||||
License of the Program or a work on which the Program is based. The
|
License of the Program or a work on which the Program is based. The
|
||||||
work thus licensed is called the contributor's “contributor version”.
|
work thus licensed is called the contributor's “contributor version”.
|
||||||
|
|
||||||
A contributor's “essential patent claims” are all patent claims
|
A contributor's “essential patent claims” are all patent claims
|
||||||
@@ -457,7 +456,7 @@ owned or controlled by the contributor, whether already acquired or
|
|||||||
hereafter acquired, that would be infringed by some manner, permitted
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
by this License, of making, using, or selling its contributor version,
|
by this License, of making, using, or selling its contributor version,
|
||||||
but do not include claims that would be infringed only as a
|
but do not include claims that would be infringed only as a
|
||||||
consequence of further modification of the contributor version. For
|
consequence of further modification of the contributor version. For
|
||||||
purposes of this definition, “control” includes the right to grant
|
purposes of this definition, “control” includes the right to grant
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
this License.
|
this License.
|
||||||
@@ -470,7 +469,7 @@ propagate the contents of its contributor version.
|
|||||||
In the following three paragraphs, a “patent license” is any express
|
In the following three paragraphs, a “patent license” is any express
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
(such as an express permission to practice a patent or covenant not to
|
(such as an express permission to practice a patent or covenant not to
|
||||||
sue for patent infringement). To “grant” such a patent license to a
|
sue for patent infringement). To “grant” such a patent license to a
|
||||||
party means to make such an agreement or commitment not to enforce a
|
party means to make such an agreement or commitment not to enforce a
|
||||||
patent against the party.
|
patent against the party.
|
||||||
|
|
||||||
@@ -482,7 +481,7 @@ then you must either **(1)** cause the Corresponding Source to be so
|
|||||||
available, or **(2)** arrange to deprive yourself of the benefit of the
|
available, or **(2)** arrange to deprive yourself of the benefit of the
|
||||||
patent license for this particular work, or **(3)** arrange, in a manner
|
patent license for this particular work, or **(3)** arrange, in a manner
|
||||||
consistent with the requirements of this License, to extend the patent
|
consistent with the requirements of this License, to extend the patent
|
||||||
license to downstream recipients. “Knowingly relying” means you have
|
license to downstream recipients. “Knowingly relying” means you have
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
covered work in a country, or your recipient's use of the covered work
|
covered work in a country, or your recipient's use of the covered work
|
||||||
in a country, would infringe one or more identifiable patents in that
|
in a country, would infringe one or more identifiable patents in that
|
||||||
@@ -499,7 +498,7 @@ work and works based on it.
|
|||||||
A patent license is “discriminatory” if it does not include within
|
A patent license is “discriminatory” if it does not include within
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
specifically granted under this License. You may not convey a covered
|
specifically granted under this License. You may not convey a covered
|
||||||
work if you are a party to an arrangement with a third party that is
|
work if you are a party to an arrangement with a third party that is
|
||||||
in the business of distributing software, under which you make payment
|
in the business of distributing software, under which you make payment
|
||||||
to the third party based on the extent of your activity of conveying
|
to the third party based on the extent of your activity of conveying
|
||||||
@@ -519,10 +518,10 @@ otherwise be available to you under applicable patent law.
|
|||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
the Program, the only way you could satisfy both those terms and this
|
the Program, the only way you could satisfy both those terms and this
|
||||||
License would be to refrain entirely from conveying the Program.
|
License would be to refrain entirely from conveying the Program.
|
||||||
@@ -535,7 +534,7 @@ interacting with it remotely through a computer network (if your version
|
|||||||
supports such interaction) an opportunity to receive the Corresponding
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
Source of your version by providing access to the Corresponding Source
|
Source of your version by providing access to the Corresponding Source
|
||||||
from a network server at no charge, through some standard or customary
|
from a network server at no charge, through some standard or customary
|
||||||
means of facilitating copying of software. This Corresponding Source
|
means of facilitating copying of software. This Corresponding Source
|
||||||
shall include the Corresponding Source for any work covered by version 3
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
of the GNU General Public License that is incorporated pursuant to the
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
following paragraph.
|
following paragraph.
|
||||||
@@ -543,7 +542,7 @@ following paragraph.
|
|||||||
Notwithstanding any other provision of this License, you have
|
Notwithstanding any other provision of this License, you have
|
||||||
permission to link or combine any covered work with a work licensed
|
permission to link or combine any covered work with a work licensed
|
||||||
under version 3 of the GNU General Public License into a single
|
under version 3 of the GNU General Public License into a single
|
||||||
combined work, and to convey the resulting work. The terms of this
|
combined work, and to convey the resulting work. The terms of this
|
||||||
License will continue to apply to the part which is the covered work,
|
License will continue to apply to the part which is the covered work,
|
||||||
but the work with which it is combined will remain governed by version
|
but the work with which it is combined will remain governed by version
|
||||||
3 of the GNU General Public License.
|
3 of the GNU General Public License.
|
||||||
@@ -551,16 +550,16 @@ but the work with which it is combined will remain governed by version
|
|||||||
### 14. Revised Versions of this License
|
### 14. Revised Versions of this License
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
the GNU Affero General Public License from time to time. Such new versions
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
will be similar in spirit to the present version, but may differ in detail to
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
address new problems or concerns.
|
address new problems or concerns.
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
Each version is given a distinguishing version number. If the
|
||||||
Program specifies that a certain numbered version of the GNU Affero General
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
Public License “or any later version” applies to it, you have the
|
Public License “or any later version” applies to it, you have the
|
||||||
option of following the terms and conditions either of that numbered
|
option of following the terms and conditions either of that numbered
|
||||||
version or of any later version published by the Free Software
|
version or of any later version published by the Free Software
|
||||||
Foundation. If the Program does not specify a version number of the
|
Foundation. If the Program does not specify a version number of the
|
||||||
GNU Affero General Public License, you may choose any version ever published
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
by the Free Software Foundation.
|
by the Free Software Foundation.
|
||||||
|
|
||||||
@@ -570,19 +569,19 @@ public statement of acceptance of a version permanently authorizes you
|
|||||||
to choose that version for the Program.
|
to choose that version for the Program.
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
Later license versions may give you additional or different
|
||||||
permissions. However, no additional obligations are imposed on any
|
permissions. However, no additional obligations are imposed on any
|
||||||
author or copyright holder as a result of your choosing to follow a
|
author or copyright holder as a result of your choosing to follow a
|
||||||
later version.
|
later version.
|
||||||
|
|
||||||
### 15. Disclaimer of Warranty
|
### 15. Disclaimer of Warranty
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
### 16. Limitation of Liability
|
### 16. Limitation of Liability
|
||||||
@@ -614,7 +613,7 @@ If you develop a new program, and you want it to be of the greatest
|
|||||||
possible use to the public, the best way to achieve this is to make it
|
possible use to the public, the best way to achieve this is to make it
|
||||||
free software which everyone can redistribute and change under these terms.
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
To do so, attach the following notices to the program. It is safest
|
||||||
to attach them to the start of each source file to most effectively
|
to attach them to the start of each source file to most effectively
|
||||||
state the exclusion of warranty; and each file should have at least
|
state the exclusion of warranty; and each file should have at least
|
||||||
the “copyright” line and a pointer to where the full notice is found.
|
the “copyright” line and a pointer to where the full notice is found.
|
||||||
@@ -639,9 +638,9 @@ Also add information on how to contact you by electronic and paper mail.
|
|||||||
|
|
||||||
If your software can interact with users remotely through a computer
|
If your software can interact with users remotely through a computer
|
||||||
network, you should also make sure that it provides a way for users to
|
network, you should also make sure that it provides a way for users to
|
||||||
get its source. For example, if your program is a web application, its
|
get its source. For example, if your program is a web application, its
|
||||||
interface could display a “Source” link that leads users to an archive
|
interface could display a “Source” link that leads users to an archive
|
||||||
of the code. There are many ways you could offer source, and different
|
of the code. There are many ways you could offer source, and different
|
||||||
solutions will be better for different programs; see section 13 for the
|
solutions will be better for different programs; see section 13 for the
|
||||||
specific requirements.
|
specific requirements.
|
||||||
|
|
||||||
|
|||||||
162
package.json
162
package.json
@@ -1,75 +1,97 @@
|
|||||||
{
|
{
|
||||||
"name": "@panic/web-ui",
|
"name": "@panic/web-ui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.18",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"description": "Core components for panic.haus web applications",
|
"description": "Core components for panic.haus web applications",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js"
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./components/MDXEditorField": {
|
||||||
|
"types": "./dist/components/MDXEditorField.d.ts",
|
||||||
|
"import": "./dist/components/MDXEditorField.js"
|
||||||
|
},
|
||||||
|
"./styles/base.css": "./dist/styles/base.css",
|
||||||
|
"./styles/components.css": "./dist/styles/components.css",
|
||||||
|
"./styles/utilities.css": "./dist/styles/utilities.css",
|
||||||
|
"./tailwind-preset": "./dist/tailwind-preset.cjs"
|
||||||
},
|
},
|
||||||
"./components/MDXEditorField": {
|
"files": [
|
||||||
"types": "./dist/components/MDXEditorField.d.ts",
|
"dist"
|
||||||
"import": "./dist/components/MDXEditorField.js"
|
],
|
||||||
|
"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",
|
||||||
|
"publish:nexus": "npm publish --registry ${NEXUS_NPM_REGISTRY:-https://nexus.beatrice.wtf/repository/npm-hosted/}"
|
||||||
},
|
},
|
||||||
"./styles/base.css": "./dist/styles/base.css",
|
"publishConfig": {
|
||||||
"./styles/components.css": "./dist/styles/components.css",
|
"registry": "https://nexus.beatrice.wtf/repository/npm-hosted/",
|
||||||
"./styles/utilities.css": "./dist/styles/utilities.css",
|
"access": "restricted"
|
||||||
"./tailwind-preset": "./dist/tailwind-preset.cjs"
|
},
|
||||||
},
|
"peerDependencies": {
|
||||||
"files": [
|
"@heroicons/react": "^2.2.0",
|
||||||
"dist"
|
"@mdxeditor/editor": "^3.52.4",
|
||||||
],
|
"react": "^19.0.0",
|
||||||
"scripts": {
|
"react-dom": "^19.0.0",
|
||||||
"clean": "rm -rf dist",
|
"react-router-dom": "^7.0.0"
|
||||||
"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",
|
},
|
||||||
"storybook": "storybook dev -p 6006",
|
"peerDependenciesMeta": {
|
||||||
"build-storybook": "storybook build",
|
"@mdxeditor/editor": {
|
||||||
"prepublishOnly": "yarn build",
|
"optional": true
|
||||||
"publish:nexus": "npm publish --registry ${NEXUS_NPM_REGISTRY:-https://nexus.beatrice.wtf/repository/npm-hosted/}"
|
}
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"devDependencies": {
|
||||||
"registry": "https://nexus.beatrice.wtf/repository/npm-hosted/",
|
"@codemirror/language": "^6.11.3",
|
||||||
"access": "restricted"
|
"@eslint/js": "^10",
|
||||||
},
|
"@heroicons/react": "^2.2.0",
|
||||||
"peerDependencies": {
|
"@lezer/highlight": "^1.2.1",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@mdxeditor/editor": "^3.52.4",
|
||||||
"@mdxeditor/editor": "^3.52.4",
|
"@storybook/addon-a11y": "^10.2.10",
|
||||||
"react": "^19.0.0",
|
"@storybook/addon-docs": "^10.2.10",
|
||||||
"react-dom": "^19.0.0",
|
"@storybook/addon-themes": "^10.2.10",
|
||||||
"react-router-dom": "^7.0.0"
|
"@storybook/react": "^10.2.10",
|
||||||
},
|
"@storybook/react-vite": "^10.2.10",
|
||||||
"peerDependenciesMeta": {
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@mdxeditor/editor": {
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"optional": true
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
|
"eslint": "^10",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.0-canary-ab18f33d-20260220",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.1",
|
||||||
|
"globals": "^17.3.0",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.0.0",
|
||||||
|
"storybook": "^10.2.10",
|
||||||
|
"tailwindcss": "^3.4.16",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"typescript-eslint": "^8.56.0",
|
||||||
|
"vite": "^7.0.0",
|
||||||
|
"vitest": "^4.0.18",
|
||||||
|
"yjs": "^13.6.24"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^25.3.0"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@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",
|
|
||||||
"@types/react": "^19.0.0",
|
|
||||||
"@types/react-dom": "^19.0.0",
|
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
|
||||||
"postcss": "^8.4.49",
|
|
||||||
"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",
|
|
||||||
"vite": "^7.0.0",
|
|
||||||
"yjs": "^13.6.24"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {
|
tailwindcss: {
|
||||||
config: './tailwind.storybook.config.cjs'
|
config: './tailwind.storybook.config.cjs',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||||
}
|
}
|
||||||
@@ -3,83 +3,92 @@ import { PlusIcon } from '@heroicons/react/24/solid';
|
|||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Components/Button',
|
title: 'Components/Button',
|
||||||
component: Button,
|
component: Button,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
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' } }
|
|
||||||
},
|
},
|
||||||
type: {
|
argTypes: {
|
||||||
description: 'Base visual style.',
|
label: {
|
||||||
options: ['solid', 'outlined', 'noborder'],
|
description: 'Visible button label text.',
|
||||||
control: 'inline-radio',
|
control: 'text',
|
||||||
table: { type: { summary: "'solid' | 'outlined' | 'noborder'" } }
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
description: 'Base visual style.',
|
||||||
|
options: ['solid', 'outlined', 'noborder'],
|
||||||
|
control: 'inline-radio',
|
||||||
|
table: { type: { summary: "'solid' | 'outlined' | 'noborder'" } },
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
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'" } },
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
description: 'Button size.',
|
||||||
|
options: ['sm', 'md', 'lg', 'full'],
|
||||||
|
control: 'inline-radio',
|
||||||
|
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' } },
|
||||||
|
},
|
||||||
|
htmlType: {
|
||||||
|
description: 'HTML button type used when rendering a native `<button>`.',
|
||||||
|
options: ['button', 'submit', 'reset'],
|
||||||
|
control: 'inline-radio',
|
||||||
|
table: { type: { summary: "'button' | 'submit' | 'reset'" } },
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
description: 'Disables interaction and hover/click states.',
|
||||||
|
control: 'boolean',
|
||||||
|
table: { type: { summary: 'boolean' } },
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
description: 'Optional icon component (for example Heroicons).',
|
||||||
|
control: false,
|
||||||
|
table: { type: { summary: 'ElementType' } },
|
||||||
|
},
|
||||||
|
ariaLabel: {
|
||||||
|
description: 'Accessible label. Falls back to `label` when not provided.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
className: {
|
||||||
|
description: 'Extra CSS classes for the root element.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
onClick: {
|
||||||
|
description: 'Click handler callback.',
|
||||||
|
action: 'clicked',
|
||||||
|
table: { type: { summary: 'MouseEventHandler<HTMLElement>' } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
variant: {
|
args: {
|
||||||
description: 'Color variant. If omitted: `primary` for `solid`, `secondary` for the other types.',
|
type: 'solid',
|
||||||
options: ['primary', 'secondary', 'important'],
|
variant: 'primary',
|
||||||
control: 'inline-radio',
|
size: 'md',
|
||||||
table: { type: { summary: "'primary' | 'secondary' | 'important'" } }
|
width: 'md',
|
||||||
|
label: 'Save',
|
||||||
},
|
},
|
||||||
size: {
|
|
||||||
description: 'Button size.',
|
|
||||||
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' } }
|
|
||||||
},
|
|
||||||
htmlType: {
|
|
||||||
description: 'HTML button type used when rendering a native `<button>`.',
|
|
||||||
options: ['button', 'submit', 'reset'],
|
|
||||||
control: 'inline-radio',
|
|
||||||
table: { type: { summary: "'button' | 'submit' | 'reset'" } }
|
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
description: 'Disables interaction and hover/click states.',
|
|
||||||
control: 'boolean',
|
|
||||||
table: { type: { summary: 'boolean' } }
|
|
||||||
},
|
|
||||||
icon: {
|
|
||||||
description: 'Optional icon component (for example Heroicons).',
|
|
||||||
control: false,
|
|
||||||
table: { type: { summary: 'ElementType' } }
|
|
||||||
},
|
|
||||||
ariaLabel: {
|
|
||||||
description: 'Accessible label. Falls back to `label` when not provided.',
|
|
||||||
control: 'text',
|
|
||||||
table: { type: { summary: 'string' } }
|
|
||||||
},
|
|
||||||
className: {
|
|
||||||
description: 'Extra CSS classes for the root element.',
|
|
||||||
control: 'text',
|
|
||||||
table: { type: { summary: 'string' } }
|
|
||||||
},
|
|
||||||
onClick: {
|
|
||||||
description: 'Click handler callback.',
|
|
||||||
action: 'clicked',
|
|
||||||
table: { type: { summary: 'MouseEventHandler<HTMLElement>' } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
args: {
|
|
||||||
type: 'solid',
|
|
||||||
variant: 'primary',
|
|
||||||
size: 'md',
|
|
||||||
label: 'Save'
|
|
||||||
}
|
|
||||||
} satisfies Meta<typeof Button>;
|
} satisfies Meta<typeof Button>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
@@ -88,49 +97,49 @@ type Story = StoryObj<typeof meta>;
|
|||||||
export const SolidPrimary: Story = {};
|
export const SolidPrimary: Story = {};
|
||||||
|
|
||||||
export const SolidImportant: Story = {
|
export const SolidImportant: Story = {
|
||||||
args: {
|
args: {
|
||||||
type: 'solid',
|
type: 'solid',
|
||||||
variant: 'important',
|
variant: 'important',
|
||||||
label: 'Delete'
|
label: 'Delete',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OutlinedSecondary: Story = {
|
export const OutlinedSecondary: Story = {
|
||||||
args: {
|
args: {
|
||||||
type: 'outlined',
|
type: 'outlined',
|
||||||
variant: 'secondary',
|
variant: 'secondary',
|
||||||
label: 'Cancel'
|
label: 'Cancel',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IconOnly: Story = {
|
export const IconOnly: Story = {
|
||||||
args: {
|
args: {
|
||||||
icon: PlusIcon,
|
icon: PlusIcon,
|
||||||
label: undefined,
|
label: undefined,
|
||||||
ariaLabel: 'Add item'
|
ariaLabel: 'Add item',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LinkButton: Story = {
|
export const LinkButton: Story = {
|
||||||
args: {
|
args: {
|
||||||
to: '/demo',
|
to: '/demo',
|
||||||
label: 'Go to demo'
|
label: 'Go to demo',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Disabled: Story = {
|
export const Disabled: Story = {
|
||||||
args: {
|
args: {
|
||||||
disabled: true
|
disabled: true,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SizeMatrix: Story = {
|
export const SizeMatrix: Story = {
|
||||||
render: (args) => (
|
render: (args) => (
|
||||||
<div className="grid grid-cols-1 gap-3">
|
<div className="grid grid-cols-1 gap-3">
|
||||||
<Button {...args} size="sm" label="Small" />
|
<Button {...args} size="sm" label="Small" />
|
||||||
<Button {...args} size="md" label="Medium" />
|
<Button {...args} size="md" label="Medium" />
|
||||||
<Button {...args} size="lg" label="Large" />
|
<Button {...args} size="lg" label="Large" />
|
||||||
<Button {...args} size="full" label="Full width" />
|
<Button {...args} size="md" width="full" label="Full width" />
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,131 +7,143 @@ type ButtonVariant = 'primary' | 'secondary' | 'important';
|
|||||||
type NativeButtonType = 'button' | 'submit' | 'reset';
|
type NativeButtonType = 'button' | 'submit' | 'reset';
|
||||||
|
|
||||||
type ButtonProps = {
|
type ButtonProps = {
|
||||||
label?: string;
|
label?: string;
|
||||||
type: ButtonType;
|
type: ButtonType;
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: ComponentSize;
|
size?: ComponentSize;
|
||||||
to?: string;
|
width?: ComponentSize;
|
||||||
htmlType?: NativeButtonType;
|
to?: string;
|
||||||
onClick?: MouseEventHandler<HTMLElement>;
|
htmlType?: NativeButtonType;
|
||||||
disabled?: boolean;
|
onClick?: MouseEventHandler<HTMLElement>;
|
||||||
icon?: ElementType;
|
disabled?: boolean;
|
||||||
ariaLabel?: string;
|
icon?: ElementType;
|
||||||
className?: string;
|
ariaLabel?: string;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIZE_CLASS: Record<ComponentSize, string> = {
|
const SIZE_CLASS: Record<ComponentSize, string> = {
|
||||||
sm: 'h-8 px-3 text-xs',
|
sm: 'h-8 px-3 text-xs',
|
||||||
md: 'h-10 px-4 text-sm',
|
md: 'h-10 px-4 text-sm',
|
||||||
lg: 'h-12 px-5 text-base',
|
lg: 'h-12 px-5 text-base',
|
||||||
full: 'h-10 w-full px-4 text-sm'
|
full: 'h-10 px-4 text-sm',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ICON_ONLY_SIZE_CLASS: Record<ComponentSize, string> = {
|
const ICON_ONLY_SIZE_CLASS: Record<ComponentSize, string> = {
|
||||||
sm: 'h-8 w-8 !p-0',
|
sm: 'h-8 w-8 !p-0',
|
||||||
md: 'h-10 w-10 !p-0',
|
md: 'h-10 w-10 !p-0',
|
||||||
lg: 'h-12 w-12 !p-0',
|
lg: 'h-12 w-12 !p-0',
|
||||||
full: 'h-10 w-full !p-0'
|
full: 'h-10 w-10 !p-0',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ICON_CLASS: Record<ComponentSize, string> = {
|
const ICON_CLASS: Record<ComponentSize, string> = {
|
||||||
sm: 'h-4 w-4',
|
sm: 'h-4 w-4',
|
||||||
md: 'h-4 w-4',
|
md: 'h-4 w-4',
|
||||||
lg: 'h-5 w-5',
|
lg: 'h-5 w-5',
|
||||||
full: 'h-4 w-4'
|
full: 'h-4 w-4',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ICON_ONLY_CLASS: Record<ComponentSize, string> = {
|
const ICON_ONLY_CLASS: Record<ComponentSize, string> = {
|
||||||
sm: 'h-4 w-4',
|
sm: 'h-4 w-4',
|
||||||
md: 'h-5 w-5',
|
md: 'h-5 w-5',
|
||||||
lg: 'h-6 w-6',
|
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> = {
|
const TYPE_CLASS: Record<ButtonType, string> = {
|
||||||
solid: 'btn-solid',
|
solid: 'btn-solid',
|
||||||
outlined: 'btn-outlined',
|
outlined: 'btn-outlined',
|
||||||
noborder: 'btn-noborder'
|
noborder: 'btn-noborder',
|
||||||
};
|
};
|
||||||
|
|
||||||
const VARIANT_CLASS: Record<ButtonVariant, string> = {
|
const VARIANT_CLASS: Record<ButtonVariant, string> = {
|
||||||
primary: 'btn-primary',
|
primary: 'btn-primary',
|
||||||
secondary: 'btn-secondary',
|
secondary: 'btn-secondary',
|
||||||
important: 'btn-important'
|
important: 'btn-important',
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveVariant(type: ButtonType, variant?: ButtonVariant): ButtonVariant {
|
function resolveVariant(type: ButtonType, variant?: ButtonVariant): ButtonVariant {
|
||||||
if (variant) {
|
if (variant) {
|
||||||
return variant;
|
return variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
return type === 'solid' ? 'primary' : 'secondary';
|
return type === 'solid' ? 'primary' : 'secondary';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
label,
|
label,
|
||||||
type,
|
type,
|
||||||
variant,
|
variant,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
to,
|
width = 'md',
|
||||||
htmlType = 'button',
|
to,
|
||||||
onClick,
|
htmlType = 'button',
|
||||||
disabled = false,
|
onClick,
|
||||||
icon: Icon,
|
disabled = false,
|
||||||
ariaLabel,
|
icon: Icon,
|
||||||
className = ''
|
ariaLabel,
|
||||||
|
className = '',
|
||||||
}: Readonly<ButtonProps>) {
|
}: Readonly<ButtonProps>) {
|
||||||
const isIconOnly = Icon != null && !label;
|
const isIconOnly = Icon != null && !label;
|
||||||
const resolvedVariant = resolveVariant(type, variant);
|
const resolvedVariant = resolveVariant(type, variant);
|
||||||
const composedClassName = [
|
const composedClassName = [
|
||||||
TYPE_CLASS[type],
|
TYPE_CLASS[type],
|
||||||
VARIANT_CLASS[resolvedVariant],
|
VARIANT_CLASS[resolvedVariant],
|
||||||
isIconOnly ? ICON_ONLY_SIZE_CLASS[size] : SIZE_CLASS[size],
|
isIconOnly ? ICON_ONLY_SIZE_CLASS[size] : SIZE_CLASS[size],
|
||||||
Icon && label ? 'gap-1.5' : '',
|
WIDTH_CLASS[width],
|
||||||
disabled ? 'pointer-events-none cursor-not-allowed opacity-45 saturate-50' : '',
|
Icon && label ? 'gap-1.5' : '',
|
||||||
className
|
disabled ? 'pointer-events-none cursor-not-allowed opacity-45 saturate-50' : '',
|
||||||
].join(' ').trim();
|
className,
|
||||||
const computedAriaLabel = ariaLabel ?? label;
|
]
|
||||||
const iconClass = `${isIconOnly ? ICON_ONLY_CLASS[size] : ICON_CLASS[size]} shrink-0`;
|
.join(' ')
|
||||||
const content = (
|
.trim();
|
||||||
<>
|
const computedAriaLabel = ariaLabel ?? label;
|
||||||
{Icon ? <Icon className={iconClass} aria-hidden="true" /> : null}
|
const iconClass = `${isIconOnly ? ICON_ONLY_CLASS[size] : ICON_CLASS[size]} shrink-0`;
|
||||||
{label ?? null}
|
const content = (
|
||||||
</>
|
<>
|
||||||
);
|
{Icon ? <Icon className={iconClass} aria-hidden="true" /> : null}
|
||||||
|
{label ?? null}
|
||||||
const handleLinkClick: MouseEventHandler<HTMLElement> = (event) => {
|
</>
|
||||||
if (disabled) {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onClick?.(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (to) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={to}
|
|
||||||
onClick={handleLinkClick}
|
|
||||||
aria-disabled={disabled}
|
|
||||||
aria-label={computedAriaLabel}
|
|
||||||
tabIndex={disabled ? -1 : undefined}
|
|
||||||
className={composedClassName}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
const handleLinkClick: MouseEventHandler<HTMLElement> = (event) => {
|
||||||
<button
|
if (disabled) {
|
||||||
type={htmlType}
|
event.preventDefault();
|
||||||
onClick={onClick}
|
return;
|
||||||
disabled={disabled}
|
}
|
||||||
aria-label={computedAriaLabel}
|
onClick?.(event);
|
||||||
className={composedClassName}
|
};
|
||||||
>
|
|
||||||
{content}
|
if (to) {
|
||||||
</button>
|
return (
|
||||||
);
|
<Link
|
||||||
|
to={to}
|
||||||
|
onClick={handleLinkClick}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
aria-label={computedAriaLabel}
|
||||||
|
tabIndex={disabled ? -1 : undefined}
|
||||||
|
className={composedClassName}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={htmlType}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={computedAriaLabel}
|
||||||
|
className={composedClassName}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,48 +2,51 @@ import type { Meta, StoryObj } from '@storybook/react';
|
|||||||
import { Chip } from './Chip';
|
import { Chip } from './Chip';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Components/Chip',
|
title: 'Components/Chip',
|
||||||
component: Chip,
|
component: Chip,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
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'" } }
|
|
||||||
},
|
},
|
||||||
tone: {
|
argTypes: {
|
||||||
description: 'Tailwind color token (format: `<color>-<shade>`, for example `cyan-700`, `indigo-600`, `rose-500`).',
|
variant: {
|
||||||
control: 'text',
|
description: 'Chip visual style.',
|
||||||
table: { type: { summary: 'string' } }
|
options: ['solid', 'outlined'],
|
||||||
|
control: 'inline-radio',
|
||||||
|
table: { type: { summary: "'solid' | 'outlined'" } },
|
||||||
|
},
|
||||||
|
tone: {
|
||||||
|
description:
|
||||||
|
'Tailwind color token (format: `<color>-<shade>`, for example `cyan-700`, `indigo-600`, `rose-500`).',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
as: {
|
||||||
|
description:
|
||||||
|
"Root tag or component to render (for example `'span'`, `'a'`, `'button'`).",
|
||||||
|
control: false,
|
||||||
|
table: { type: { summary: 'ElementType' } },
|
||||||
|
},
|
||||||
|
className: {
|
||||||
|
description: 'Extra CSS classes for the root element.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
children: {
|
||||||
|
description: 'Text or React node rendered inside the chip.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'ReactNode' } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
as: {
|
args: {
|
||||||
description: "Root tag or component to render (for example `'span'`, `'a'`, `'button'`).",
|
children: 'Published',
|
||||||
control: false,
|
variant: 'solid',
|
||||||
table: { type: { summary: 'ElementType' } }
|
|
||||||
},
|
},
|
||||||
className: {
|
|
||||||
description: 'Extra CSS classes for the root element.',
|
|
||||||
control: 'text',
|
|
||||||
table: { type: { summary: 'string' } }
|
|
||||||
},
|
|
||||||
children: {
|
|
||||||
description: 'Text or React node rendered inside the chip.',
|
|
||||||
control: 'text',
|
|
||||||
table: { type: { summary: 'ReactNode' } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
args: {
|
|
||||||
children: 'Published',
|
|
||||||
variant: 'solid'
|
|
||||||
}
|
|
||||||
} satisfies Meta<typeof Chip>;
|
} satisfies Meta<typeof Chip>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
@@ -52,32 +55,44 @@ type Story = StoryObj<typeof meta>;
|
|||||||
export const SolidDefault: Story = {};
|
export const SolidDefault: Story = {};
|
||||||
|
|
||||||
export const OutlinedIndigo: Story = {
|
export const OutlinedIndigo: Story = {
|
||||||
args: {
|
args: {
|
||||||
variant: 'outlined',
|
variant: 'outlined',
|
||||||
tone: 'indigo-700',
|
tone: 'indigo-700',
|
||||||
children: 'Draft'
|
children: 'Draft',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OutlinedCyan: Story = {
|
export const OutlinedCyan: Story = {
|
||||||
args: {
|
args: {
|
||||||
variant: 'outlined',
|
variant: 'outlined',
|
||||||
tone: 'cyan-700',
|
tone: 'cyan-700',
|
||||||
children: 'Archived'
|
children: 'Archived',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ToneMatrix: Story = {
|
export const ToneMatrix: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Chip variant="solid">Default</Chip>
|
<Chip variant="solid">Default</Chip>
|
||||||
<Chip variant="solid" tone="indigo-700">Indigo</Chip>
|
<Chip variant="solid" tone="indigo-700">
|
||||||
<Chip variant="solid" tone="cyan-700">Cyan</Chip>
|
Indigo
|
||||||
<Chip variant="solid" tone="rose-600">Rose</Chip>
|
</Chip>
|
||||||
<Chip variant="outlined">Default</Chip>
|
<Chip variant="solid" tone="cyan-700">
|
||||||
<Chip variant="outlined" tone="indigo-700">Indigo</Chip>
|
Cyan
|
||||||
<Chip variant="outlined" tone="cyan-700">Cyan</Chip>
|
</Chip>
|
||||||
<Chip variant="outlined" tone="rose-600">Rose</Chip>
|
<Chip variant="solid" tone="rose-600">
|
||||||
</div>
|
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>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,62 +4,67 @@ import type { CSSProperties, ElementType, ReactNode } from 'react';
|
|||||||
type ChipVariant = 'solid' | 'outlined';
|
type ChipVariant = 'solid' | 'outlined';
|
||||||
|
|
||||||
type ChipProps<T extends ElementType> = {
|
type ChipProps<T extends ElementType> = {
|
||||||
variant?: ChipVariant;
|
variant?: ChipVariant;
|
||||||
// Tailwind color token, e.g. "cyan-700", "indigo-500", "rose-600".
|
// Tailwind color token, e.g. "cyan-700", "indigo-500", "rose-600".
|
||||||
tone?: string;
|
tone?: string;
|
||||||
as?: T;
|
as?: T;
|
||||||
className?: string;
|
className?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const variantClassMap: Record<ChipVariant, string> = {
|
const variantClassMap: Record<ChipVariant, string> = {
|
||||||
solid: 'chip-solid',
|
solid: 'chip-solid',
|
||||||
outlined: 'chip-outlined'
|
outlined: 'chip-outlined',
|
||||||
};
|
};
|
||||||
|
|
||||||
type TailwindPalette = Record<string, string>;
|
type TailwindPalette = Record<string, string>;
|
||||||
|
|
||||||
function resolveTailwindToneColor(tone: string | undefined): string | null {
|
function resolveTailwindToneColor(tone: string | undefined): string | null {
|
||||||
const normalizedTone = tone?.trim().toLowerCase();
|
const normalizedTone = tone?.trim().toLowerCase();
|
||||||
if (normalizedTone == null || normalizedTone === '') {
|
if (normalizedTone == null || normalizedTone === '') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const colorSource = tailwindColors as Record<string, unknown>;
|
const colorSource = tailwindColors as unknown as Record<string, unknown>;
|
||||||
const lastDashIndex = normalizedTone.lastIndexOf('-');
|
const lastDashIndex = normalizedTone.lastIndexOf('-');
|
||||||
|
|
||||||
if (lastDashIndex === -1) {
|
if (lastDashIndex === -1) {
|
||||||
const direct = colorSource[normalizedTone];
|
const direct = colorSource[normalizedTone];
|
||||||
return typeof direct === 'string' ? direct : null;
|
return typeof direct === 'string' ? direct : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const colorName = normalizedTone.slice(0, lastDashIndex);
|
const colorName = normalizedTone.slice(0, lastDashIndex);
|
||||||
const shade = normalizedTone.slice(lastDashIndex + 1);
|
const shade = normalizedTone.slice(lastDashIndex + 1);
|
||||||
const palette = colorSource[colorName];
|
const palette = colorSource[colorName];
|
||||||
|
|
||||||
if (palette == null || typeof palette !== 'object') {
|
if (palette == null || typeof palette !== 'object') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shadeColor = (palette as TailwindPalette)[shade];
|
const shadeColor = (palette as TailwindPalette)[shade];
|
||||||
return typeof shadeColor === 'string' ? shadeColor : null;
|
return typeof shadeColor === 'string' ? shadeColor : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Chip<T extends ElementType = 'span'>({
|
export function Chip<T extends ElementType = 'span'>({
|
||||||
variant = 'solid',
|
variant = 'solid',
|
||||||
tone,
|
tone,
|
||||||
as,
|
as,
|
||||||
className = '',
|
className = '',
|
||||||
children
|
children,
|
||||||
}: Readonly<ChipProps<T>>) {
|
}: Readonly<ChipProps<T>>) {
|
||||||
const Component = as ?? 'span' as ElementType;
|
const Component = as ?? ('span' as ElementType);
|
||||||
const toneColor = resolveTailwindToneColor(tone);
|
const toneColor = resolveTailwindToneColor(tone);
|
||||||
const toneStyle: CSSProperties | undefined = toneColor == null
|
const toneStyle: CSSProperties | undefined =
|
||||||
? undefined
|
toneColor == null
|
||||||
: variant === 'solid'
|
? undefined
|
||||||
? { borderColor: toneColor, backgroundColor: toneColor, color: '#ffffff' }
|
: variant === 'solid'
|
||||||
: { borderColor: toneColor, color: toneColor };
|
? { borderColor: toneColor, backgroundColor: toneColor, color: '#ffffff' }
|
||||||
const classes = `chip-root ${variantClassMap[variant]} ${className}`.trim();
|
: { 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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
280
src/components/DatePicker.stories.tsx
Normal file
280
src/components/DatePicker.stories.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { CalendarDaysIcon } from '@heroicons/react/24/solid';
|
||||||
|
import { DatePicker } from './DatePicker';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/DatePicker',
|
||||||
|
component: DatePicker,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'In-house date/time selection field with InputField-compatible API. Uses a custom popup (not native browser pickers) and supports date, time, and date-time modes.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
label: {
|
||||||
|
description: 'Label text shown above (stacked) or on the left (inline).',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
description: 'Input placeholder text.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
description: 'DatePicker mode.',
|
||||||
|
options: ['date', 'date-time', 'time'],
|
||||||
|
control: 'inline-radio',
|
||||||
|
table: { type: { summary: "'date' | 'date-time' | 'time'" } },
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
description: 'Input size.',
|
||||||
|
options: ['sm', 'md', 'lg', 'full'],
|
||||||
|
control: 'inline-radio',
|
||||||
|
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } },
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
description: 'Input width constraint.',
|
||||||
|
options: ['sm', 'md', 'lg', 'full'],
|
||||||
|
control: 'inline-radio',
|
||||||
|
table: { type: { summary: "'sm' | 'md' | 'lg' | 'full'" } },
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
description: 'Label/input layout mode.',
|
||||||
|
options: ['stacked', 'inline'],
|
||||||
|
control: 'inline-radio',
|
||||||
|
table: { type: { summary: "'stacked' | 'inline'" } },
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
description: 'Controlled input value.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
description:
|
||||||
|
'Optional input/output format. Supported tokens: `dd`, `mm`, `yyyy`, `HH` (for example `dd/mm/yyyy HH:mm`).',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
min: {
|
||||||
|
description: 'Optional minimum value in the same format as `value`.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
max: {
|
||||||
|
description: 'Optional maximum value in the same format as `value`.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
description: 'Native input `name` attribute.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
description: 'Disables the input.',
|
||||||
|
control: 'boolean',
|
||||||
|
table: { type: { summary: 'boolean' } },
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
description: 'Sets the native HTML `required` attribute.',
|
||||||
|
control: 'boolean',
|
||||||
|
table: { type: { summary: 'boolean' } },
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
description: 'Validation message shown below the field.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
rightIcon: {
|
||||||
|
description: 'Optional trailing icon node.',
|
||||||
|
control: false,
|
||||||
|
table: { type: { summary: 'ReactNode' } },
|
||||||
|
},
|
||||||
|
className: {
|
||||||
|
description: 'Extra CSS classes for the outer wrapper.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
inputClassName: {
|
||||||
|
description: 'Extra CSS classes for the `<input>` element.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
onChange: {
|
||||||
|
description: 'Change handler callback.',
|
||||||
|
action: 'changed',
|
||||||
|
table: { type: { summary: 'ChangeEventHandler<HTMLInputElement>' } },
|
||||||
|
},
|
||||||
|
onBlur: {
|
||||||
|
description: 'Blur handler callback.',
|
||||||
|
control: false,
|
||||||
|
table: { type: { summary: 'FocusEventHandler<HTMLInputElement>' } },
|
||||||
|
},
|
||||||
|
inputRef: {
|
||||||
|
description: 'Ref forwarded to the native `<input>` element.',
|
||||||
|
control: false,
|
||||||
|
table: { type: { summary: 'Ref<HTMLInputElement>' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
label: 'Schedule at',
|
||||||
|
type: 'date-time',
|
||||||
|
value: '',
|
||||||
|
size: 'md',
|
||||||
|
width: 'md',
|
||||||
|
layout: 'stacked',
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof DatePicker>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const DateOnly: Story = {
|
||||||
|
args: {
|
||||||
|
type: 'date',
|
||||||
|
label: 'Publish date',
|
||||||
|
},
|
||||||
|
render: function DateOnlyRender(args) {
|
||||||
|
const [value, setValue] = useState('2031/05/20');
|
||||||
|
return (
|
||||||
|
<DatePicker
|
||||||
|
{...args}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => {
|
||||||
|
setValue(event.target.value);
|
||||||
|
args.onChange?.(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DateTime: Story = {
|
||||||
|
args: {
|
||||||
|
type: 'date-time',
|
||||||
|
label: 'Schedule at',
|
||||||
|
},
|
||||||
|
render: function DateTimeRender(args) {
|
||||||
|
const [value, setValue] = useState('2031/05/20 14:30');
|
||||||
|
return (
|
||||||
|
<DatePicker
|
||||||
|
{...args}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => {
|
||||||
|
setValue(event.target.value);
|
||||||
|
args.onChange?.(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TimeOnlyInline: Story = {
|
||||||
|
args: {
|
||||||
|
type: 'time',
|
||||||
|
label: 'Start time',
|
||||||
|
layout: 'inline',
|
||||||
|
size: 'sm',
|
||||||
|
rightIcon: <CalendarDaysIcon className="h-4 w-4 ui-body-secondary" />,
|
||||||
|
},
|
||||||
|
render: function TimeOnlyInlineRender(args) {
|
||||||
|
const [value, setValue] = useState('09:00');
|
||||||
|
return (
|
||||||
|
<DatePicker
|
||||||
|
{...args}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => {
|
||||||
|
setValue(event.target.value);
|
||||||
|
args.onChange?.(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ErrorState: Story = {
|
||||||
|
name: 'Error',
|
||||||
|
args: {
|
||||||
|
type: 'date-time',
|
||||||
|
label: 'Schedule at',
|
||||||
|
value: '',
|
||||||
|
error: 'Pick a valid future date and time',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
type: 'date-time',
|
||||||
|
label: 'Published at',
|
||||||
|
value: '2031/05/20 14:30',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SizeMatrix: Story = {
|
||||||
|
args: {
|
||||||
|
type: 'date-time',
|
||||||
|
label: 'Schedule at',
|
||||||
|
},
|
||||||
|
render: function SizeMatrixRender(args) {
|
||||||
|
const [value, setValue] = useState('2031/05/20 14:30');
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
<DatePicker
|
||||||
|
{...args}
|
||||||
|
value={value}
|
||||||
|
size="sm"
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
/>
|
||||||
|
<DatePicker
|
||||||
|
{...args}
|
||||||
|
value={value}
|
||||||
|
size="md"
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
/>
|
||||||
|
<DatePicker
|
||||||
|
{...args}
|
||||||
|
value={value}
|
||||||
|
size="lg"
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
/>
|
||||||
|
<DatePicker
|
||||||
|
{...args}
|
||||||
|
value={value}
|
||||||
|
size="full"
|
||||||
|
width="full"
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomFormatWithRange: Story = {
|
||||||
|
args: {
|
||||||
|
type: 'date-time',
|
||||||
|
label: 'Starts at',
|
||||||
|
format: 'dd/mm/yyyy HH:mm',
|
||||||
|
min: '10/03/2026 09:00',
|
||||||
|
max: '24/03/2026 18:30',
|
||||||
|
},
|
||||||
|
render: function CustomFormatWithRangeRender(args) {
|
||||||
|
const [value, setValue] = useState('22/03/2026 14:30');
|
||||||
|
return (
|
||||||
|
<DatePicker
|
||||||
|
{...args}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => {
|
||||||
|
setValue(event.target.value);
|
||||||
|
args.onChange?.(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
1892
src/components/DatePicker.tsx
Normal file
1892
src/components/DatePicker.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,151 +3,166 @@ import type { Meta, StoryObj } from '@storybook/react';
|
|||||||
import { Dropdown } from './Dropdown';
|
import { Dropdown } from './Dropdown';
|
||||||
|
|
||||||
const choices = [
|
const choices = [
|
||||||
{ id: 'draft', label: 'Draft' },
|
{ id: 'draft', label: 'Draft' },
|
||||||
{ id: 'review', label: 'In review' },
|
{ id: 'review', label: 'In review' },
|
||||||
{ id: 'published', label: 'Published' }
|
{ id: 'published', label: 'Published' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Components/Dropdown',
|
title: 'Components/Dropdown',
|
||||||
component: Dropdown,
|
component: Dropdown,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
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' } }
|
|
||||||
},
|
},
|
||||||
value: {
|
argTypes: {
|
||||||
description: 'Current selected value (must match one `choices[].id`).',
|
label: {
|
||||||
control: 'text',
|
description: 'Label text shown above (stacked) or on the left (inline).',
|
||||||
table: { type: { summary: 'string' } }
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
description: 'Current selected value (must match one `choices[].id`).',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
choices: {
|
||||||
|
description: 'Options list in `{ id: string; label: string }` format.',
|
||||||
|
control: 'object',
|
||||||
|
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'" } },
|
||||||
|
},
|
||||||
|
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'" } },
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
description: 'Disables the field.',
|
||||||
|
control: 'boolean',
|
||||||
|
table: { type: { summary: 'boolean' } },
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
description: 'Sets the native HTML `required` attribute.',
|
||||||
|
control: 'boolean',
|
||||||
|
table: { type: { summary: 'boolean' } },
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
description: 'Error message shown below the field.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
className: {
|
||||||
|
description: 'Extra CSS classes for the wrapper.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
selectClassName: {
|
||||||
|
description: 'Extra CSS classes for the `<select>` element.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
onChange: {
|
||||||
|
description: 'Callback fired with the newly selected value.',
|
||||||
|
action: 'changed',
|
||||||
|
table: { type: { summary: '(value: string) => void' } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
choices: {
|
args: {
|
||||||
description: "Options list in `{ id: string; label: string }` format.",
|
label: 'Status',
|
||||||
control: 'object',
|
value: 'draft',
|
||||||
table: { type: { summary: 'Array<{ id: string; label: string }>' } }
|
choices,
|
||||||
|
size: 'md',
|
||||||
|
width: 'md',
|
||||||
|
layout: 'stacked',
|
||||||
},
|
},
|
||||||
size: {
|
|
||||||
description: 'Control size.',
|
|
||||||
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'" } }
|
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
description: 'Disables the field.',
|
|
||||||
control: 'boolean',
|
|
||||||
table: { type: { summary: 'boolean' } }
|
|
||||||
},
|
|
||||||
required: {
|
|
||||||
description: 'Sets the native HTML `required` attribute.',
|
|
||||||
control: 'boolean',
|
|
||||||
table: { type: { summary: 'boolean' } }
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
description: 'Error message shown below the field.',
|
|
||||||
control: 'text',
|
|
||||||
table: { type: { summary: 'string' } }
|
|
||||||
},
|
|
||||||
className: {
|
|
||||||
description: 'Extra CSS classes for the wrapper.',
|
|
||||||
control: 'text',
|
|
||||||
table: { type: { summary: 'string' } }
|
|
||||||
},
|
|
||||||
selectClassName: {
|
|
||||||
description: 'Extra CSS classes for the `<select>` element.',
|
|
||||||
control: 'text',
|
|
||||||
table: { type: { summary: 'string' } }
|
|
||||||
},
|
|
||||||
onChange: {
|
|
||||||
description: 'Callback fired with the newly selected value.',
|
|
||||||
action: 'changed',
|
|
||||||
table: { type: { summary: '(value: string) => void' } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
args: {
|
|
||||||
label: 'Status',
|
|
||||||
value: 'draft',
|
|
||||||
choices,
|
|
||||||
size: 'md',
|
|
||||||
layout: 'stacked'
|
|
||||||
}
|
|
||||||
} satisfies Meta<typeof Dropdown>;
|
} satisfies Meta<typeof Dropdown>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Stacked: Story = {
|
export const Stacked: Story = {
|
||||||
render: (args) => {
|
render: function StackedRender(args) {
|
||||||
const [value, setValue] = useState(args.value);
|
const [value, setValue] = useState(args.value);
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
{...args}
|
{...args}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
setValue(next);
|
setValue(next);
|
||||||
args.onChange?.(next);
|
args.onChange?.(next);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Inline: Story = {
|
export const Inline: Story = {
|
||||||
args: {
|
args: {
|
||||||
layout: 'inline',
|
layout: 'inline',
|
||||||
size: 'sm'
|
size: 'sm',
|
||||||
},
|
},
|
||||||
render: (args) => {
|
render: function InlineRender(args) {
|
||||||
const [value, setValue] = useState(args.value);
|
const [value, setValue] = useState(args.value);
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
{...args}
|
{...args}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
setValue(next);
|
setValue(next);
|
||||||
args.onChange?.(next);
|
args.onChange?.(next);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Disabled: Story = {
|
export const Disabled: Story = {
|
||||||
args: {
|
args: {
|
||||||
disabled: true
|
disabled: true,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WithError: Story = {
|
export const WithError: Story = {
|
||||||
args: {
|
args: {
|
||||||
error: 'Please choose a valid status'
|
error: 'Please choose a valid status',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SizeMatrix: Story = {
|
export const SizeMatrix: Story = {
|
||||||
render: (args) => {
|
render: function SizeMatrixRender(args) {
|
||||||
const [value, setValue] = useState(args.value);
|
const [value, setValue] = useState(args.value);
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-3">
|
<div className="grid grid-cols-1 gap-3">
|
||||||
<Dropdown {...args} value={value} size="sm" label="Small" onChange={setValue} />
|
<Dropdown {...args} value={value} size="sm" label="Small" onChange={setValue} />
|
||||||
<Dropdown {...args} value={value} size="md" label="Medium" onChange={setValue} />
|
<Dropdown {...args} value={value} size="md" label="Medium" onChange={setValue} />
|
||||||
<Dropdown {...args} value={value} size="lg" label="Large" onChange={setValue} />
|
<Dropdown {...args} value={value} size="lg" label="Large" onChange={setValue} />
|
||||||
<Dropdown {...args} value={value} size="full" label="Full" onChange={setValue} />
|
<Dropdown
|
||||||
</div>
|
{...args}
|
||||||
);
|
value={value}
|
||||||
}
|
size="full"
|
||||||
|
width="full"
|
||||||
|
label="Full"
|
||||||
|
onChange={setValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,84 +5,93 @@ import type { ComponentSize } from './types';
|
|||||||
type DropdownLayout = 'stacked' | 'inline';
|
type DropdownLayout = 'stacked' | 'inline';
|
||||||
|
|
||||||
type DropdownChoice = {
|
type DropdownChoice = {
|
||||||
label: string;
|
label: string;
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DropdownProps = {
|
type DropdownProps = {
|
||||||
label?: string;
|
label?: string;
|
||||||
value: string;
|
value: string;
|
||||||
choices: DropdownChoice[];
|
choices: DropdownChoice[];
|
||||||
size?: ComponentSize;
|
size?: ComponentSize;
|
||||||
layout?: DropdownLayout;
|
width?: ComponentSize;
|
||||||
disabled?: boolean;
|
layout?: DropdownLayout;
|
||||||
required?: boolean;
|
disabled?: boolean;
|
||||||
onChange?: (value: string) => void;
|
required?: boolean;
|
||||||
error?: string;
|
onChange?: (value: string) => void;
|
||||||
className?: string;
|
error?: string;
|
||||||
selectClassName?: string;
|
className?: string;
|
||||||
|
selectClassName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Dropdown({
|
export function Dropdown({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
choices,
|
choices,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
layout = 'stacked',
|
width = 'md',
|
||||||
disabled = false,
|
layout = 'stacked',
|
||||||
required = false,
|
disabled = false,
|
||||||
onChange,
|
required = false,
|
||||||
error,
|
onChange,
|
||||||
className = '',
|
error,
|
||||||
selectClassName = ''
|
className = '',
|
||||||
|
selectClassName = '',
|
||||||
}: Readonly<DropdownProps>) {
|
}: Readonly<DropdownProps>) {
|
||||||
const containerSizeClass = {
|
const containerWidthClass = {
|
||||||
sm: 'max-w-xs',
|
sm: 'max-w-xs',
|
||||||
md: 'max-w-sm',
|
md: 'max-w-sm',
|
||||||
lg: 'max-w-md',
|
lg: 'max-w-md',
|
||||||
full: 'max-w-none'
|
full: 'max-w-none',
|
||||||
}[size];
|
}[width];
|
||||||
|
|
||||||
const selectSizeClass = {
|
const selectSizeClass = {
|
||||||
sm: 'h-8 !text-xs',
|
sm: 'h-8 !text-xs',
|
||||||
md: 'h-10 text-sm',
|
md: 'h-10 text-sm',
|
||||||
lg: 'h-12 text-sm',
|
lg: 'h-12 text-sm',
|
||||||
full: 'h-10 text-sm'
|
full: 'h-10 text-sm',
|
||||||
}[size];
|
}[size];
|
||||||
|
|
||||||
const handleChange: ChangeEventHandler<HTMLSelectElement> = (event) => {
|
const handleChange: ChangeEventHandler<HTMLSelectElement> = (event) => {
|
||||||
onChange?.(event.target.value);
|
onChange?.(event.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const wrapperClass = layout === 'inline'
|
const wrapperClass =
|
||||||
? 'inline-flex w-auto items-center gap-2'
|
layout === 'inline' ? 'inline-flex w-auto items-center gap-2' : 'block w-full gap-1';
|
||||||
: 'block w-full gap-1';
|
|
||||||
|
|
||||||
const selectWrapperClass = layout === 'inline' ? 'relative' : 'relative mt-1';
|
const selectWrapperClass = layout === 'inline' ? 'relative' : 'relative mt-1';
|
||||||
const labelClass = layout === 'inline' ? 'text-xs ui-body-secondary' : '';
|
const labelClass = layout === 'inline' ? 'text-xs ui-body-secondary' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className={`${wrapperClass} text-sm font-medium ${disabled ? 'ui-label-disabled' : 'ui-label'} ${containerSizeClass} ${className}`.trim()}>
|
<label
|
||||||
{label ? <span className={labelClass}>{label}</span> : null}
|
className={`${wrapperClass} text-sm font-medium ${disabled ? 'ui-label-disabled' : 'ui-label'} ${containerWidthClass} ${className}`.trim()}
|
||||||
<div className={selectWrapperClass}>
|
|
||||||
<select
|
|
||||||
value={value}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={disabled}
|
|
||||||
required={required}
|
|
||||||
className={`field w-full appearance-none pr-9 disabled:opacity-100 ${selectSizeClass} ${error ? 'border-red-400/70 focus:border-red-400 focus:ring-red-400/30' : ''} ${selectClassName}`.trim()}
|
|
||||||
>
|
>
|
||||||
{choices.map((choice) => (
|
{label ? <span className={labelClass}>{label}</span> : null}
|
||||||
<option key={choice.id} value={choice.id}>
|
<div className={selectWrapperClass}>
|
||||||
{choice.label}
|
<select
|
||||||
</option>
|
value={value}
|
||||||
))}
|
onChange={handleChange}
|
||||||
</select>
|
disabled={disabled}
|
||||||
<span className={`pointer-events-none absolute inset-y-0 right-3 flex items-center ${disabled ? 'ui-label-disabled' : 'ui-body-secondary'}`}>
|
required={required}
|
||||||
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
|
className={`field w-full appearance-none pr-9 disabled:opacity-100 ${selectSizeClass} ${error ? 'border-red-400/70 focus:border-red-400 focus:ring-red-400/30' : ''} ${selectClassName}`.trim()}
|
||||||
</span>
|
>
|
||||||
</div>
|
{choices.map((choice) => (
|
||||||
{error ? <span className="mt-1 block text-xs" style={{ color: 'var(--error-text)' }}>{error}</span> : null}
|
<option key={choice.id} value={choice.id}>
|
||||||
</label>
|
{choice.label}
|
||||||
);
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<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}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,96 +6,95 @@ import { Form } from './Form';
|
|||||||
import { InputField } from './InputField';
|
import { InputField } from './InputField';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Components/Form',
|
title: 'Components/Form',
|
||||||
component: Form,
|
component: Form,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
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' } }
|
|
||||||
},
|
},
|
||||||
titleBarRight: {
|
argTypes: {
|
||||||
description: 'Optional node rendered on the right side of the title bar.',
|
title: {
|
||||||
control: false,
|
description: 'Form title displayed in the header bar.',
|
||||||
table: { type: { summary: 'ReactNode' } }
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
titleBarRight: {
|
||||||
|
description: 'Optional node rendered on the right side of the title bar.',
|
||||||
|
control: false,
|
||||||
|
table: { type: { summary: 'ReactNode' } },
|
||||||
|
},
|
||||||
|
children: {
|
||||||
|
description: 'Form content rendered inside the responsive grid.',
|
||||||
|
control: false,
|
||||||
|
table: { type: { summary: 'ReactNode' } },
|
||||||
|
},
|
||||||
|
className: {
|
||||||
|
description: 'Extra CSS classes for the root container.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
children: {
|
args: {
|
||||||
description: 'Form content rendered inside the responsive grid.',
|
title: 'Post details',
|
||||||
control: false,
|
|
||||||
table: { type: { summary: 'ReactNode' } }
|
|
||||||
},
|
},
|
||||||
className: {
|
|
||||||
description: 'Extra CSS classes for the root container.',
|
|
||||||
control: 'text',
|
|
||||||
table: { type: { summary: 'string' } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
args: {
|
|
||||||
title: 'Post details'
|
|
||||||
}
|
|
||||||
} satisfies Meta<typeof Form>;
|
} satisfies Meta<typeof Form>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Basic: Story = {
|
export const Basic: Story = {
|
||||||
args: {
|
args: {
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<InputField label="Title" type="text" value="A short post title" />
|
<InputField label="Title" type="text" value="A short post title" />
|
||||||
<Dropdown
|
<Dropdown
|
||||||
label="Status"
|
label="Status"
|
||||||
value="draft"
|
value="draft"
|
||||||
choices={[
|
choices={[
|
||||||
{ id: 'draft', label: 'Draft' },
|
{ id: 'draft', label: 'Draft' },
|
||||||
{ id: 'published', label: 'Published' }
|
{ id: 'published', label: 'Published' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<InputField label="Slug" type="text" value="a-short-post-title" />
|
<InputField label="Slug" type="text" value="a-short-post-title" />
|
||||||
</>
|
</>
|
||||||
)
|
),
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WithActions: Story = {
|
export const WithActions: Story = {
|
||||||
render: (args) => {
|
render: function WithActionsRender(args) {
|
||||||
const [title, setTitle] = useState('Storybook powered CMS');
|
const [title, setTitle] = useState('Storybook powered CMS');
|
||||||
const [status, setStatus] = useState('draft');
|
const [status, setStatus] = useState('draft');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form {...args} titleBarRight={<Button type="solid" size="sm" label="Save" />}>
|
||||||
{...args}
|
<div className="col-span-2">
|
||||||
titleBarRight={<Button type="solid" size="sm" label="Save" />}
|
<InputField
|
||||||
>
|
label="Title"
|
||||||
<div className="col-span-2">
|
type="text"
|
||||||
<InputField
|
value={title}
|
||||||
label="Title"
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
type="text"
|
size="full"
|
||||||
value={title}
|
width="full"
|
||||||
onChange={(event) => setTitle(event.target.value)}
|
/>
|
||||||
size="full"
|
</div>
|
||||||
/>
|
<Dropdown
|
||||||
</div>
|
label="Status"
|
||||||
<Dropdown
|
value={status}
|
||||||
label="Status"
|
onChange={setStatus}
|
||||||
value={status}
|
choices={[
|
||||||
onChange={setStatus}
|
{ id: 'draft', label: 'Draft' },
|
||||||
choices={[
|
{ id: 'review', label: 'In review' },
|
||||||
{ id: 'draft', label: 'Draft' },
|
{ id: 'published', label: 'Published' },
|
||||||
{ id: 'review', label: 'In review' },
|
]}
|
||||||
{ id: 'published', label: 'Published' }
|
/>
|
||||||
]}
|
<InputField label="Slug" type="text" value="storybook-powered-cms" />
|
||||||
/>
|
</Form>
|
||||||
<InputField label="Slug" type="text" value="storybook-powered-cms" />
|
);
|
||||||
</Form>
|
},
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,23 +2,24 @@ import type { ReactNode } from 'react';
|
|||||||
import { Label } from './Label';
|
import { Label } from './Label';
|
||||||
|
|
||||||
type FormProps = {
|
type FormProps = {
|
||||||
title: string;
|
title: string;
|
||||||
titleBarRight?: ReactNode;
|
titleBarRight?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Form({ title, titleBarRight, children, className = '' }: Readonly<FormProps>) {
|
export function Form({ title, titleBarRight, children, className = '' }: Readonly<FormProps>) {
|
||||||
return (
|
return (
|
||||||
<div className={`surface overflow-hidden rounded-xl ${className}`.trim()}>
|
<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
|
||||||
<Label variant="h4">{title}</Label>
|
className="flex items-center justify-between border-b px-4 py-3 sm:px-5"
|
||||||
{titleBarRight ? <div>{titleBarRight}</div> : null}
|
style={{ borderColor: 'var(--surface-divider)' }}
|
||||||
</div>
|
>
|
||||||
|
<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">
|
<div className="grid grid-cols-1 gap-4 p-4 sm:p-5 lg:grid-cols-3">{children}</div>
|
||||||
{children}
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,212 +4,243 @@ import { MagnifyingGlassIcon } from '@heroicons/react/24/solid';
|
|||||||
import { InputField } from './InputField';
|
import { InputField } from './InputField';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Components/InputField',
|
title: 'Components/InputField',
|
||||||
component: InputField,
|
component: InputField,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
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' } }
|
|
||||||
},
|
},
|
||||||
placeholder: {
|
argTypes: {
|
||||||
description: 'Input placeholder text.',
|
label: {
|
||||||
control: 'text',
|
description: 'Label text shown above (stacked) or on the left (inline).',
|
||||||
table: { type: { summary: 'string' } }
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
description: 'Input placeholder text.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
description: 'Native input type.',
|
||||||
|
options: ['text', 'password', 'email'],
|
||||||
|
control: 'inline-radio',
|
||||||
|
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'" } },
|
||||||
|
},
|
||||||
|
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 (ignored for password type because toggle icon is used).',
|
||||||
|
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>' } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
type: {
|
args: {
|
||||||
description: 'Native input type.',
|
label: 'Email',
|
||||||
options: ['text', 'password', 'email'],
|
type: 'email',
|
||||||
control: 'inline-radio',
|
placeholder: 'name@example.com',
|
||||||
table: { type: { summary: "'text' | 'password' | 'email'" } }
|
value: '',
|
||||||
|
size: 'md',
|
||||||
|
width: 'md',
|
||||||
|
layout: 'stacked',
|
||||||
},
|
},
|
||||||
size: {
|
|
||||||
description: 'Input size.',
|
|
||||||
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 (ignored for password type because toggle icon is used).',
|
|
||||||
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: 'Email',
|
|
||||||
type: 'email',
|
|
||||||
placeholder: 'name@example.com',
|
|
||||||
value: '',
|
|
||||||
size: 'md',
|
|
||||||
layout: 'stacked'
|
|
||||||
}
|
|
||||||
} satisfies Meta<typeof InputField>;
|
} satisfies Meta<typeof InputField>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Text: Story = {
|
export const Text: Story = {
|
||||||
args: {
|
args: {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
label: 'Title',
|
label: 'Title',
|
||||||
placeholder: 'Write a title'
|
placeholder: 'Write a title',
|
||||||
},
|
},
|
||||||
render: (args) => {
|
render: function TextRender(args) {
|
||||||
const [value, setValue] = useState('Storybook integration');
|
const [value, setValue] = useState('Storybook integration');
|
||||||
return (
|
return (
|
||||||
<InputField
|
<InputField
|
||||||
{...args}
|
{...args}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setValue(event.target.value);
|
setValue(event.target.value);
|
||||||
args.onChange?.(event);
|
args.onChange?.(event);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PasswordWithToggle: Story = {
|
export const PasswordWithToggle: Story = {
|
||||||
args: {
|
args: {
|
||||||
type: 'password',
|
type: 'password',
|
||||||
label: 'Password',
|
label: 'Password',
|
||||||
placeholder: 'Type a strong password'
|
placeholder: 'Type a strong password',
|
||||||
},
|
},
|
||||||
render: (args) => {
|
render: function PasswordWithToggleRender(args) {
|
||||||
const [value, setValue] = useState('pa55word');
|
const [value, setValue] = useState('pa55word');
|
||||||
return (
|
return (
|
||||||
<InputField
|
<InputField
|
||||||
{...args}
|
{...args}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setValue(event.target.value);
|
setValue(event.target.value);
|
||||||
args.onChange?.(event);
|
args.onChange?.(event);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const InlineWithIcon: Story = {
|
export const InlineWithIcon: Story = {
|
||||||
args: {
|
args: {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
label: 'Search',
|
label: 'Search',
|
||||||
layout: 'inline',
|
layout: 'inline',
|
||||||
size: 'sm',
|
size: 'sm',
|
||||||
rightIcon: <MagnifyingGlassIcon className="h-4 w-4 ui-body-secondary" />
|
rightIcon: <MagnifyingGlassIcon className="h-4 w-4 ui-body-secondary" />,
|
||||||
},
|
},
|
||||||
render: (args) => {
|
render: function InlineWithIconRender(args) {
|
||||||
const [value, setValue] = useState('posts');
|
const [value, setValue] = useState('posts');
|
||||||
return (
|
return (
|
||||||
<InputField
|
<InputField
|
||||||
{...args}
|
{...args}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setValue(event.target.value);
|
setValue(event.target.value);
|
||||||
args.onChange?.(event);
|
args.onChange?.(event);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Error: Story = {
|
export const ErrorState: Story = {
|
||||||
args: {
|
name: 'Error',
|
||||||
type: 'email',
|
args: {
|
||||||
label: 'Email',
|
type: 'email',
|
||||||
value: 'invalid.mail',
|
label: 'Email',
|
||||||
error: 'Enter a valid email address'
|
value: 'invalid.mail',
|
||||||
}
|
error: 'Enter a valid email address',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Disabled: Story = {
|
export const Disabled: Story = {
|
||||||
args: {
|
args: {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
label: 'Read only field',
|
label: 'Read only field',
|
||||||
value: 'Locked content',
|
value: 'Locked content',
|
||||||
disabled: true
|
disabled: true,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SizeMatrix: Story = {
|
export const SizeMatrix: Story = {
|
||||||
args: {
|
args: {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
label: 'Name',
|
label: 'Name',
|
||||||
placeholder: 'Enter value'
|
placeholder: 'Enter value',
|
||||||
},
|
},
|
||||||
render: (args) => {
|
render: function SizeMatrixRender(args) {
|
||||||
const [value, setValue] = useState('Beatrice');
|
const [value, setValue] = useState('Beatrice');
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-3">
|
<div className="grid grid-cols-1 gap-3">
|
||||||
<InputField {...args} value={value} size="sm" onChange={(event) => setValue(event.target.value)} />
|
<InputField
|
||||||
<InputField {...args} value={value} size="md" onChange={(event) => setValue(event.target.value)} />
|
{...args}
|
||||||
<InputField {...args} value={value} size="lg" onChange={(event) => setValue(event.target.value)} />
|
value={value}
|
||||||
<InputField {...args} value={value} size="full" onChange={(event) => setValue(event.target.value)} />
|
size="sm"
|
||||||
</div>
|
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>
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,99 +8,106 @@ type InputKind = 'text' | 'password' | 'email';
|
|||||||
type Layout = 'stacked' | 'inline';
|
type Layout = 'stacked' | 'inline';
|
||||||
|
|
||||||
type InputFieldProps = {
|
type InputFieldProps = {
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
type: InputKind;
|
type: InputKind;
|
||||||
size?: ComponentSize;
|
size?: ComponentSize;
|
||||||
layout?: Layout;
|
width?: ComponentSize;
|
||||||
value: string;
|
layout?: Layout;
|
||||||
name?: string;
|
value: string;
|
||||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
name?: string;
|
||||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||||
inputRef?: Ref<HTMLInputElement>;
|
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||||
disabled?: boolean;
|
inputRef?: Ref<HTMLInputElement>;
|
||||||
required?: boolean;
|
disabled?: boolean;
|
||||||
error?: string;
|
required?: boolean;
|
||||||
rightIcon?: ReactNode;
|
error?: string;
|
||||||
className?: string;
|
rightIcon?: ReactNode;
|
||||||
inputClassName?: string;
|
className?: string;
|
||||||
|
inputClassName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InputField({
|
export function InputField({
|
||||||
label,
|
label,
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
type,
|
type,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
layout = 'stacked',
|
width = 'md',
|
||||||
value,
|
layout = 'stacked',
|
||||||
name,
|
value,
|
||||||
onChange,
|
name,
|
||||||
onBlur,
|
onChange,
|
||||||
inputRef,
|
onBlur,
|
||||||
disabled = false,
|
inputRef,
|
||||||
required = false,
|
disabled = false,
|
||||||
error,
|
required = false,
|
||||||
rightIcon,
|
error,
|
||||||
className = '',
|
rightIcon,
|
||||||
inputClassName = ''
|
className = '',
|
||||||
|
inputClassName = '',
|
||||||
}: Readonly<InputFieldProps>) {
|
}: Readonly<InputFieldProps>) {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const containerSizeClass = {
|
const containerWidthClass = {
|
||||||
sm: 'max-w-xs',
|
sm: 'max-w-xs',
|
||||||
md: 'max-w-sm',
|
md: 'max-w-sm',
|
||||||
lg: 'max-w-md',
|
lg: 'max-w-md',
|
||||||
full: 'max-w-none'
|
full: 'max-w-none',
|
||||||
}[size];
|
}[width];
|
||||||
|
|
||||||
const inputSizeClass = {
|
const inputSizeClass = {
|
||||||
sm: 'h-8 !text-xs',
|
sm: 'h-8 !text-xs',
|
||||||
md: 'h-10 text-sm',
|
md: 'h-10 text-sm',
|
||||||
lg: 'h-12 text-sm',
|
lg: 'h-12 text-sm',
|
||||||
full: 'h-10 text-sm'
|
full: 'h-10 text-sm',
|
||||||
}[size];
|
}[size];
|
||||||
|
|
||||||
const wrapperClass = layout === 'inline'
|
const wrapperClass =
|
||||||
? 'inline-flex w-auto items-center gap-2'
|
layout === 'inline' ? 'inline-flex w-auto items-center gap-2' : 'block w-full gap-1';
|
||||||
: 'block w-full gap-1';
|
const labelClass = layout === 'inline' ? 'text-xs ui-body-secondary' : '';
|
||||||
const labelClass = layout === 'inline' ? 'text-xs ui-body-secondary' : '';
|
const isPasswordType = type === 'password';
|
||||||
const isPasswordType = type === 'password';
|
const resolvedType: InputKind = isPasswordType && showPassword ? 'text' : type;
|
||||||
const resolvedType: InputKind = isPasswordType && showPassword ? 'text' : type;
|
const hasTrailingIcon = isPasswordType || Boolean(rightIcon);
|
||||||
const hasTrailingIcon = isPasswordType || Boolean(rightIcon);
|
const inputWrapperClass = layout === 'inline' ? 'relative' : 'relative mt-1';
|
||||||
const inputWrapperClass = layout === 'inline' ? 'relative' : 'relative mt-1';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className={`${wrapperClass} text-sm font-medium ${disabled ? 'ui-label-disabled' : 'ui-label'} ${containerSizeClass} ${className}`.trim()}>
|
<label
|
||||||
{label ? <span className={labelClass}>{label}</span> : null}
|
className={`${wrapperClass} text-sm font-medium ${disabled ? 'ui-label-disabled' : 'ui-label'} ${containerWidthClass} ${className}`.trim()}
|
||||||
<div className={inputWrapperClass}>
|
>
|
||||||
<input
|
{label ? <span className={labelClass}>{label}</span> : null}
|
||||||
type={resolvedType}
|
<div className={inputWrapperClass}>
|
||||||
value={value}
|
<input
|
||||||
name={name}
|
type={resolvedType}
|
||||||
onChange={onChange}
|
value={value}
|
||||||
onBlur={onBlur}
|
name={name}
|
||||||
ref={inputRef}
|
onChange={onChange}
|
||||||
placeholder={placeholder}
|
onBlur={onBlur}
|
||||||
disabled={disabled}
|
ref={inputRef}
|
||||||
required={required}
|
placeholder={placeholder}
|
||||||
className={`field w-full ${hasTrailingIcon ? 'pr-10' : ''} ${inputSizeClass} ${error ? 'border-red-400/70 focus:border-red-400 focus:ring-red-400/30' : ''} ${inputClassName}`.trim()}
|
disabled={disabled}
|
||||||
/>
|
required={required}
|
||||||
{isPasswordType ? (
|
className={`field w-full ${hasTrailingIcon ? 'pr-10' : ''} ${inputSizeClass} ${error ? 'border-red-400/70 focus:border-red-400 focus:ring-red-400/30' : ''} ${inputClassName}`.trim()}
|
||||||
<Button
|
/>
|
||||||
type="noborder"
|
{isPasswordType ? (
|
||||||
size="sm"
|
<Button
|
||||||
icon={showPassword ? EyeSlashIcon : EyeIcon}
|
type="noborder"
|
||||||
onClick={() => setShowPassword((prev) => !prev)}
|
size="sm"
|
||||||
disabled={disabled}
|
icon={showPassword ? EyeSlashIcon : EyeIcon}
|
||||||
className="absolute inset-y-0 right-2 my-auto !h-6 !w-6 !rounded-md !p-0 ui-body-secondary transition hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-60"
|
onClick={() => setShowPassword((prev) => !prev)}
|
||||||
ariaLabel={showPassword ? 'Hide password' : 'Show password'}
|
disabled={disabled}
|
||||||
/>
|
className="absolute inset-y-0 right-2 my-auto !h-6 !w-6 !rounded-md !p-0 ui-body-secondary transition hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
) : rightIcon ? (
|
ariaLabel={showPassword ? 'Hide password' : 'Show password'}
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-2 inline-flex items-center justify-center px-1">
|
/>
|
||||||
{rightIcon}
|
) : rightIcon ? (
|
||||||
</span>
|
<span className="pointer-events-none absolute inset-y-0 right-2 inline-flex items-center justify-center px-1">
|
||||||
) : null}
|
{rightIcon}
|
||||||
</div>
|
</span>
|
||||||
{error ? <span className="mt-1 block text-xs" style={{ color: 'var(--error-text)' }}>{error}</span> : null}
|
) : null}
|
||||||
</label>
|
</div>
|
||||||
);
|
{error ? (
|
||||||
|
<span className="mt-1 block text-xs" style={{ color: 'var(--error-text)' }}>
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,43 +2,50 @@ import type { Meta, StoryObj } from '@storybook/react';
|
|||||||
import { Label } from './Label';
|
import { Label } from './Label';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Components/Label',
|
title: 'Components/Label',
|
||||||
component: Label,
|
component: Label,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
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'" } }
|
|
||||||
},
|
},
|
||||||
as: {
|
argTypes: {
|
||||||
description: "Override rendered HTML tag or component (for example `'p'`, `'span'`, `'h2'`).",
|
variant: {
|
||||||
control: false,
|
description: 'Typography style preset.',
|
||||||
table: { type: { summary: 'ElementType' } }
|
options: ['h1', 'h2', 'h3', 'h4', 'body', 'body2', 'caption', 'error', 'code'],
|
||||||
|
control: 'select',
|
||||||
|
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'`).",
|
||||||
|
control: false,
|
||||||
|
table: { type: { summary: 'ElementType' } },
|
||||||
|
},
|
||||||
|
className: {
|
||||||
|
description: 'Extra CSS classes.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
children: {
|
||||||
|
description: 'Label content.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'ReactNode' } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
className: {
|
args: {
|
||||||
description: 'Extra CSS classes.',
|
variant: 'body',
|
||||||
control: 'text',
|
children: 'Label text',
|
||||||
table: { type: { summary: 'string' } }
|
|
||||||
},
|
},
|
||||||
children: {
|
|
||||||
description: 'Label content.',
|
|
||||||
control: 'text',
|
|
||||||
table: { type: { summary: 'ReactNode' } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
args: {
|
|
||||||
variant: 'body',
|
|
||||||
children: 'Label text'
|
|
||||||
}
|
|
||||||
} satisfies Meta<typeof Label>;
|
} satisfies Meta<typeof Label>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
@@ -46,32 +53,33 @@ type Story = StoryObj<typeof meta>;
|
|||||||
|
|
||||||
export const Body: Story = {};
|
export const Body: Story = {};
|
||||||
|
|
||||||
export const Error: Story = {
|
export const ErrorState: Story = {
|
||||||
args: {
|
name: 'Error',
|
||||||
variant: 'error',
|
args: {
|
||||||
children: 'This field is required'
|
variant: 'error',
|
||||||
}
|
children: 'This field is required',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Code: Story = {
|
export const Code: Story = {
|
||||||
args: {
|
args: {
|
||||||
variant: 'code',
|
variant: 'code',
|
||||||
children: 'const isPublished = true;'
|
children: 'const isPublished = true;',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VariantScale: Story = {
|
export const VariantScale: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex max-w-md flex-col gap-2">
|
<div className="flex max-w-md flex-col gap-2">
|
||||||
<Label variant="caption">Caption</Label>
|
<Label variant="caption">Caption</Label>
|
||||||
<Label variant="h1">Heading 1</Label>
|
<Label variant="h1">Heading 1</Label>
|
||||||
<Label variant="h2">Heading 2</Label>
|
<Label variant="h2">Heading 2</Label>
|
||||||
<Label variant="h3">Heading 3</Label>
|
<Label variant="h3">Heading 3</Label>
|
||||||
<Label variant="h4">Heading 4</Label>
|
<Label variant="h4">Heading 4</Label>
|
||||||
<Label variant="body">Primary body copy</Label>
|
<Label variant="body">Primary body copy</Label>
|
||||||
<Label variant="body2">Secondary body copy</Label>
|
<Label variant="body2">Secondary body copy</Label>
|
||||||
<Label variant="error">Error copy</Label>
|
<Label variant="error">Error copy</Label>
|
||||||
<Label variant="code">npm run build</Label>
|
<Label variant="code">npm run build</Label>
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,55 +1,46 @@
|
|||||||
import type { ElementType, ReactNode } from 'react';
|
import type { ElementType, ReactNode } from 'react';
|
||||||
|
|
||||||
type LabelVariant =
|
type LabelVariant = 'h1' | 'h2' | 'h3' | 'h4' | 'body' | 'body2' | 'caption' | 'error' | 'code';
|
||||||
| 'h1'
|
|
||||||
| 'h2'
|
|
||||||
| 'h3'
|
|
||||||
| 'h4'
|
|
||||||
| 'body'
|
|
||||||
| 'body2'
|
|
||||||
| 'caption'
|
|
||||||
| 'error'
|
|
||||||
| 'code';
|
|
||||||
|
|
||||||
type LabelProps<T extends ElementType> = {
|
type LabelProps<T extends ElementType> = {
|
||||||
variant?: LabelVariant;
|
variant?: LabelVariant;
|
||||||
as?: T;
|
as?: T;
|
||||||
className?: string;
|
className?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const variantClassMap: Record<LabelVariant, string> = {
|
const variantClassMap: Record<LabelVariant, string> = {
|
||||||
h1: 'ui-title text-3xl font-bold',
|
h1: 'ui-title text-3xl font-bold',
|
||||||
h2: 'ui-title text-2xl font-semibold',
|
h2: 'ui-title text-2xl font-semibold',
|
||||||
h3: 'ui-title text-xl font-semibold',
|
h3: 'ui-title text-xl font-semibold',
|
||||||
h4: 'ui-title text-base font-semibold',
|
h4: 'ui-title text-base font-semibold',
|
||||||
body: 'ui-body-primary text-sm',
|
body: 'ui-body-primary text-sm',
|
||||||
body2: 'ui-body-secondary text-sm',
|
body2: 'ui-body-secondary text-sm',
|
||||||
caption: 'ui-kicker text-xs font-semibold uppercase tracking-[0.12em]',
|
caption: 'ui-kicker text-xs font-semibold uppercase tracking-[0.12em]',
|
||||||
error: 'ui-error text-sm',
|
error: 'ui-error text-sm',
|
||||||
code: 'ui-code text-sm font-mono'
|
code: 'ui-code text-sm font-mono',
|
||||||
};
|
};
|
||||||
|
|
||||||
const variantTagMap: Record<LabelVariant, ElementType> = {
|
const variantTagMap: Record<LabelVariant, ElementType> = {
|
||||||
h1: 'h1',
|
h1: 'h1',
|
||||||
h2: 'h2',
|
h2: 'h2',
|
||||||
h3: 'h3',
|
h3: 'h3',
|
||||||
h4: 'h3',
|
h4: 'h3',
|
||||||
body: 'p',
|
body: 'p',
|
||||||
body2: 'p',
|
body2: 'p',
|
||||||
caption: 'p',
|
caption: 'p',
|
||||||
error: 'p',
|
error: 'p',
|
||||||
code: 'code'
|
code: 'code',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Label<T extends ElementType = 'p'>({
|
export function Label<T extends ElementType = 'p'>({
|
||||||
variant = 'body',
|
variant = 'body',
|
||||||
as,
|
as,
|
||||||
className = '',
|
className = '',
|
||||||
children
|
children,
|
||||||
}: Readonly<LabelProps<T>>) {
|
}: Readonly<LabelProps<T>>) {
|
||||||
const Component = as ?? variantTagMap[variant];
|
const Component = as ?? variantTagMap[variant];
|
||||||
const classes = `${variantClassMap[variant]} ${className}`.trim();
|
const classes = `${variantClassMap[variant]} ${className}`.trim();
|
||||||
|
|
||||||
return <Component className={classes}>{children}</Component>;
|
return <Component className={classes}>{children}</Component>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Meta, StoryObj } from '@storybook/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';
|
import { MDXEditorField } from './MDXEditorField';
|
||||||
|
|
||||||
const basePlugins = [
|
const basePlugins = [headingsPlugin(), listsPlugin(), quotePlugin(), markdownShortcutPlugin()];
|
||||||
headingsPlugin(),
|
|
||||||
listsPlugin(),
|
|
||||||
quotePlugin(),
|
|
||||||
markdownShortcutPlugin()
|
|
||||||
];
|
|
||||||
|
|
||||||
const sampleMarkdown = `# Hello from MDXEditor
|
const sampleMarkdown = `# Hello from MDXEditor
|
||||||
|
|
||||||
@@ -19,143 +19,145 @@ This is a paragraph with **bold** and _italic_ text.
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Components/MDXEditorField',
|
title: 'Components/MDXEditorField',
|
||||||
component: MDXEditorField,
|
component: MDXEditorField,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
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' } }
|
|
||||||
},
|
},
|
||||||
markdown: {
|
argTypes: {
|
||||||
description: 'Controlled markdown content value.',
|
label: {
|
||||||
control: 'text',
|
description: 'Field label shown above the editor.',
|
||||||
table: { type: { summary: 'string' } }
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
markdown: {
|
||||||
|
description: 'Controlled markdown content value.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
readOnly: {
|
||||||
|
description: 'Enables read-only mode.',
|
||||||
|
control: 'boolean',
|
||||||
|
table: { type: { summary: 'boolean' } },
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
description: 'Disables editing and applies disabled visuals.',
|
||||||
|
control: 'boolean',
|
||||||
|
table: { type: { summary: 'boolean' } },
|
||||||
|
},
|
||||||
|
themeClassName: {
|
||||||
|
description:
|
||||||
|
'Theme class applied to MDXEditor (for example `light-theme` or `dark-theme`).',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
description: 'MDXEditor plugins array.',
|
||||||
|
control: false,
|
||||||
|
table: { type: { summary: 'MDXEditorProps["plugins"]' } },
|
||||||
|
},
|
||||||
|
contentEditableClassName: {
|
||||||
|
description: 'CSS class used on the content editable area.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
className: {
|
||||||
|
description: 'Extra CSS classes for the outer wrapper.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
editorWrapperClassName: {
|
||||||
|
description: 'Extra CSS classes for the editor shell element.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
editorWrapperStyle: {
|
||||||
|
description: 'Inline style object for the editor shell.',
|
||||||
|
control: 'object',
|
||||||
|
table: { type: { summary: 'CSSProperties' } },
|
||||||
|
},
|
||||||
|
editorClassName: {
|
||||||
|
description: 'Extra CSS classes for the MDXEditor instance.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
description: 'Error message shown below the editor.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
onChange: {
|
||||||
|
description: 'Callback fired when markdown changes in editable mode.',
|
||||||
|
action: 'changed',
|
||||||
|
table: { type: { summary: '(markdown: string) => void' } },
|
||||||
|
},
|
||||||
|
editorRef: {
|
||||||
|
description: 'Ref to MDXEditor methods.',
|
||||||
|
control: false,
|
||||||
|
table: { type: { summary: 'Ref<MDXEditorMethods | null>' } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
readOnly: {
|
args: {
|
||||||
description: 'Enables read-only mode.',
|
label: 'Content',
|
||||||
control: 'boolean',
|
markdown: sampleMarkdown,
|
||||||
table: { type: { summary: 'boolean' } }
|
plugins: basePlugins,
|
||||||
|
themeClassName: '',
|
||||||
},
|
},
|
||||||
disabled: {
|
|
||||||
description: 'Disables editing and applies disabled visuals.',
|
|
||||||
control: 'boolean',
|
|
||||||
table: { type: { summary: 'boolean' } }
|
|
||||||
},
|
|
||||||
themeClassName: {
|
|
||||||
description: 'Theme class applied to MDXEditor (for example `light-theme` or `dark-theme`).',
|
|
||||||
control: 'text',
|
|
||||||
table: { type: { summary: 'string' } }
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
description: 'MDXEditor plugins array.',
|
|
||||||
control: false,
|
|
||||||
table: { type: { summary: 'MDXEditorProps["plugins"]' } }
|
|
||||||
},
|
|
||||||
contentEditableClassName: {
|
|
||||||
description: 'CSS class used on the content editable area.',
|
|
||||||
control: 'text',
|
|
||||||
table: { type: { summary: 'string' } }
|
|
||||||
},
|
|
||||||
className: {
|
|
||||||
description: 'Extra CSS classes for the outer wrapper.',
|
|
||||||
control: 'text',
|
|
||||||
table: { type: { summary: 'string' } }
|
|
||||||
},
|
|
||||||
editorWrapperClassName: {
|
|
||||||
description: 'Extra CSS classes for the editor shell element.',
|
|
||||||
control: 'text',
|
|
||||||
table: { type: { summary: 'string' } }
|
|
||||||
},
|
|
||||||
editorWrapperStyle: {
|
|
||||||
description: 'Inline style object for the editor shell.',
|
|
||||||
control: 'object',
|
|
||||||
table: { type: { summary: 'CSSProperties' } }
|
|
||||||
},
|
|
||||||
editorClassName: {
|
|
||||||
description: 'Extra CSS classes for the MDXEditor instance.',
|
|
||||||
control: 'text',
|
|
||||||
table: { type: { summary: 'string' } }
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
description: 'Error message shown below the editor.',
|
|
||||||
control: 'text',
|
|
||||||
table: { type: { summary: 'string' } }
|
|
||||||
},
|
|
||||||
onChange: {
|
|
||||||
description: 'Callback fired when markdown changes in editable mode.',
|
|
||||||
action: 'changed',
|
|
||||||
table: { type: { summary: '(markdown: string) => void' } }
|
|
||||||
},
|
|
||||||
editorRef: {
|
|
||||||
description: 'Ref to MDXEditor methods.',
|
|
||||||
control: false,
|
|
||||||
table: { type: { summary: 'Ref<MDXEditorMethods | null>' } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
args: {
|
|
||||||
label: 'Content',
|
|
||||||
markdown: sampleMarkdown,
|
|
||||||
plugins: basePlugins,
|
|
||||||
themeClassName: ''
|
|
||||||
}
|
|
||||||
} satisfies Meta<typeof MDXEditorField>;
|
} satisfies Meta<typeof MDXEditorField>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Editable: Story = {
|
export const Editable: Story = {
|
||||||
render: (args) => {
|
render: function EditableRender(args) {
|
||||||
const [markdown, setMarkdown] = useState(args.markdown);
|
const [markdown, setMarkdown] = useState(args.markdown);
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-2xl">
|
<div className="w-full max-w-2xl">
|
||||||
<MDXEditorField
|
<MDXEditorField
|
||||||
{...args}
|
{...args}
|
||||||
markdown={markdown}
|
markdown={markdown}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
setMarkdown(next);
|
setMarkdown(next);
|
||||||
args.onChange?.(next);
|
args.onChange?.(next);
|
||||||
}}
|
}}
|
||||||
editorWrapperClassName="mt-2 overflow-hidden rounded-xl border"
|
editorWrapperClassName="mt-2 overflow-hidden rounded-xl border"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ReadOnly: Story = {
|
export const ReadOnly: Story = {
|
||||||
args: {
|
args: {
|
||||||
readOnly: true
|
readOnly: true,
|
||||||
},
|
},
|
||||||
render: (args) => (
|
render: (args) => (
|
||||||
<div className="w-full max-w-2xl">
|
<div className="w-full max-w-2xl">
|
||||||
<MDXEditorField
|
<MDXEditorField
|
||||||
{...args}
|
{...args}
|
||||||
editorWrapperClassName="mt-2 overflow-hidden rounded-xl border"
|
editorWrapperClassName="mt-2 overflow-hidden rounded-xl border"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DisabledWithError: Story = {
|
export const DisabledWithError: Story = {
|
||||||
args: {
|
args: {
|
||||||
disabled: true,
|
disabled: true,
|
||||||
error: 'Editor is currently disabled'
|
error: 'Editor is currently disabled',
|
||||||
},
|
},
|
||||||
render: (args) => (
|
render: (args) => (
|
||||||
<div className="w-full max-w-2xl">
|
<div className="w-full max-w-2xl">
|
||||||
<MDXEditorField
|
<MDXEditorField
|
||||||
{...args}
|
{...args}
|
||||||
editorWrapperClassName="mt-2 overflow-hidden rounded-xl border"
|
editorWrapperClassName="mt-2 overflow-hidden rounded-xl border"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,63 +3,75 @@ import type { CSSProperties, Ref } from 'react';
|
|||||||
import { Label } from './Label';
|
import { Label } from './Label';
|
||||||
|
|
||||||
type MDXEditorFieldProps = {
|
type MDXEditorFieldProps = {
|
||||||
label?: string;
|
label?: string;
|
||||||
markdown: string;
|
markdown: string;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onChange?: (markdown: string) => void;
|
onChange?: (markdown: string) => void;
|
||||||
editorRef?: Ref<MDXEditorMethods | null>;
|
editorRef?: Ref<MDXEditorMethods | null>;
|
||||||
themeClassName: string;
|
themeClassName: string;
|
||||||
plugins: MDXEditorProps['plugins'];
|
plugins: MDXEditorProps['plugins'];
|
||||||
contentEditableClassName?: string;
|
contentEditableClassName?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
editorWrapperClassName?: string;
|
editorWrapperClassName?: string;
|
||||||
editorWrapperStyle?: CSSProperties;
|
editorWrapperStyle?: CSSProperties;
|
||||||
editorClassName?: string;
|
editorClassName?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MDXEditorField({
|
export function MDXEditorField({
|
||||||
label,
|
label,
|
||||||
markdown,
|
markdown,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onChange,
|
onChange,
|
||||||
editorRef,
|
editorRef,
|
||||||
themeClassName,
|
themeClassName,
|
||||||
plugins,
|
plugins,
|
||||||
contentEditableClassName = 'mdx-content',
|
contentEditableClassName = 'mdx-content',
|
||||||
className = '',
|
className = '',
|
||||||
editorWrapperClassName = 'post-mdx-editor mt-2 overflow-hidden rounded-xl border',
|
editorWrapperClassName = 'post-mdx-editor mt-2 overflow-hidden rounded-xl border',
|
||||||
editorWrapperStyle,
|
editorWrapperStyle,
|
||||||
editorClassName = '',
|
editorClassName = '',
|
||||||
error
|
error,
|
||||||
}: Readonly<MDXEditorFieldProps>) {
|
}: Readonly<MDXEditorFieldProps>) {
|
||||||
const resolvedEditorClassName = `${themeClassName} ${editorClassName}`.trim();
|
const resolvedEditorClassName = `${themeClassName} ${editorClassName}`.trim();
|
||||||
const editorModeKey = disabled || readOnly ? 'read-only' : 'editable';
|
const editorModeKey = disabled || readOnly ? 'read-only' : 'editable';
|
||||||
const resolvedEditorWrapperClassName = `${editorWrapperClassName} ${disabled ? 'post-mdx-editor--disabled' : 'post-mdx-editor--enabled'}`.trim();
|
const resolvedEditorWrapperClassName =
|
||||||
const resolvedEditorWrapperStyle: CSSProperties = {
|
`${editorWrapperClassName} ${disabled ? 'post-mdx-editor--disabled' : 'post-mdx-editor--enabled'}`.trim();
|
||||||
backgroundColor: disabled ? 'var(--field-disabled-bg)' : 'var(--field-bg)',
|
const resolvedEditorWrapperStyle: CSSProperties = {
|
||||||
borderColor: disabled ? 'var(--field-disabled-border)' : 'var(--field-border)',
|
backgroundColor: disabled ? 'var(--field-disabled-bg)' : 'var(--field-bg)',
|
||||||
...editorWrapperStyle
|
borderColor: disabled ? 'var(--field-disabled-border)' : 'var(--field-border)',
|
||||||
};
|
...editorWrapperStyle,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{label ? <Label variant="body" className={`font-medium ${disabled ? 'ui-label-disabled' : 'ui-label'}`}>{label}</Label> : null}
|
{label ? (
|
||||||
<div className={resolvedEditorWrapperClassName} style={resolvedEditorWrapperStyle}>
|
<Label
|
||||||
<MDXEditor
|
variant="body"
|
||||||
key={editorModeKey}
|
className={`font-medium ${disabled ? 'ui-label-disabled' : 'ui-label'}`}
|
||||||
ref={editorRef}
|
>
|
||||||
markdown={markdown}
|
{label}
|
||||||
onChange={disabled || readOnly ? undefined : onChange}
|
</Label>
|
||||||
readOnly={disabled || readOnly}
|
) : null}
|
||||||
className={resolvedEditorClassName}
|
<div className={resolvedEditorWrapperClassName} style={resolvedEditorWrapperStyle}>
|
||||||
contentEditableClassName={contentEditableClassName}
|
<MDXEditor
|
||||||
plugins={plugins}
|
key={editorModeKey}
|
||||||
/>
|
ref={editorRef}
|
||||||
</div>
|
markdown={markdown}
|
||||||
{error ? <Label variant="error" className="mt-2 ui-error">{error}</Label> : null}
|
onChange={disabled || readOnly ? undefined : onChange}
|
||||||
</div>
|
readOnly={disabled || readOnly}
|
||||||
);
|
className={resolvedEditorClassName}
|
||||||
|
contentEditableClassName={contentEditableClassName}
|
||||||
|
plugins={plugins}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<Label variant="error" className="mt-2 ui-error">
|
||||||
|
{error}
|
||||||
|
</Label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,74 +3,80 @@ import { HomeIcon, UserCircleIcon, UsersIcon } from '@heroicons/react/24/outline
|
|||||||
import { SidebarNavItem } from './SidebarNavItem';
|
import { SidebarNavItem } from './SidebarNavItem';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Components/SidebarNavItem',
|
title: 'Components/SidebarNavItem',
|
||||||
component: SidebarNavItem,
|
component: SidebarNavItem,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
parameters: {
|
parameters: {
|
||||||
layout: 'padded',
|
layout: 'padded',
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
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' } }
|
|
||||||
},
|
},
|
||||||
label: {
|
argTypes: {
|
||||||
description: 'Navigation item label.',
|
to: {
|
||||||
control: 'text',
|
description: 'Destination route path.',
|
||||||
table: { type: { summary: 'string' } }
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
description: 'Navigation item label.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
description: 'Icon component rendered before the label.',
|
||||||
|
control: false,
|
||||||
|
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' } },
|
||||||
|
},
|
||||||
|
onClick: {
|
||||||
|
description: 'Optional click callback (for example to close mobile drawer).',
|
||||||
|
action: 'clicked',
|
||||||
|
table: { type: { summary: '() => void' } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
icon: {
|
args: {
|
||||||
description: 'Icon component rendered before the label.',
|
to: '/',
|
||||||
control: false,
|
label: 'Dashboard',
|
||||||
table: { type: { summary: 'ComponentType<SVGProps<SVGSVGElement>>' } }
|
icon: HomeIcon,
|
||||||
|
collapsed: false,
|
||||||
},
|
},
|
||||||
collapsed: {
|
|
||||||
description: 'Collapsed state. When true, desktop view shows icon-only rail style.',
|
|
||||||
control: 'boolean',
|
|
||||||
table: { type: { summary: 'boolean' } }
|
|
||||||
},
|
|
||||||
onClick: {
|
|
||||||
description: 'Optional click callback (for example to close mobile drawer).',
|
|
||||||
action: 'clicked',
|
|
||||||
table: { type: { summary: '() => void' } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
args: {
|
|
||||||
to: '/',
|
|
||||||
label: 'Dashboard',
|
|
||||||
icon: HomeIcon,
|
|
||||||
collapsed: false
|
|
||||||
}
|
|
||||||
} satisfies Meta<typeof SidebarNavItem>;
|
} satisfies Meta<typeof SidebarNavItem>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Expanded: Story = {
|
export const Expanded: Story = {
|
||||||
render: (args) => (
|
render: (args) => (
|
||||||
<nav className="flex w-56 flex-col gap-1">
|
<nav className="flex w-56 flex-col gap-1">
|
||||||
<SidebarNavItem {...args} />
|
<SidebarNavItem {...args} />
|
||||||
<SidebarNavItem to="/users" label="Users" icon={UsersIcon} collapsed={args.collapsed} />
|
<SidebarNavItem to="/users" label="Users" icon={UsersIcon} collapsed={args.collapsed} />
|
||||||
<SidebarNavItem to="/profile" label="Profile" icon={UserCircleIcon} collapsed={args.collapsed} />
|
<SidebarNavItem
|
||||||
</nav>
|
to="/profile"
|
||||||
)
|
label="Profile"
|
||||||
|
icon={UserCircleIcon}
|
||||||
|
collapsed={args.collapsed}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Collapsed: Story = {
|
export const Collapsed: Story = {
|
||||||
args: {
|
args: {
|
||||||
collapsed: true
|
collapsed: true,
|
||||||
},
|
},
|
||||||
render: (args) => (
|
render: (args) => (
|
||||||
<nav className="flex w-14 flex-col gap-1">
|
<nav className="flex w-14 flex-col gap-1">
|
||||||
<SidebarNavItem {...args} />
|
<SidebarNavItem {...args} />
|
||||||
<SidebarNavItem to="/users" label="Users" icon={UsersIcon} collapsed />
|
<SidebarNavItem to="/users" label="Users" icon={UsersIcon} collapsed />
|
||||||
<SidebarNavItem to="/profile" label="Profile" icon={UserCircleIcon} collapsed />
|
<SidebarNavItem to="/profile" label="Profile" icon={UserCircleIcon} collapsed />
|
||||||
</nav>
|
</nav>
|
||||||
)
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,30 +4,40 @@ import { NavLink } from 'react-router-dom';
|
|||||||
type IconType = ComponentType<SVGProps<SVGSVGElement>>;
|
type IconType = ComponentType<SVGProps<SVGSVGElement>>;
|
||||||
|
|
||||||
type SidebarNavItemProps = {
|
type SidebarNavItemProps = {
|
||||||
to: string;
|
to: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: IconType;
|
icon: IconType;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SidebarNavItem({ to, label, icon: Icon, collapsed, onClick }: Readonly<SidebarNavItemProps>) {
|
export function SidebarNavItem({
|
||||||
const layoutClass = collapsed
|
to,
|
||||||
? 'mx-auto w-8 justify-center px-0'
|
label,
|
||||||
: 'px-2 lg:w-full lg:justify-start';
|
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';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
to={to}
|
to={to}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={({ isActive }) => (
|
className={({ isActive }) =>
|
||||||
`inline-flex h-8 items-center rounded-lg text-sm font-medium transition ${layoutClass} ${
|
`inline-flex h-8 items-center rounded-lg text-sm font-medium transition ${layoutClass} ${
|
||||||
isActive ? 'bg-accent-500 text-white' : 'ui-body-secondary hover:bg-zinc-500/15'
|
isActive ? 'ui-accent-active' : 'ui-body-secondary hover:bg-zinc-500/15'
|
||||||
}`
|
}`
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 shrink-0" />
|
<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 ? (
|
||||||
</NavLink>
|
<span className="ml-2 truncate leading-none">{label}</span>
|
||||||
);
|
) : (
|
||||||
|
<span className="ml-2 lg:hidden">{label}</span>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,161 +5,163 @@ import { Chip } from './Chip';
|
|||||||
import { Table, type TableHeader } from './Table';
|
import { Table, type TableHeader } from './Table';
|
||||||
|
|
||||||
type UserRow = {
|
type UserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: 'ADMIN' | 'EDITOR' | 'AUTHOR';
|
role: 'ADMIN' | 'EDITOR' | 'AUTHOR';
|
||||||
status: 'Active' | 'Pending';
|
status: 'Active' | 'Pending';
|
||||||
posts: number;
|
posts: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const rows: UserRow[] = [
|
const rows: UserRow[] = [
|
||||||
{ id: '1', name: 'Beatrice Rosa', role: 'ADMIN', status: 'Active', posts: 48 },
|
{ id: '1', name: 'Beatrice Rosa', role: 'ADMIN', status: 'Active', posts: 48 },
|
||||||
{ id: '2', name: 'Luca Valli', role: 'EDITOR', status: 'Active', posts: 26 },
|
{ id: '2', name: 'Luca Valli', role: 'EDITOR', status: 'Active', posts: 26 },
|
||||||
{ id: '3', name: 'Marta Bellini', role: 'AUTHOR', status: 'Pending', posts: 4 },
|
{ id: '3', name: 'Marta Bellini', role: 'AUTHOR', status: 'Pending', posts: 4 },
|
||||||
{ id: '4', name: 'Giulia Fontana', role: 'AUTHOR', status: 'Active', posts: 12 },
|
{ id: '4', name: 'Giulia Fontana', role: 'AUTHOR', status: 'Active', posts: 12 },
|
||||||
{ id: '5', name: 'Andrea Pini', role: 'EDITOR', status: 'Pending', posts: 9 },
|
{ id: '5', name: 'Andrea Pini', role: 'EDITOR', status: 'Pending', posts: 9 },
|
||||||
{ id: '6', name: 'Sofia Denti', role: 'AUTHOR', status: 'Active', posts: 7 },
|
{ id: '6', name: 'Sofia Denti', role: 'AUTHOR', status: 'Active', posts: 7 },
|
||||||
{ id: '7', name: 'Marco Serra', role: 'AUTHOR', status: 'Active', posts: 18 },
|
{ 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>[] = [
|
const headers: TableHeader<UserRow>[] = [
|
||||||
{
|
{
|
||||||
id: 'name',
|
id: 'name',
|
||||||
label: 'Name',
|
label: 'Name',
|
||||||
value: (row) => row.name,
|
value: (row) => row.name,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'name',
|
sortField: 'name',
|
||||||
cellClassName: 'table-cell-primary'
|
cellClassName: 'table-cell-primary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'role',
|
id: 'role',
|
||||||
label: 'Role',
|
label: 'Role',
|
||||||
value: (row) => row.role,
|
value: (row) => row.role,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'role'
|
sortField: 'role',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
value: (row) => (
|
value: (row) => (
|
||||||
<Chip variant="outlined" tone={row.status === 'Active' ? 'indigo-700' : 'cyan-700'}>
|
<Chip variant="outlined" tone={row.status === 'Active' ? 'indigo-700' : 'cyan-700'}>
|
||||||
{row.status}
|
{row.status}
|
||||||
</Chip>
|
</Chip>
|
||||||
)
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'posts',
|
id: 'posts',
|
||||||
label: 'Posts',
|
label: 'Posts',
|
||||||
value: (row) => row.posts,
|
value: (row) => row.posts,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'posts'
|
sortField: 'posts',
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
type UsersTableProps = {
|
type UsersTableProps = {
|
||||||
data: UserRow[];
|
data: UserRow[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
sorting?: SortState | null;
|
sorting?: SortState | null;
|
||||||
onSortChange?: (field: string) => void;
|
onSortChange?: (field: string) => void;
|
||||||
pagination?: {
|
pagination?: {
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
total: number;
|
total: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
onPageSizeChange?: (pageSize: number) => void;
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
function UsersTable(props: Readonly<UsersTableProps>) {
|
function UsersTable(props: Readonly<UsersTableProps>) {
|
||||||
return (
|
return (
|
||||||
<Table<UserRow>
|
<Table<UserRow>
|
||||||
headers={headers}
|
headers={headers}
|
||||||
data={props.data}
|
data={props.data}
|
||||||
rowKey={(row) => row.id}
|
rowKey={(row) => row.id}
|
||||||
isLoading={props.isLoading}
|
isLoading={props.isLoading}
|
||||||
emptyMessage={props.emptyMessage}
|
emptyMessage={props.emptyMessage}
|
||||||
sorting={props.sorting}
|
sorting={props.sorting}
|
||||||
onSortChange={props.onSortChange}
|
onSortChange={props.onSortChange}
|
||||||
pagination={props.pagination}
|
pagination={props.pagination}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortRows(data: UserRow[], sorting: SortState | null): UserRow[] {
|
function sortRows(data: UserRow[], sorting: SortState | null): UserRow[] {
|
||||||
if (!sorting) {
|
if (!sorting) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = [...data];
|
const sorted = [...data];
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
const left = a[sorting.field as keyof UserRow];
|
const left = a[sorting.field as keyof UserRow];
|
||||||
const right = b[sorting.field as keyof UserRow];
|
const right = b[sorting.field as keyof UserRow];
|
||||||
if (left === right) {
|
if (left === right) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (typeof left === 'number' && typeof right === 'number') {
|
if (typeof left === 'number' && typeof right === 'number') {
|
||||||
return sorting.direction === 'asc' ? left - right : right - left;
|
return sorting.direction === 'asc' ? left - right : right - left;
|
||||||
}
|
}
|
||||||
return sorting.direction === 'asc'
|
return sorting.direction === 'asc'
|
||||||
? String(left).localeCompare(String(right))
|
? String(left).localeCompare(String(right))
|
||||||
: String(right).localeCompare(String(left));
|
: String(right).localeCompare(String(left));
|
||||||
});
|
});
|
||||||
return sorted;
|
return sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Components/Table',
|
title: 'Components/Table',
|
||||||
component: UsersTable,
|
component: UsersTable,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
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[]' } }
|
|
||||||
},
|
},
|
||||||
isLoading: {
|
argTypes: {
|
||||||
description: 'When true, shows the loading indicator row.',
|
data: {
|
||||||
control: 'boolean',
|
description: 'Rows rendered in the table body.',
|
||||||
table: { type: { summary: 'boolean' } }
|
control: 'object',
|
||||||
|
table: { type: { summary: 'UserRow[]' } },
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
description: 'When true, shows the loading indicator row.',
|
||||||
|
control: 'boolean',
|
||||||
|
table: { type: { summary: 'boolean' } },
|
||||||
|
},
|
||||||
|
emptyMessage: {
|
||||||
|
description: 'Message shown when `data` is empty and `isLoading` is false.',
|
||||||
|
control: 'text',
|
||||||
|
table: { type: { summary: 'string' } },
|
||||||
|
},
|
||||||
|
sorting: {
|
||||||
|
description: 'Current sort state object. Use `null` for no active sorting.',
|
||||||
|
control: 'object',
|
||||||
|
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' } },
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
description: 'Pagination config object. When omitted, pagination footer is hidden.',
|
||||||
|
control: 'object',
|
||||||
|
table: {
|
||||||
|
type: {
|
||||||
|
summary:
|
||||||
|
'{ page; pageSize; total; totalPages; onPageChange; onPageSizeChange? }',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
emptyMessage: {
|
args: {
|
||||||
description: 'Message shown when `data` is empty and `isLoading` is false.',
|
data: rows,
|
||||||
control: 'text',
|
|
||||||
table: { type: { summary: 'string' } }
|
|
||||||
},
|
},
|
||||||
sorting: {
|
|
||||||
description: "Current sort state object. Use `null` for no active sorting.",
|
|
||||||
control: 'object',
|
|
||||||
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' } }
|
|
||||||
},
|
|
||||||
pagination: {
|
|
||||||
description: 'Pagination config object. When omitted, pagination footer is hidden.',
|
|
||||||
control: 'object',
|
|
||||||
table: {
|
|
||||||
type: {
|
|
||||||
summary: '{ page; pageSize; total; totalPages; onPageChange; onPageSizeChange? }'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
args: {
|
|
||||||
data: rows
|
|
||||||
}
|
|
||||||
} satisfies Meta<typeof UsersTable>;
|
} satisfies Meta<typeof UsersTable>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
@@ -168,61 +170,61 @@ type Story = StoryObj<typeof meta>;
|
|||||||
export const WithRows: Story = {};
|
export const WithRows: Story = {};
|
||||||
|
|
||||||
export const Loading: Story = {
|
export const Loading: Story = {
|
||||||
args: {
|
args: {
|
||||||
isLoading: true
|
isLoading: true,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Empty: Story = {
|
export const Empty: Story = {
|
||||||
args: {
|
args: {
|
||||||
data: [],
|
data: [],
|
||||||
emptyMessage: 'No users found'
|
emptyMessage: 'No users found',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const InteractiveSortingAndPagination: Story = {
|
export const InteractiveSortingAndPagination: Story = {
|
||||||
render: () => {
|
render: function InteractiveSortingAndPaginationRender() {
|
||||||
const [sorting, setSorting] = useState<SortState | null>({
|
const [sorting, setSorting] = useState<SortState | null>({
|
||||||
field: 'name',
|
field: 'name',
|
||||||
direction: 'asc'
|
direction: 'asc',
|
||||||
});
|
});
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(5);
|
const [pageSize, setPageSize] = useState(5);
|
||||||
|
|
||||||
const sorted = useMemo(() => sortRows(rows, sorting), [sorting]);
|
const sorted = useMemo(() => sortRows(rows, sorting), [sorting]);
|
||||||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||||
const safePage = Math.min(page, totalPages);
|
const safePage = Math.min(page, totalPages);
|
||||||
const start = (safePage - 1) * pageSize;
|
const start = (safePage - 1) * pageSize;
|
||||||
const pagedRows = sorted.slice(start, start + pageSize);
|
const pagedRows = sorted.slice(start, start + pageSize);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UsersTable
|
<UsersTable
|
||||||
data={pagedRows}
|
data={pagedRows}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
onSortChange={(field) => {
|
onSortChange={(field) => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setSorting((prev) => {
|
setSorting((prev) => {
|
||||||
if (!prev || prev.field !== field) {
|
if (!prev || prev.field !== field) {
|
||||||
return { field, direction: 'asc' };
|
return { field, direction: 'asc' };
|
||||||
}
|
}
|
||||||
if (prev.direction === 'asc') {
|
if (prev.direction === 'asc') {
|
||||||
return { field, direction: 'desc' };
|
return { field, direction: 'desc' };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
pagination={{
|
pagination={{
|
||||||
page: safePage,
|
page: safePage,
|
||||||
pageSize,
|
pageSize,
|
||||||
total: sorted.length,
|
total: sorted.length,
|
||||||
totalPages,
|
totalPages,
|
||||||
onPageChange: setPage,
|
onPageChange: setPage,
|
||||||
onPageSizeChange: (next) => {
|
onPageSizeChange: (next) => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setPageSize(next);
|
setPageSize(next);
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { ReactNode } from 'react';
|
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 { ArrowsUpDownIcon } from '@heroicons/react/24/outline';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
import { Dropdown } from './Dropdown';
|
import { Dropdown } from './Dropdown';
|
||||||
@@ -9,172 +15,192 @@ import type { SortState } from '../types/sort';
|
|||||||
type HeaderValue<T> = ReactNode | ((row: T) => ReactNode);
|
type HeaderValue<T> = ReactNode | ((row: T) => ReactNode);
|
||||||
|
|
||||||
export type TableHeader<T> = {
|
export type TableHeader<T> = {
|
||||||
label: string;
|
label: string;
|
||||||
id: string;
|
id: string;
|
||||||
value: HeaderValue<T>;
|
value: HeaderValue<T>;
|
||||||
sortable?: boolean;
|
sortable?: boolean;
|
||||||
sortField?: string;
|
sortField?: string;
|
||||||
headerClassName?: string;
|
headerClassName?: string;
|
||||||
cellClassName?: string;
|
cellClassName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TableProps<T> = {
|
type TableProps<T> = {
|
||||||
headers: TableHeader<T>[];
|
headers: TableHeader<T>[];
|
||||||
data: T[];
|
data: T[];
|
||||||
rowKey: (row: T, index: number) => string;
|
rowKey: (row: T, index: number) => string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
sorting?: SortState | null;
|
sorting?: SortState | null;
|
||||||
onSortChange?: (field: string) => void;
|
onSortChange?: (field: string) => void;
|
||||||
pagination?: {
|
pagination?: {
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
total: number;
|
total: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
onPageSizeChange?: (pageSize: number) => void;
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Table<T>({
|
export function Table<T>({
|
||||||
headers,
|
headers,
|
||||||
data,
|
data,
|
||||||
rowKey,
|
rowKey,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
emptyMessage = 'No data to show.',
|
emptyMessage = 'No data to show.',
|
||||||
className = '',
|
className = '',
|
||||||
sorting = null,
|
sorting = null,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
pagination
|
pagination,
|
||||||
}: Readonly<TableProps<T>>) {
|
}: Readonly<TableProps<T>>) {
|
||||||
const canGoPrev = pagination != null && pagination.page > 1;
|
const canGoPrev = pagination != null && pagination.page > 1;
|
||||||
const canGoNext = pagination != null && pagination.page < pagination.totalPages;
|
const canGoNext = pagination != null && pagination.page < pagination.totalPages;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`table-shell ${className}`.trim()}>
|
<div className={`table-shell ${className}`.trim()}>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table className="table-root">
|
<table className="table-root">
|
||||||
<thead className="table-head">
|
<thead className="table-head">
|
||||||
<tr>
|
<tr>
|
||||||
{headers.map((header) => {
|
{headers.map((header) => {
|
||||||
const canSort = header.sortable === true
|
const canSort =
|
||||||
&& typeof onSortChange === 'function'
|
header.sortable === true &&
|
||||||
&& typeof header.sortField === 'string'
|
typeof onSortChange === 'function' &&
|
||||||
&& header.sortField.length > 0;
|
typeof header.sortField === 'string' &&
|
||||||
const isActiveSort = canSort && sorting?.field === header.sortField;
|
header.sortField.length > 0;
|
||||||
const sortDirection = isActiveSort ? sorting?.direction : null;
|
const isActiveSort = canSort && sorting?.field === header.sortField;
|
||||||
|
const sortDirection = isActiveSort ? sorting?.direction : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<th key={header.id} className={`table-head-cell ${header.headerClassName ?? ''}`.trim()}>
|
<th
|
||||||
{canSort ? (
|
key={header.id}
|
||||||
<button
|
className={`table-head-cell ${header.headerClassName ?? ''}`.trim()}
|
||||||
type="button"
|
>
|
||||||
className="table-sort-button"
|
{canSort ? (
|
||||||
onClick={() => onSortChange(header.sortField as string)}
|
<button
|
||||||
aria-label={`Sort by ${header.label}`}
|
type="button"
|
||||||
>
|
className="table-sort-button"
|
||||||
<span>{header.label}</span>
|
onClick={() =>
|
||||||
<span className="table-sort-icon" aria-hidden="true" data-sort-state={sortDirection ?? 'none'}>
|
onSortChange(header.sortField as string)
|
||||||
{sortDirection === 'asc' ? (
|
}
|
||||||
<ChevronUpIcon className="h-4 w-4" />
|
aria-label={`Sort by ${header.label}`}
|
||||||
) : null}
|
>
|
||||||
{sortDirection === 'desc' ? (
|
<span>{header.label}</span>
|
||||||
<ChevronDownIcon className="h-4 w-4" />
|
<span
|
||||||
) : null}
|
className="table-sort-icon"
|
||||||
{sortDirection == null ? (
|
aria-hidden="true"
|
||||||
<ArrowsUpDownIcon className="h-4 w-4" />
|
data-sort-state={sortDirection ?? 'none'}
|
||||||
) : null}
|
>
|
||||||
</span>
|
{sortDirection === 'asc' ? (
|
||||||
</button>
|
<ChevronUpIcon className="h-4 w-4" />
|
||||||
) : (
|
) : null}
|
||||||
header.label
|
{sortDirection === 'desc' ? (
|
||||||
)}
|
<ChevronDownIcon className="h-4 w-4" />
|
||||||
</th>
|
) : null}
|
||||||
);
|
{sortDirection == null ? (
|
||||||
})}
|
<ArrowsUpDownIcon className="h-4 w-4" />
|
||||||
</tr>
|
) : null}
|
||||||
</thead>
|
</span>
|
||||||
<tbody>
|
</button>
|
||||||
{isLoading ? (
|
) : (
|
||||||
<tr className="table-body-row">
|
header.label
|
||||||
<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">
|
</th>
|
||||||
<ArrowPathIcon className="h-5 w-5 animate-spin" aria-hidden="true" />
|
);
|
||||||
</Label>
|
})}
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{!isLoading && data.length === 0 ? (
|
||||||
|
<tr className="table-body-row">
|
||||||
|
<td colSpan={headers.length} className="px-4 py-6 text-center">
|
||||||
|
<Label variant="body2" className="ui-empty">
|
||||||
|
{emptyMessage}
|
||||||
|
</Label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{!isLoading &&
|
||||||
|
data.map((row, index) => (
|
||||||
|
<tr key={rowKey(row, index)} className="table-body-row">
|
||||||
|
{headers.map((header) => {
|
||||||
|
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()}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{pagination.onPageSizeChange ? (
|
||||||
|
<Dropdown
|
||||||
|
label="Rows"
|
||||||
|
value={String(pagination.pageSize)}
|
||||||
|
choices={[5, 10, 20, 50, 100].map((size) => ({
|
||||||
|
id: String(size),
|
||||||
|
label: String(size),
|
||||||
|
}))}
|
||||||
|
size="sm"
|
||||||
|
layout="inline"
|
||||||
|
className="max-w-none"
|
||||||
|
selectClassName="rounded-lg px-2"
|
||||||
|
disabled={isLoading}
|
||||||
|
onChange={(value) => pagination.onPageSizeChange?.(Number(value))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="outlined"
|
||||||
|
size="sm"
|
||||||
|
icon={ChevronLeftIcon}
|
||||||
|
ariaLabel="Previous page"
|
||||||
|
disabled={!canGoPrev || isLoading}
|
||||||
|
onClick={() => pagination.onPageChange(pagination.page - 1)}
|
||||||
|
/>
|
||||||
|
<Label variant="body2" className="px-1 text-xs ui-body-secondary">
|
||||||
|
Page {pagination.page} of {Math.max(pagination.totalPages, 1)}
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
type="outlined"
|
||||||
|
size="sm"
|
||||||
|
icon={ChevronRightIcon}
|
||||||
|
ariaLabel="Next page"
|
||||||
|
disabled={!canGoNext || isLoading}
|
||||||
|
onClick={() => pagination.onPageChange(pagination.page + 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!isLoading && data.length === 0 ? (
|
|
||||||
<tr className="table-body-row">
|
|
||||||
<td colSpan={headers.length} className="px-4 py-6 text-center">
|
|
||||||
<Label variant="body2" className="ui-empty">
|
|
||||||
{emptyMessage}
|
|
||||||
</Label>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
{!isLoading && data.map((row, index) => (
|
|
||||||
<tr key={rowKey(row, index)} className="table-body-row">
|
|
||||||
{headers.map((header) => {
|
|
||||||
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()}>
|
|
||||||
{content}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</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>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{pagination.onPageSizeChange ? (
|
|
||||||
<Dropdown
|
|
||||||
label="Rows"
|
|
||||||
value={String(pagination.pageSize)}
|
|
||||||
choices={[5, 10, 20, 50, 100].map((size) => ({
|
|
||||||
id: String(size),
|
|
||||||
label: String(size)
|
|
||||||
}))}
|
|
||||||
size="sm"
|
|
||||||
layout="inline"
|
|
||||||
className="max-w-none"
|
|
||||||
selectClassName="rounded-lg px-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
onChange={(value) => pagination.onPageSizeChange?.(Number(value))}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
type="outlined"
|
|
||||||
size="sm"
|
|
||||||
icon={ChevronLeftIcon}
|
|
||||||
ariaLabel="Previous page"
|
|
||||||
disabled={!canGoPrev || isLoading}
|
|
||||||
onClick={() => pagination.onPageChange(pagination.page - 1)}
|
|
||||||
/>
|
|
||||||
<Label variant="body2" className="px-1 text-xs ui-body-secondary">
|
|
||||||
Page {pagination.page} of {Math.max(pagination.totalPages, 1)}
|
|
||||||
</Label>
|
|
||||||
<Button
|
|
||||||
type="outlined"
|
|
||||||
size="sm"
|
|
||||||
icon={ChevronRightIcon}
|
|
||||||
ariaLabel="Next page"
|
|
||||||
disabled={!canGoNext || isLoading}
|
|
||||||
onClick={() => pagination.onPageChange(pagination.page + 1)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export { Button } from './components/Button';
|
export { Button } from './components/Button';
|
||||||
export { Chip } from './components/Chip';
|
export { Chip } from './components/Chip';
|
||||||
|
export { DatePicker } from './components/DatePicker';
|
||||||
export { Dropdown } from './components/Dropdown';
|
export { Dropdown } from './components/Dropdown';
|
||||||
export { Form } from './components/Form';
|
export { Form } from './components/Form';
|
||||||
export { InputField } from './components/InputField';
|
export { InputField } from './components/InputField';
|
||||||
@@ -8,5 +9,6 @@ export { SidebarNavItem } from './components/SidebarNavItem';
|
|||||||
export { Table } from './components/Table';
|
export { Table } from './components/Table';
|
||||||
|
|
||||||
export type { TableHeader } from './components/Table';
|
export type { TableHeader } from './components/Table';
|
||||||
|
export type { DatePickerProps } from './components/DatePicker';
|
||||||
export type { ComponentSize } from './components/types';
|
export type { ComponentSize } from './components/types';
|
||||||
export type { SortDirection, SortState } from './types/sort';
|
export type { SortDirection, SortState } from './types/sort';
|
||||||
|
|||||||
@@ -1,94 +1,105 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-page: #16121a;
|
/* Consumer projects can override these accent tokens after importing @panic/web-ui styles. */
|
||||||
--surface-bg: rgba(24, 24, 27, 0.45);
|
--accent-300: 168 155 191;
|
||||||
--surface-bg-strong: rgba(24, 24, 27, 0.62);
|
--accent-400: 149 135 173;
|
||||||
--surface-border: rgba(82, 82, 91, 0.6);
|
--accent-500: 125 111 152;
|
||||||
--surface-divider: rgba(63, 63, 70, 0.85);
|
--accent-600: 106 93 132;
|
||||||
--text-primary: #d5cfdf;
|
--accent-contrast: 255 255 255;
|
||||||
--text-secondary: #bcb7c8;
|
--bg-page: #16121a;
|
||||||
--text-muted: #a1a1aa;
|
--surface-bg: rgba(24, 24, 27, 0.45);
|
||||||
--text-soft: #8f8b9c;
|
--surface-bg-strong: rgba(24, 24, 27, 0.62);
|
||||||
--field-bg: rgba(24, 24, 27, 0.6);
|
--datepicker-menu-bg: #18181b;
|
||||||
--field-border: #3f3f46;
|
--surface-border: rgba(82, 82, 91, 0.6);
|
||||||
--field-disabled-bg: rgba(24, 24, 27, 0.5);
|
--surface-divider: rgba(63, 63, 70, 0.85);
|
||||||
--field-disabled-border: #3f3f46;
|
--text-primary: #d5cfdf;
|
||||||
--field-disabled-text: #bbb6c3;
|
--text-secondary: #bcb7c8;
|
||||||
--field-disabled-placeholder: #71717a;
|
--text-muted: #a1a1aa;
|
||||||
--ghost-bg: rgba(24, 24, 27, 0.5);
|
--text-soft: #8f8b9c;
|
||||||
--ghost-border: #3f3f46;
|
--field-bg: rgba(24, 24, 27, 0.6);
|
||||||
--ghost-hover: rgba(39, 39, 42, 0.7);
|
--field-border: #3f3f46;
|
||||||
--ghost-disabled-bg: rgba(24, 24, 27, 0.3);
|
--field-disabled-bg: rgba(24, 24, 27, 0.5);
|
||||||
--ghost-disabled-border: #27272a;
|
--field-disabled-border: #3f3f46;
|
||||||
--ghost-disabled-text: #71717a;
|
--field-disabled-text: #bbb6c3;
|
||||||
--primary-disabled-bg: #3f3f46;
|
--field-disabled-placeholder: #71717a;
|
||||||
--primary-disabled-text: #a1a1aa;
|
--field-selection-bg: rgb(var(--accent-500) / 0.42);
|
||||||
--table-head-bg: rgba(24, 24, 27, 0.8);
|
--field-selection-text: var(--text-primary);
|
||||||
--table-row-divider: #27272a;
|
--ghost-bg: rgba(24, 24, 27, 0.5);
|
||||||
--auth-chrome-bg: rgba(24, 24, 27, 0.7);
|
--ghost-border: #3f3f46;
|
||||||
--auth-glass-blur: 22px;
|
--ghost-hover: rgba(39, 39, 42, 0.7);
|
||||||
--auth-sidebar-mobile-width: min(86vw, 320px);
|
--ghost-disabled-bg: rgba(24, 24, 27, 0.3);
|
||||||
--auth-right-sidebar-mobile-width: min(86vw, 340px);
|
--ghost-disabled-border: #27272a;
|
||||||
--error-border: rgba(252, 165, 165, 0.3);
|
--ghost-disabled-text: #71717a;
|
||||||
--error-bg: rgba(239, 68, 68, 0.1);
|
--primary-disabled-bg: #3f3f46;
|
||||||
--error-text: #fecaca;
|
--primary-disabled-text: #a1a1aa;
|
||||||
--mdx-link: #7d6f98;
|
--table-head-bg: rgba(24, 24, 27, 0.8);
|
||||||
--mdx-link-hover: #9587ad;
|
--table-row-divider: #27272a;
|
||||||
--mdx-inline-code-bg: #27272a;
|
--auth-chrome-bg: rgba(24, 24, 27, 0.7);
|
||||||
--mdx-inline-code-border: #3f3f46;
|
--auth-glass-blur: 22px;
|
||||||
--mdx-codeblock-bg: #18181b;
|
--auth-sidebar-mobile-width: min(86vw, 320px);
|
||||||
--mdx-codeblock-border: #3f3f46;
|
--auth-right-sidebar-mobile-width: min(86vw, 340px);
|
||||||
--mdx-codeblock-text: #e4e4e7;
|
--error-border: rgba(252, 165, 165, 0.3);
|
||||||
--mdx-codeblock-gutter: #a1a1aa;
|
--error-bg: rgba(239, 68, 68, 0.1);
|
||||||
--mdx-codeblock-active: #27272a;
|
--error-text: #fecaca;
|
||||||
--mdx-codeblock-selection: rgba(125, 111, 152, 0.35);
|
--mdx-link: rgb(var(--accent-500));
|
||||||
--mdx-codeblock-bracket: rgba(125, 111, 152, 0.45);
|
--mdx-link-hover: rgb(var(--accent-400));
|
||||||
--shadow-glow: 0 0 0 1px rgba(63, 63, 70, 0.65), 0 18px 44px rgba(0, 0, 0, 0.45);
|
--mdx-inline-code-bg: #27272a;
|
||||||
|
--mdx-inline-code-border: #3f3f46;
|
||||||
|
--mdx-codeblock-bg: #18181b;
|
||||||
|
--mdx-codeblock-border: #3f3f46;
|
||||||
|
--mdx-codeblock-text: #e4e4e7;
|
||||||
|
--mdx-codeblock-gutter: #a1a1aa;
|
||||||
|
--mdx-codeblock-active: #27272a;
|
||||||
|
--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);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='light'] {
|
:root[data-theme='light'] {
|
||||||
--bg-page: #f7f7fb;
|
--bg-page: #f7f7fb;
|
||||||
--surface-bg: rgba(255, 255, 255, 0.9);
|
--surface-bg: rgba(255, 255, 255, 0.9);
|
||||||
--surface-bg-strong: rgba(255, 255, 255, 0.98);
|
--surface-bg-strong: rgba(255, 255, 255, 0.98);
|
||||||
--surface-border: rgba(161, 161, 170, 0.45);
|
--datepicker-menu-bg: #ffffff;
|
||||||
--surface-divider: rgba(212, 212, 216, 0.9);
|
--surface-border: rgba(161, 161, 170, 0.45);
|
||||||
--text-primary: #52485c;
|
--surface-divider: rgba(212, 212, 216, 0.9);
|
||||||
--text-secondary: #514e60;
|
--text-primary: #52485c;
|
||||||
--text-muted: #52525b;
|
--text-secondary: #514e60;
|
||||||
--text-soft: #71717a;
|
--text-muted: #52525b;
|
||||||
--field-bg: rgba(253, 253, 253, 0.8);
|
--text-soft: #71717a;
|
||||||
--field-border: #d4d4d8;
|
--field-bg: rgba(253, 253, 253, 0.8);
|
||||||
--field-disabled-bg: rgba(248, 248, 248, 0.8);
|
--field-border: #d4d4d8;
|
||||||
--field-disabled-border: #d7d7d7;
|
--field-disabled-bg: rgba(248, 248, 248, 0.8);
|
||||||
--field-disabled-text: #71717a;
|
--field-disabled-border: #d7d7d7;
|
||||||
--field-disabled-placeholder: #a1a1aa;
|
--field-disabled-text: #71717a;
|
||||||
--ghost-bg: rgba(255, 255, 255, 0.88);
|
--field-disabled-placeholder: #a1a1aa;
|
||||||
--ghost-border: #d4d4d8;
|
--field-selection-bg: rgb(var(--accent-500) / 0.24);
|
||||||
--ghost-hover: #f4f4f5;
|
--field-selection-text: var(--text-primary);
|
||||||
--ghost-disabled-bg: #f4f4f5;
|
--ghost-bg: rgba(255, 255, 255, 0.88);
|
||||||
--ghost-disabled-border: #e4e4e7;
|
--ghost-border: #d4d4d8;
|
||||||
--ghost-disabled-text: #a1a1aa;
|
--ghost-hover: #f4f4f5;
|
||||||
--primary-disabled-bg: #e4e4e7;
|
--ghost-disabled-bg: #f4f4f5;
|
||||||
--primary-disabled-text: #a1a1aa;
|
--ghost-disabled-border: #e4e4e7;
|
||||||
--table-head-bg: #f4f4f5;
|
--ghost-disabled-text: #a1a1aa;
|
||||||
--table-row-divider: #e4e4e7;
|
--primary-disabled-bg: #e4e4e7;
|
||||||
--auth-chrome-bg: rgba(255, 255, 255, 0.7);
|
--primary-disabled-text: #a1a1aa;
|
||||||
--auth-glass-blur: 15px;
|
--table-head-bg: #f4f4f5;
|
||||||
--error-border: rgba(248, 113, 113, 0.35);
|
--table-row-divider: #e4e4e7;
|
||||||
--error-bg: rgba(254, 226, 226, 0.8);
|
--auth-chrome-bg: rgba(255, 255, 255, 0.7);
|
||||||
--error-text: #991b1b;
|
--auth-glass-blur: 15px;
|
||||||
--mdx-link: #7d6f98;
|
--error-border: rgba(248, 113, 113, 0.35);
|
||||||
--mdx-link-hover: #6a5d84;
|
--error-bg: rgba(254, 226, 226, 0.8);
|
||||||
--mdx-inline-code-bg: #f4f4f5;
|
--error-text: #991b1b;
|
||||||
--mdx-inline-code-border: #d4d4d8;
|
--mdx-link: rgb(var(--accent-500));
|
||||||
--mdx-codeblock-bg: #ffffff;
|
--mdx-link-hover: rgb(var(--accent-600));
|
||||||
--mdx-codeblock-border: #d4d4d8;
|
--mdx-inline-code-bg: #f4f4f5;
|
||||||
--mdx-codeblock-text: #18181b;
|
--mdx-inline-code-border: #d4d4d8;
|
||||||
--mdx-codeblock-gutter: #71717a;
|
--mdx-codeblock-bg: #ffffff;
|
||||||
--mdx-codeblock-active: #f4f4f5;
|
--mdx-codeblock-border: #d4d4d8;
|
||||||
--mdx-codeblock-selection: rgba(125, 111, 152, 0.22);
|
--mdx-codeblock-text: #18181b;
|
||||||
--mdx-codeblock-bracket: rgba(125, 111, 152, 0.32);
|
--mdx-codeblock-gutter: #71717a;
|
||||||
--shadow-glow: 0 0 0 1px rgba(212, 212, 216, 0.9), 0 18px 36px rgba(15, 23, 42, 0.08);
|
--mdx-codeblock-active: #f4f4f5;
|
||||||
|
--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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,289 +1,516 @@
|
|||||||
.surface {
|
.surface {
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
background-color: var(--surface-bg);
|
background-color: var(--surface-bg);
|
||||||
box-shadow: var(--shadow-glow);
|
box-shadow: var(--shadow-glow);
|
||||||
@apply rounded-2xl backdrop-blur-xl;
|
@apply rounded-2xl backdrop-blur-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
border: 1px solid var(--field-border);
|
border: 1px solid var(--field-border);
|
||||||
background-color: var(--field-bg);
|
background-color: var(--field-bg);
|
||||||
color: var(--text-primary);
|
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 {
|
.field::placeholder {
|
||||||
color: var(--text-soft);
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field::selection {
|
||||||
|
background-color: var(--field-selection-bg);
|
||||||
|
color: var(--field-selection-text);
|
||||||
|
-webkit-text-fill-color: var(--field-selection-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field:disabled {
|
.field:disabled {
|
||||||
border-color: var(--field-disabled-border);
|
border-color: var(--field-disabled-border);
|
||||||
background-color: var(--field-disabled-bg);
|
background-color: var(--field-disabled-bg);
|
||||||
color: var(--field-disabled-text);
|
color: var(--field-disabled-text);
|
||||||
|
-webkit-text-fill-color: var(--field-disabled-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field:disabled::placeholder {
|
.field:disabled::placeholder {
|
||||||
color: var(--field-disabled-placeholder);
|
color: var(--field-disabled-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-solid:disabled,
|
||||||
|
.btn-outlined:disabled,
|
||||||
|
.btn-noborder:disabled {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-solid,
|
.btn-solid,
|
||||||
.btn-outlined,
|
.btn-outlined,
|
||||||
.btn-noborder {
|
.btn-noborder {
|
||||||
@apply inline-flex items-center justify-center rounded-xl px-4 py-2 text-sm font-semibold transition;
|
@apply inline-flex items-center justify-center rounded-xl px-4 py-2 text-sm font-semibold transition;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-solid {
|
.btn-solid {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outlined {
|
.btn-outlined {
|
||||||
border: 1px solid var(--ghost-border);
|
border: 1px solid var(--ghost-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-noborder {
|
.btn-noborder {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-solid.btn-primary {
|
.btn-solid.btn-primary {
|
||||||
@apply bg-accent-500 text-white hover:bg-accent-400 disabled:opacity-100;
|
background-color: rgb(var(--accent-500));
|
||||||
|
color: rgb(var(--accent-contrast));
|
||||||
|
@apply disabled:opacity-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-solid.btn-primary:hover {
|
||||||
|
background-color: rgb(var(--accent-400));
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-solid.btn-primary:disabled {
|
.btn-solid.btn-primary:disabled {
|
||||||
background-color: var(--primary-disabled-bg);
|
background-color: var(--primary-disabled-bg);
|
||||||
color: var(--primary-disabled-text);
|
color: var(--primary-disabled-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-solid.btn-secondary {
|
.btn-solid.btn-secondary {
|
||||||
border-color: var(--ghost-border);
|
border-color: var(--ghost-border);
|
||||||
background-color: var(--ghost-border);
|
background-color: var(--ghost-border);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-solid.btn-secondary:hover {
|
.btn-solid.btn-secondary:hover {
|
||||||
background-color: var(--ghost-hover);
|
background-color: var(--ghost-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-solid.btn-secondary:disabled {
|
.btn-solid.btn-secondary:disabled {
|
||||||
border-color: var(--ghost-disabled-border);
|
border-color: var(--ghost-disabled-border);
|
||||||
background-color: var(--ghost-disabled-bg);
|
background-color: var(--ghost-disabled-bg);
|
||||||
color: var(--ghost-disabled-text);
|
color: var(--ghost-disabled-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-solid.btn-important {
|
.btn-solid.btn-important {
|
||||||
border-color: #dc2626;
|
border-color: #dc2626;
|
||||||
@apply bg-red-600 text-white hover:bg-red-500 disabled:opacity-100;
|
@apply bg-red-600 text-white hover:bg-red-500 disabled:opacity-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-solid.btn-important:disabled {
|
.btn-solid.btn-important:disabled {
|
||||||
border-color: #7f1d1d;
|
border-color: #7f1d1d;
|
||||||
background-color: #7f1d1d;
|
background-color: #7f1d1d;
|
||||||
color: #fecaca;
|
color: #fecaca;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outlined.btn-secondary {
|
.btn-outlined.btn-secondary {
|
||||||
border-color: var(--ghost-border);
|
border-color: var(--ghost-border);
|
||||||
background-color: var(--ghost-bg);
|
background-color: var(--ghost-bg);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outlined.btn-secondary:hover {
|
.btn-outlined.btn-secondary:hover {
|
||||||
background-color: var(--ghost-hover);
|
background-color: var(--ghost-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outlined.btn-secondary:disabled {
|
.btn-outlined.btn-secondary:disabled {
|
||||||
border-color: var(--ghost-disabled-border);
|
border-color: var(--ghost-disabled-border);
|
||||||
background-color: var(--ghost-disabled-bg);
|
background-color: var(--ghost-disabled-bg);
|
||||||
color: var(--ghost-disabled-text);
|
color: var(--ghost-disabled-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outlined.btn-primary {
|
.btn-outlined.btn-primary {
|
||||||
@apply border-accent-500 text-accent-300;
|
border-color: rgb(var(--accent-500));
|
||||||
background-color: transparent;
|
color: rgb(var(--accent-300));
|
||||||
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outlined.btn-primary:hover {
|
.btn-outlined.btn-primary:hover {
|
||||||
@apply bg-accent-500/15 text-accent-300;
|
background-color: rgb(var(--accent-500) / 0.15);
|
||||||
|
color: rgb(var(--accent-300));
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outlined.btn-primary:disabled {
|
.btn-outlined.btn-primary:disabled {
|
||||||
@apply border-accent-500/40 text-accent-300/60;
|
border-color: rgb(var(--accent-500) / 0.4);
|
||||||
background-color: transparent;
|
color: rgb(var(--accent-300) / 0.6);
|
||||||
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outlined.btn-important {
|
.btn-outlined.btn-important {
|
||||||
@apply border-red-500 text-red-400;
|
@apply border-red-500 text-red-400;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outlined.btn-important:hover {
|
.btn-outlined.btn-important:hover {
|
||||||
@apply bg-red-500/10 text-red-300;
|
@apply bg-red-500/10 text-red-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outlined.btn-important:disabled {
|
.btn-outlined.btn-important:disabled {
|
||||||
@apply border-red-900 text-red-900;
|
@apply border-red-900 text-red-900;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-noborder.btn-secondary {
|
.btn-noborder.btn-secondary {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-noborder.btn-secondary:hover {
|
.btn-noborder.btn-secondary:hover {
|
||||||
background-color: var(--ghost-hover);
|
background-color: var(--ghost-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-noborder.btn-secondary:disabled {
|
.btn-noborder.btn-secondary:disabled {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--ghost-disabled-text);
|
color: var(--ghost-disabled-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-noborder.btn-primary {
|
.btn-noborder.btn-primary {
|
||||||
@apply text-accent-300;
|
color: rgb(var(--accent-300));
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-noborder.btn-primary:hover {
|
.btn-noborder.btn-primary:hover {
|
||||||
@apply bg-accent-500/15 text-accent-300;
|
background-color: rgb(var(--accent-500) / 0.15);
|
||||||
|
color: rgb(var(--accent-300));
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-noborder.btn-primary:disabled {
|
.btn-noborder.btn-primary:disabled {
|
||||||
@apply text-accent-300/60;
|
color: rgb(var(--accent-300) / 0.6);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-noborder.btn-important {
|
.btn-noborder.btn-important {
|
||||||
@apply text-red-400;
|
@apply text-red-400;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-noborder.btn-important:hover {
|
.btn-noborder.btn-important:hover {
|
||||||
@apply bg-red-500/10 text-red-300;
|
@apply bg-red-500/10 text-red-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-noborder.btn-important:disabled {
|
.btn-noborder.btn-important:disabled {
|
||||||
@apply text-red-900;
|
@apply text-red-900;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-kicker {
|
.ui-kicker {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-title {
|
.ui-title {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-body-secondary {
|
.ui-body-secondary {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-code {
|
.ui-code {
|
||||||
border: 1px solid var(--surface-divider);
|
border: 1px solid var(--surface-divider);
|
||||||
background-color: var(--ghost-bg);
|
background-color: var(--ghost-bg);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@apply rounded-md px-1.5 py-0.5;
|
@apply rounded-md px-1.5 py-0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-body-primary {
|
.ui-body-primary {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-loading {
|
.ui-loading {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-empty {
|
.ui-empty {
|
||||||
color: var(--text-soft);
|
color: var(--text-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-link {
|
.ui-link {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@apply font-semibold transition;
|
@apply font-semibold transition;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-link:hover {
|
.ui-link:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-label {
|
.ui-label {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-label-disabled {
|
.ui-label-disabled {
|
||||||
color: var(--text-soft);
|
color: var(--text-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-error {
|
.ui-error {
|
||||||
color: var(--error-text);
|
color: var(--error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-accent-active {
|
||||||
|
background-color: rgb(var(--accent-500));
|
||||||
|
color: rgb(var(--accent-contrast));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-accent-active:hover {
|
||||||
|
background-color: rgb(var(--accent-400));
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-root {
|
.chip-root {
|
||||||
@apply inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold leading-none;
|
@apply inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold leading-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-solid {
|
.chip-solid {
|
||||||
border-color: var(--ghost-border);
|
border-color: var(--ghost-border);
|
||||||
background-color: var(--ghost-border);
|
background-color: var(--ghost-border);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-outlined {
|
.chip-outlined {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border-color: var(--ghost-border);
|
border-color: var(--ghost-border);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-error {
|
.alert-error {
|
||||||
border: 1px solid var(--error-border);
|
border: 1px solid var(--error-border);
|
||||||
background-color: var(--error-bg);
|
background-color: var(--error-bg);
|
||||||
color: var(--error-text);
|
color: var(--error-text);
|
||||||
@apply rounded-lg px-3 py-2 text-sm;
|
@apply rounded-lg px-3 py-2 text-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-shell {
|
.table-shell {
|
||||||
border: 1px solid var(--surface-divider);
|
border: 1px solid var(--surface-divider);
|
||||||
background-color: var(--surface-bg-strong);
|
background-color: var(--surface-bg-strong);
|
||||||
@apply overflow-hidden rounded-xl;
|
@apply overflow-hidden rounded-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-scroll {
|
.table-scroll {
|
||||||
@apply overflow-x-auto;
|
@apply overflow-x-auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-root {
|
.table-root {
|
||||||
@apply min-w-full;
|
@apply min-w-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-head {
|
.table-head {
|
||||||
background-color: var(--table-head-bg);
|
background-color: var(--table-head-bg);
|
||||||
border-bottom: 1px solid var(--surface-divider);
|
border-bottom: 1px solid var(--surface-divider);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-head-cell {
|
.table-head-cell {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@apply px-4 py-3 text-left text-sm font-semibold tracking-wider;
|
@apply px-4 py-3 text-left text-sm font-semibold tracking-wider;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-sort-button {
|
.table-sort-button {
|
||||||
@apply inline-flex items-center gap-1.5 text-left;
|
@apply inline-flex items-center gap-1.5 text-left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-sort-icon {
|
.table-sort-icon {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@apply inline-flex items-center;
|
@apply inline-flex items-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-body-row {
|
.table-body-row {
|
||||||
border-top: 1px solid var(--table-row-divider);
|
border-top: 1px solid var(--table-row-divider);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-cell-primary {
|
.table-cell-primary {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@apply px-4 py-3 text-sm;
|
@apply px-4 py-3 text-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-cell-secondary {
|
.table-cell-secondary {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@apply px-4 py-3 text-sm;
|
@apply px-4 py-3 text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-icon-btn {
|
||||||
|
color: var(--text-muted);
|
||||||
|
@apply absolute inset-y-0 right-2 my-auto inline-flex h-6 w-6 items-center justify-center rounded-md border border-transparent bg-transparent p-0 transition;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-icon-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--ghost-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-icon-btn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgb(var(--accent-400));
|
||||||
|
box-shadow: 0 0 0 2px rgb(var(--accent-400) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-icon-btn:disabled {
|
||||||
|
color: var(--ghost-disabled-text);
|
||||||
|
background-color: transparent;
|
||||||
|
@apply cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-popup {
|
||||||
|
border: 1px solid var(--surface-divider);
|
||||||
|
background-color: var(--surface-bg-strong);
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
color: var(--text-primary);
|
||||||
|
backdrop-filter: saturate(145%) blur(var(--auth-glass-blur));
|
||||||
|
-webkit-backdrop-filter: saturate(145%) blur(var(--auth-glass-blur));
|
||||||
|
will-change: backdrop-filter;
|
||||||
|
max-width: min(96vw, 440px);
|
||||||
|
@apply fixed z-[70] flex flex-col gap-3 rounded-xl p-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-popup-top {
|
||||||
|
transform-origin: bottom center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-popup-bottom {
|
||||||
|
transform-origin: top center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@screen sm {
|
||||||
|
.datepicker-popup {
|
||||||
|
@apply flex-row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-panel {
|
||||||
|
@apply min-w-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-calendar-nav {
|
||||||
|
@apply mb-2 flex items-center justify-between gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-nav-btn {
|
||||||
|
border: 1px solid var(--ghost-border);
|
||||||
|
background-color: var(--ghost-bg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
@apply inline-flex h-8 w-8 items-center justify-center rounded-lg transition;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-nav-btn:hover {
|
||||||
|
background-color: var(--ghost-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-heading-controls {
|
||||||
|
@apply relative flex items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-chooser {
|
||||||
|
@apply relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-chooser-btn {
|
||||||
|
border: 1px solid var(--field-border);
|
||||||
|
background-color: var(--field-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
@apply inline-flex h-8 items-center rounded-lg px-2.5 text-xs font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-chooser-menu {
|
||||||
|
border: 1px solid var(--surface-divider);
|
||||||
|
background-color: var(--datepicker-menu-bg);
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
@apply absolute left-0 top-full z-20 mt-1 max-h-48 min-w-[9rem] overflow-y-auto rounded-lg p-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-chooser-option {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
@apply block w-full rounded-md px-2 py-1.5 text-left text-xs font-medium transition;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-chooser-option:hover {
|
||||||
|
background-color: var(--ghost-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-chooser-option.is-selected {
|
||||||
|
background-color: rgb(var(--accent-500) / 0.22);
|
||||||
|
color: rgb(var(--accent-300));
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-weekdays {
|
||||||
|
@apply mb-1 grid grid-cols-7 gap-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-weekday {
|
||||||
|
color: var(--text-muted);
|
||||||
|
@apply text-center text-[0.65rem] font-semibold uppercase tracking-[0.08em];
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-grid {
|
||||||
|
@apply grid grid-cols-7 gap-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-day {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
@apply inline-flex h-8 w-8 items-center justify-center rounded-lg text-sm transition;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-day:hover {
|
||||||
|
background-color: var(--ghost-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-day.is-selected {
|
||||||
|
background-color: rgb(var(--accent-500));
|
||||||
|
color: rgb(var(--accent-contrast));
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-day.is-today {
|
||||||
|
border: 1px solid rgb(var(--accent-400) / 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-day.is-outside-month {
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-day:disabled {
|
||||||
|
color: var(--ghost-disabled-text);
|
||||||
|
background-color: transparent;
|
||||||
|
@apply cursor-not-allowed opacity-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-time-root {
|
||||||
|
@apply grid min-w-[180px] grid-cols-2 gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-time-column {
|
||||||
|
@apply flex min-w-0 flex-col gap-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-time-title {
|
||||||
|
color: var(--text-muted);
|
||||||
|
@apply text-[0.65rem] font-semibold uppercase tracking-[0.08em];
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-time-list {
|
||||||
|
border: 1px solid var(--field-border);
|
||||||
|
background-color: var(--field-bg);
|
||||||
|
@apply max-h-52 overflow-y-auto rounded-lg p-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-time-option {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
@apply block w-full rounded-md px-2 py-1.5 text-left text-xs font-semibold transition;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-time-option:hover {
|
||||||
|
background-color: var(--ghost-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-time-option.is-selected {
|
||||||
|
background-color: rgb(var(--accent-500) / 0.22);
|
||||||
|
color: rgb(var(--accent-300));
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-time-option:disabled {
|
||||||
|
color: var(--ghost-disabled-text);
|
||||||
|
background-color: transparent;
|
||||||
|
@apply cursor-not-allowed opacity-55;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export type SortDirection = 'asc' | 'desc';
|
export type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
export type SortState = {
|
export type SortState = {
|
||||||
field: string;
|
field: string;
|
||||||
direction: SortDirection;
|
direction: SortDirection;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
accent: {
|
accent: {
|
||||||
300: '#a89bbf',
|
300: '#a89bbf',
|
||||||
400: '#9587ad',
|
400: '#9587ad',
|
||||||
500: '#7d6f98',
|
500: '#7d6f98',
|
||||||
600: '#6a5d84'
|
600: '#6a5d84',
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif']
|
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||||
},
|
},
|
||||||
boxShadow: {
|
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)',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,15 +2,13 @@ const webUiPreset = require('./tailwind-preset.cjs');
|
|||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
presets: [webUiPreset],
|
presets: [webUiPreset],
|
||||||
content: [
|
content: ['./src/**/*.{ts,tsx,js,jsx}'],
|
||||||
'./src/**/*.{ts,tsx,js,jsx}'
|
corePlugins: {
|
||||||
],
|
preflight: false,
|
||||||
corePlugins: {
|
},
|
||||||
preflight: false
|
theme: {
|
||||||
},
|
extend: {},
|
||||||
theme: {
|
},
|
||||||
extend: {}
|
plugins: [],
|
||||||
},
|
|
||||||
plugins: []
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,13 +2,10 @@ const webUiPreset = require('./tailwind-preset.cjs');
|
|||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
presets: [webUiPreset],
|
presets: [webUiPreset],
|
||||||
content: [
|
content: ['./src/**/*.{ts,tsx,js,jsx,mdx}', './.storybook/**/*.{ts,tsx,js,jsx,mdx}'],
|
||||||
'./src/**/*.{ts,tsx,js,jsx,mdx}',
|
theme: {
|
||||||
'./.storybook/**/*.{ts,tsx,js,jsx,mdx}'
|
extend: {},
|
||||||
],
|
},
|
||||||
theme: {
|
plugins: [],
|
||||||
extend: {}
|
|
||||||
},
|
|
||||||
plugins: []
|
|
||||||
};
|
};
|
||||||
|
|||||||
77
tests/components/Button.test.tsx
Normal file
77
tests/components/Button.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { HomeIcon } from '@heroicons/react/24/solid';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Button } from '../../src/components/Button';
|
||||||
|
import { renderWithRouter } from '../helpers/renderWithRouter';
|
||||||
|
|
||||||
|
describe('Button', () => {
|
||||||
|
it('renders native button with expected type and disabled state', () => {
|
||||||
|
render(<Button label="Save" type="solid" htmlType="submit" disabled />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: 'Save' });
|
||||||
|
expect(button.tagName).toBe('BUTTON');
|
||||||
|
expect(button).toHaveAttribute('type', 'submit');
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
expect(button).toHaveClass('btn-solid');
|
||||||
|
expect(button).toHaveClass('btn-primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults non-solid button variants to secondary', () => {
|
||||||
|
render(<Button label="Details" type="noborder" />);
|
||||||
|
expect(screen.getByRole('button', { name: 'Details' })).toHaveClass('btn-secondary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses explicit variant when provided', () => {
|
||||||
|
render(<Button label="Danger" type="outlined" variant="important" />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Danger' })).toHaveClass('btn-important');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders icon-only button and custom aria label', () => {
|
||||||
|
render(<Button type="solid" icon={HomeIcon} ariaLabel="Open home" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: 'Open home' });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
expect(button).toHaveClass('!p-0');
|
||||||
|
expect(button.textContent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders link button and prevents click when disabled', () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
renderWithRouter(
|
||||||
|
<Button label="Go home" type="outlined" to="/home" onClick={onClick} disabled />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const link = screen.getByRole('link', { name: 'Go home' });
|
||||||
|
fireEvent.click(link);
|
||||||
|
|
||||||
|
expect(link).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
expect(link).toHaveAttribute('tabindex', '-1');
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClick for enabled link buttons', () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
renderWithRouter(<Button label="Profile" type="solid" to="/profile" onClick={onClick} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('link', { name: 'Profile' }));
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders icon+label spacing and custom class names', () => {
|
||||||
|
render(
|
||||||
|
<Button
|
||||||
|
label="Home"
|
||||||
|
type="outlined"
|
||||||
|
icon={HomeIcon}
|
||||||
|
className="custom-button"
|
||||||
|
width="full"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: 'Home' });
|
||||||
|
expect(button).toHaveClass('gap-1.5');
|
||||||
|
expect(button).toHaveClass('custom-button');
|
||||||
|
expect(button).toHaveClass('w-full');
|
||||||
|
});
|
||||||
|
});
|
||||||
62
tests/components/Chip.test.tsx
Normal file
62
tests/components/Chip.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { Chip } from '../../src/components/Chip';
|
||||||
|
|
||||||
|
describe('Chip', () => {
|
||||||
|
it('renders default span with solid classes', () => {
|
||||||
|
render(<Chip>Default</Chip>);
|
||||||
|
|
||||||
|
const chip = screen.getByText('Default');
|
||||||
|
expect(chip.tagName).toBe('SPAN');
|
||||||
|
expect(chip.className).toContain('chip-solid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports outlined variant with valid tone', () => {
|
||||||
|
render(
|
||||||
|
<Chip as="div" variant="outlined" tone="indigo-700">
|
||||||
|
Custom
|
||||||
|
</Chip>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const chip = screen.getByText('Custom');
|
||||||
|
expect(chip.tagName).toBe('DIV');
|
||||||
|
expect(chip.className).toContain('chip-outlined');
|
||||||
|
expect(chip).toHaveStyle({
|
||||||
|
borderColor: 'rgb(67, 56, 202)',
|
||||||
|
color: 'rgb(67, 56, 202)',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports direct tone tokens without shades for solid variant', () => {
|
||||||
|
render(<Chip tone="white">Solid</Chip>);
|
||||||
|
|
||||||
|
expect(screen.getByText('Solid')).toHaveStyle({
|
||||||
|
borderColor: 'rgb(255, 255, 255)',
|
||||||
|
backgroundColor: 'rgb(255, 255, 255)',
|
||||||
|
color: 'rgb(255, 255, 255)',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores invalid/empty tones and keeps className', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<Chip tone="not-a-token" className="chip-custom">
|
||||||
|
Invalid
|
||||||
|
</Chip>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalid = screen.getByText('Invalid');
|
||||||
|
expect(invalid).toHaveClass('chip-custom');
|
||||||
|
expect(invalid.getAttribute('style')).toBeNull();
|
||||||
|
|
||||||
|
rerender(<Chip tone=" ">Blank</Chip>);
|
||||||
|
expect(screen.getByText('Blank').getAttribute('style')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores unresolved direct palettes and unknown shades', () => {
|
||||||
|
const { rerender } = render(<Chip tone="indigo">Palette token</Chip>);
|
||||||
|
expect(screen.getByText('Palette token').getAttribute('style')).toBeNull();
|
||||||
|
|
||||||
|
rerender(<Chip tone="indigo-999">Unknown shade</Chip>);
|
||||||
|
expect(screen.getByText('Unknown shade').getAttribute('style')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
428
tests/components/DatePicker.logic.test.ts
Normal file
428
tests/components/DatePicker.logic.test.ts
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { __datePickerTestUtils as utils } from '../../src/components/DatePicker';
|
||||||
|
|
||||||
|
type GlobalDescriptor = PropertyDescriptor | undefined;
|
||||||
|
|
||||||
|
function setGlobalProperty(name: string, descriptor: PropertyDescriptor): GlobalDescriptor {
|
||||||
|
const key = name as keyof typeof globalThis;
|
||||||
|
const original = Object.getOwnPropertyDescriptor(globalThis, key);
|
||||||
|
Object.defineProperty(globalThis, key, descriptor);
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreGlobalProperty(name: string, original: GlobalDescriptor): void {
|
||||||
|
const key = name as keyof typeof globalThis;
|
||||||
|
if (original) {
|
||||||
|
Object.defineProperty(globalThis, key, original);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Reflect.deleteProperty(globalThis, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DatePicker logic helpers', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats and clamps numeric values', () => {
|
||||||
|
expect(utils.pad2(3)).toBe('03');
|
||||||
|
expect(utils.pad4(12)).toBe('0012');
|
||||||
|
expect(utils.clampNumber(99, 0, 50)).toBe(50);
|
||||||
|
expect(utils.clampNumber(-1, 0, 50)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates calendar dates strictly', () => {
|
||||||
|
expect(utils.createValidatedDate(2026, 2, 29)).toBeNull();
|
||||||
|
expect(utils.createValidatedDate(2028, 2, 29)).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves locale with and without navigator', () => {
|
||||||
|
const originalNavigator = setGlobalProperty('navigator', {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
expect(utils.resolveLocale()).toBe('en-US');
|
||||||
|
restoreGlobalProperty('navigator', originalNavigator);
|
||||||
|
|
||||||
|
const locale = utils.resolveLocale();
|
||||||
|
expect(typeof locale).toBe('string');
|
||||||
|
expect(locale.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves week start across locale and fallback branches', () => {
|
||||||
|
const originalIntl = setGlobalProperty('Intl', {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
expect(utils.resolveWeekStart('en-US')).toBe(0);
|
||||||
|
setGlobalProperty('Intl', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
Locale: class MockLocale {
|
||||||
|
weekInfo = { firstDay: 2 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(utils.resolveWeekStart('de-DE')).toBe(2);
|
||||||
|
|
||||||
|
setGlobalProperty('Intl', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
Locale: class MockLocale {
|
||||||
|
weekInfo = { firstDay: 7 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(utils.resolveWeekStart('en-US')).toBe(0);
|
||||||
|
|
||||||
|
setGlobalProperty('Intl', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
Locale: class MockLocale {
|
||||||
|
weekInfo = { firstDay: 0 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(utils.resolveWeekStart('en-US')).toBe(0);
|
||||||
|
|
||||||
|
setGlobalProperty('Intl', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
Locale: class MockLocale {
|
||||||
|
weekInfo = { firstDay: 'x' as unknown as number };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(utils.resolveWeekStart('en-US')).toBe(0);
|
||||||
|
|
||||||
|
setGlobalProperty('Intl', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
Locale: class MockLocale {
|
||||||
|
constructor() {
|
||||||
|
throw new Error('boom');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(utils.resolveWeekStart('en-US')).toBe(0);
|
||||||
|
|
||||||
|
restoreGlobalProperty('Intl', originalIntl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds and validates format configuration', () => {
|
||||||
|
expect(utils.buildFormatConfigOrNull('date-time', '')).toBeNull();
|
||||||
|
expect(utils.buildFormatConfigOrNull('date-time', 'yyyy/mm/dd HH')).toBeNull();
|
||||||
|
|
||||||
|
const config = utils.buildFormatConfigOrNull('date-time', 'dd/mm/yyyy HH:mm');
|
||||||
|
expect(config).not.toBeNull();
|
||||||
|
expect(config?.segments).toHaveLength(5);
|
||||||
|
expect(config?.totalLength).toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to default format when requested format is invalid', () => {
|
||||||
|
const config = utils.buildFormatConfig('date-time', 'yyyy/mm/dd');
|
||||||
|
expect(config.format).toBe('yyyy/mm/dd HH:mm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses valid values and rejects invalid input', () => {
|
||||||
|
const dateTimeConfig = utils.buildFormatConfig('date-time', 'dd/mm/yyyy HH:mm');
|
||||||
|
expect(utils.parsePickerValueWithFormat('22/02/2026 14:30', dateTimeConfig)).toEqual({
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 1, 22),
|
||||||
|
hour: 14,
|
||||||
|
minute: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(utils.parsePickerValueWithFormat('2/2/2026 14:30', dateTimeConfig)).toBeNull();
|
||||||
|
expect(utils.parsePickerValueWithFormat('22-02-2026 14:30', dateTimeConfig)).toBeNull();
|
||||||
|
expect(utils.parsePickerValueWithFormat('aa/02/2026 14:30', dateTimeConfig)).toBeNull();
|
||||||
|
expect(utils.parsePickerValueWithFormat('31/02/2026 14:30', dateTimeConfig)).toBeNull();
|
||||||
|
expect(utils.parsePickerValueWithFormat('22/02/2026 24:30', dateTimeConfig)).toBeNull();
|
||||||
|
|
||||||
|
const timeConfig = utils.buildFormatConfig('time', 'HH:mm');
|
||||||
|
expect(utils.parsePickerValueWithFormat('09:00', timeConfig)).toEqual({
|
||||||
|
date: utils.startOfDay(new Date()),
|
||||||
|
hour: 9,
|
||||||
|
minute: 0,
|
||||||
|
});
|
||||||
|
expect(utils.parsePickerValueWithFormat('09:77', timeConfig)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats picker values with the chosen configuration', () => {
|
||||||
|
const config = utils.buildFormatConfig('date-time', 'dd/mm/yyyy HH:mm');
|
||||||
|
const text = utils.formatPickerValueWithFormat(
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2027, 2, 11),
|
||||||
|
hour: 6,
|
||||||
|
minute: 5,
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(text).toBe('11/03/2027 06:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares values by picker type', () => {
|
||||||
|
const date = utils.comparePickerValue(
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 1, 1),
|
||||||
|
hour: 23,
|
||||||
|
minute: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 1, 2),
|
||||||
|
hour: 1,
|
||||||
|
minute: 0,
|
||||||
|
},
|
||||||
|
'date',
|
||||||
|
);
|
||||||
|
expect(date).toBeLessThan(0);
|
||||||
|
|
||||||
|
const time = utils.comparePickerValue(
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 1, 1),
|
||||||
|
hour: 9,
|
||||||
|
minute: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 1, 1),
|
||||||
|
hour: 8,
|
||||||
|
minute: 59,
|
||||||
|
},
|
||||||
|
'time',
|
||||||
|
);
|
||||||
|
expect(time).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const dateTime = utils.comparePickerValue(
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 1, 1),
|
||||||
|
hour: 1,
|
||||||
|
minute: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 1, 1),
|
||||||
|
hour: 1,
|
||||||
|
minute: 1,
|
||||||
|
},
|
||||||
|
'date-time',
|
||||||
|
);
|
||||||
|
expect(dateTime).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes and enforces picker ranges', () => {
|
||||||
|
const min = {
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 9,
|
||||||
|
minute: 0,
|
||||||
|
};
|
||||||
|
const max = {
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 10,
|
||||||
|
minute: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = utils.normalizeRange(max, min, 'date-time');
|
||||||
|
expect(normalized.minValue).toEqual(min);
|
||||||
|
expect(normalized.maxValue).toEqual(max);
|
||||||
|
|
||||||
|
const below = utils.clampPickerToRange(
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 8,
|
||||||
|
minute: 0,
|
||||||
|
},
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
'date-time',
|
||||||
|
);
|
||||||
|
expect(below).toEqual(min);
|
||||||
|
|
||||||
|
const above = utils.clampPickerToRange(
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 10,
|
||||||
|
minute: 30,
|
||||||
|
},
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
'date-time',
|
||||||
|
);
|
||||||
|
expect(above).toEqual(max);
|
||||||
|
|
||||||
|
expect(utils.isWithinRange(min, min, max, 'date-time')).toBe(true);
|
||||||
|
expect(
|
||||||
|
utils.isWithinRange(
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 7,
|
||||||
|
minute: 59,
|
||||||
|
},
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
'date-time',
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
utils.isWithinRange(
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 10,
|
||||||
|
minute: 1,
|
||||||
|
},
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
'date-time',
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies segment edits across year, month, day, hour and minute', () => {
|
||||||
|
const base = {
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 1, 28),
|
||||||
|
hour: 14,
|
||||||
|
minute: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(utils.applySegmentDigits(base, 'year', '0001').date.getFullYear()).toBe(1);
|
||||||
|
expect(utils.applySegmentDigits(base, 'month', '13').date.getMonth()).toBe(11);
|
||||||
|
expect(utils.applySegmentDigits(base, 'day', '99').date.getDate()).toBe(28);
|
||||||
|
expect(utils.applySegmentDigits(base, 'hour', '88').hour).toBe(23);
|
||||||
|
expect(utils.applySegmentDigits(base, 'minute', '99').minute).toBe(59);
|
||||||
|
|
||||||
|
const unchanged = utils.applySegmentDigits(base, 'day', 'not-a-number');
|
||||||
|
expect(unchanged).toEqual(base);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves segment index from caret position', () => {
|
||||||
|
const config = utils.buildFormatConfig('date-time', 'dd/mm/yyyy HH:mm');
|
||||||
|
|
||||||
|
expect(utils.findSegmentIndexByCaret([], 3)).toBe(0);
|
||||||
|
expect(utils.findSegmentIndexByCaret(config.segments, null)).toBe(0);
|
||||||
|
expect(utils.findSegmentIndexByCaret(config.segments, 0)).toBe(0);
|
||||||
|
expect(utils.findSegmentIndexByCaret(config.segments, 6)).toBe(2);
|
||||||
|
expect(utils.findSegmentIndexByCaret(config.segments, 99)).toBe(
|
||||||
|
config.segments[config.segments.length - 1].segmentIndex,
|
||||||
|
);
|
||||||
|
expect(utils.findSegmentIndexByCaret(config.segments, 2)).toBe(0);
|
||||||
|
|
||||||
|
const nonStandardSegments = [
|
||||||
|
{
|
||||||
|
type: 'segment',
|
||||||
|
kind: 'day',
|
||||||
|
token: 'dd',
|
||||||
|
length: 2,
|
||||||
|
start: 5,
|
||||||
|
end: 7,
|
||||||
|
segmentIndex: 0,
|
||||||
|
},
|
||||||
|
] as unknown as Parameters<typeof utils.findSegmentIndexByCaret>[0];
|
||||||
|
expect(utils.findSegmentIndexByCaret(nonStandardSegments, 1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks date and hour selectability inside constrained ranges', () => {
|
||||||
|
const min = {
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 9,
|
||||||
|
minute: 30,
|
||||||
|
};
|
||||||
|
const max = {
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 10,
|
||||||
|
minute: 15,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
utils.isDateSelectableForRange(
|
||||||
|
utils.createDateAtLocalMidnight(2026, 2, 9),
|
||||||
|
'date-time',
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
utils.isDateSelectableForRange(
|
||||||
|
utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
'date-time',
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
utils.isDateSelectableForRange(
|
||||||
|
utils.createDateAtLocalMidnight(2026, 2, 11),
|
||||||
|
'date-time',
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
utils.isDateSelectableForRange(
|
||||||
|
utils.createDateAtLocalMidnight(2026, 2, 9),
|
||||||
|
'date',
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 0,
|
||||||
|
minute: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 0,
|
||||||
|
minute: 0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
utils.isHourSelectableForRange(
|
||||||
|
utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
8,
|
||||||
|
'date-time',
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
utils.isHourSelectableForRange(
|
||||||
|
utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
9,
|
||||||
|
'date-time',
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
utils.isHourSelectableForRange(
|
||||||
|
utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
11,
|
||||||
|
'date-time',
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds month grids and joins class names', () => {
|
||||||
|
const month = utils.createDateAtLocalMidnight(2026, 2, 10);
|
||||||
|
const grid = utils.buildMonthGrid(month, 1);
|
||||||
|
expect(grid).toHaveLength(42);
|
||||||
|
expect(grid[0]).toBeInstanceOf(Date);
|
||||||
|
expect(grid[41]).toBeInstanceOf(Date);
|
||||||
|
|
||||||
|
expect(utils.joinClassNames('a', false, 'b', undefined, '', null, 'c')).toBe('a b c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns refs for callback and object refs', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
const objectRef = { current: null as HTMLInputElement | null };
|
||||||
|
const node = document.createElement('input');
|
||||||
|
|
||||||
|
utils.assignRef(callback, node);
|
||||||
|
utils.assignRef(objectRef, node);
|
||||||
|
utils.assignRef(undefined, node);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(node);
|
||||||
|
expect(objectRef.current).toBe(node);
|
||||||
|
});
|
||||||
|
});
|
||||||
740
tests/components/DatePicker.test.tsx
Normal file
740
tests/components/DatePicker.test.tsx
Normal file
@@ -0,0 +1,740 @@
|
|||||||
|
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import { createRef, type FocusEvent as ReactFocusEvent, useState } from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { DatePicker } from '../../src/components/DatePicker';
|
||||||
|
|
||||||
|
type ControlledProps = {
|
||||||
|
type: 'date' | 'time' | 'date-time';
|
||||||
|
initialValue: string;
|
||||||
|
format?: string;
|
||||||
|
min?: string;
|
||||||
|
max?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
onBlur?: (event: ReactFocusEvent<HTMLInputElement>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ControlledDatePicker({
|
||||||
|
type,
|
||||||
|
initialValue,
|
||||||
|
format,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
onValueChange,
|
||||||
|
onBlur,
|
||||||
|
disabled = false,
|
||||||
|
}: Readonly<ControlledProps>) {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DatePicker
|
||||||
|
label="Schedule"
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
format={format}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
disabled={disabled}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onChange={(event) => {
|
||||||
|
setValue(event.target.value);
|
||||||
|
onValueChange?.(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickCurrentMonthDay(label: string): void {
|
||||||
|
const dayButtons = Array.from(document.querySelectorAll('.datepicker-day')) as HTMLButtonElement[];
|
||||||
|
const targetButton = dayButtons.find(
|
||||||
|
(button) => button.textContent === label && !button.classList.contains('is-outside-month'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(targetButton).toBeDefined();
|
||||||
|
fireEvent.click(targetButton as HTMLButtonElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentMonthDayButton(label: string): HTMLButtonElement {
|
||||||
|
const dayButtons = Array.from(document.querySelectorAll('.datepicker-day')) as HTMLButtonElement[];
|
||||||
|
const targetButton = dayButtons.find(
|
||||||
|
(button) => button.textContent === label && !button.classList.contains('is-outside-month'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!targetButton) {
|
||||||
|
throw new Error(`Could not find day button for ${label}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPasteEvent(text: string): Event {
|
||||||
|
const event = new Event('paste', { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(event, 'clipboardData', {
|
||||||
|
value: {
|
||||||
|
getData: () => text,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRect(left: number, top: number, width: number, height: number): DOMRect {
|
||||||
|
return {
|
||||||
|
x: left,
|
||||||
|
y: top,
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
right: left + width,
|
||||||
|
bottom: top + height,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
} as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DatePicker', () => {
|
||||||
|
it('opens popup from icon button and closes with Escape', () => {
|
||||||
|
render(<DatePicker label="Schedule" type="date-time" value="2031/05/20 14:30" onChange={() => {}} />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
expect(input.type).toBe('text');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole('dialog', { name: 'Date and time picker popup' }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('dialog', { name: 'Date and time picker popup' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Close date picker' }));
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('dialog', { name: 'Date and time picker popup' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports segment-by-segment editing with custom format and auto-advance', async () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ControlledDatePicker
|
||||||
|
type="date-time"
|
||||||
|
format="dd/mm/yyyy HH:mm"
|
||||||
|
initialValue="22/02/2026 14:30"
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
fireEvent.focus(input);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.selectionStart).toBe(0);
|
||||||
|
expect(input.selectionEnd).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: '1' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.value).toBe('01/02/2026 14:30');
|
||||||
|
expect(input.selectionStart).toBe(0);
|
||||||
|
expect(input.selectionEnd).toBe(2);
|
||||||
|
});
|
||||||
|
fireEvent.keyDown(input, { key: '1' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.value).toBe('11/02/2026 14:30');
|
||||||
|
expect(input.selectionStart).toBe(3);
|
||||||
|
expect(input.selectionEnd).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: '0' });
|
||||||
|
fireEvent.keyDown(input, { key: '3' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.value).toBe('11/03/2026 14:30');
|
||||||
|
expect(input.selectionStart).toBe(6);
|
||||||
|
expect(input.selectionEnd).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: '2' });
|
||||||
|
fireEvent.keyDown(input, { key: '0' });
|
||||||
|
fireEvent.keyDown(input, { key: '2' });
|
||||||
|
fireEvent.keyDown(input, { key: '7' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.value).toBe('11/03/2027 14:30');
|
||||||
|
expect(input.selectionStart).toBe(11);
|
||||||
|
expect(input.selectionEnd).toBe(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: '1' });
|
||||||
|
fireEvent.keyDown(input, { key: '6' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.value).toBe('11/03/2027 16:30');
|
||||||
|
expect(input.selectionStart).toBe(14);
|
||||||
|
expect(input.selectionEnd).toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: '4' });
|
||||||
|
fireEvent.keyDown(input, { key: '5' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.value).toBe('11/03/2027 16:45');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onValueChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves year 0000 while editing and does not coerce to 19xx', async () => {
|
||||||
|
render(
|
||||||
|
<ControlledDatePicker
|
||||||
|
type="date-time"
|
||||||
|
format="dd/mm/yyyy HH:mm"
|
||||||
|
initialValue="22/02/2026 14:30"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
fireEvent.focus(input);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.selectionStart).toBe(0);
|
||||||
|
expect(input.selectionEnd).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: '/' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.selectionStart).toBe(3);
|
||||||
|
expect(input.selectionEnd).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: '/' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.selectionStart).toBe(6);
|
||||||
|
expect(input.selectionEnd).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: '0' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.value).toBe('22/02/0000 14:30');
|
||||||
|
expect(input.selectionStart).toBe(6);
|
||||||
|
expect(input.selectionEnd).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: '0' });
|
||||||
|
fireEvent.keyDown(input, { key: '0' });
|
||||||
|
fireEvent.keyDown(input, { key: '0' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.value).toBe('22/02/0000 14:30');
|
||||||
|
expect(input.selectionStart).toBe(11);
|
||||||
|
expect(input.selectionEnd).toBe(13);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses separator keys to move between editable segments', async () => {
|
||||||
|
render(
|
||||||
|
<ControlledDatePicker
|
||||||
|
type="date-time"
|
||||||
|
format="dd/mm/yyyy HH:mm"
|
||||||
|
initialValue="22/02/2026 14:30"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
fireEvent.focus(input);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.selectionStart).toBe(0);
|
||||||
|
expect(input.selectionEnd).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: '/' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.selectionStart).toBe(3);
|
||||||
|
expect(input.selectionEnd).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: '/' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.selectionStart).toBe(6);
|
||||||
|
expect(input.selectionEnd).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: ' ' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.selectionStart).toBe(11);
|
||||||
|
expect(input.selectionEnd).toBe(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: ':' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.selectionStart).toBe(14);
|
||||||
|
expect(input.selectionEnd).toBe(16);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies min/max constraints to calendar and time options', () => {
|
||||||
|
render(
|
||||||
|
<ControlledDatePicker
|
||||||
|
type="date-time"
|
||||||
|
initialValue="2026/03/10 10:00"
|
||||||
|
min="2026/03/10 09:30"
|
||||||
|
max="2026/03/10 10:15"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
||||||
|
|
||||||
|
expect(getCurrentMonthDayButton('9')).toBeDisabled();
|
||||||
|
expect(getCurrentMonthDayButton('10')).toBeEnabled();
|
||||||
|
expect(getCurrentMonthDayButton('11')).toBeDisabled();
|
||||||
|
|
||||||
|
const hoursList = screen.getByRole('listbox', { name: 'Hours' });
|
||||||
|
expect(within(hoursList).getByRole('button', { name: '08' })).toBeDisabled();
|
||||||
|
expect(within(hoursList).getByRole('button', { name: '09' })).toBeEnabled();
|
||||||
|
expect(within(hoursList).getByRole('button', { name: '10' })).toBeEnabled();
|
||||||
|
expect(within(hoursList).getByRole('button', { name: '11' })).toBeDisabled();
|
||||||
|
|
||||||
|
const minutesList = screen.getByRole('listbox', { name: 'Minutes' });
|
||||||
|
expect(within(minutesList).getByRole('button', { name: '15' })).toBeEnabled();
|
||||||
|
expect(within(minutesList).getByRole('button', { name: '20' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders date mode calendar only and auto-closes after day selection', () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ControlledDatePicker
|
||||||
|
type="date"
|
||||||
|
initialValue="2031/05/20"
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
||||||
|
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Date picker popup' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Hours')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Minutes')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
pickCurrentMonthDay('15');
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
expect(input.value).toMatch(/^\d{4}\/\d{2}\/15$/);
|
||||||
|
expect(onValueChange).toHaveBeenCalled();
|
||||||
|
expect(screen.queryByRole('dialog', { name: 'Date picker popup' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commits calendar date changes in date-time mode without closing the popup', () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ControlledDatePicker
|
||||||
|
type="date-time"
|
||||||
|
initialValue="2031/05/20 14:30"
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
||||||
|
pickCurrentMonthDay('15');
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
expect(input.value).toMatch(/^\d{4}\/\d{2}\/15 14:30$/);
|
||||||
|
expect(onValueChange).toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders time mode selectors only and keeps popup open after selection', () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
render(<ControlledDatePicker type="time" initialValue="09:00" onValueChange={onValueChange} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
||||||
|
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Time picker popup' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('grid')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const hoursList = screen.getByRole('listbox', { name: 'Hours' });
|
||||||
|
const minutesList = screen.getByRole('listbox', { name: 'Minutes' });
|
||||||
|
|
||||||
|
fireEvent.click(within(hoursList).getByRole('button', { name: '13' }));
|
||||||
|
fireEvent.click(within(minutesList).getByRole('button', { name: '45' }));
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
expect(input.value).toBe('13:45');
|
||||||
|
expect(onValueChange).toHaveBeenCalledTimes(2);
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Time picker popup' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks popup interactions while disabled', () => {
|
||||||
|
render(<ControlledDatePicker type="date" initialValue="2031/05/20" disabled />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
const iconButton = screen.getByRole('button', { name: 'Open date picker' });
|
||||||
|
|
||||||
|
expect(input).toBeDisabled();
|
||||||
|
expect(iconButton).toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.click(iconButton);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('dialog', { name: 'Date picker popup' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders right icon and error message', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DatePicker
|
||||||
|
label="Schedule"
|
||||||
|
type="date-time"
|
||||||
|
value="2031/05/20 14:30"
|
||||||
|
onChange={() => {}}
|
||||||
|
rightIcon={<span data-testid="right-icon">R</span>}
|
||||||
|
error="Invalid date"
|
||||||
|
inputClassName="custom-input"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = container.querySelector('input');
|
||||||
|
expect(input).toBeInstanceOf(HTMLInputElement);
|
||||||
|
expect(screen.getByTestId('right-icon')).toBeInTheDocument();
|
||||||
|
expect(input).toHaveClass('pr-10');
|
||||||
|
expect(input).toHaveClass('custom-input');
|
||||||
|
expect(screen.getByText('Invalid date')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards inputRef for callback and object refs', () => {
|
||||||
|
const callbackRef = vi.fn();
|
||||||
|
const objectRef = createRef<HTMLInputElement>();
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<DatePicker
|
||||||
|
label="Schedule"
|
||||||
|
type="date-time"
|
||||||
|
value="2031/05/20 14:30"
|
||||||
|
onChange={() => {}}
|
||||||
|
inputRef={callbackRef}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(callbackRef).toHaveBeenCalled();
|
||||||
|
const callbackNode = callbackRef.mock.calls.find(([node]) => node instanceof HTMLInputElement)?.[0];
|
||||||
|
expect(callbackNode).toBeInstanceOf(HTMLInputElement);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<DatePicker
|
||||||
|
label="Schedule"
|
||||||
|
type="date-time"
|
||||||
|
value="2031/05/20 14:30"
|
||||||
|
onChange={() => {}}
|
||||||
|
inputRef={objectRef}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(objectRef.current).toBeInstanceOf(HTMLInputElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports inline layout', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DatePicker label="Start time" type="time" value="09:00" onChange={() => {}} layout="inline" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector('label')).toHaveClass('inline-flex');
|
||||||
|
expect(container.querySelector('label > div')).not.toHaveClass('mt-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles outside vs inside pointer interactions for popup close behavior', () => {
|
||||||
|
render(<DatePicker label="Schedule" type="date-time" value="2031/05/20 14:30" onChange={() => {}} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'Date and time picker popup' });
|
||||||
|
|
||||||
|
fireEvent.mouseDown(dialog);
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.mouseDown(document.body);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('dialog', { name: 'Date and time picker popup' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports month/year chooser interactions and month navigation buttons', async () => {
|
||||||
|
render(<DatePicker label="Schedule" type="date" value="2031/05/20" onChange={() => {}} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Previous month' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Next month' }));
|
||||||
|
|
||||||
|
const chooserButtons = document.querySelectorAll('.datepicker-chooser-btn');
|
||||||
|
const monthChooserButton = chooserButtons[0] as HTMLButtonElement;
|
||||||
|
const yearChooserButton = chooserButtons[1] as HTMLButtonElement;
|
||||||
|
|
||||||
|
const initialMonthText = monthChooserButton.textContent;
|
||||||
|
fireEvent.click(monthChooserButton);
|
||||||
|
const monthList = screen.getByRole('listbox', { name: 'Choose month' });
|
||||||
|
const monthOptions = within(monthList).getAllByRole('button');
|
||||||
|
const nextMonth = monthOptions.find((option) => option.textContent !== initialMonthText) ?? monthOptions[0];
|
||||||
|
fireEvent.click(nextMonth);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('listbox', { name: 'Choose month' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect((document.querySelectorAll('.datepicker-chooser-btn')[0] as HTMLButtonElement).textContent).toBe(
|
||||||
|
nextMonth.textContent,
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialYearText = yearChooserButton.textContent;
|
||||||
|
fireEvent.click(yearChooserButton);
|
||||||
|
const yearList = screen.getByRole('listbox', { name: 'Choose year' });
|
||||||
|
const yearOptions = within(yearList).getAllByRole('button');
|
||||||
|
const nextYear = yearOptions.find((option) => option.textContent !== initialYearText) ?? yearOptions[0];
|
||||||
|
fireEvent.click(nextYear);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('listbox', { name: 'Choose year' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect((document.querySelectorAll('.datepicker-chooser-btn')[1] as HTMLButtonElement).textContent).toBe(
|
||||||
|
nextYear.textContent,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('guards month navigation at absolute calendar boundaries', () => {
|
||||||
|
const first = render(
|
||||||
|
<DatePicker label="Schedule" type="date" value="0000/01/15" onChange={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Previous month' }));
|
||||||
|
const lowerYearButton = document.querySelectorAll('.datepicker-chooser-btn')[1] as HTMLButtonElement;
|
||||||
|
expect(lowerYearButton.textContent).toBe('0');
|
||||||
|
|
||||||
|
first.unmount();
|
||||||
|
|
||||||
|
render(<DatePicker label="Schedule" type="date" value="9999/12/15" onChange={() => {}} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Next month' }));
|
||||||
|
const upperYearButton = document.querySelectorAll('.datepicker-chooser-btn')[1] as HTMLButtonElement;
|
||||||
|
expect(upperYearButton.textContent).toBe('9999');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports keyboard navigation commands and segment reset shortcuts', async () => {
|
||||||
|
const onBlur = vi.fn();
|
||||||
|
render(
|
||||||
|
<ControlledDatePicker
|
||||||
|
type="date-time"
|
||||||
|
format="dd/mm/yyyy HH:mm"
|
||||||
|
initialValue="22/02/2026 14:30"
|
||||||
|
onBlur={onBlur}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
fireEvent.focus(input);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.selectionStart).toBe(0);
|
||||||
|
expect(input.selectionEnd).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: '1' });
|
||||||
|
fireEvent.keyDown(input, { key: 'ArrowRight' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.value.startsWith('01/')).toBe(true);
|
||||||
|
expect(input.selectionStart).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.mouseUp(input);
|
||||||
|
fireEvent.click(input);
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: 'ArrowRight' });
|
||||||
|
await waitFor(() => expect(input.selectionStart).toBe(3));
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
||||||
|
await waitFor(() => expect((input.selectionStart ?? 0) <= 3).toBe(true));
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: 'Tab' });
|
||||||
|
await waitFor(() => expect((input.selectionStart ?? 0) >= 0).toBe(true));
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: 'Tab', shiftKey: true });
|
||||||
|
await waitFor(() => expect((input.selectionStart ?? 0) >= 0).toBe(true));
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: 'Backspace' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.value.startsWith('01/')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: '/' });
|
||||||
|
await waitFor(() => expect(input.selectionStart).toBe(3));
|
||||||
|
fireEvent.keyDown(input, { key: 'Delete' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.value.slice(3, 5)).toBe('01');
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: '/' });
|
||||||
|
await waitFor(() => expect(input.selectionStart).toBe(6));
|
||||||
|
fireEvent.keyDown(input, { key: 'Delete' });
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: ' ' });
|
||||||
|
fireEvent.keyDown(input, { key: 'Delete' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(/\s00:|:00$/.test(input.value)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
fireEvent.keyDown(input, { key: 'x' });
|
||||||
|
fireEvent.blur(input);
|
||||||
|
expect(onBlur).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles paste validation and value commits', async () => {
|
||||||
|
render(
|
||||||
|
<ControlledDatePicker
|
||||||
|
type="date-time"
|
||||||
|
format="dd/mm/yyyy HH:mm"
|
||||||
|
initialValue="22/02/2026 14:30"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
input.dispatchEvent(createPasteEvent('invalid value'));
|
||||||
|
});
|
||||||
|
expect(input.value).toBe('22/02/2026 14:30');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
input.dispatchEvent(createPasteEvent('11/03/2027 16:45'));
|
||||||
|
});
|
||||||
|
expect(input.value).toBe('11/03/2027 16:45');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns early from interaction handlers when disabled', () => {
|
||||||
|
render(
|
||||||
|
<DatePicker
|
||||||
|
label="Schedule"
|
||||||
|
type="date-time"
|
||||||
|
format="dd/mm/yyyy HH:mm"
|
||||||
|
value="22/02/2026 14:30"
|
||||||
|
disabled
|
||||||
|
onChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.focus(input);
|
||||||
|
fireEvent.mouseUp(input);
|
||||||
|
fireEvent.click(input);
|
||||||
|
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||||
|
|
||||||
|
const disabledPaste = createPasteEvent('11/03/2027 16:45');
|
||||||
|
input.dispatchEvent(disabledPaste);
|
||||||
|
expect(disabledPaste.defaultPrevented).toBe(false);
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to direct assignment and synthetic onChange when native dispatch is stubbed', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const originalGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DatePicker
|
||||||
|
label="Schedule"
|
||||||
|
type="date-time"
|
||||||
|
format="dd/mm/yyyy HH:mm"
|
||||||
|
value="22/02/2026 14:30"
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
const nativeDispatchEvent = input.dispatchEvent.bind(input);
|
||||||
|
vi.spyOn(input, 'dispatchEvent').mockImplementation((event: Event) => {
|
||||||
|
if (event.type === 'change') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nativeDispatchEvent(event);
|
||||||
|
});
|
||||||
|
vi.spyOn(Object, 'getOwnPropertyDescriptor').mockImplementation((target, property) => {
|
||||||
|
if (target === HTMLInputElement.prototype && property === 'value') {
|
||||||
|
return {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
get: originalGetOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.get,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalGetOwnPropertyDescriptor(target, property);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.focus(input);
|
||||||
|
input.setSelectionRange(0, 2);
|
||||||
|
fireEvent.keyDown(input, { key: '1' });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('safely handles pending segment selection during unmount', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<DatePicker
|
||||||
|
label="Schedule"
|
||||||
|
type="date-time"
|
||||||
|
format="dd/mm/yyyy HH:mm"
|
||||||
|
value="22/02/2026 14:30"
|
||||||
|
onChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
fireEvent.focus(input);
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recalculates popup position on window resize and scroll', async () => {
|
||||||
|
const originalInnerWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth');
|
||||||
|
const originalInnerHeight = Object.getOwnPropertyDescriptor(window, 'innerHeight');
|
||||||
|
const rectSpy = vi
|
||||||
|
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||||
|
.mockImplementation(function mockRect(this: HTMLElement) {
|
||||||
|
if (this.classList.contains('datepicker-popup')) {
|
||||||
|
return createRect(0, 0, 300, 200);
|
||||||
|
}
|
||||||
|
if (this.classList.contains('relative') && this.querySelector('input')) {
|
||||||
|
return createRect(500, 80, 120, 40);
|
||||||
|
}
|
||||||
|
return createRect(0, 0, 100, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 600 });
|
||||||
|
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 480 });
|
||||||
|
|
||||||
|
render(<DatePicker label="Schedule" type="date-time" value="2031/05/20 14:30" onChange={() => {}} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'Date and time picker popup' });
|
||||||
|
expect(dialog).toBeInTheDocument();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(dialog.style.left).toBe('292px');
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent(window, new Event('resize'));
|
||||||
|
fireEvent(window, new Event('scroll'));
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument();
|
||||||
|
expect(dialog.style.left).toBe('292px');
|
||||||
|
|
||||||
|
rectSpy.mockRestore();
|
||||||
|
if (originalInnerWidth) {
|
||||||
|
Object.defineProperty(window, 'innerWidth', originalInnerWidth);
|
||||||
|
}
|
||||||
|
if (originalInnerHeight) {
|
||||||
|
Object.defineProperty(window, 'innerHeight', originalInnerHeight);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
60
tests/components/Dropdown.test.tsx
Normal file
60
tests/components/Dropdown.test.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Dropdown } from '../../src/components/Dropdown';
|
||||||
|
|
||||||
|
const choices = [
|
||||||
|
{ id: 'USER', label: 'User' },
|
||||||
|
{ id: 'ADMIN', label: 'Admin' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Dropdown', () => {
|
||||||
|
it('calls onChange with selected value', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<Dropdown label="Role" value="USER" choices={choices} onChange={onChange} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Role'), { target: { value: 'ADMIN' } });
|
||||||
|
expect(onChange).toHaveBeenCalledWith('ADMIN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports inline layout and disabled/required state', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Dropdown
|
||||||
|
label="Rows"
|
||||||
|
value="10"
|
||||||
|
choices={[{ id: '10', label: '10' }]}
|
||||||
|
layout="inline"
|
||||||
|
disabled
|
||||||
|
required
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText('Rows');
|
||||||
|
expect(select).toBeDisabled();
|
||||||
|
expect(select).toBeRequired();
|
||||||
|
expect(container.querySelector('label')).toHaveClass('inline-flex');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders error and custom class names', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Dropdown
|
||||||
|
label="Role"
|
||||||
|
value="USER"
|
||||||
|
choices={choices}
|
||||||
|
error="Role is invalid"
|
||||||
|
className="custom-wrapper"
|
||||||
|
selectClassName="custom-select"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = container.querySelector('select');
|
||||||
|
expect(select).toBeInstanceOf(HTMLSelectElement);
|
||||||
|
expect(screen.getByText('Role is invalid')).toBeInTheDocument();
|
||||||
|
expect(select).toHaveClass('custom-select');
|
||||||
|
expect(screen.getByText('Role').closest('label')).toHaveClass('custom-wrapper');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports rendering without a label', () => {
|
||||||
|
const { container } = render(<Dropdown value="USER" choices={choices} />);
|
||||||
|
expect(container.querySelector('label > span')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
28
tests/components/Form.test.tsx
Normal file
28
tests/components/Form.test.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { Form } from '../../src/components/Form';
|
||||||
|
|
||||||
|
describe('Form', () => {
|
||||||
|
it('renders title, title actions and children', () => {
|
||||||
|
render(
|
||||||
|
<Form title="User Details" titleBarRight={<button type="button">Action</button>}>
|
||||||
|
<div>Form child</div>
|
||||||
|
</Form>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('User Details')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Form child')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports custom class names and optional title actions', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Form title="No Actions" className="form-custom">
|
||||||
|
<div>Child</div>
|
||||||
|
</Form>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.firstElementChild).toHaveClass('form-custom');
|
||||||
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
97
tests/components/InputField.test.tsx
Normal file
97
tests/components/InputField.test.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { createRef } from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { InputField } from '../../src/components/InputField';
|
||||||
|
|
||||||
|
describe('InputField', () => {
|
||||||
|
it('supports email type and emits change/blur callbacks', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const onBlur = vi.fn();
|
||||||
|
render(
|
||||||
|
<InputField
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value=""
|
||||||
|
onChange={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
required
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Email') as HTMLInputElement;
|
||||||
|
expect(input.type).toBe('email');
|
||||||
|
expect(input).toBeRequired();
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: 'new@example.com' } });
|
||||||
|
fireEvent.blur(input);
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onBlur).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes refs to input element', () => {
|
||||||
|
const inputRef = createRef<HTMLInputElement>();
|
||||||
|
render(<InputField label="Username" type="text" value="john" onChange={() => {}} inputRef={inputRef} />);
|
||||||
|
|
||||||
|
expect(inputRef.current).toBeInstanceOf(HTMLInputElement);
|
||||||
|
expect(inputRef.current?.value).toBe('john');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles password visibility', () => {
|
||||||
|
render(<InputField label="Password" type="password" value="abc" onChange={() => {}} />);
|
||||||
|
|
||||||
|
expect((screen.getByLabelText('Password') as HTMLInputElement).type).toBe('password');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Show password' }));
|
||||||
|
expect((screen.getByLabelText('Password') as HTMLInputElement).type).toBe('text');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Hide password' }));
|
||||||
|
expect((screen.getByLabelText('Password') as HTMLInputElement).type).toBe('password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders rightIcon for non-password input and displays errors', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<InputField
|
||||||
|
label="Username"
|
||||||
|
type="text"
|
||||||
|
value="john"
|
||||||
|
onChange={() => {}}
|
||||||
|
rightIcon={<span data-testid="right-icon">R</span>}
|
||||||
|
error="Invalid username"
|
||||||
|
inputClassName="custom-input"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = container.querySelector('input');
|
||||||
|
expect(input).toBeInstanceOf(HTMLInputElement);
|
||||||
|
expect(input).toHaveClass('pr-10');
|
||||||
|
expect(input).toHaveClass('custom-input');
|
||||||
|
expect(screen.getByTestId('right-icon')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Invalid username')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables password toggle when input is disabled', () => {
|
||||||
|
render(
|
||||||
|
<InputField
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value="secret"
|
||||||
|
onChange={() => {}}
|
||||||
|
disabled
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Show password' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports inline layout classes', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<InputField label="Username" type="text" value="" onChange={() => {}} layout="inline" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector('label')).toHaveClass('inline-flex');
|
||||||
|
expect(container.querySelector('label > div')).not.toHaveClass('mt-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports rendering without a label', () => {
|
||||||
|
const { container } = render(<InputField type="text" value="" onChange={() => {}} />);
|
||||||
|
expect(container.querySelector('label > span')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
25
tests/components/Label.test.tsx
Normal file
25
tests/components/Label.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { Label } from '../../src/components/Label';
|
||||||
|
|
||||||
|
describe('Label', () => {
|
||||||
|
it('uses default and variant-specific tags/classes', () => {
|
||||||
|
const { rerender } = render(<Label>Body</Label>);
|
||||||
|
expect(screen.getByText('Body').tagName).toBe('P');
|
||||||
|
expect(screen.getByText('Body')).toHaveClass('ui-body-primary');
|
||||||
|
|
||||||
|
rerender(<Label variant="h1">Title</Label>);
|
||||||
|
expect(screen.getByText('Title').tagName).toBe('H1');
|
||||||
|
expect(screen.getByText('Title')).toHaveClass('ui-title');
|
||||||
|
|
||||||
|
rerender(<Label variant="h4">Section</Label>);
|
||||||
|
expect(screen.getByText('Section').tagName).toBe('H3');
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Label variant="h2" as="span">
|
||||||
|
Custom
|
||||||
|
</Label>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Custom').tagName).toBe('SPAN');
|
||||||
|
});
|
||||||
|
});
|
||||||
82
tests/components/MDXEditorField.test.tsx
Normal file
82
tests/components/MDXEditorField.test.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { MDXEditorField } from '../../src/components/MDXEditorField';
|
||||||
|
|
||||||
|
describe('MDXEditorField', () => {
|
||||||
|
it('renders label and change handler in editable mode', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MDXEditorField
|
||||||
|
label="Content"
|
||||||
|
markdown=""
|
||||||
|
onChange={onChange}
|
||||||
|
themeClassName="light-theme"
|
||||||
|
editorClassName="extra-editor"
|
||||||
|
plugins={[]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Content')).toHaveClass('ui-label');
|
||||||
|
fireEvent.change(screen.getByLabelText('Markdown Editor'), {
|
||||||
|
target: { value: '# Hello' },
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenCalledWith('# Hello');
|
||||||
|
expect(screen.getByLabelText('Markdown Editor')).toHaveAttribute(
|
||||||
|
'data-class-name',
|
||||||
|
'light-theme extra-editor',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders preview and disabled classes when disabled', () => {
|
||||||
|
render(
|
||||||
|
<MDXEditorField
|
||||||
|
label="Content"
|
||||||
|
markdown="Disabled content"
|
||||||
|
disabled
|
||||||
|
themeClassName="dark-theme"
|
||||||
|
plugins={[]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = screen.getByText('Content');
|
||||||
|
expect(label).toHaveClass('ui-label-disabled');
|
||||||
|
expect(screen.getByTestId('md-preview')).toHaveTextContent('Disabled content');
|
||||||
|
expect(screen.getByTestId('md-preview')).toHaveAttribute('data-class-name', 'dark-theme');
|
||||||
|
expect(screen.queryByLabelText('Markdown Editor')).not.toBeInTheDocument();
|
||||||
|
expect(document.querySelector('.post-mdx-editor--disabled')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders read-only preview without label when label is omitted', () => {
|
||||||
|
render(
|
||||||
|
<MDXEditorField
|
||||||
|
markdown="Read only content"
|
||||||
|
readOnly
|
||||||
|
themeClassName="dark-theme"
|
||||||
|
plugins={[]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Content')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('md-preview')).toHaveTextContent('Read only content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports wrapper style/class overrides and error rendering', () => {
|
||||||
|
render(
|
||||||
|
<MDXEditorField
|
||||||
|
label="Content"
|
||||||
|
markdown=""
|
||||||
|
themeClassName="light-theme"
|
||||||
|
plugins={[]}
|
||||||
|
editorWrapperClassName="editor-wrapper"
|
||||||
|
editorWrapperStyle={{ borderWidth: '2px' }}
|
||||||
|
error="Content is required"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrapper = document.querySelector('.editor-wrapper');
|
||||||
|
expect(wrapper).toHaveClass('post-mdx-editor--enabled');
|
||||||
|
expect(wrapper).toHaveStyle({ borderWidth: '2px' });
|
||||||
|
expect(screen.getByText('Content is required')).toHaveClass('ui-error');
|
||||||
|
});
|
||||||
|
});
|
||||||
55
tests/components/SidebarNavItem.test.tsx
Normal file
55
tests/components/SidebarNavItem.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { UserCircleIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { fireEvent, screen } from '@testing-library/react';
|
||||||
|
import { Route, Routes } from 'react-router-dom';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { SidebarNavItem } from '../../src/components/SidebarNavItem';
|
||||||
|
import { renderWithRouter } from '../helpers/renderWithRouter';
|
||||||
|
|
||||||
|
describe('SidebarNavItem', () => {
|
||||||
|
it('renders active style and collapsed label behavior', () => {
|
||||||
|
renderWithRouter(
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/users"
|
||||||
|
element={<SidebarNavItem to="/users" label="Users" icon={UserCircleIcon} collapsed />}
|
||||||
|
/>
|
||||||
|
</Routes>,
|
||||||
|
{ route: '/users' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const link = screen.getByRole('link', { name: 'Users' });
|
||||||
|
expect(link.className).toContain('ui-accent-active');
|
||||||
|
expect(link.className).toContain('mx-auto');
|
||||||
|
expect(screen.getByText('Users').className).toContain('lg:hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders inactive style and triggers onClick', () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
renderWithRouter(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/users" element={<div>Users page</div>} />
|
||||||
|
<Route
|
||||||
|
path="/profile"
|
||||||
|
element={
|
||||||
|
<SidebarNavItem
|
||||||
|
to="/users"
|
||||||
|
label="Users"
|
||||||
|
icon={UserCircleIcon}
|
||||||
|
collapsed={false}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>,
|
||||||
|
{ route: '/profile' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const link = screen.getByRole('link', { name: 'Users' });
|
||||||
|
expect(link.className).toContain('hover:bg-zinc-500/15');
|
||||||
|
expect(link.className).toContain('lg:w-full');
|
||||||
|
expect(screen.getByText('Users').className).toContain('truncate');
|
||||||
|
|
||||||
|
fireEvent.click(link);
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
199
tests/components/Table.test.tsx
Normal file
199
tests/components/Table.test.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Table, type TableHeader } from '../../src/components/Table';
|
||||||
|
import type { SortState } from '../../src/types/sort';
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers: TableHeader<Row>[] = [
|
||||||
|
{ label: 'Name', id: 'name', value: (row) => row.name, headerClassName: 'head-name' },
|
||||||
|
{ label: 'Email', id: 'email', value: (row) => row.email, cellClassName: 'cell-email' },
|
||||||
|
{ label: 'Static', id: 'static', value: 'Always', sortable: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const data: Row[] = [{ id: '1', name: 'Jane', email: 'jane@example.com' }];
|
||||||
|
|
||||||
|
describe('Table', () => {
|
||||||
|
it('renders loading state', () => {
|
||||||
|
render(<Table headers={headers} data={[]} isLoading rowKey={(row) => row.id} />);
|
||||||
|
|
||||||
|
expect(document.querySelector('.animate-spin')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state message when no rows are available', () => {
|
||||||
|
render(
|
||||||
|
<Table
|
||||||
|
headers={headers}
|
||||||
|
data={[]}
|
||||||
|
emptyMessage="Nothing here"
|
||||||
|
rowKey={(row) => row.id}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Nothing here')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders row values from function and static header values', () => {
|
||||||
|
const rowKey = vi.fn((row: Row) => row.id);
|
||||||
|
render(<Table headers={headers} data={data} rowKey={rowKey} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Jane')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('jane@example.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Always')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Name').closest('th')).toHaveClass('head-name');
|
||||||
|
expect(screen.getByText('jane@example.com').closest('td')).toHaveClass('cell-email');
|
||||||
|
expect(rowKey).toHaveBeenCalledWith(data[0], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows sortable buttons only when sort config is complete', () => {
|
||||||
|
const onSortChange = vi.fn();
|
||||||
|
const sortableHeaders: TableHeader<Row>[] = [
|
||||||
|
{ label: 'No field', id: 'a', sortable: true, value: (row) => row.name },
|
||||||
|
{ label: 'Empty field', id: 'b', sortable: true, sortField: '', value: (row) => row.name },
|
||||||
|
{ label: 'Name', id: 'c', sortable: true, sortField: 'name', value: (row) => row.name },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<Table headers={sortableHeaders} data={data} rowKey={(row) => row.id} onSortChange={onSortChange} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: 'Sort by No field' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Sort by Empty field' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Sort by Name' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<Table headers={sortableHeaders} data={data} rowKey={(row) => row.id} />);
|
||||||
|
expect(screen.queryByRole('button', { name: 'Sort by Name' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders sort states and notifies callback on header click', () => {
|
||||||
|
const onSortChange = vi.fn();
|
||||||
|
const sortableHeaders: TableHeader<Row>[] = [
|
||||||
|
{ label: 'Name', id: 'name', sortable: true, sortField: 'name', value: (row) => row.name },
|
||||||
|
{ label: 'Email', id: 'email', sortable: true, sortField: 'email', value: (row) => row.email },
|
||||||
|
];
|
||||||
|
const sorting: SortState = { field: 'name', direction: 'asc' };
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<Table
|
||||||
|
headers={sortableHeaders}
|
||||||
|
data={data}
|
||||||
|
rowKey={(row) => row.id}
|
||||||
|
sorting={sorting}
|
||||||
|
onSortChange={onSortChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameSort = screen.getByRole('button', { name: 'Sort by Name' });
|
||||||
|
const emailSort = screen.getByRole('button', { name: 'Sort by Email' });
|
||||||
|
expect(nameSort.querySelector('[data-sort-state]')).toHaveAttribute('data-sort-state', 'asc');
|
||||||
|
expect(emailSort.querySelector('[data-sort-state]')).toHaveAttribute('data-sort-state', 'none');
|
||||||
|
|
||||||
|
fireEvent.click(nameSort);
|
||||||
|
expect(onSortChange).toHaveBeenCalledWith('name');
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Table
|
||||||
|
headers={sortableHeaders}
|
||||||
|
data={data}
|
||||||
|
rowKey={(row) => row.id}
|
||||||
|
sorting={{ field: 'name', direction: 'desc' }}
|
||||||
|
onSortChange={onSortChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(nameSort.querySelector('[data-sort-state]')).toHaveAttribute('data-sort-state', 'desc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports pagination controls and page-size changes', () => {
|
||||||
|
const onPageChange = vi.fn();
|
||||||
|
const onPageSizeChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Table
|
||||||
|
headers={headers}
|
||||||
|
data={data}
|
||||||
|
rowKey={(row) => row.id}
|
||||||
|
pagination={{
|
||||||
|
page: 2,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 21,
|
||||||
|
totalPages: 3,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Previous page' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Next page' }));
|
||||||
|
fireEvent.change(screen.getByLabelText('Rows'), { target: { value: '20' } });
|
||||||
|
|
||||||
|
expect(onPageChange).toHaveBeenNthCalledWith(1, 1);
|
||||||
|
expect(onPageChange).toHaveBeenNthCalledWith(2, 3);
|
||||||
|
expect(onPageSizeChange).toHaveBeenCalledWith(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides rows selector when onPageSizeChange is absent and clamps page count display', () => {
|
||||||
|
render(
|
||||||
|
<Table
|
||||||
|
headers={headers}
|
||||||
|
data={data}
|
||||||
|
rowKey={(row) => row.id}
|
||||||
|
pagination={{
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 1,
|
||||||
|
totalPages: 0,
|
||||||
|
onPageChange: vi.fn(),
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText('Rows')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Page 1 of 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables prev/next at bounds or while loading', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<Table
|
||||||
|
headers={headers}
|
||||||
|
data={data}
|
||||||
|
rowKey={(row) => row.id}
|
||||||
|
pagination={{
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 10,
|
||||||
|
totalPages: 1,
|
||||||
|
onPageChange: vi.fn(),
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled();
|
||||||
|
expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Table
|
||||||
|
headers={headers}
|
||||||
|
data={data}
|
||||||
|
rowKey={(row) => row.id}
|
||||||
|
isLoading
|
||||||
|
pagination={{
|
||||||
|
page: 2,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 100,
|
||||||
|
totalPages: 10,
|
||||||
|
onPageChange: vi.fn(),
|
||||||
|
onPageSizeChange: vi.fn(),
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled();
|
||||||
|
expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
|
||||||
|
expect(screen.getByLabelText('Rows')).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
12
tests/helpers/renderWithRouter.tsx
Normal file
12
tests/helpers/renderWithRouter.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
|
||||||
|
type RenderWithRouterOptions = {
|
||||||
|
route?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function renderWithRouter(ui: ReactElement, options: RenderWithRouterOptions = {}) {
|
||||||
|
const { route = '/' } = options;
|
||||||
|
return render(<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>);
|
||||||
|
}
|
||||||
16
tests/index.test.ts
Normal file
16
tests/index.test.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as webUi from '../src/index';
|
||||||
|
|
||||||
|
describe('index exports', () => {
|
||||||
|
it('exposes runtime component exports', () => {
|
||||||
|
expect(typeof webUi.Button).toBe('function');
|
||||||
|
expect(typeof webUi.Chip).toBe('function');
|
||||||
|
expect(typeof webUi.DatePicker).toBe('function');
|
||||||
|
expect(typeof webUi.Dropdown).toBe('function');
|
||||||
|
expect(typeof webUi.Form).toBe('function');
|
||||||
|
expect(typeof webUi.InputField).toBe('function');
|
||||||
|
expect(typeof webUi.Label).toBe('function');
|
||||||
|
expect(typeof webUi.SidebarNavItem).toBe('function');
|
||||||
|
expect(typeof webUi.Table).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
74
tests/mocks/mdxeditor.tsx
Normal file
74
tests/mocks/mdxeditor.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { forwardRef, useImperativeHandle, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type MDXEditorMethods = {
|
||||||
|
setMarkdown: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MDXEditorProps = {
|
||||||
|
markdown: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
className?: string;
|
||||||
|
contentEditableClassName?: string;
|
||||||
|
plugins?: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MDXEditor = forwardRef<MDXEditorMethods, MDXEditorProps>(function MDXEditor(
|
||||||
|
{ markdown, onChange, readOnly, className },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
setMarkdown: () => undefined,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (readOnly) {
|
||||||
|
return (
|
||||||
|
<div data-testid="md-preview" data-class-name={className}>
|
||||||
|
{markdown}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
aria-label="Markdown Editor"
|
||||||
|
value={markdown}
|
||||||
|
data-class-name={className}
|
||||||
|
onChange={(event) => onChange?.(event.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function Control(): ReactNode {
|
||||||
|
return <span />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plugin(): Record<string, never> {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BlockTypeSelect = Control;
|
||||||
|
export const BoldItalicUnderlineToggles = Control;
|
||||||
|
export const CodeToggle = Control;
|
||||||
|
export const CreateLink = Control;
|
||||||
|
export const InsertCodeBlock = Control;
|
||||||
|
export const InsertTable = Control;
|
||||||
|
export const ListsToggle = Control;
|
||||||
|
export const Separator = Control;
|
||||||
|
export const UndoRedo = Control;
|
||||||
|
|
||||||
|
export const codeBlockPlugin = plugin;
|
||||||
|
export const codeMirrorPlugin = plugin;
|
||||||
|
export const headingsPlugin = plugin;
|
||||||
|
export const linkDialogPlugin = plugin;
|
||||||
|
export const linkPlugin = plugin;
|
||||||
|
export const listsPlugin = plugin;
|
||||||
|
export const markdownShortcutPlugin = plugin;
|
||||||
|
export const quotePlugin = plugin;
|
||||||
|
export const tablePlugin = plugin;
|
||||||
|
export const thematicBreakPlugin = plugin;
|
||||||
|
export const toolbarPlugin = plugin;
|
||||||
18
tests/setup.ts
Normal file
18
tests/setup.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Required by React to silence act(...) warnings in jsdom tests.
|
||||||
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
import { afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@mdxeditor/editor', () => import('./mocks/mdxeditor'));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
const storage = globalThis.window?.localStorage ?? globalThis.localStorage;
|
||||||
|
if (typeof storage?.clear === 'function') {
|
||||||
|
storage.clear();
|
||||||
|
}
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"emitDeclarationOnly": true,
|
"emitDeclarationOnly": true,
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"declarationMap": true
|
"declarationMap": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"exclude": ["src/**/*.stories.ts", "src/**/*.stories.tsx"]
|
"exclude": ["src/**/*.stories.ts", "src/**/*.stories.tsx"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"allowImportingTsExtensions": false,
|
"allowImportingTsExtensions": false,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["react", "react-dom"]
|
"types": ["react", "react-dom"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,51 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vitest/config';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
build: {
|
build: {
|
||||||
lib: {
|
lib: {
|
||||||
entry: {
|
entry: {
|
||||||
index: resolve(__dirname, 'src/index.ts'),
|
index: resolve(__dirname, 'src/index.ts'),
|
||||||
'components/MDXEditorField': resolve(__dirname, 'src/components/MDXEditorField.tsx')
|
'components/MDXEditorField': resolve(
|
||||||
},
|
__dirname,
|
||||||
name: 'PanicWebUi',
|
'src/components/MDXEditorField.tsx',
|
||||||
formats: ['es'],
|
),
|
||||||
fileName: (_format, entryName) => `${entryName}.js`
|
},
|
||||||
|
name: 'PanicWebUi',
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: (_format, entryName) => `${entryName}.js`,
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: [
|
||||||
|
'react',
|
||||||
|
'react-dom',
|
||||||
|
'react-router-dom',
|
||||||
|
'@heroicons/react',
|
||||||
|
'@mdxeditor/editor',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./tests/setup.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
include: ['src/**/*.{ts,tsx}'],
|
||||||
|
exclude: [
|
||||||
|
'src/**/*.story.{ts,tsx}',
|
||||||
|
'src/**/*.stories.{ts,tsx}',
|
||||||
|
'src/index.ts',
|
||||||
|
'src/styles/**',
|
||||||
|
'src/components/types.ts',
|
||||||
|
'src/types/**',
|
||||||
|
],
|
||||||
|
thresholds: {
|
||||||
|
lines: 80,
|
||||||
|
functions: 75,
|
||||||
|
branches: 70,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
|
||||||
external: [
|
|
||||||
'react',
|
|
||||||
'react-dom',
|
|
||||||
'react-router-dom',
|
|
||||||
'@heroicons/react',
|
|
||||||
'@mdxeditor/editor'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user