diff --git a/.storybook/main.js b/.storybook/main.js index bd9dcac02..c27976f81 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,12 +1,8 @@ +const { TsconfigPathsPlugin } = require('tsconfig-paths-webpack-plugin'); const path = require('path'); module.exports = { - stories: [ - '../src/**/*.stories.mdx', - '../src/**/*.stories.@(js|jsx|ts|tsx)', - '../stories/**/*.stories.mdx', - '../stories/**/*.stories.@(js|jsx|ts|tsx)', - ], + stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], addons: [ '@storybook/addon-links', '@storybook/addon-essentials', @@ -20,6 +16,9 @@ module.exports = { autodocs: true, }, webpackFinal: async (config) => { + // config.resolve.plugins = config.resolve.plugins || []; + // config.resolve.plugins.push(new TsconfigPathsPlugin({})); + config.module.rules.push({ test: /\.(ts|tsx)$/, exclude: /node_modules/, diff --git a/create_components.sh b/create_components.sh new file mode 100755 index 000000000..b03a217c9 --- /dev/null +++ b/create_components.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# List of component names +components=( + "InputBase" "InputLabel" "InputControl" "InputIcon" "InputHelperText" + "InputErrorMessage" "InputGroup" "TextAreaControl" "NumberInput" "DatePicker" + "TimePicker" "FileUpload" "ColorPicker" "SelectBase" "MultiSelect" "Combobox" + "AutoComplete" "CheckboxGroup" "RadioGroup" "ToggleSwitch" "ScrollArea" + "Badge" "Pill" "Tooltip" "Popover" "ValidationSummary" "ConfirmDialog" + "FormActions" "ProgressIndicator" "LoadingSpinner" "ValidationIcon" + "FormGroup" "FormRow" "FormDivider" "FormMessage" "FormSection" + "FormGrid" "FormStepper" "FormAccordion" "FormTabs" "FieldArray" + "DynamicField" +) + +# Loop through each component and create folder and files +for component in "${components[@]}" +do + # Create component directory + mkdir -p "$component" + echo "Created directory $component" + + # Create index.ts + touch "$component/index.ts" + echo "Created $component/index.ts" + + # Create .tsx + touch "$component/${component}.tsx" + echo "Created $component/${component}.tsx" + + # Create TODO.md + touch "$component/TODO.md" + echo "Created $component/TODO.md" + + # Create styled.ts + touch "$component/styled.ts" + echo "Created $component/styled.ts" + + # Create types.ts + touch "$component/types.ts" + echo "Created $component/types.ts" +done \ No newline at end of file diff --git a/example/.npmignore b/example/.npmignore deleted file mode 100644 index 587e4ec7a..000000000 --- a/example/.npmignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules -.cache -dist \ No newline at end of file diff --git a/example/index.html b/example/index.html deleted file mode 100644 index 41d7811b1..000000000 --- a/example/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - Playground - - - -
- - - diff --git a/example/index.tsx b/example/index.tsx deleted file mode 100644 index 73387c60e..000000000 --- a/example/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import 'react-app-polyfill/ie11'; -import * as React from 'react'; -import * as ReactDOM from 'react-dom'; -import { Thing } from '../.'; - -const App = () => { - return ( -
- -
- ); -}; - -ReactDOM.render(, document.getElementById('root')); diff --git a/example/package.json b/example/package.json deleted file mode 100644 index a50960f5c..000000000 --- a/example/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "example", - "version": "1.0.0", - "main": "index.js", - "license": "MIT", - "scripts": { - "start": "parcel index.html", - "build": "parcel build index.html" - }, - "dependencies": { - "react-app-polyfill": "^1.0.0" - }, - "alias": { - "react": "../node_modules/react", - "react-dom": "../node_modules/react-dom/profiling", - "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" - }, - "devDependencies": { - "@types/react": "^16.9.11", - "@types/react-dom": "^16.8.4", - "parcel": "^1.12.3", - "typescript": "^3.4.5" - } -} diff --git a/example/tsconfig.json b/example/tsconfig.json deleted file mode 100644 index 1e2e4fd9c..000000000 --- a/example/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "allowSyntheticDefaultImports": false, - "target": "es5", - "module": "commonjs", - "jsx": "react", - "moduleResolution": "node", - "noImplicitAny": false, - "noUnusedLocals": false, - "noUnusedParameters": false, - "removeComments": true, - "strictNullChecks": true, - "preserveConstEnums": true, - "sourceMap": true, - "lib": ["es2015", "es2016", "dom"], - "types": ["node"] - } -} diff --git a/package.json b/package.json index 111dbb6ac..b53561b9f 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "0.0.14", + "version": "0.0.37", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" @@ -139,6 +139,7 @@ "storybook": "^8.4.7", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", + "tsconfig-paths-webpack-plugin": "^4.2.0", "tsdx": "^0.14.1", "tslib": "^2.8.1", "typescript": "^5.7.2", @@ -149,9 +150,8 @@ }, "dependencies": { "@hookform/resolvers": "^3.9.1", + "@matthew.ngo/react-form-kit": "^0.0.14", "lodash": "^4.17.21", - "react-dnd": "^16.0.1", - "react-dnd-html5-backend": "^16.0.1", "react-hook-form": "^7.49.3", "styled-components": "^6.1.13", "yup": "^1.6.1" diff --git a/src/DynamicForm.stories.tsx b/src/DynamicForm.stories.tsx index 2f98c6496..833a349f7 100644 --- a/src/DynamicForm.stories.tsx +++ b/src/DynamicForm.stories.tsx @@ -5,6 +5,8 @@ import { CustomInputProps, defaultTheme, DynamicForm, FormValues } from '.'; import { userEvent, within, expect } from '@storybook/test'; // Updated import import { FlexLayout } from './features/inputs/registry/components/FlexLayout'; import { useController, useFormContext } from 'react-hook-form'; +import { SearchParams } from './features/inputs/components/combobox/types'; +import { InputErrorMessage } from '@matthew.ngo/react-form-kit'; export default { title: 'DynamicForm', @@ -24,6 +26,11 @@ const Template: StoryFn = (args) => ( // --- Examples --- +const CustomizedInput = (props: any) => { + console.log('propssss', props); + return
null
; +}; + // Story 1: Basic Input Types export const BasicInputTypes = Template.bind({}); BasicInputTypes.args = { @@ -31,41 +38,63 @@ BasicInputTypes.args = { config: { firstName: { label: 'First Name', - type: 'text', - defaultValue: 'John', + type: 'custom', + defaultValue: '', + inputComponent: CustomizedInput, inputProps: { placeholder: 'Enter your first name', }, validation: { required: { value: true, message: 'This field is required' }, - minLength: { value: 3, message: 'Minimum length is 3' }, + // minLength: { value: 3, message: 'Minimum length is 3' }, + validate: (value) => { + if (!value) { + return 'Requireddd'; + } + + return undefined; + }, + }, + renderErrorMessage: (error) => { + console.log('error', error); + + // return
{error?.message}
; + return ( + + ); }, }, - lastName: { - label: 'Last Name', - type: 'text', - // defaultValue: 'Doe', - }, - email: { - label: 'Email', - type: 'email', - // defaultValue: 'john.doe@example.com', - }, - age: { - label: 'Age', - type: 'number', - // defaultValue: 30, - inputProps: {}, - }, - subscribe: { - label: 'Subscribe to newsletter?', - type: 'checkbox', - // defaultValue: true, - }, + // lastName: { + // label: 'Last Name', + // type: 'text', + // // defaultValue: 'Doe', + // }, + // email: { + // label: 'Email', + // type: 'email', + // // defaultValue: 'john.doe@example.com', + // }, + // age: { + // label: 'Age', + // type: 'number', + // // defaultValue: 30, + // inputProps: {}, + // }, + // subscribe: { + // label: 'Subscribe to newsletter?', + // type: 'checkbox', + // // defaultValue: true, + // }, }, onSubmit: (data) => { console.log('🚀 ~ file: DynamicForm.stories.tsx ~ data:', data); }, + renderSubmitButton: (onSubmitHandler, isSubmitting) => ( + + ), + showInlineError: true, onFormReady: fn(), }; BasicInputTypes.storyName = 'Basic Input Types'; @@ -386,167 +415,167 @@ ComprehensiveForm.args = { ), config: { // --- Basic Inputs --- - firstName: { - label: 'First Name', - type: 'text', - // defaultValue: 'John', - validation: { - required: { value: true, message: 'This field is required' }, - }, - classNameConfig: { - input: 'border border-gray-400 p-2 rounded w-full', - label: 'block text-gray-700 text-sm font-bold mb-2', - }, - }, - lastName: { - label: 'Last Name', - type: 'text', - // defaultValue: 'Doe', - validation: { - required: { value: true, message: 'This field is required' }, - }, - classNameConfig: { - input: 'border border-gray-400 p-2 rounded w-full', - label: 'block text-gray-700 text-sm font-bold mb-2', - }, - }, - email: { - label: 'Email', - type: 'email', - // defaultValue: 'john.doe@example.com', - validation: { - required: { value: true, message: 'This field is required' }, - pattern: { - value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i, - message: 'Invalid email address', - }, - }, - classNameConfig: { - input: 'border border-gray-400 p-2 rounded w-full', - label: 'block text-gray-700 text-sm font-bold mb-2', - errorMessage: 'text-red-500 text-xs italic', - }, - }, - age: { - label: 'Age', - type: 'number', - // defaultValue: 30, - validation: { - required: { value: true, message: 'This field is required' }, - min: { value: 18, message: 'Must be 18 or older' }, - max: { value: 99, message: 'Must be 99 or younger' }, - }, - classNameConfig: { - input: 'border border-gray-400 p-2 rounded w-full', - label: 'block text-gray-700 text-sm font-bold mb-2', - }, - }, - subscribe: { - label: 'Subscribe to newsletter?', - type: 'checkbox', - // defaultValue: true, - validation: { - required: { value: true, message: 'This field is required' }, - }, - classNameConfig: { - checkboxInput: 'mr-2 leading-tight', - label: 'block text-gray-700 text-sm font-bold mb-2', - }, - }, - // --- Advanced Inputs --- - startDate: { - label: 'Start Date', - type: 'date', - // defaultValue: '2023-11-20', - classNameConfig: { - date: 'border border-gray-400 p-2 rounded w-full', // Apply the 'date' class here - label: 'block text-gray-700 text-sm font-bold mb-2', - }, - validation: { - required: { value: true, message: 'This field is required' }, - }, - }, - startTime: { - label: 'Start Time', - type: 'time', - // defaultValue: '09:00', - classNameConfig: { - time: 'border border-gray-400 p-2 rounded w-full', // Apply the 'time' class here - label: 'block text-gray-700 text-sm font-bold mb-2', - }, - }, - dateTime: { - label: 'Date and Time', - type: 'datetime-local', - // defaultValue: '2023-11-20T09:00', - classNameConfig: { - dateTime: 'border border-gray-400 p-2 rounded w-full', // Apply the 'dateTime' class here - label: 'block text-gray-700 text-sm font-bold mb-2', - }, - }, - notes: { - label: 'Notes', - type: 'textarea', - // defaultValue: 'Some notes...', - classNameConfig: { - textarea: 'border border-gray-400 p-2 rounded w-full', - label: 'block text-gray-700 text-sm font-bold mb-2', - }, - }, - country: { - label: 'Country', - type: 'select', - // defaultValue: 'US', - options: [ - { value: 'US', label: 'United States' }, - { value: 'CA', label: 'Canada' }, - { value: 'UK', label: 'United Kingdom' }, - ], - classNameConfig: { - select: 'border border-gray-400 p-2 rounded w-full', - label: 'block text-gray-700 text-sm font-bold mb-2', - }, - }, - gender: { - label: 'Gender', - type: 'radio', - // defaultValue: 'male', - options: [ - { value: 'male', label: 'Male' }, - { value: 'female', label: 'Female' }, - { value: 'other', label: 'Other' }, - ], - classNameConfig: { - radioGroup: 'flex items-center', - radioLabel: 'mr-4', - radioButton: 'mr-1', - label: 'block text-gray-700 text-sm font-bold mb-2', - }, - }, - notification: { - label: 'Enable Notifications', - type: 'switch', - // defaultValue: true, - validation: { - required: { value: true, message: 'This field is required' }, - }, - classNameConfig: { - switchContainer: - 'relative inline-block w-10 mr-2 align-middle select-none', - switch: - 'absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer', - switchSlider: - 'absolute cursor-pointer top-0 left-0 right-0 bottom-0 bg-gray-400 transition-all duration-300 rounded-full', - label: 'block text-gray-700 text-sm font-bold mb-2', - }, - }, + // firstName: { + // label: 'First Name', + // type: 'text', + // // defaultValue: 'John', + // validation: { + // required: { value: true, message: 'This field is required' }, + // }, + // classNameConfig: { + // input: 'border border-gray-400 p-2 rounded w-full', + // label: 'block text-gray-700 text-sm font-bold mb-2', + // }, + // }, + // lastName: { + // label: 'Last Name', + // type: 'text', + // // defaultValue: 'Doe', + // validation: { + // required: { value: true, message: 'This field is required' }, + // }, + // classNameConfig: { + // input: 'border border-gray-400 p-2 rounded w-full', + // label: 'block text-gray-700 text-sm font-bold mb-2', + // }, + // }, + // email: { + // label: 'Email', + // type: 'email', + // // defaultValue: 'john.doe@example.com', + // validation: { + // required: { value: true, message: 'This field is required' }, + // pattern: { + // value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i, + // message: 'Invalid email address', + // }, + // }, + // classNameConfig: { + // input: 'border border-gray-400 p-2 rounded w-full', + // label: 'block text-gray-700 text-sm font-bold mb-2', + // errorMessage: 'text-red-500 text-xs italic', + // }, + // }, + // age: { + // label: 'Age', + // type: 'number', + // // defaultValue: 30, + // validation: { + // required: { value: true, message: 'This field is required' }, + // min: { value: 18, message: 'Must be 18 or older' }, + // max: { value: 99, message: 'Must be 99 or younger' }, + // }, + // classNameConfig: { + // input: 'border border-gray-400 p-2 rounded w-full', + // label: 'block text-gray-700 text-sm font-bold mb-2', + // }, + // }, + // subscribe: { + // label: 'Subscribe to newsletter?', + // type: 'checkbox', + // // defaultValue: true, + // validation: { + // required: { value: true, message: 'This field is required' }, + // }, + // classNameConfig: { + // checkboxInput: 'mr-2 leading-tight', + // label: 'block text-gray-700 text-sm font-bold mb-2', + // }, + // }, + // // --- Advanced Inputs --- + // startDate: { + // label: 'Start Date', + // type: 'date', + // // defaultValue: '2023-11-20', + // classNameConfig: { + // date: 'border border-gray-400 p-2 rounded w-full', // Apply the 'date' class here + // label: 'block text-gray-700 text-sm font-bold mb-2', + // }, + // validation: { + // required: { value: true, message: 'This field is required' }, + // }, + // }, + // startTime: { + // label: 'Start Time', + // type: 'time', + // // defaultValue: '09:00', + // classNameConfig: { + // time: 'border border-gray-400 p-2 rounded w-full', // Apply the 'time' class here + // label: 'block text-gray-700 text-sm font-bold mb-2', + // }, + // }, + // dateTime: { + // label: 'Date and Time', + // type: 'datetime-local', + // // defaultValue: '2023-11-20T09:00', + // classNameConfig: { + // dateTime: 'border border-gray-400 p-2 rounded w-full', // Apply the 'dateTime' class here + // label: 'block text-gray-700 text-sm font-bold mb-2', + // }, + // }, + // notes: { + // label: 'Notes', + // type: 'textarea', + // // defaultValue: 'Some notes...', + // classNameConfig: { + // textarea: 'border border-gray-400 p-2 rounded w-full', + // label: 'block text-gray-700 text-sm font-bold mb-2', + // }, + // }, + // country: { + // label: 'Country', + // type: 'select', + // // defaultValue: 'US', + // options: [ + // { value: 'US', label: 'United States' }, + // { value: 'CA', label: 'Canada' }, + // { value: 'UK', label: 'United Kingdom' }, + // ], + // classNameConfig: { + // select: 'border border-gray-400 p-2 rounded w-full', + // label: 'block text-gray-700 text-sm font-bold mb-2', + // }, + // }, + // gender: { + // label: 'Gender', + // type: 'radio', + // // defaultValue: 'male', + // options: [ + // { value: 'male', label: 'Male' }, + // { value: 'female', label: 'Female' }, + // { value: 'other', label: 'Other' }, + // ], + // classNameConfig: { + // radioGroup: 'flex items-center', + // radioLabel: 'mr-4', + // radioButton: 'mr-1', + // label: 'block text-gray-700 text-sm font-bold mb-2', + // }, + // }, + // notification: { + // label: 'Enable Notifications', + // type: 'switch', + // // defaultValue: true, + // validation: { + // required: { value: true, message: 'This field is required' }, + // }, + // classNameConfig: { + // switchContainer: + // 'relative inline-block w-10 mr-2 align-middle select-none', + // switch: + // 'absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer', + // switchSlider: + // 'absolute cursor-pointer top-0 left-0 right-0 bottom-0 bg-gray-400 transition-all duration-300 rounded-full', + // label: 'block text-gray-700 text-sm font-bold mb-2', + // }, + // }, favoriteFruit: { label: 'Favorite Fruit', type: 'combobox', // defaultValue: 'Apple', - validation: { - required: { value: true, message: 'This field is required' }, - }, + // validation: { + // required: { value: true, message: 'This field is required' }, + // }, options: [ { value: 'Apple', label: 'Apple' }, { value: 'Banana', label: 'Banana' }, @@ -865,30 +894,57 @@ CustomInput.storyName = 'Custom Input (ColorPicker)'; // Story 8: ComboBox Input // Mock data for ComboBox const mockComboBoxData = [ - { value: 'apple', label: 'Apple' }, - { value: 'banana', label: 'Banana' }, - { value: 'orange', label: 'Orange' }, - { value: 'grape', label: 'Grape' }, - { value: 'watermelon', label: 'Watermelon' }, - { value: 'pineapple', label: 'Pineapple' }, - { value: 'mango', label: 'Mango' }, - { value: 'strawberry', label: 'Strawberry' }, - { value: 'blueberry', label: 'Blueberry' }, - { value: 'raspberry', label: 'Raspberry' }, + { id: 'apple', label: 'Apple', disabled: true }, + { id: 'banana', label: 'Banana' }, + { id: 'orange', label: 'Orange', disabled: true }, + { id: 'grape', label: 'Grape' }, + { id: 'watermelon', label: 'Watermelon', disabled: true }, + { id: 'pineapple', label: 'Pineapple' }, + { id: 'mango', label: 'Mango' }, + { id: 'strawberry', label: 'Strawberry' }, + { id: 'blueberry', label: 'Blueberry' }, + { id: 'raspberry', label: 'Raspberry', disabled: true }, + { id: 'blackberry', label: 'Blackberry' }, + { id: 'kiwi', label: 'Kiwi' }, + { id: 'peach', label: 'Peach' }, + { id: 'plum', label: 'Plum' }, + { id: 'apricot', label: 'Apricot' }, + { id: 'cherry', label: 'Cherry' }, + { id: 'coconut', label: 'Coconut' }, + { id: 'fig', label: 'Fig' }, + { id: 'lime', label: 'Lime' }, + { id: 'lemon', label: 'Lemon' }, + { id: 'papaya', label: 'Papaya', disabled: true }, + { id: 'guava', label: 'Guava' }, + { id: 'dragonfruit', label: 'Dragon Fruit' }, + { id: 'pomegranate', label: 'Pomegranate' }, + { id: 'avocado', label: 'Avocado' }, ]; -// Mock search API function for ComboBox -const mockSearchApi = async (params: { query: string }) => { - return new Promise<{ data: { value: string; label: string }[] }>( - (resolve) => { - setTimeout(() => { - const filteredData = mockComboBoxData.filter((item) => - item.label.toLowerCase().includes(params.query.toLowerCase()) - ); - resolve({ data: filteredData }); - }, 500); // Simulate 500ms delay - } - ); +// Mock search API with pagination +export const mockSearchApi = async (params: SearchParams) => { + const { query = '', pageIndex = 1, pageSize = 10 } = params; + console.log('🚀 ~ file: DynamicForm.stories.tsx ~ params:', params); + return new Promise((resolve) => { + setTimeout(() => { + // Filter data based on search query + const filteredData = mockComboBoxData.filter((item) => + item.label.toLowerCase().includes(query.toLowerCase()) + ); + + // Apply pagination + const startIndex = (pageIndex - 1) * pageSize; + const endIndex = startIndex + pageSize; + const paginatedData = filteredData.slice(startIndex, endIndex); + + resolve({ + data: paginatedData, + total: filteredData.length, + pageSize, + pageIndex, + }); + }, 300); // Reduced delay for testing + }); }; export const ComboBoxInput = Template.bind({}); @@ -905,10 +961,21 @@ ComboBoxInput.args = { loadingMessage: 'Loading fruits...', disabled: false, required: true, + showDraggableList: true, }, + defaultValue: [ + { + id: 'apple', + label: 'aaa', + }, + ], validation: { - validate: (value) => { - console.log('🚀 ~ file: DynamicForm.stories.tsx ~ value:', value); + validate: (value, formValues) => { + console.log( + '🚀 ~ file: DynamicForm.stories.tsx ~ value:', + value, + formValues + ); if (!value) { return 'This field is required'; } @@ -927,3 +994,93 @@ ComboBoxInput.args = { onFormReady: fn(), }; ComboBoxInput.storyName = 'ComboBox Input'; + +// Story for Form Lifecycle Hooks +export const FormLifecycleHooks = Template.bind({}); +FormLifecycleHooks.args = { + theme: defaultTheme, + config: { + username: { + label: 'Username', + type: 'text', + validation: { + required: { value: true, message: 'Username is required' }, + minLength: { + value: 3, + message: 'Username must be at least 3 characters', + }, + }, + }, + email: { + label: 'Email', + type: 'email', + validation: { + required: { value: true, message: 'Email is required' }, + pattern: { + value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i, + message: 'Invalid email address', + }, + }, + }, + }, + + beforeValidate: async (data) => { + console.log('Before Validate:', data); + if (!data.username) { + window.alert('Username cannot empty'); + return false; + } + if (data.username && /[^a-zA-Z0-9]/.test(data.username)) { + window.alert('Username cannot contain special characters'); + return false; + } + return true; + }, + afterValidate: (isValid, errors) => { + console.log('After Validate - Is Valid:', isValid); + console.log('After Validate - Errors:', errors); + if (!isValid) { + window.alert('Validation failed! Please check the form.'); + } + }, + beforeSubmit: async (data) => { + console.log('Before Submit:', data); + return window.confirm('Are you sure you want to submit?'); + }, + afterSubmit: (data, error) => { + if (error) { + console.log('After Submit - Error:', error); + window.alert('Submission failed!'); + } else { + console.log('After Submit - Success:', data); + window.alert('Form submitted successfully!'); + } + }, + onSubmit: async (data) => { + console.log('OnSubmit:', data); + // Simulate API call with potential error + await new Promise((resolve, reject) => { + setTimeout(() => { + if (data.email === 'error@test.com') { + reject(new Error('Submission failed')); + } else { + resolve(data); + } + }, 1000); + }); + }, + onError: (errors) => { + console.log('OnError:', errors); + }, + showInlineError: true, + formClassNameConfig: { + formContainer: 'p-6 border border-gray-300 rounded-md', + inputWrapper: 'mb-4', + label: 'block text-gray-700 text-sm font-bold mb-2', + input: 'border border-gray-400 p-2 rounded w-full', + errorMessage: 'text-red-500 text-xs italic mt-1', + button: + 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded', + }, +}; +FormLifecycleHooks.storyName = 'Form Lifecycle Hooks'; diff --git a/src/DynamicForm.tsx b/src/DynamicForm.tsx index 0b349aa4a..38b93688d 100644 --- a/src/DynamicForm.tsx +++ b/src/DynamicForm.tsx @@ -54,6 +54,10 @@ const DynamicForm: React.FC = ({ onError, renderErrorSummary, validationMessages, + beforeSubmit, + afterSubmit, + beforeValidate, + afterValidate, }) => { const mergedFormOptions = useRHFOptions( config, @@ -61,7 +65,8 @@ const DynamicForm: React.FC = ({ validateOnSubmit, validateOnChange, validateOnBlur, - validationMessages + validationMessages, + beforeValidate ); const form = useDynamicForm({ @@ -85,11 +90,45 @@ const DynamicForm: React.FC = ({ validationMessages ); - const onSubmitHandler = (): any => { + const onSubmitHandler = async (): Promise => { + const formData = form.getValues(); + + // Validate form + const isValid = await form.trigger(); + + // After validation hook + if (afterValidate) { + afterValidate(isValid, form.formState.errors); + } + + if (!isValid) return; + + // Before submit hook + if (beforeSubmit) { + const shouldContinue = await beforeSubmit(formData); + if (!shouldContinue) return; + } + + // Submit form handleSubmit( - (data) => { - if (onSubmit) { - onSubmit(data); + async (data) => { + try { + if (onSubmit) { + await onSubmit(data); + } + // After submit hook - success + if (afterSubmit) { + afterSubmit(data); + } + } catch (error) { + // After submit hook - error + if (afterSubmit) { + afterSubmit(data, error); + } + // Handle error + if (onError) { + onError(form.formState.errors); + } } }, (errors: FieldErrors) => { diff --git a/src/components/InputBase/InputBase.stories.tsx b/src/components/InputBase/InputBase.stories.tsx new file mode 100644 index 000000000..53f4d591b --- /dev/null +++ b/src/components/InputBase/InputBase.stories.tsx @@ -0,0 +1,222 @@ +import React from 'react'; +import { Meta, StoryFn } from '@storybook/react'; +import InputBase from './InputBase'; +import { InputBaseProps } from './types'; +import InputLabel from '../InputLabel'; + +export default { + title: 'Components/InputBase', + component: InputBase, + argTypes: { + size: { + options: ['sm', 'md', 'lg'], + control: { type: 'radio' }, + }, + type: { + options: ['text', 'password', 'email', 'number', 'tel', 'url'], + control: { type: 'select' }, + }, + validationTiming: { + options: ['onBlur', 'onChange'], + control: { type: 'radio' }, + }, + validationStatus: { + options: ['error', 'success', 'warning', 'default', ''], + control: { type: 'radio' }, + }, + iconLeft: { + control: { type: 'object' }, // Manually define control for complex objects + }, + iconRight: { + control: { type: 'object' }, // Manually define control for complex objects + }, + customClearButton: { + control: { type: 'object' }, + }, + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + placeholder: 'Enter text', + size: 'md', +}; + +export const Disabled = Template.bind({}); +Disabled.args = { + ...Default.args, + disabled: true, + value: 'Disabled input', +}; + +export const ReadOnly = Template.bind({}); +ReadOnly.args = { + ...Default.args, + readOnly: true, + value: 'Read-only input', +}; + +export const WithError = Template.bind({}); +WithError.args = { + ...Default.args, + required: true, + validationStatus: 'error', + errorMessage: 'This field is required.', +}; + +export const WithSuccess = Template.bind({}); +WithSuccess.args = { + ...Default.args, + value: 'Valid input', + validationStatus: 'success', +}; + +export const WithWarning = Template.bind({}); +WithWarning.args = { + ...Default.args, + value: 'Input with warning', + validationStatus: 'warning', +}; + +export const WithCustomValidation = Template.bind({}); +WithCustomValidation.args = { + ...Default.args, + placeholder: 'Enter a number greater than 10', + customValidation: (value: string) => { + const num = parseFloat(value); + if (isNaN(num) || num <= 10) { + return 'Value must be greater than 10'; + } + return null; + }, + validationTiming: 'onChange', +}; + +export const WithIconLeft = Template.bind({}); +WithIconLeft.args = { + ...Default.args, + iconLeft: ( + + + + ), +}; + +export const WithIconRight = Template.bind({}); +WithIconRight.args = { + ...Default.args, + iconRight: ( + + + + ), +}; + +export const WithLoading = Template.bind({}); +WithLoading.args = { + ...Default.args, + loading: true, +}; + +export const WithClearButton = Template.bind({}); +WithClearButton.args = { + ...Default.args, + clearButton: true, + value: 'Text to clear', +}; + +export const WithCustomClearButton = Template.bind({}); +WithCustomClearButton.args = { + ...Default.args, + clearButton: true, + value: 'Text to clear', + customClearButton: ( + + ), +}; +export const PasswordType = Template.bind({}); +PasswordType.args = { + ...Default.args, + type: 'password', + value: 'mysecretpassword', +}; + +export const EmailType = Template.bind({}); +EmailType.args = { + ...Default.args, + type: 'email', + placeholder: 'Enter email address', +}; + +export const NumberType = Template.bind({}); +NumberType.args = { + ...Default.args, + type: 'number', + placeholder: 'Enter a number', +}; + +export const LargeSize = Template.bind({}); +LargeSize.args = { + ...Default.args, + size: 'lg', + placeholder: 'Large input', +}; + +export const SmallSize = Template.bind({}); +SmallSize.args = { + ...Default.args, + size: 'sm', + placeholder: 'Small input', +}; + +export const WithLabel = Template.bind({}); +WithLabel.args = { + ...Default.args, + id: 'with-label', + placeholder: 'Input with Label', +}; + +WithLabel.decorators = [ + (Story) => ( +
+ + +
+ ), +]; diff --git a/src/components/InputBase/InputBase.tsx b/src/components/InputBase/InputBase.tsx new file mode 100644 index 000000000..758de2fc1 --- /dev/null +++ b/src/components/InputBase/InputBase.tsx @@ -0,0 +1,169 @@ +import React, { + ChangeEvent, + FocusEvent, + useState, + useEffect, + useRef, + useCallback, +} from 'react'; +import { InputBaseProps, ValidationStatus } from './types'; +import { + StyledInput, + Message, + IconWrapper, + StyledWrapper, + ClearButton, +} from './styled'; +import { ThemeProvider } from 'styled-components'; +import { Loader, X } from 'lucide-react'; +import theme from '../../theme'; + +const InputBase: React.FC = ({ + value: propValue, + defaultValue, + onChange, + onBlur, + onFocus, + placeholder, + disabled, + readOnly, + type = 'text', + name, + id, + autoComplete, + maxLength, + minLength, + size = 'md', + className, + style, + required, + pattern, + customValidation, + errorMessage: customErrorMessage, + successMessage, + validationStatus: externalValidationStatus, + validationTiming = 'onBlur', + loading, + iconLeft, + iconRight, + ariaLabel, + ariaDescribedBy, + ariaInvalid, + ariaRequired, + ariaDisabled, + autoFocus, + clearButton, + customClearButton, + customStyles, + onClear, +}) => { + const [internalValue, setInternalValue] = useState( + defaultValue || propValue || '' + ); + const [isFocused, setIsFocused] = useState(false); + const [internalValidationStatus, setInternalValidationStatus] = + useState(''); + const [errorMessage, setErrorMessage] = useState(null); + const inputRef = useRef(null); + + const hasIconLeft = !!iconLeft; + const hasIconRight = !!iconRight || clearButton || loading; + + // Determine the current value to use + const value = propValue !== undefined ? propValue : internalValue; + + // Handle validation logic + const validateInput = useCallback( + (inputValue: string) => { + let status: ValidationStatus = ''; + let errorMsg: React.ReactNode = null; + + if (required && !inputValue) { + status = 'error'; + errorMsg = customErrorMessage || 'This field is required.'; + } else if (pattern && !new RegExp(pattern).test(inputValue)) { + status = 'error'; + errorMsg = customErrorMessage || 'Invalid format.'; + } else if ( + customValidation && + (errorMsg = customValidation(inputValue)) + ) { + status = 'error'; + } else if (inputValue && successMessage && !errorMsg) { + status = 'success'; + } + + setInternalValidationStatus(status); + setErrorMessage(errorMsg); + return status; + }, + [required, pattern, customValidation, customErrorMessage, successMessage] + ); + + // Handle changes + const handleChange = (e: ChangeEvent) => { + const inputValue = e.target.value; + setInternalValue(inputValue); + + if (validationTiming === 'onChange') { + validateInput(inputValue); + } + + onChange?.(e); + }; + + // Handle focus + const handleFocus = (e: FocusEvent) => { + setIsFocused(true); + onFocus?.(e); + }; + + // Handle blur + const handleBlur = (e: FocusEvent) => { + setIsFocused(false); + if (validationTiming === 'onBlur') { + validateInput(value); + } + onBlur?.(e); + }; + + const validationStatus = externalValidationStatus || internalValidationStatus; + const handleClear = () => { + const event = new Event('input', { bubbles: true }); + inputRef.current?.dispatchEvent(event); + // Dispatch an additional 'change' event for better compatibility + const changeEvent = new Event('change', { bubbles: true }); + inputRef.current?.dispatchEvent(changeEvent); + if (propValue !== undefined) { + onChange?.(event as unknown as ChangeEvent); + } else { + setInternalValue(''); + } + setInternalValidationStatus(''); + setErrorMessage(null); + onClear?.(); + }; + useEffect(() => { + // Perform validation on mount if validationTiming is 'onChange' + // Or if there's an external validation status + if (validationTiming === 'onChange' || externalValidationStatus) { + validateInput(value); + } + }, [ + value, + validationTiming, + externalValidationStatus, + validateInput, + required, + pattern, + customValidation, + ]); // Include dependencies + + return ( + + {errorMessage && {errorMessage}} + + ); +}; + +export default InputBase; diff --git a/src/components/InputBase/index.ts b/src/components/InputBase/index.ts new file mode 100644 index 000000000..fb1cc9a16 --- /dev/null +++ b/src/components/InputBase/index.ts @@ -0,0 +1,2 @@ +export { default } from './InputBase'; +export * from './types'; diff --git a/src/components/InputBase/styled.ts b/src/components/InputBase/styled.ts new file mode 100644 index 000000000..65fdcc50f --- /dev/null +++ b/src/components/InputBase/styled.ts @@ -0,0 +1,192 @@ +import styled, { css } from 'styled-components'; +import { InputSize, ValidationStatus, InputBaseProps } from './types'; + +const getSizeStyles = (size: InputSize) => { + const sizeMap: Record = { + sm: css` + padding: ${({ theme }) => theme.space['xs']}; + font-size: ${({ theme }) => theme.fontSizes['12']}; + `, + md: css` + padding: ${({ theme }) => theme.space['sm']}; + font-size: ${({ theme }) => theme.fontSizes['14']}; + `, + lg: css` + padding: ${({ theme }) => theme.space['md']}; + font-size: ${({ theme }) => theme.fontSizes['16']}; + `, + }; + return sizeMap[size]; +}; + +const getValidationStatusStyles = (status: ValidationStatus) => { + const statusMap: Record = { + error: css` + border-color: ${({ theme }) => theme.colors.danger}; + &:focus { + box-shadow: 0 0 0 2px ${({ theme }) => theme.colors['danger-200']}; + } + `, + success: css` + border-color: ${({ theme }) => theme.colors.success}; + &:focus { + box-shadow: 0 0 0 2px ${({ theme }) => theme.colors['success-200']}; + } + `, + warning: css` + border-color: ${({ theme }) => theme.colors.warning}; + &:focus { + box-shadow: 0 0 0 2px ${({ theme }) => theme.colors['warning-200']}; + } + `, + default: css` + border-color: ${({ theme }) => theme.colors['default-border']}; + &:focus { + box-shadow: 0 0 0 2px ${({ theme }) => theme.colors['primary-200']}; + } + `, + '': css``, + }; + return statusMap[status]; +}; + +interface StyledWrapperProps { + $hasIconLeft: boolean; + $hasIconRight: boolean; +} +export const StyledWrapper = styled.div` + position: relative; + display: inline-flex; + align-items: center; + width: 100%; + ${({ $hasIconLeft, $hasIconRight }) => + ($hasIconLeft || $hasIconRight) && + css` + flex-direction: row; + `} +`; + +interface StyledInputProps extends InputBaseProps { + $hasIconLeft: boolean; + $hasIconRight: boolean; +} + +export const StyledInput = styled.input` + width: 100%; + border: 1px solid ${({ theme }) => theme.colors.border}; + border-radius: ${({ theme }) => theme.radius.md}; + background-color: ${({ theme }) => theme.colors.white}; + color: ${({ theme }) => theme.colors.text}; + outline: none; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; + + ${({ size }) => size && getSizeStyles(size)} + + &:hover { + border-color: ${({ theme }) => theme.colors['primary-600']}; + } + + &:focus { + border-color: ${({ theme }) => theme.colors.primary}; + box-shadow: 0 0 0 2px ${({ theme }) => theme.colors['primary-200']}; + } + + &:disabled { + background-color: ${({ theme }) => theme.colors['light-200']}; + color: ${({ theme }) => theme.colors['text-muted']}; + cursor: not-allowed; + border-color: ${({ theme }) => theme.colors['light-400']}; + } + &[readonly] { + background-color: ${({ theme }) => theme.colors['light-200']}; + cursor: default; + border-color: ${({ theme }) => theme.colors['light-400']}; + } + + ${({ validationStatus }) => + getValidationStatusStyles(validationStatus || 'default')} + ${({ $hasIconLeft, theme }) => + $hasIconLeft && + `padding-left: calc(${ + theme.space.md + } + ${theme.fontSizes.medium} + ${theme.space.sm});`} + ${({ $hasIconRight, theme }) => + $hasIconRight && + `padding-right: calc(${ + theme.space.md + } + ${theme.fontSizes.medium} + ${theme.space.sm});`} + + ${({ theme, customStyles }) => + customStyles && + css` + ${customStyles} + `} +`; + +interface IconWrapperProps { + $position: 'left' | 'right'; + $validationStatus: ValidationStatus; + $hasIconLeft: boolean; + $hasIconRight: boolean; +} + +export const IconWrapper = styled.span` + position: absolute; + ${({ $position, $hasIconLeft, $hasIconRight }) => { + if ($position === 'left' && $hasIconLeft) { + return `left: 8px;`; + } else if ($position === 'right' && $hasIconRight) { + return `right: 8px;`; + } else if (!$hasIconLeft && !$hasIconRight && $position === 'right') { + return `right: 8px;`; + } else if (!$hasIconLeft && !$hasIconRight && $position === 'left') { + return `left: 8px;`; + } + }} + top: 50%; + transform: translateY(-50%); + color: ${({ theme, $validationStatus }) => + $validationStatus === 'success' + ? theme.colors.success + : $validationStatus === 'error' + ? theme.colors.danger + : $validationStatus === 'warning' + ? theme.colors.warning + : theme.colors.text}; + display: flex; +`; + +export const Message = styled.div` + margin-top: ${({ theme }) => theme.space.xs}; + font-size: ${({ theme }) => theme.fontSizes['12']}; + color: ${({ theme }) => theme.colors.danger}; +`; + +export const ClearButton = styled.button` + position: absolute; + right: ${({ theme }) => theme.space.sm}; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + padding: 0; + color: ${({ theme }) => theme.colors.text}; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s ease; + &:hover { + color: ${({ theme }) => theme.colors.primary}; + } + &:focus { + outline: none; + box-shadow: 0 0 0 2px ${({ theme }) => theme.colors['primary-200']}; + } + svg { + width: ${({ theme }) => theme.fontSizes.medium}; + height: ${({ theme }) => theme.fontSizes.medium}; + } +`; diff --git a/src/components/InputBase/types.ts b/src/components/InputBase/types.ts new file mode 100644 index 000000000..ac0288986 --- /dev/null +++ b/src/components/InputBase/types.ts @@ -0,0 +1,47 @@ +import { ChangeEvent, FocusEvent, ReactNode } from 'react'; +import Theme, { Interpolation } from 'styled-components'; + +export type InputSize = 'sm' | 'md' | 'lg'; +export type ValidationStatus = 'error' | 'success' | 'warning' | 'default' | ''; +export type ValidationTiming = 'onBlur' | 'onChange'; + +export interface InputBaseProps { + value?: string; + defaultValue?: string; + onChange?: (e: ChangeEvent) => void; + onBlur?: (e: FocusEvent) => void; + onFocus?: (e: FocusEvent) => void; + placeholder?: string; + disabled?: boolean; + readOnly?: boolean; + type?: string; + name?: string; + id?: string; + autoComplete?: string; + maxLength?: number; + minLength?: number; + size?: InputSize; + className?: string; + style?: React.CSSProperties; + ref?: React.Ref; + required?: boolean; + pattern?: string; + customValidation?: (value: string) => string | null; + errorMessage?: ReactNode; + successMessage?: ReactNode; + validationStatus?: ValidationStatus; + validationTiming?: ValidationTiming; + loading?: boolean; + iconLeft?: ReactNode; + iconRight?: ReactNode; + ariaLabel?: string; + ariaDescribedBy?: string; + ariaInvalid?: boolean; + ariaRequired?: boolean; + ariaDisabled?: boolean; + autoFocus?: boolean; + clearButton?: boolean; + customClearButton?: ReactNode; + customStyles?: Interpolation; + onClear?: () => void; +} diff --git a/src/components/InputLabel/InputLabel.stories.tsx b/src/components/InputLabel/InputLabel.stories.tsx new file mode 100644 index 000000000..26733f15c --- /dev/null +++ b/src/components/InputLabel/InputLabel.stories.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { Meta, StoryFn } from '@storybook/react'; +import InputLabel from './InputLabel'; +import { InputLabelProps } from './types'; + +export default { + title: 'Components/InputLabel', + component: InputLabel, + argTypes: { + position: { + control: { type: 'radio' }, + options: ['top', 'left'], + }, + tooltipPlacement: { + control: { type: 'select' }, + options: [ + 'top', + 'top-start', + 'top-end', + 'right', + 'right-start', + 'right-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'left', + 'left-start', + 'left-end', + ], + }, + }, +} as Meta; + +const Template: StoryFn = (args) => ( +
+ +
+); + +export const Default = Template.bind({}); +Default.args = { + htmlFor: 'input-id', + label: 'Input Label', +}; + +export const Required = Template.bind({}); +Required.args = { + htmlFor: 'input-id', + label: 'Required Input Label', + required: true, +}; + +export const Optional = Template.bind({}); +Optional.args = { + htmlFor: 'input-id', + label: 'Optional Input Label', + optional: true, +}; + +export const Disabled = Template.bind({}); +Disabled.args = { + htmlFor: 'input-id', + label: 'Disabled Input Label', + disabled: true, +}; + +export const PositionLeft = Template.bind({}); +PositionLeft.args = { + htmlFor: 'input-id', + label: 'Left Positioned Label', + position: 'left', +}; + +export const WithTooltip = Template.bind({}); +WithTooltip.args = { + htmlFor: 'input-id', + label: 'Label with Tooltip', + tooltip: 'This is a tooltip message!', + tooltipPlacement: 'right', +}; diff --git a/src/components/InputLabel/InputLabel.tsx b/src/components/InputLabel/InputLabel.tsx new file mode 100644 index 000000000..48c5e5b77 --- /dev/null +++ b/src/components/InputLabel/InputLabel.tsx @@ -0,0 +1,71 @@ +import React, { forwardRef } from 'react'; +import { ThemeProvider } from 'styled-components'; +import theme from '../../theme'; +import { + StyledLabel, + StyledIndicator, + StyledLabelContainer, + StyledRequired, + StyledOptional, +} from './styled'; +import { InputLabelProps } from './types'; +import Tooltip from '../Tooltip'; + +const InputLabel = forwardRef( + ( + { + htmlFor, + label, + required = false, + optional = false, + disabled = false, + position = 'top', + tooltip = null, + tooltipPlacement = 'top', + className, + style, + }, + ref + ) => { + if (!htmlFor) { + console.error('InputLabel requires htmlFor prop'); + } + + const RequiredIndicator = *; + + const OptionalIndicator = (optional); + + return ( + + + + {tooltip ? ( + + {label} + + ) : ( + label + )} + {required && RequiredIndicator} + {optional && !required && OptionalIndicator} + + + + ); + } +); + +InputLabel.displayName = 'InputLabel'; + +export default InputLabel; diff --git a/src/components/InputLabel/index.ts b/src/components/InputLabel/index.ts new file mode 100644 index 000000000..af38bfcfa --- /dev/null +++ b/src/components/InputLabel/index.ts @@ -0,0 +1,2 @@ +export { default } from './InputLabel'; +export * from './types'; diff --git a/src/components/InputLabel/plan.md b/src/components/InputLabel/plan.md new file mode 100644 index 000000000..9ff890e81 --- /dev/null +++ b/src/components/InputLabel/plan.md @@ -0,0 +1,110 @@ +# DEMO InputLabel + +Để xây dựng một InputLabel component hoàn chỉnh, bạn cần đảm bảo các tiêu chí sau: + +### 1. Props & Cấu hình cơ bản + +**Thuộc tính cần thiết:** + +- `htmlFor`: ID của input field mà label này liên kết tới. **Bắt buộc**. +- `label`: Nội dung text của label. **Bắt buộc**. +- `required`: (boolean) Đánh dấu label là bắt buộc (thường hiển thị dấu \*). Mặc định: `false`. +- `optional`: (boolean) Đánh dấu label là tùy chọn (thường hiển thị "(optional)"). Mặc định: `false`. +- `disabled`: (boolean) Vô hiệu hóa label (thường làm mờ đi). Mặc định: `false`. +- `position`: (string) Vị trí của label so với input (`"top"`, `"left"`). Mặc định: `"top"`. +- `tooltip`: (string | React.ReactNode) Nội dung hiển thị khi di chuột qua label (dùng để hiển thị help text). +- `tooltipPlacement`: (string) Vị trí hiển thị tooltip (`"top"`, `"right"`, `"bottom"`, `"left"`, `"top-start"`, `"top-end"`, `"bottom-start"`, `"bottom-end"`, `"right-start"`, `"right-end"`, `"left-start"`, `"left-end"`). Mặc định: `"top"`. +- `className/style`: Custom styling cho label. +- `ref`: React ref cho DOM access. + +### 2. Typography & Màu sắc + +**Typography:** + +- `font-family`: Nên sử dụng font-family phù hợp với toàn bộ design system. +- `font-size`: Kích thước font chữ dễ đọc, thường nhỏ hơn font chữ nội dung chính một chút (ví dụ: 14px - 16px). +- `font-weight`: Thường sử dụng font-weight `normal` hoặc `medium`. +- `line-height`: Đảm bảo line-height đủ thoáng để dễ đọc (ví dụ: 1.5). + +**Màu sắc:** + +- `color`: Màu sắc của label cần có độ tương phản tốt với nền. Nên sử dụng màu sắc từ design system. +- `disabledColor`: Màu sắc khi label bị `disabled`, thường là màu xám nhạt. +- Màu sắc cho `required` indicator (thường là màu đỏ). +- Màu sắc cho `optional` indicator (thường là màu xám). + +### 3. Hiển thị & Bố cục + +**Indicators:** + +- `required`: Hiển thị dấu `*` màu đỏ cạnh label text. +- `optional`: Hiển thị `(optional)` màu xám cạnh label text. +- Ưu tiên `required` hơn `optional`. Chỉ hiển thị 1 trong 2. + +**Căn chỉnh (position):** + +- `top`: Label nằm phía trên input. +- `left`: Label nằm bên trái input. Khi dùng `left`, nên có chiều rộng cố định cho label để các label trên cùng form được căn gióng thẳng hàng. +- Canh giữa nội dung label theo chiều dọc (vertical-align: middle) khi `position` là `left`. + +**Khoảng cách:** + +- `marginBottom` (khi `position="top"`): Khoảng cách giữa label và input. Nên có khoảng cách hợp lý để phân biệt rõ ràng label và input, nhưng không quá xa (ví dụ: 4px - 8px). +- `marginRight` (khi `position="left"`): Khoảng cách giữa label và input (ví dụ: 8px - 16px). + +### 4. Accessibility (A11y) + +**Liên kết với input:** + +- Sử dụng thuộc tính `htmlFor` để liên kết label với input field tương ứng. Điều này giúp cho screen reader đọc được label khi focus vào input. +- `htmlFor` phải khớp với `id` của input. + +**ARIA Attributes:** + +- Khi `disabled`, thêm `aria-disabled="true"`. +- `role="label"` có thể thêm vào để hỗ trợ tốt hơn, nhưng thường không bắt buộc vì đã có `