add datepicker, v0.1.9
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
232
src/components/DatePicker.stories.tsx
Normal file
232
src/components/DatePicker.stories.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
95
src/components/DatePicker.tsx
Normal file
95
src/components/DatePicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user