Compare commits

..

12 Commits

Author SHA1 Message Date
d1266f24a3 Update dependency tailwindcss to v4
Some checks failed
continuous-integration/drone/pr Build is failing
2026-02-23 22:16:57 +00:00
5cc3e3646c make accent overridable, v0.1.11
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 22:59:57 +01:00
29a4e8c2ee add width, v0.1.10
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 19:57:19 +01:00
3ddd108186 add datepicker, v0.1.9
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 19:21:38 +01:00
836d24943e update eslint
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 15:18:13 +01:00
a312141c21 v0.1.8
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 14:50:55 +01:00
457962ede2 update css
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 14:50:12 +01:00
f1c7e245aa update prettier
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 14:23:37 +01:00
c2e370f0a8 add eslint / prettier
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 14:19:16 +01:00
01b00b5717 update package.json
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 14:01:34 +01:00
f9a9c89e4f v0.1.7
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 13:57:36 +01:00
6ba98fa6b6 fix build
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 13:56:52 +01:00
42 changed files with 3245 additions and 2422 deletions

View File

@@ -19,6 +19,13 @@ steps:
- corepack prepare yarn@1.22.22 --activate
- yarn install --frozen-lockfile
- name: lint
image: node:22
commands:
- corepack enable
- corepack prepare yarn@1.22.22 --activate
- yarn lint
- name: build
image: node:22
commands:

5
.prettierignore Normal file
View File

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

7
.prettierrc Normal file
View File

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

View File

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

View File

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

48
eslint.config.mjs Normal file
View File

@@ -0,0 +1,48 @@
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 }],
},
},
);

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@panic/web-ui",
"version": "0.1.6",
"version": "0.1.11",
"license": "AGPL-3.0-only",
"description": "Core components for panic.haus web applications",
"type": "module",
@@ -27,6 +27,10 @@
"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",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier . --write",
"format:check": "prettier . --check",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"prepublishOnly": "yarn build",
@@ -49,26 +53,33 @@
}
},
"devDependencies": {
"@codemirror/language": "^6.11.3",
"@eslint/js": "^10",
"@heroicons/react": "^2.2.0",
"@lezer/highlight": "^1.2.1",
"@mdxeditor/editor": "^3.52.4",
"@storybook/addon-a11y": "^10.2.10",
"@storybook/addon-docs": "^10.2.10",
"@storybook/addon-themes": "^10.2.10",
"@storybook/react": "^10.2.10",
"@storybook/react-vite": "^10.2.10",
"@codemirror/language": "^6.11.3",
"@heroicons/react": "^2.2.0",
"@lezer/highlight": "^1.2.1",
"@mdxeditor/editor": "^3.52.4",
"@testing-library/dom": "^10.4.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^10",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.1",
"globals": "^17.3.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",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.2",
"typescript-eslint": "^8.56.0",
"vite": "^7.0.0",
"yjs": "^13.6.24"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,11 +8,14 @@ export default defineConfig({
lib: {
entry: {
index: resolve(__dirname, 'src/index.ts'),
'components/MDXEditorField': resolve(__dirname, 'src/components/MDXEditorField.tsx')
'components/MDXEditorField': resolve(
__dirname,
'src/components/MDXEditorField.tsx',
),
},
name: 'PanicWebUi',
formats: ['es'],
fileName: (_format, entryName) => `${entryName}.js`
fileName: (_format, entryName) => `${entryName}.js`,
},
rollupOptions: {
external: [
@@ -20,8 +23,8 @@ export default defineConfig({
'react-dom',
'react-router-dom',
'@heroicons/react',
'@mdxeditor/editor'
]
}
}
'@mdxeditor/editor',
],
},
},
});

922
yarn.lock

File diff suppressed because it is too large Load Diff