add datepicker, v0.1.9
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing

This commit is contained in:
2026-02-23 19:21:38 +01:00
parent 836d24943e
commit 3ddd108186
4 changed files with 330 additions and 1 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@panic/web-ui", "name": "@panic/web-ui",
"version": "0.1.8", "version": "0.1.9",
"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",

View File

@@ -0,0 +1,232 @@
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'" } },
},
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',
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"
onChange={(event) => setValue(event.target.value)}
/>
</div>
);
},
};

View File

@@ -0,0 +1,95 @@
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;
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',
layout = 'stacked',
value,
name,
onChange,
onBlur,
inputRef,
disabled = false,
required = false,
error,
rightIcon,
className = '',
inputClassName = '',
}: Readonly<DatePickerProps>) {
const containerSizeClass = {
sm: 'max-w-xs',
md: 'max-w-sm',
lg: 'max-w-md',
full: 'max-w-none',
}[size];
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'} ${containerSizeClass} ${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

@@ -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';