[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, QuestionMarkCircleIcon,
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import type { ComponentMeta } from '@storybook/react'; import type { ComponentMeta } from '@storybook/react';
import { TextInput } from '@tih/ui'; import { Select, TextInput } from '@tih/ui';
export default { export default {
argTypes: { argTypes: {
@ -70,7 +70,8 @@ export function Email() {
<TextInput <TextInput
label="Email" label="Email"
placeholder="john.doe@email.com" placeholder="john.doe@email.com"
startIcon={EnvelopeIcon} startAddOn={EnvelopeIcon}
startAddOnType="icon"
type="email" type="email"
value={value} value={value}
onChange={setValue} onChange={setValue}
@ -94,7 +95,8 @@ export function Icon() {
<TextInput <TextInput
label="Account number" label="Account number"
placeholder="000-00-0000" placeholder="000-00-0000"
startIcon={QuestionMarkCircleIcon} startAddOn={QuestionMarkCircleIcon}
startAddOnType="icon"
type="text" type="text"
value={value} value={value}
onChange={setValue} onChange={setValue}
@ -105,12 +107,44 @@ export function Icon() {
export function Disabled() { export function Disabled() {
return ( return (
<TextInput <div className="space-y-4">
disabled={true} <TextInput
label="Disabled input" disabled={true}
placeholder="John Doe" label="Disabled input"
type="text" 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 value.length < 6 ? 'Password must be at least 6 characters' : undefined
} }
label="Email" label="Email"
startIcon={KeyIcon} startAddOn={KeyIcon}
startAddOnType="icon"
type="password" type="password"
value={value} value={value}
onChange={setValue} 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 SelectDisplay = 'block' | 'inline';
export type SelectBorderStyle = 'bordered' | 'borderless';
type Props<T> = Readonly<{ type Props<T> = Readonly<{
borderStyle?: SelectBorderStyle;
defaultValue?: T; defaultValue?: T;
display?: SelectDisplay; display?: SelectDisplay;
isLabelHidden?: boolean; isLabelHidden?: boolean;
@ -27,8 +29,14 @@ type Props<T> = Readonly<{
}> & }> &
Readonly<Attributes>; Readonly<Attributes>;
const borderClasses: Record<SelectBorderStyle, string> = {
bordered: 'border-slate-300',
borderless: 'border-transparent bg-transparent',
};
function Select<T>( function Select<T>(
{ {
borderStyle = 'bordered',
defaultValue, defaultValue,
display, display,
disabled, disabled,
@ -45,20 +53,20 @@ function Select<T>(
return ( return (
<div> <div>
<label {!isLabelHidden && (
className={clsx( <label
'mb-1 block text-sm font-medium text-slate-700', className={clsx('mb-1 block text-sm font-medium text-slate-700')}
isLabelHidden && 'sr-only', htmlFor={id ?? undefined}>
)} {label}
htmlFor={id ?? undefined}> </label>
{label} )}
</label>
<select <select
ref={ref} ref={ref}
aria-label={isLabelHidden ? label : undefined} aria-label={isLabelHidden ? label : undefined}
className={clsx( className={clsx(
display === 'block' && 'block w-full', 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', disabled && 'bg-slate-100',
)} )}
defaultValue={defaultValue != null ? String(defaultValue) : undefined} defaultValue={defaultValue != null ? String(defaultValue) : undefined}

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

Loading…
Cancel
Save