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,
|
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}>
|
||||||
|
Reference in New Issue
Block a user