Skip to content

Commit 485731c

Browse files
authored
feat(ui): enhance Checkbox, Select and Input components with error ha… (#344)
* feat(ui): enhance Checkbox, Select and Input components with error handling and details support
1 parent 8a32384 commit 485731c

File tree

7 files changed

+418
-51
lines changed

7 files changed

+418
-51
lines changed

.changeset/gold-baths-shave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@o2s/ui': minor
3+
---
4+
5+
enhance Checkbox, Select and Input components with error support

packages/ui/src/elements/checkbox.stories.tsx

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Meta, StoryObj } from '@storybook/nextjs';
22
import { useEffect, useState } from 'react';
33

4-
import { Checkbox, CheckboxWithLabel } from './checkbox';
4+
import { Checkbox, CheckboxWithDetails, CheckboxWithLabel } from './checkbox';
55

66
const meta = {
77
title: 'Elements/Checkbox',
@@ -58,3 +58,73 @@ export const WithLabel: Story = {
5858
);
5959
},
6060
};
61+
62+
export const WithError: Story = {
63+
args: {
64+
label: 'Accept terms and conditions',
65+
checked: false,
66+
disabled: false,
67+
hasError: true,
68+
},
69+
render: ({ label, checked, disabled, hasError, ...props }) => {
70+
// eslint-disable-next-line react-hooks/rules-of-hooks
71+
const [isChecked, setIsChecked] = useState(checked);
72+
73+
// eslint-disable-next-line react-hooks/rules-of-hooks
74+
useEffect(() => {
75+
setIsChecked(checked);
76+
}, [checked]);
77+
78+
return (
79+
<CheckboxWithLabel
80+
label={label}
81+
checked={isChecked}
82+
onCheckedChange={setIsChecked}
83+
disabled={disabled}
84+
hasError={hasError}
85+
{...props}
86+
/>
87+
);
88+
},
89+
};
90+
91+
export const WithDetailsDescription: StoryObj<typeof CheckboxWithDetails> = {
92+
args: {
93+
label: 'Accept terms and conditions',
94+
checked: false,
95+
disabled: false,
96+
description: 'You must accept the terms and conditions to continue.',
97+
},
98+
render: ({ checked, ...props }) => {
99+
// eslint-disable-next-line react-hooks/rules-of-hooks
100+
const [isChecked, setIsChecked] = useState(checked);
101+
102+
// eslint-disable-next-line react-hooks/rules-of-hooks
103+
useEffect(() => {
104+
setIsChecked(checked);
105+
}, [checked]);
106+
107+
return <CheckboxWithDetails checked={isChecked} onCheckedChange={setIsChecked} {...props} />;
108+
},
109+
};
110+
111+
export const WithDetailsError: StoryObj<typeof CheckboxWithDetails> = {
112+
args: {
113+
label: 'Accept terms and conditions',
114+
checked: false,
115+
disabled: false,
116+
hasError: true,
117+
errorMessage: 'You must accept the terms and conditions.',
118+
},
119+
render: ({ checked, ...props }) => {
120+
// eslint-disable-next-line react-hooks/rules-of-hooks
121+
const [isChecked, setIsChecked] = useState(checked);
122+
123+
// eslint-disable-next-line react-hooks/rules-of-hooks
124+
useEffect(() => {
125+
setIsChecked(checked);
126+
}, [checked]);
127+
128+
return <CheckboxWithDetails checked={isChecked} onCheckedChange={setIsChecked} {...props} />;
129+
},
130+
};

packages/ui/src/elements/checkbox.tsx

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ import { Label } from '@o2s/ui/elements/label';
88

