[ui][text input] support element add ons

pull/314/head
Yangshun Tay 2 years ago
parent 0062199bd6
commit 2906dbdc75

@ -5,7 +5,7 @@ import {
QuestionMarkCircleIcon,
} from '@heroicons/react/24/solid';
import type { ComponentMeta } from '@storybook/react';
import { TextInput } from '@tih/ui';
import { Select, TextInput } from '@tih/ui';
export default {
argTypes: {
@ -70,7 +70,8 @@ export function Email() {
<TextInput
label="Email"
placeholder="john.doe@email.com"
startIcon={EnvelopeIcon}
startAddOn={EnvelopeIcon}
startAddOnType="icon"
type="email"
value={value}
onChange={setValue}
@ -94,7 +95,8 @@ export function Icon() {
<TextInput
label="Account number"
placeholder="000-00-0000"
startIcon={QuestionMarkCircleIcon}
startAddOn={QuestionMarkCircleIcon}
startAddOnType="icon"
type="text"
value={value}
onChange={setValue}
@ -105,12 +107,44 @@ export function Icon() {
export function Disabled() {
return (
<TextInput
disabled={true}
label="Disabled input"
placeholder="John Doe"
type="text"
/>
<div className="space-y-4">
<TextInput
disabled={true}
label="Disabled input"
placeholder="John Doe"
type="text"
/>
<TextInput
disabled={true}
endAddOn={
<Select
borderStyle="borderless"
isLabelHidden={true}
label="Currency"
options={[
{
label: 'USD',
value: 'USD',
},
{
label: 'SGD',
value: 'SGD',
},
{
label: 'EUR',
value: 'EUR',
},
]}
/>
}
endAddOnType="element"
label="Price"
placeholder="0.00"
startAddOn="$"
startAddOnType="label"
type="text"
/>
</div>
);
}
@ -134,10 +168,97 @@ export function Error() {
value.length < 6 ? 'Password must be at least 6 characters' : undefined
}
label="Email"
startIcon={KeyIcon}
startAddOn={KeyIcon}
startAddOnType="icon"
type="password"
value={value}
onChange={setValue}
/>
);
}
export function AddOns() {
return (
<div className="space-y-4">
<TextInput
label="Price"
placeholder="0.00"
startAddOn="$"
startAddOnType="label"
type="text"
/>
<TextInput
endAddOn="USD"
endAddOnType="label"
label="Price"
placeholder="0.00"
type="text"
/>
<TextInput
endAddOn="USD"
endAddOnType="label"
label="Price"
placeholder="0.00"
startAddOn="$"
startAddOnType="label"
type="text"
/>
<TextInput
label="Phone Number"
placeholder="+1 (123) 456-7890"
startAddOn={
<Select
borderStyle="borderless"
isLabelHidden={true}
label="country"
options={[
{
label: 'US',
value: 'US',
},
{
label: 'SG',
value: 'SG',
},
{
label: 'JP',
value: 'JP',
},
]}
/>
}
startAddOnType="element"
type="text"
/>
<TextInput
endAddOn={
<Select
borderStyle="borderless"
isLabelHidden={true}
label="Currency"
options={[
{
label: 'USD',
value: 'USD',
},
{
label: 'SGD',
value: 'SGD',
},
{
label: 'EUR',
value: 'EUR',
},
]}
/>
}
endAddOnType="element"
label="Price"
placeholder="0.00"
startAddOn="$"
startAddOnType="label"
type="text"
/>
</div>
);
}

@ -14,8 +14,10 @@ export type SelectItem<T> = Readonly<{
}>;
export type SelectDisplay = 'block' | 'inline';
export type SelectBorderStyle = 'bordered' | 'borderless';
type Props<T> = Readonly<{
borderStyle?: SelectBorderStyle;
defaultValue?: T;
display?: SelectDisplay;
isLabelHidden?: boolean;
@ -27,8 +29,14 @@ type Props<T> = Readonly<{
}> &
Readonly<Attributes>;
const borderClasses: Record<SelectBorderStyle, string> = {
bordered: 'border-slate-300',
borderless: 'border-transparent bg-transparent',
};
function Select<T>(
{
borderStyle = 'bordered',
defaultValue,
display,
disabled,
@ -45,20 +53,20 @@ function Select<T>(
return (
<div>
<label
className={clsx(
'mb-1 block text-sm font-medium text-slate-700',
isLabelHidden && 'sr-only',
)}
htmlFor={id ?? undefined}>
{label}
</label>
{!isLabelHidden && (
<label
className={clsx('mb-1 block text-sm font-medium text-slate-700')}
htmlFor={id ?? undefined}>
{label}
</label>
)}
<select
ref={ref}
aria-label={isLabelHidden ? label : undefined}
className={clsx(
display === 'block' && 'block w-full',
'focus:border-primary-500 focus:ring-primary-500 rounded-md border-slate-300 py-2 pl-3 pr-10 text-base focus:outline-none sm:text-sm',
'focus:border-primary-500 focus:ring-primary-500 rounded-md py-2 pl-3 pr-8 text-base focus:outline-none sm:text-sm',
borderClasses[borderStyle],
disabled && 'bg-slate-100',
)}
defaultValue={defaultValue != null ? String(defaultValue) : undefined}

@ -24,7 +24,43 @@ type Attributes = Pick<
| 'type'
>;
type Props = Readonly<{
type StartAddOnProps =
| Readonly<{
startAddOn: React.ComponentType<React.ComponentProps<'svg'>>;
startAddOnType: 'icon';
}>
| Readonly<{
startAddOn: React.ReactNode;
startAddOnType: 'element';
}>
| Readonly<{
startAddOn: string;
startAddOnType: 'label';
}>
| Readonly<{
startAddOn?: undefined;
startAddOnType?: undefined;
}>;
type EndAddOnProps =
| Readonly<{
endAddOn: React.ComponentType<React.ComponentProps<'svg'>>;
endAddOnType: 'icon';
}>
| Readonly<{
endAddOn: React.ReactNode;
endAddOnType: 'element';
}>
| Readonly<{
endAddOn: string;
endAddOnType: 'label';
}>
| Readonly<{
endAddOn?: undefined;
endAddOnType?: undefined;
}>;
type BaseProps = Readonly<{
defaultValue?: string;
endIcon?: React.ComponentType<React.ComponentProps<'svg'>>;
errorMessage?: React.ReactNode;
@ -33,31 +69,46 @@ type Props = Readonly<{
label: string;
onBlur?: (event: FocusEvent<HTMLInputElement>) => void;
onChange?: (value: string, event: ChangeEvent<HTMLInputElement>) => void;
startIcon?: React.ComponentType<React.ComponentProps<'svg'>>;
value?: string;
}> &
Readonly<Attributes>;
type Props = BaseProps & EndAddOnProps & StartAddOnProps;
type State = 'error' | 'normal';
const stateClasses: Record<State, string> = {
error:
'border-danger-300 text-danger-900 placeholder-danger-300 focus:outline-none focus:ring-danger-500 focus:border-danger-500',
normal:
'placeholder:text-slate-400 focus:ring-primary-500 focus:border-primary-500 border-slate-300',
const stateClasses: Record<
State,
Readonly<{
container: string;
input: string;
}>
> = {
error: {
container:
'border-danger-300 focus-within:outline-none focus-within:ring-danger-500 focus-within:border-danger-500',
input: 'text-danger-900 placeholder-danger-300',
},
normal: {
container:
'focus-within:ring-primary-500 focus-within:border-primary-500 border-slate-300',
input: 'placeholder:text-slate-400',
},
};
function TextInput(
{
defaultValue,
disabled,
endIcon: EndIcon,
endAddOn,
endAddOnType,
errorMessage,
id: idParam,
isLabelHidden = false,
label,
required,
startIcon: StartIcon,
startAddOn,
startAddOnType,
type = 'text',
value,
onChange,
@ -70,6 +121,7 @@ function TextInput(
const id = idParam ?? generatedId;
const errorId = useId();
const state: State = hasError ? 'error' : 'normal';
const { input: inputClass, container: containerClass } = stateClasses[state];
return (
<div>
@ -81,24 +133,55 @@ function TextInput(
)}
htmlFor={id}>
{label}
{required && <span className="text-danger-500 not-sr-only"> *</span>}
</label>
<div className="relative mt-1">
{StartIcon && (
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<StartIcon aria-hidden="true" className="h-5 w-5 text-slate-400" />
</div>
{required && (
<span aria-hidden="true" className="text-danger-500">
{' '}
*
</span>
)}
</label>
<div
className={clsx(
'flex w-full overflow-hidden rounded-md border focus-within:ring-1 sm:text-sm',
!isLabelHidden && 'mt-1',
disabled && 'pointer-events-none select-none bg-slate-100',
containerClass,
)}>
{(() => {
if (startAddOnType == null) {
return;
}
switch (startAddOnType) {
case 'label':
return (
<div className="pointer-events-none flex items-center pl-3 text-slate-500">
{startAddOn}
</div>
);
case 'icon': {
const StartAddOn = startAddOn;
return (
<div className="pointer-events-none flex items-center pl-3">
<StartAddOn
aria-hidden="true"
className="h-5 w-5 text-slate-400"
/>
</div>
);
}
case 'element':
return startAddOn;
}
})()}
<input
ref={ref}
aria-describedby={hasError ? errorId : undefined}
aria-invalid={hasError ? true : undefined}
className={clsx(
'block w-full rounded-md sm:text-sm',
StartIcon && 'pl-10',
EndIcon && 'pr-10',
stateClasses[state],
disabled && 'bg-slate-100',
'flex-1 border-none focus:outline-none focus:ring-0 sm:text-sm',
inputClass,
disabled && 'bg-transparent',
)}
defaultValue={defaultValue}
disabled={disabled}
@ -115,11 +198,33 @@ function TextInput(
}}
{...props}
/>
{EndIcon && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<EndIcon aria-hidden="true" className="h-5 w-5 text-slate-400" />
</div>
)}
{(() => {
if (endAddOnType == null) {
return;
}
switch (endAddOnType) {
case 'label':
return (
<div className="pointer-events-none flex items-center pr-3 text-slate-500">
{endAddOn}
</div>
);
case 'icon': {
const EndAddOn = endAddOn;
return (
<div className="pointer-events-none flex items-center pr-3">
<EndAddOn
aria-hidden="true"
className="h-5 w-5 text-slate-400"
/>
</div>
);
}
case 'element':
return endAddOn;
}
})()}
</div>
{errorMessage && (
<p className="text-danger-600 mt-2 text-sm" id={errorId}>

Loading…
Cancel
Save