diff --git a/examples/core/src/App.tsx b/examples/core/src/App.tsx index 590e522..7bc2d55 100644 --- a/examples/core/src/App.tsx +++ b/examples/core/src/App.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import './App.css'; import { Heading, @@ -7,11 +8,28 @@ import { Detail, Label, Link, + RadioButtonGroup, } from '@krds-ui/core'; function App() { + const [selectedValue, setSelectedValue] = useState('on'); return ( <> +
+ { + console.log(`Switched to ${value}`); + setSelectedValue(value); + }} + /> +
Display Large
diff --git a/packages/core/lib/components/Checkbox.tsx b/packages/core/lib/components/Checkbox.tsx new file mode 100644 index 0000000..fafe016 --- /dev/null +++ b/packages/core/lib/components/Checkbox.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { Label } from './Label'; + +export type CheckboxStatus = 'on' | 'off' | 'intermediate'; + +export type CheckboxProps = { + status: CheckboxStatus; + onChange: (newStatus: CheckboxStatus) => void; + label?: string; + disabled?: boolean; + size?: 'sm' | 'md' | 'lg'; + id: string; +}; + +const CheckIcon = () => ( + + + +); + +const IntermediateIcon = () => ( + + + +); + +export const Checkbox: React.FC = ({ + status, + onChange, + label, + disabled = false, + size = 'md', + id, +}) => { + const sizeClasses = { + sm: 'w-4 h-4', + md: 'w-5 h-5', + lg: 'w-6 h-6', + }; + + const labelSizeClasses = { + sm: 's' as const, + md: 'm' as const, + lg: 'l' as const, + }; + + const handleChange = () => { + if (!disabled) { + const newStatus: CheckboxStatus = status === 'on' ? 'off' : 'on'; + onChange(newStatus); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleChange(); + } + }; + + const baseClasses = ` + inline-flex items-center justify-center border rounded cursor-pointer + ${sizeClasses[size]} transition-all duration-300 ease-in-out`; + + const stateClasses = disabled + ? 'bg-gray-10 border-gray-30 text-gray-40 cursor-not-allowed' + : status === 'on' + ? 'bg-primary border-primary text-gray-0' + : status === 'intermediate' + ? 'bg-primary border-primary text-gray-0' + : 'bg-gray-0 border-gray-30 hover:border-primary'; + + return ( +
+
+
+ +
+
+ +
+ +
+ {label && ( + + )} +
+ ); +}; diff --git a/packages/core/lib/components/Chip.tsx b/packages/core/lib/components/Chip.tsx new file mode 100644 index 0000000..db8debd --- /dev/null +++ b/packages/core/lib/components/Chip.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { Label } from './Label'; + +export type ChipProps = { + label: string; + checked: boolean; + disabled?: boolean; + size?: 'sm' | 'md' | 'lg'; + onChange: (newCheckedState: boolean) => void; + id: string; +}; + +export const Chip: React.FC = ({ + label, + checked, + disabled = false, + size = 'md', + onChange, + id, +}) => { + const sizeClasses = { + sm: 'px-4 h-8', + md: 'px-4 h-9', + lg: 'px-4 h-10', + }; + + const labelSize = { + sm: 's' as const, + md: 'm' as const, + lg: 'm' as const, + }[size]; + const labelColor = disabled ? 'gray-50' : checked ? 'primary' : 'gray-90'; + + const baseClasses = `inline-flex items-center gap-2 rounded-3 border transition-colors duration-200 ${ + sizeClasses[size] + }`; + + const stateClasses = disabled + ? 'bg-gray-20 text-gray-50 border-gray-30 cursor-not-allowed' + : checked + ? 'bg-primary-5 text-primary border-primary hover:bg-primary-10 cursor-pointer' + : 'bg-gray-0 text-gray-70 border-gray-30 hover:bg-gray-20 cursor-pointer'; + + const iconClasses = disabled + ? 'text-gray-40' + : checked + ? 'text-primary' + : 'text-gray-40'; + + const iconSizes = { + sm: 'w-5 h-5', + md: 'w-6 h-6', + lg: 'w-7 h-7', + }; + + const handleClick = () => { + if (!disabled) { + onChange(!checked); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if (!disabled) { + onChange(!checked); + } + } + }; + + return ( +
+ + + + + +
+ ); +}; diff --git a/packages/core/lib/components/RadioButton.tsx b/packages/core/lib/components/RadioButton.tsx new file mode 100644 index 0000000..aa69899 --- /dev/null +++ b/packages/core/lib/components/RadioButton.tsx @@ -0,0 +1,196 @@ +import React, { useRef, useEffect } from 'react'; +import { Label } from './Label'; + +type RadioButtonProps = { + id: string; + name: string; + value: string; + checked: boolean; + onChange: (value: string) => void; + label: string; + disabled?: boolean; + size?: 'sm' | 'md' | 'lg'; +}; + +const RadioButton: React.FC = ({ + id, + name, + value, + checked, + onChange, + label, + disabled = false, + size = 'md', +}) => { + const sizeClasses = { + sm: 'w-5 h-5', + md: 'w-6 h-6', + lg: 'w-7 h-7', + }; + + const innerCircleSizes = { + sm: 'w-3 h-3', + md: 'w-4 h-4', + lg: 'w-5 h-5', + }; + + const labelSizeClasses = { + sm: 's' as const, + md: 'm' as const, + lg: 'l' as const, + }; + + const handleChange = () => { + if (!disabled) { + onChange(value); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleChange(); + } + }; + + const baseClasses = ` + inline-flex items-center justify-center border rounded-full + ${sizeClasses[size]} transition-all duration-300 ease-in-out + `; + + const stateClasses = disabled + ? 'bg-gray-10 border-gray-30 cursor-not-allowed' + : checked + ? 'bg-gray-0 border-primary cursor-pointer' + : 'bg-gray-0 border-gray-30 hover:border-primary cursor-pointer'; + + return ( +
+
+
+ +
+ +
+ ); +}; + +type RadioButtonGroupProps = { + name: string; + options: Array<{ value: string; label: string }>; + selectedValue?: string; + onChange: (value: string) => void; + disabled?: boolean; + size?: 'sm' | 'md' | 'lg'; + direction?: 'horizontal' | 'vertical'; +}; + +export const RadioButtonGroup: React.FC = ({ + name, + options, + selectedValue, + onChange, + disabled = false, + size = 'md', + direction = 'vertical', +}) => { + const directionClasses = direction === 'horizontal' ? 'flex-row' : 'flex-col'; + const gapStyle = direction === 'horizontal' ? 'gap-5' : 'gap-2'; + const groupRef = useRef(null); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!groupRef.current || disabled) return; + + const radioButtons = Array.from( + groupRef.current.querySelectorAll('input[type="radio"]') + ); + const currentIndex = radioButtons.findIndex( + (radio) => (radio as HTMLInputElement).checked + ); + + let nextIndex: number; + + switch (event.key) { + case 'ArrowUp': + case 'ArrowLeft': + event.preventDefault(); + nextIndex = + currentIndex > 0 ? currentIndex - 1 : radioButtons.length - 1; + break; + case 'ArrowDown': + case 'ArrowRight': + event.preventDefault(); + nextIndex = + currentIndex < radioButtons.length - 1 ? currentIndex + 1 : 0; + break; + default: + return; + } + + const nextRadio = radioButtons[nextIndex] as HTMLInputElement; + nextRadio.checked = true; + nextRadio.focus(); + onChange(nextRadio.value); + }; + + const currentRef = groupRef.current; + currentRef?.addEventListener('keydown', handleKeyDown); + + return () => { + currentRef?.removeEventListener('keydown', handleKeyDown); + }; + }, [onChange, disabled]); + + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +}; diff --git a/packages/core/lib/components/Switch.tsx b/packages/core/lib/components/Switch.tsx new file mode 100644 index 0000000..5ee3c3d --- /dev/null +++ b/packages/core/lib/components/Switch.tsx @@ -0,0 +1,107 @@ +import { Label } from './Label'; + +export type SwitchProps = { + status: boolean; + onChange: (checked: boolean) => void; + size?: 'lg' | 'md'; + disabled?: boolean; + label?: string; + labelPosition?: 'left' | 'right'; + id: string; +}; + +export const Switch = ({ + status, + size = 'md', + disabled = false, + onChange, + label, + labelPosition = 'right', + id, +}: SwitchProps) => { + const handleToggle = () => { + if (!disabled) { + onChange(!status); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if (!disabled) { + onChange(!status); + } + } + }; + + const sizeClasses = { + lg: 'w-9 h-7', + md: 'w-8 h-6', + }; + + const toggleClasses = { + lg: { style: 'w-6 h-6', translate: 'translate-x-5' }, + md: { style: 'w-5 h-5', translate: 'translate-x-4' }, + }; + + const labelSizeClasses = { + lg: { size: 'm' as const, gap: 'gap-3' }, + md: { size: 's' as const, gap: 'gap-2' }, + }[size]; + + const switchComponent = ( +
+ +
+
+
+ ); + + if (!label) { + return switchComponent; + } + + return ( +
+ {labelPosition === 'left' && ( + + )} + {switchComponent} + {labelPosition === 'right' && ( + + )} +
+ ); +}; diff --git a/packages/core/lib/index.ts b/packages/core/lib/index.ts index 59131a6..9357ef0 100644 --- a/packages/core/lib/index.ts +++ b/packages/core/lib/index.ts @@ -16,6 +16,10 @@ import { Badge } from './components/Badge'; import { TextInput } from './components/TextInput'; import { TextArea } from './components/TextArea'; import { Breadcrumb } from './components/Breadcrumb'; +import { Switch } from './components/Switch'; +import { Chip } from './components/Chip'; +import { Checkbox } from './components/Checkbox'; +import { RadioButtonGroup } from './components/RadioButton'; export { Display, Heading, Title, Body, Detail, Label, Link, colors }; export { @@ -24,6 +28,10 @@ export { Tag, Spinner, Badge, + Switch, + Chip, + Checkbox, + RadioButtonGroup, TextInput, TextArea, Breadcrumb, diff --git a/stories/core/Checkbox.stories.ts b/stories/core/Checkbox.stories.ts new file mode 100644 index 0000000..c61b91c --- /dev/null +++ b/stories/core/Checkbox.stories.ts @@ -0,0 +1,155 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { Checkbox } from '../../packages/core/lib'; + +const meta = { + title: 'Components/Checkbox', + component: Checkbox, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + status: { + control: { + type: 'select', + options: ['on', 'off', 'intermediate'], + }, + }, + label: { + control: { + type: 'text', + }, + }, + disabled: { + control: { + type: 'boolean', + }, + }, + size: { + control: { + type: 'select', + options: ['sm', 'md', 'lg'], + }, + }, + onChange: { action: 'clicked' }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Checked: Story = { + args: { + status: 'on', + onChange: (checked: 'on' | 'off' | 'intermediate') => + console.log(`Switched to ${checked}`), + label: 'CheckBox', + id: 'cb-1', + }, +}; + +export const Unchecked: Story = { + args: { + status: 'off', + onChange: (checked: 'on' | 'off' | 'intermediate') => + console.log(`Switched to ${checked}`), + label: 'CheckBox', + id: 'cb-2', + }, +}; + +export const Intermediate: Story = { + args: { + status: 'intermediate', + onChange: (checked: 'on' | 'off' | 'intermediate') => + console.log(`Switched to ${checked}`), + label: 'CheckBox', + id: 'cb-3', + }, +}; + +export const Disabled: Story = { + args: { + status: 'on', + onChange: (checked: 'on' | 'off' | 'intermediate') => + console.log(`Switched to ${checked}`), + label: 'CheckBox', + disabled: true, + id: 'cb-4', + }, +}; + +export const DisabledUnchecked: Story = { + args: { + status: 'off', + onChange: (checked: 'on' | 'off' | 'intermediate') => + console.log(`Switched to ${checked}`), + label: 'CheckBox', + disabled: true, + id: 'cb-5', + }, +}; + +export const DisabledIntermediate: Story = { + args: { + status: 'intermediate', + onChange: (checked: 'on' | 'off' | 'intermediate') => + console.log(`Switched to ${checked}`), + label: 'CheckBox', + disabled: true, + id: 'cb-6', + }, +}; + +export const NoLabel: Story = { + args: { + status: 'on', + onChange: (checked: 'on' | 'off' | 'intermediate') => + console.log(`Switched to ${checked}`), + id: 'cb-7', + }, +}; + +export const NoLabelDisabled: Story = { + args: { + status: 'on', + onChange: (checked: 'on' | 'off' | 'intermediate') => + console.log(`Switched to ${checked}`), + disabled: true, + id: 'cb-8', + }, +}; + +export const SmallCheckbox: Story = { + args: { + status: 'on', + onChange: (checked: 'on' | 'off' | 'intermediate') => + console.log(`Switched to ${checked}`), + label: 'CheckBox', + size: 'sm', + id: 'cb-9', + }, +}; + +export const MediumCheckbox: Story = { + args: { + status: 'on', + onChange: (checked: 'on' | 'off' | 'intermediate') => + console.log(`Switched to ${checked}`), + label: 'CheckBox', + size: 'md', + id: 'cb-10', + }, +}; + +export const LargeCheckbox: Story = { + args: { + status: 'on', + onChange: (checked: 'on' | 'off' | 'intermediate') => + console.log(`Switched to ${checked}`), + label: 'CheckBox', + size: 'lg', + id: 'cb-11', + }, +}; diff --git a/stories/core/Chip.stories.ts b/stories/core/Chip.stories.ts new file mode 100644 index 0000000..8b9fe69 --- /dev/null +++ b/stories/core/Chip.stories.ts @@ -0,0 +1,107 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { Chip } from '../../packages/core/lib'; + +const meta = { + title: 'Components/Chip', + component: Chip, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + label: { + control: { + type: 'text', + }, + }, + checked: { + control: { + type: 'boolean', + }, + }, + disabled: { + control: { + type: 'boolean', + }, + }, + size: { + control: { + type: 'select', + options: ['sm', 'md', 'lg'], + }, + }, + onChange: { action: 'clicked' }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Checked: Story = { + args: { + checked: true, + onChange: (checked: boolean) => console.log(`Switched to ${checked}`), + label: 'Chip', + id: 'chip-1', + }, +}; + +export const Unchecked: Story = { + args: { + checked: false, + onChange: (checked: boolean) => console.log(`Switched to ${checked}`), + label: 'Chip', + id: 'chip-2', + }, +}; + +export const Disabled: Story = { + args: { + checked: true, + onChange: (checked: boolean) => console.log(`Switched to ${checked}`), + label: 'Chip', + disabled: true, + id: 'chip-3', + }, +}; + +export const UncheckedDisabled: Story = { + args: { + checked: false, + onChange: (checked: boolean) => console.log(`Switched to ${checked}`), + label: 'Chip', + disabled: true, + id: 'chip-4', + }, +}; + +export const SmallChip: Story = { + args: { + checked: true, + onChange: (checked: boolean) => console.log(`Switched to ${checked}`), + label: 'Chip', + size: 'sm', + id: 'chip-5', + }, +}; + +export const MediumChip: Story = { + args: { + checked: true, + onChange: (checked: boolean) => console.log(`Switched to ${checked}`), + label: 'Chip', + size: 'md', + id: 'chip-6', + }, +}; + +export const LargeChip: Story = { + args: { + checked: true, + onChange: (checked: boolean) => console.log(`Switched to ${checked}`), + label: 'Chip', + size: 'lg', + id: 'chip-7', + }, +}; diff --git a/stories/core/RadioButton.stories.ts b/stories/core/RadioButton.stories.ts new file mode 100644 index 0000000..3779926 --- /dev/null +++ b/stories/core/RadioButton.stories.ts @@ -0,0 +1,160 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { RadioButtonGroup } from '../../packages/core/lib'; + +const meta = { + title: 'Components/RadioButton', + component: RadioButtonGroup, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + name: { + control: { + type: 'text', + }, + }, + options: { + control: { + type: 'object', + }, + }, + selectedValue: { + control: { + type: 'text', + }, + }, + disabled: { + control: { + type: 'boolean', + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Checked: Story = { + args: { + name: 'Name', + options: [ + { value: 'on', label: 'On' }, + { value: 'off', label: 'Off' }, + { value: 'intermediate', label: 'Intermediate' }, + ], + selectedValue: 'on', + onChange: (value: string) => console.log(`Switched to ${value}`), + }, +}; + +export const NothingSelected: Story = { + args: { + name: 'Name', + options: [ + { value: 'on', label: 'On' }, + { value: 'off', label: 'Off' }, + { value: 'intermediate', label: 'Intermediate' }, + ], + onChange: (value: string) => console.log(`Switched to ${value}`), + }, +}; + +export const Disabled: Story = { + args: { + name: 'Name', + options: [ + { value: 'on', label: 'On' }, + { value: 'off', label: 'Off' }, + { value: 'intermediate', label: 'Intermediate' }, + ], + selectedValue: 'on', + disabled: true, + onChange: (value: string) => console.log(`Switched to ${value}`), + }, +}; + +export const DisabledSelected: Story = { + args: { + name: 'Name', + options: [ + { value: 'on', label: 'On' }, + { value: 'off', label: 'Off' }, + { value: 'intermediate', label: 'Intermediate' }, + ], + selectedValue: 'on', + disabled: true, + onChange: (value: string) => console.log(`Switched to ${value}`), + }, +}; + +export const horizontal: Story = { + args: { + name: 'Name', + options: [ + { value: 'on', label: 'On' }, + { value: 'off', label: 'Off' }, + { value: 'intermediate', label: 'Intermediate' }, + ], + selectedValue: 'on', + direction: 'horizontal', + onChange: (value: string) => console.log(`Switched to ${value}`), + }, +}; + +export const vertical: Story = { + args: { + name: 'Name', + options: [ + { value: 'on', label: 'On' }, + { value: 'off', label: 'Off' }, + { value: 'intermediate', label: 'Intermediate' }, + ], + selectedValue: 'on', + direction: 'vertical', + onChange: (value: string) => console.log(`Switched to ${value}`), + }, +}; + +export const Small: Story = { + args: { + name: 'Name', + options: [ + { value: 'on', label: 'On' }, + { value: 'off', label: 'Off' }, + { value: 'intermediate', label: 'Intermediate' }, + ], + selectedValue: 'on', + size: 'sm', + onChange: (value: string) => console.log(`Switched to ${value}`), + }, +}; + +export const Medium: Story = { + args: { + name: 'Name', + options: [ + { value: 'on', label: 'On' }, + { value: 'off', label: 'Off' }, + { value: 'intermediate', label: 'Intermediate' }, + ], + selectedValue: 'on', + size: 'md', + onChange: (value: string) => console.log(`Switched to ${value}`), + }, +}; + +export const Large: Story = { + args: { + name: 'Name', + options: [ + { value: 'on', label: 'On' }, + { value: 'off', label: 'Off' }, + { value: 'intermediate', label: 'Intermediate' }, + ], + selectedValue: 'on', + size: 'lg', + onChange: (value: string) => console.log(`Switched to ${value}`), + }, +}; diff --git a/stories/core/Switch.stories.ts b/stories/core/Switch.stories.ts new file mode 100644 index 0000000..c117ada --- /dev/null +++ b/stories/core/Switch.stories.ts @@ -0,0 +1,123 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { Switch } from '../../packages/core/lib'; + +const meta = { + title: 'Components/Switch', + component: Switch, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + status: { + control: { + type: 'boolean', + }, + }, + size: { + control: { + type: 'select', + options: ['lg', 'md'], + }, + }, + onChange: { action: 'clicked' }, + disabled: { + control: { + type: 'boolean', + }, + }, + label: { + control: { + type: 'text', + }, + }, + labelPosition: { + control: { + type: 'select', + options: ['left', 'right'], + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Checked: Story = { + args: { + status: true, + onChange: (checked: boolean) => console.log(`Switched to ${checked}`), + label: 'Switch', + id: 'switch-1', + }, +}; + +export const UnChecked: Story = { + args: { + status: false, + onChange: (checked: boolean) => console.log(`Switched to ${checked}`), + label: 'Switch', + id: 'switch-2', + }, +}; + +export const Disabled: Story = { + args: { + status: true, + onChange: (checked: boolean) => console.log(`Switched to ${checked}`), + label: 'Switch', + disabled: true, + id: 'switch-3', + }, +}; + +export const UncheckedDisabled: Story = { + args: { + status: false, + onChange: (checked: boolean) => console.log(`Switched to ${checked}`), + label: 'Switch', + disabled: true, + id: 'switch-4', + }, +}; + +export const LeftLabel: Story = { + args: { + status: true, + onChange: (checked: boolean) => console.log(`Switched to ${checked}`), + label: 'Switch', + labelPosition: 'left', + id: 'switch-5', + }, +}; + +export const RightLabel: Story = { + args: { + status: true, + onChange: (checked: boolean) => console.log(`Switched to ${checked}`), + label: 'Switch', + labelPosition: 'right', + id: 'switch-6', + }, +}; + +export const LargeSwitch: Story = { + args: { + status: true, + onChange: (checked: boolean) => console.log(`Switched to ${checked}`), + label: 'Switch', + size: 'lg', + id: 'switch-7', + }, +}; + +export const MediumSwitch: Story = { + args: { + status: true, + onChange: (checked: boolean) => console.log(`Switched to ${checked}`), + label: 'Switch', + size: 'md', + id: 'switch-8', + }, +};