99
type CheckboxProps = React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
1010
ref?: React.Ref<React.ComponentRef<typeof CheckboxPrimitive.Root>>;
11+
hasError?: boolean;
1112
};
12-
const Checkbox = ({ className, ref, ...props }: CheckboxProps) => (
13+
const Checkbox = ({ className, ref, hasError, ...props }: CheckboxProps) => (
1314
<CheckboxPrimitive.Root
1415
ref={ref}
1516
className={cn(
1617
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
18+
hasError &&
19+
'border-destructive data-[state=checked]:bg-destructive data-[state=checked]:border-destructive',
1720
className,
1821
)}
1922
{...props}
@@ -24,26 +27,73 @@ const Checkbox = ({ className, ref, ...props }: CheckboxProps) => (
2427
</CheckboxPrimitive.Root>
2528
);
2629

27-
export interface CheckboxWithLabelProps extends React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> {
30+
export interface CheckboxWithLabelProps extends Readonly<CheckboxProps> {
2831
label: string | React.ReactNode;
2932
labelClassName?: string;
33+
children?: React.ReactNode;
34+
isRequired?: boolean;
35+
requiredLabel?: string;
36+
optionalLabel?: string;
3037
}
3138

3239
type CheckboxWithLabelOwnProps = CheckboxWithLabelProps & {
3340
ref?: React.Ref<React.ComponentRef<typeof CheckboxPrimitive.Root>>;
41+
hasError?: boolean;
3442
};
35-
const CheckboxWithLabel = ({ className, label, labelClassName, id, ref, ...props }: CheckboxWithLabelOwnProps) => {
43+
const CheckboxWithLabel = ({
44+
className,
45+
label,
46+
labelClassName,
47+
id,
48+
children,
49+
hasError,
50+
isRequired,
51+
requiredLabel = '',
52+
optionalLabel = '',
53+
ref,
54+
...props
55+
}: CheckboxWithLabelOwnProps) => {
3656
const generatedId = React.useId();
3757
const checkboxId = id || generatedId;
3858

3959
return (
4060
<div className="flex items-start space-x-2">
41-
<Checkbox id={checkboxId} ref={ref} {...props} className={className} />
42-
<Label htmlFor={checkboxId} className={cn('mt-[1px]', labelClassName)}>
43-
{label}
44-
</Label>
61+
<Checkbox id={checkboxId} ref={ref} {...props} className={className} hasError={hasError} />
62+
<div className="space-y-1 leading-none">
63+
<Label
64+
htmlFor={checkboxId}
65+
className={cn(
66+
'mt-[1px]',
67+
labelClassName,
68+
hasError && 'text-destructive',
69+
props.disabled && 'opacity-70 cursor-default',
70+
)}
71+
>
72+
<span className="pr-2">{label}</span>
73+
<span className="font-normal text-sm">{isRequired ? requiredLabel : optionalLabel}</span>
74+
</Label>
75+
{children}
76+
</div>
4577
</div>
4678
);
4779
};
4880

49-
export { Checkbox, Label, CheckboxWithLabel };
81+
export type CheckboxWithDetailsProps = Readonly<
82+
CheckboxWithLabelProps & {
83+
description?: string;
84+
errorMessage?: string;
85+
}
86+
>;
87+
88+
const CheckboxWithDetails = React.forwardRef<React.ElementRef<typeof CheckboxPrimitive.Root>, CheckboxWithDetailsProps>(
89+
({ description, errorMessage, ...props }, ref) => {
90+
return (
91+
<CheckboxWithLabel {...props} ref={ref}>
92+
{description && <p className="text-sm text-muted-foreground">{description}</p>}
93+
{errorMessage && props.hasError && <p className="text-sm text-destructive">{errorMessage}</p>}
94+
</CheckboxWithLabel>
95+
);
96+
},
97+
);
98+
99+
export { Checkbox, Label, CheckboxWithLabel, CheckboxWithDetails };

packages/ui/src/elements/input.stories.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Meta, StoryObj } from '@storybook/nextjs';
22
import { Search } from 'lucide-react';
33

4-
import { Input, InputWithLabel } from './input';
4+
import { Input, InputWithDetails, InputWithLabel } from './input';
55

66
const meta = {
77
title: 'Elements/Input',
@@ -46,3 +46,33 @@ export const WithLabel: Story = {
4646
/>
4747
),
4848
};
49+
50+
export const WithDetailsCaption: StoryObj<typeof InputWithDetails> = {
51+
args: {
52+
label: 'Email',
53+
placeholder: 'Enter your email',
54+
caption: 'We will never share your email with anyone else.',
55+
},
56+
render: (args) => <InputWithDetails {...args} />,
57+
};
58+
59+
export const WithDetailsError: StoryObj<typeof InputWithDetails> = {
60+
args: {
61+
label: 'Email',
62+
placeholder: 'Enter your email',
63+
hasError: true,
64+
errorMessage: 'Please enter a valid email address',
65+
},
66+
render: (args) => <InputWithDetails {...args} />,
67+
};
68+
69+
export const WithDetailsCaptionAndError: StoryObj<typeof InputWithDetails> = {
70+
args: {
71+
label: 'Email',
72+
placeholder: 'Enter your email',
73+
hasError: true,
74+
caption: 'We will never share your email with anyone else.',
75+
errorMessage: 'Please enter a valid email address',
76+
},
77+
render: (args) => <InputWithDetails {...args} />,
78+
};

0 commit comments

Comments
 (0)