mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-14 18:05:55 +08:00
[ui][text input] support element add ons
This commit is contained in:
@ -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 (
|
||||
<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>
|
||||
{!isLabelHidden && (
|
||||
<label
|
||||
className={clsx(
|
||||
'mb-1 block text-sm font-medium text-slate-700',
|
||||
isLabelHidden && 'sr-only',
|
||||
)}
|
||||
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" />
|
||||
{(() => {
|
||||
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}>
|
||||
|
Reference in New Issue
Block a user