From 8c97dd80a6f3be52e9457b359e627e845dbdec19 Mon Sep 17 00:00:00 2001 From: Matthew Ngo Date: Mon, 13 Jan 2025 22:36:01 +0700 Subject: [PATCH 01/43] chore --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 460de76d..d90410b3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,7 +32,7 @@ // `tsdx build` now takes care of creating the .d.ts files. "outDir": "./dist", "emitDeclarationOnly": false, - "noEmit": false, // Changed to false to emit output files, + "noEmit": false // Changed to false to emit output files, // "paths": { // "@/*": ["./src/*"], // "features/*": ["./src/features/*"], From bc109f65372e9b5593f2dbb58db656297b878f0b Mon Sep 17 00:00:00 2001 From: Matthew Ngo Date: Tue, 14 Jan 2025 14:00:46 +0700 Subject: [PATCH 02/43] chore: add theme --- src/theme/index.ts | 372 ++++++++++++++++++++++++++++++++------------- 1 file changed, 266 insertions(+), 106 deletions(-) diff --git a/src/theme/index.ts b/src/theme/index.ts index d2433ea9..c6399cae 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -1,40 +1,14 @@ import { DefaultTheme } from 'styled-components'; /** - * Default theme for the form. + * Base theme with standardized values. */ -export const defaultTheme: DefaultTheme = { +const baseTheme = { colors: { + // Primary Colors primary: '#FF902F', + primaryLight: '#FFB97C', // Adjusted based on the provided shades 'primary-hover': '#e5791e', - secondary: '#212529', - 'secondary-hover': '#0d0f10', - success: '#55CE63', - 'success-hover': '#39b248', - info: '#009EFB', - 'info-hover': '#0075c7', - warning: '#FFBC34', - 'warning-hover': '#e5a62a', - danger: '#FC133D', - 'danger-hover': '#e51037', - blue: '#00c5fb', - 'blue-hover': '#0097c7', - maroon: '#f43b48', - 'maroon-hover': '#dc2734', - violet: '#667eea', - 'violet-hover': '#5063c9', - dark: '#29344a', - light: '#D5D8DA', - white: '#FFF', - black: '#000', - purple: '#9368E9', - pink: '#FC6075', - teal: '#02a8b5', - cyan: '#299cdb', - green: '#35BA67', - orange: '#fbc418', - indigo: '#4d5ddb', - yellow: '#ffd200', 'primary-100': '#FFF5EC', 'primary-200': '#FFEBDA', 'primary-300': '#FFE1C7', @@ -45,6 +19,11 @@ export const defaultTheme: DefaultTheme = { 'primary-800': '#FFAF69', 'primary-900': '#FF8012', 'primary-1000': '#FF801F', + + // Secondary Colors + secondary: '#212529', + secondaryLight: '#646669', // Adjusted based on the provided shades + 'secondary-hover': '#0d0f10', 'secondary-100': '#E9E9EA', 'secondary-200': '#D3D3D4', 'secondary-300': '#BCBEBF', @@ -54,15 +33,11 @@ export const defaultTheme: DefaultTheme = { 'secondary-700': '#646669', 'secondary-800': '#4D5154', 'secondary-900': '#373B3E', - 'light-100': '#FCFCFC', - 'light-200': '#F9F9F9', - 'light-300': '#F5F6F7', - 'light-400': '#F2F3F4', - 'light-500': '#EFF0F1', - 'light-600': '#ECEDEE', - 'light-700': '#E9EAEB', - 'light-800': '#E5E7E9', - 'light-900': '#E2E4E6', + + // Success Colors + success: '#55CE63', + successLight: '#88DD92', // Adjusted based on the provided shades + 'success-hover': '#39b248', 'success-100': '#EEFAEF', 'success-200': '#DDF5E0', 'success-300': '#CCF0D0', @@ -72,6 +47,11 @@ export const defaultTheme: DefaultTheme = { 'success-700': '#88DD92', 'success-800': '#77D882', 'success-900': '#66D373', + + // Danger Colors + danger: '#FC133D', + dangerLight: '#F96C85', // Adjusted based on the provided shades + 'danger-hover': '#e51037', 'danger-100': '#FEEAEE', 'danger-200': '#FDD5DC', 'danger-300': '#FCC0CB', @@ -81,6 +61,11 @@ export const defaultTheme: DefaultTheme = { 'danger-700': '#F96C85', 'danger-800': '#F85774', 'danger-900': '#F74262', + + // Info Colors + info: '#009EFB', + infoLight: '#4DBBFC', // Adjusted based on the provided shades + 'info-hover': '#0075c7', 'info-100': '#E6F5FF', 'info-200': '#CCECFE', 'info-300': '#B3E2FE', @@ -90,6 +75,11 @@ export const defaultTheme: DefaultTheme = { 'info-700': '#4DBBFC', 'info-800': '#33B1FC', 'info-900': '#1AA8FB', + + // Warning Colors + warning: '#FFBC34', + warningLight: '#FFD071', // Adjusted based on the provided shades + 'warning-hover': '#e5a62a', 'warning-100': '#FFF8EB', 'warning-200': '#FFF2D6', 'warning-300': '#FFEBC2', @@ -99,6 +89,21 @@ export const defaultTheme: DefaultTheme = { 'warning-700': '#FFD071', 'warning-800': '#FFC95D', 'warning-900': '#FFC348', + + // Other Colors + blue: '#00c5fb', + 'blue-hover': '#0097c7', + maroon: '#f43b48', + 'maroon-hover': '#dc2734', + violet: '#667eea', + 'violet-hover': '#5063c9', + dark: '#29344a', + light: '#D5D8DA', + white: '#FFF', + black: '#000', + + // Additional Colors + purple: '#9368E9', 'purple-100': '#F4F0FD', 'purple-200': '#E9E1FB', 'purple-300': '#DFD2F8', @@ -108,6 +113,8 @@ export const defaultTheme: DefaultTheme = { 'purple-700': '#B395F0', 'purple-800': '#A986ED', 'purple-900': '#9E77EB', + + pink: '#FC6075', 'pink-100': '#FFEFF1', 'pink-200': '#FEDFE3', 'pink-300': '#FECFD6', @@ -117,19 +124,42 @@ export const defaultTheme: DefaultTheme = { 'pink-700': '#FD909E', 'pink-800': '#FD8091', 'pink-900': '#FF445D', + teal: '#02a8b5', + cyan: '#299cdb', + green: '#35BA67', + orange: '#fbc418', + indigo: '#4d5ddb', + yellow: '#ffd200', + + 'light-100': '#FCFCFC', + 'light-200': '#F9F9F9', + 'light-300': '#F5F6F7', + 'light-400': '#F2F3F4', + 'light-500': '#EFF0F1', + 'light-600': '#ECEDEE', + 'light-700': '#E9EAEB', + 'light-800': '#E5E7E9', + 'light-900': '#E2E4E6', + + // Text Colors text: '#5B6670', 'title-color': '#373B3E', 'sub-title': '#4D5154', + 'text-muted': '#9595b5', + + // Background Colors background: '#f7f7f7', 'body-dark-bg': '#263238', 'wrapper-bg': '#f1f5f6', - 'text-muted': '#9595b5', + 'default-background': '#f7f8f9', 'black-bg': '#16191c', + + // Other Theming Colors 'theme-title': '#97A2D2', 'input-bg': '#2c2c50', 'form-control-bg': '#FFF', - 'default-background': '#f7f8f9', - // Social Icons Colors + + // Social Media Colors facebook: '#3B5998', twitter: '#00ACEE', google: '#DD4B39', @@ -154,97 +184,227 @@ export const defaultTheme: DefaultTheme = { yahoo: '#720E9E', gplus: '#DD4B39', appstore: '#000', - // Gradient Variables + + // Gradient Colors 'primary-gradient': 'linear-gradient(90.31deg, #FF902F -1.02%, #FF2D3D 132.59%)', 'blue-gradient': 'linear-gradient(to right, #00c5fb 0%, #0253cc 100%)', 'maroon-gradient': 'linear-gradient(to right, #f43b48 0%, #453a94 100%)', 'violet-gradient': 'linear-gradient(to right, #667eea 0%, #764ba2 100%)', + + // Border Colors border: '#D3D3D4', 'default-border': '#A6A8A9', 'dark-border': '#2e3840', 'input-border': '#C6C6C6', error: '#dc3545', }, - space: { - xs: '2px', - sm: '4px', - md: '6.5px', - lg: '12px', - xl: '16px', - '2xl': '24px', - '3xl': '32px', - '4xl': '40px', - '5xl': '48px', - '6xl': '52px', - '7xl': '64px', - '8xl': '72px', - '9xl': '80px', + spacing: { + gridUnit: 4, // Define the base grid unit + spacingXXS: 2, // Added XXS spacing + spacingXS: 4, + spacingSM: 8, + spacingMD: 16, + spacingLG: 24, + spacingXL: 32, + spacingXXL: 48, + spacing3XL: 64, + spacing4XL: 80, + // Add more spacing if necessary }, - fontSizes: { - '8': '8px', - '9': '9px', - '10': '10px', - '11': '11px', - '12': '12px', - '13': '13px', - '14': '14px', - small: '13px', - medium: '15px', - '17': '17px', - '18': '18px', - '19': '19px', - large: '20px', - '22': '22px', - '23': '23px', - '24': '24px', - '26': '26px', - '28': '28px', - '30': '30px', - '32': '32px', - '34': '34px', - '36': '36px', - '40': '40px', - '42': '42px', - '48': '48px', - '50': '50px', - '54': '54px', - '60': '60px', - h1: '2rem', + typography: { + primaryFont: 'Helvetica Neue, Arial, sans-serif', // Added a generic font family + // Define font sizes based on the grid unit + fontSizeXXS: 8, // Added XXS font size + fontSizeXS: 10, // Added XS font size + fontSizeSM: 13, + fontSizeMD: 15, + fontSizeLG: 20, + fontSizeXL: 24, + fontSizeXXL: 30, + fontSize3XL: 36, + fontSize4XL: 48, + + // Define font weights + fontWeightLight: 300, + fontWeightRegular: 400, + fontWeightMedium: 500, + fontWeightBold: 700, + + // Define headings + h1: '2rem', // Relative to the base font size h2: '1.75rem', h3: '1.5rem', h4: '1.25rem', h5: '1.15rem', h6: '1rem', }, + borders: { + // Define border widths + borderWidth: 1, + borderWidthSM: 1, + borderWidthMD: 2, + borderWidthLG: 4, + // Define border radius + borderRadiusSM: 4, + borderRadiusMD: 6, + borderRadiusLG: 8, + borderRadiusXL: 10, + borderRadius2XL: 12, + borderRadiusRounded: '50%', + borderRadiusPill: '1.5rem', + }, + breakpoints: { + // Define breakpoints based on common screen sizes + breakpointXS: '0px', + breakpointSM: '576px', + breakpointMD: '768px', + breakpointLG: '992px', + breakpointXL: '1200px', + breakpointXXL: '1400px', + }, + shadows: { + // Define shadows + shadowSM: '0px 4px 8px 0px rgba(255, 255, 255, 0.15)', // Added light color for better visibility + shadowMD: '0px 4px 16px 0px rgba(255, 255, 255, 0.7)', // Added light color for better visibility + shadowLG: '0px 4px 24px 0px #BCBCBC40', + }, + transitions: { + // Define transitions + transitionXS: '0.1s', + transitionSM: '0.2s', + transitionMD: '0.3s', // Default transition + transitionLG: '0.4s', + transitionXL: '0.5s', + transitionEase: 'ease', + transitionEaseIn: 'ease-in', + transitionEaseOut: 'ease-out', + transitionEaseInOut: 'ease-in-out', + transitionLinear: 'linear', + // Predefined transition effects + transitionNormal: 'all 0.3s ease', + transitionFast: 'all 0.2s ease-in-out', + transitionSlow: 'all 0.4s ease', + transitionButton: + 'background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease', + transitionInput: 'border-color 0.2s ease, box-shadow 0.2s ease', + }, + componentDefaults: { + // Define component defaults + buttonPadding: '12px 24px', // Using spacingMD + inputPadding: '12px', + inputBorder: '1px solid #C6C6C6', + inputBorderFocus: '1px solid #FF902F', + buttonPrimaryBackground: '#FF902F', + buttonPrimaryColor: '#FFF', + }, + darkMode: { + // Define dark mode colors + darkBackground: '#333', + darkText: '#FFF', + darkBorder: '#666', + // Add more dark mode colors if necessary + }, +}; + +/** + * Default theme for the form. + * Inherits from baseTheme and overrides specific values. + */ +export const defaultTheme: DefaultTheme = { + ...baseTheme, + colors: { + ...baseTheme.colors, + // Override any specific colors here if needed + }, + space: { + ...baseTheme.spacing, + xs: `${baseTheme.spacing.spacingXS}px`, + sm: `${baseTheme.spacing.spacingSM}px`, + md: `${baseTheme.spacing.spacingMD}px`, + lg: `${baseTheme.spacing.spacingLG}px`, + xl: `${baseTheme.spacing.spacingXL}px`, + '2xl': `${baseTheme.spacing.spacingXXL}px`, + '3xl': `${baseTheme.spacing.spacing3XL}px`, + '4xl': `${baseTheme.spacing.spacing4XL}px`, + '5xl': `${baseTheme.spacing.spacingXXL * 1.5}px`, // Example of calculated value + '6xl': `${baseTheme.spacing.spacingXXL * 2}px`, + '7xl': `${baseTheme.spacing.spacing3XL * 1.5}px`, + '8xl': `${baseTheme.spacing.spacing3XL * 2}px`, + '9xl': `${baseTheme.spacing.spacing4XL * 1.5}px`, + // Add more custom spacing if necessary + }, + fontSizes: { + ...baseTheme.typography, + '8': `${baseTheme.typography.fontSizeXXS}px`, + '9': `${baseTheme.typography.fontSizeXS}px`, + '10': `${baseTheme.typography.fontSizeXS + 2}px`, + '11': `${baseTheme.typography.fontSizeXS + 3}px`, + '12': `${baseTheme.typography.fontSizeSM - 1}px`, + '13': `${baseTheme.typography.fontSizeSM}px`, + '14': `${baseTheme.typography.fontSizeSM + 1}px`, + small: `${baseTheme.typography.fontSizeSM}px`, + medium: `${baseTheme.typography.fontSizeMD}px`, + '17': `${baseTheme.typography.fontSizeMD + 2}px`, + '18': `${baseTheme.typography.fontSizeMD + 3}px`, + '19': `${baseTheme.typography.fontSizeMD + 4}px`, + large: `${baseTheme.typography.fontSizeLG}px`, + '22': `${baseTheme.typography.fontSizeLG + 2}px`, + '23': `${baseTheme.typography.fontSizeLG + 3}px`, + '24': `${baseTheme.typography.fontSizeXL}px`, + '26': `${baseTheme.typography.fontSizeXL + 2}px`, + '28': `${baseTheme.typography.fontSizeXL + 4}px`, + '30': `${baseTheme.typography.fontSizeXXL}px`, + '32': `${baseTheme.typography.fontSizeXXL + 2}px`, + '34': `${baseTheme.typography.fontSizeXXL + 4}px`, + '36': `${baseTheme.typography.fontSize3XL}px`, + '40': `${baseTheme.typography.fontSize3XL + 4}px`, + '42': `${baseTheme.typography.fontSize3XL + 6}px`, + '48': `${baseTheme.typography.fontSize4XL}px`, + '50': `${baseTheme.typography.fontSize4XL + 2}px`, + '54': `${baseTheme.typography.fontSize4XL + 6}px`, + '60': `${baseTheme.typography.fontSize4XL + 12}px`, + h1: baseTheme.typography.h1, + h2: baseTheme.typography.h2, + h3: baseTheme.typography.h3, + h4: baseTheme.typography.h4, + h5: baseTheme.typography.h5, + h6: baseTheme.typography.h6, + }, fontWeights: { + ...baseTheme.typography, lighter: 'lighter', - light: 300, - normal: 400, - medium: 500, - semibold: 600, - bold: 700, + light: baseTheme.typography.fontWeightLight, + normal: baseTheme.typography.fontWeightRegular, + medium: baseTheme.typography.fontWeightMedium, + semibold: baseTheme.typography.fontWeightBold - 100, + bold: baseTheme.typography.fontWeightBold, bolder: 'bolder', }, radius: { - sm: '4px', - md: '6px', - lg: '8px', - xl: '10px', - '2xl': '12px', - rounded: '50%', - pill: '1.5rem', + ...baseTheme.borders, + sm: `${baseTheme.borders.borderRadiusSM}px`, + md: `${baseTheme.borders.borderRadiusMD}px`, + lg: `${baseTheme.borders.borderRadiusLG}px`, + xl: `${baseTheme.borders.borderRadiusXL}px`, + '2xl': `${baseTheme.borders.borderRadius2XL}px`, + rounded: `${baseTheme.borders.borderRadiusRounded}`, + pill: `${baseTheme.borders.borderRadiusPill}`, }, breakpoints: { - sm: '576px', - md: '768px', - lg: '992px', - xl: '1200px', + ...baseTheme.breakpoints, + // Use the same values as baseTheme + sm: baseTheme.breakpoints.breakpointSM, + md: baseTheme.breakpoints.breakpointMD, + lg: baseTheme.breakpoints.breakpointLG, + xl: baseTheme.breakpoints.breakpointXL, }, shadows: { - sm: '0px 4px 8px 0px rgba(255, 255, 255, 0.15)', - md: '0px 4px 16px 0px rgba(255, 255, 255, 0.7)', - lg: '0px 4px 24px 0px #BCBCBC40', + ...baseTheme.shadows, + // Use the same values as baseTheme + sm: baseTheme.shadows.shadowSM, + md: baseTheme.shadows.shadowMD, + lg: baseTheme.shadows.shadowLG, }, }; From 8e27bdd6e15df7a889cf85dbdd54d0782cbf4075 Mon Sep 17 00:00:00 2001 From: Matthew Ngo Date: Tue, 14 Jan 2025 14:32:12 +0700 Subject: [PATCH 03/43] wip: add inputbase --- .../InputBase/InputBase.stories.tsx | 205 +++++++++++++ src/components/InputBase/InputBase.tsx | 277 ++++++++++++++++++ src/components/InputBase/index.ts | 2 + src/components/InputBase/styled.ts | 192 ++++++++++++ src/components/InputBase/types.ts | 47 +++ src/theme/index.ts | 36 +-- src/type.d.ts | 254 +--------------- 7 files changed, 742 insertions(+), 271 deletions(-) create mode 100644 src/components/InputBase/InputBase.stories.tsx create mode 100644 src/components/InputBase/InputBase.tsx create mode 100644 src/components/InputBase/index.ts create mode 100644 src/components/InputBase/styled.ts create mode 100644 src/components/InputBase/types.ts diff --git a/src/components/InputBase/InputBase.stories.tsx b/src/components/InputBase/InputBase.stories.tsx new file mode 100644 index 00000000..d6d370e6 --- /dev/null +++ b/src/components/InputBase/InputBase.stories.tsx @@ -0,0 +1,205 @@ +import React from 'react'; +import { Meta, StoryFn } from '@storybook/react'; +import InputBase from './InputBase'; +import { InputBaseProps } from './types'; + +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', +}; diff --git a/src/components/InputBase/InputBase.tsx b/src/components/InputBase/InputBase.tsx new file mode 100644 index 00000000..b09d48b1 --- /dev/null +++ b/src/components/InputBase/InputBase.tsx @@ -0,0 +1,277 @@ +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 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 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 ( + + + {iconLeft && ( + + {iconLeft} + + )} + + {(iconRight || clearButton || loading) && ( + + {loading ? ( + + + + + + ) : clearButton && value ? ( + customClearButton ? ( + {customClearButton} + ) : ( + + {/* Replace with your own icon */} + + + + + ) + ) : ( + iconRight + )} + + )} + + {errorMessage && {errorMessage}} + + ); +}; + +export default InputBase; diff --git a/src/components/InputBase/index.ts b/src/components/InputBase/index.ts new file mode 100644 index 00000000..fb1cc9a1 --- /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 00000000..65fdcc50 --- /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 00000000..ac028898 --- /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/theme/index.ts b/src/theme/index.ts index c6399cae..8eea3472 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -1,3 +1,4 @@ +import { merge } from 'lodash'; import { DefaultTheme } from 'styled-components'; /** @@ -414,33 +415,10 @@ export const defaultTheme: DefaultTheme = { * @param customTheme - The custom theme object. * @returns The merged theme object. */ -export const createTheme = (customTheme: any): DefaultTheme => { - return { - ...defaultTheme, - ...customTheme, - colors: { - ...defaultTheme.colors, - ...(customTheme.colors || {}), - }, - space: { - ...defaultTheme.space, - ...(customTheme.space || {}), - }, - fontSizes: { - ...defaultTheme.fontSizes, - ...(customTheme.fontSizes || {}), - }, - fontWeights: { - ...defaultTheme.fontWeights, - ...(customTheme.fontWeights || {}), - }, - radius: { - ...defaultTheme.radius, - ...(customTheme.radius || {}), - }, - breakpoints: { - ...defaultTheme.breakpoints, - ...(customTheme.breakpoints || {}), - }, - }; +export const createTheme = >( + customTheme: T +): DefaultTheme => { + return merge({}, defaultTheme, customTheme); }; + +export default createTheme({}); diff --git a/src/type.d.ts b/src/type.d.ts index 5fa1f4fb..65c85e36 100644 --- a/src/type.d.ts +++ b/src/type.d.ts @@ -1,246 +1,16 @@ -import 'styled-components'; +// create styled-components.d.ts in your project source +// if it isn't being picked up, check tsconfig compilerOptions.types +import type { CSSProp } from 'styled-components'; +import Theme from './theme/index'; + +type ThemeType = typeof Theme; declare module 'styled-components' { - export interface DefaultTheme { - colors: { - primary: string; - 'primary-hover': string; - secondary: string; - 'secondary-hover': string; - success: string; - 'success-hover': string; - info: string; - 'info-hover': string; - warning: string; - 'warning-hover': string; - danger: string; - 'danger-hover': string; - blue: string; - 'blue-hover': string; - maroon: string; - 'maroon-hover': string; - violet: string; - 'violet-hover': string; - dark: string; - light: string; - white: string; - black: string; - purple: string; - pink: string; - teal: string; - cyan: string; - green: string; - orange: string; - indigo: string; - yellow: string; - 'primary-100': string; - 'primary-200': string; - 'primary-300': string; - 'primary-400': string; - 'primary-500': string; - 'primary-600': string; - 'primary-700': string; - 'primary-800': string; - 'primary-900': string; - 'primary-1000': string; - 'secondary-100': string; - 'secondary-200': string; - 'secondary-300': string; - 'secondary-400': string; - 'secondary-500': string; - 'secondary-600': string; - 'secondary-700': string; - 'secondary-800': string; - 'secondary-900': string; - 'light-100': string; - 'light-200': string; - 'light-300': string; - 'light-400': string; - 'light-500': string; - 'light-600': string; - 'light-700': string; - 'light-800': string; - 'light-900': string; - 'success-100': string; - 'success-200': string; - 'success-300': string; - 'success-400': string; - 'success-500': string; - 'success-600': string; - 'success-700': string; - 'success-800': string; - 'success-900': string; - 'danger-100': string; - 'danger-200': string; - 'danger-300': string; - 'danger-400': string; - 'danger-500': string; - 'danger-600': string; - 'danger-700': string; - 'danger-800': string; - 'danger-900': string; - 'info-100': string; - 'info-200': string; - 'info-300': string; - 'info-400': string; - 'info-500': string; - 'info-600': string; - 'info-700': string; - 'info-800': string; - 'info-900': string; - 'warning-100': string; - 'warning-200': string; - 'warning-300': string; - 'warning-400': string; - 'warning-500': string; - 'warning-600': string; - 'warning-700': string; - 'warning-800': string; - 'warning-900': string; - 'purple-100': string; - 'purple-200': string; - 'purple-300': string; - 'purple-400': string; - 'purple-500': string; - 'purple-600': string; - 'purple-700': string; - 'purple-800': string; - 'purple-900': string; - 'pink-100': string; - 'pink-200': string; - 'pink-300': string; - 'pink-400': string; - 'pink-500': string; - 'pink-600': string; - 'pink-700': string; - 'pink-800': string; - 'pink-900': string; - text: string; - 'title-color': string; - 'sub-title': string; - background: string; - 'body-dark-bg': string; - 'wrapper-bg': string; - 'text-muted': string; - 'black-bg': string; - 'theme-title': string; - 'input-bg': string; - 'form-control-bg': string; - 'default-background': string; - facebook: string; - twitter: string; - google: string; - telegram: string; - linkedin: string; - youtube: string; - instagram: string; - reddit: string; - pinterest: string; - vk: string; - rss: string; - skype: string; - xing: string; - tumblr: string; - email: string; - delicious: string; - stumbleupon: string; - digg: string; - blogger: string; - flickr: string; - vimeo: string; - yahoo: string; - gplus: string; - appstore: string; - // Gradient Variables - 'primary-gradient': string; - 'blue-gradient': string; - 'maroon-gradient': string; - 'violet-gradient': string; - border: string; - 'default-border': string; - 'dark-border': string; - 'input-border': string; - error: string; - }; - space: { - xs: string; - sm: string; - md: string; - lg: string; - xl: string; - '2xl': string; - '3xl': string; - '4xl': string; - '5xl': string; - '6xl': string; - '7xl': string; - '8xl': string; - '9xl': string; - }; - fontSizes: { - '8': string; - '9': string; - '10': string; - '11': string; - '12': string; - '13': string; - '14': string; - small: string; - medium: string; - '17': string; - '18': string; - '19': string; - large: string; - '22': string; - '23': string; - '24': string; - '26': string; - '28': string; - '30': string; - '32': string; - '34': string; - '36': string; - '40': string; - '42': string; - '48': string; - '50': string; - '54': string; - '60': string; - h1: string; - h2: string; - h3: string; - h4: string; - h5: string; - h6: string; - }; - fontWeights: { - lighter: string; - light: number; - normal: number; - medium: number; - semibold: number; - bold: number; - bolder: string; - }; - radius: { - sm: string; - md: string; - lg: string; - xl: string; - '2xl': string; - rounded: string; - pill: string; - }; - breakpoints: { - sm: string; - md: string; - lg: string; - xl: string; - }; - shadows: { - sm: string; - md: string; - lg: string; - }; + export interface DefaultTheme extends ThemeType {} +} + +declare module 'react' { + interface DOMAttributes { + css?: CSSProp; } } From e005145e93705e9ba780b0d5ffec0e234e40e1a3 Mon Sep 17 00:00:00 2001 From: Matthew Ngo Date: Tue, 14 Jan 2025 14:37:49 +0700 Subject: [PATCH 04/43] wip: update inputbase --- .../InputBase/InputBase.stories.tsx | 4 +- src/components/InputBase/InputBase.tsx | 62 ++++--------------- 2 files changed, 13 insertions(+), 53 deletions(-) diff --git a/src/components/InputBase/InputBase.stories.tsx b/src/components/InputBase/InputBase.stories.tsx index d6d370e6..db576061 100644 --- a/src/components/InputBase/InputBase.stories.tsx +++ b/src/components/InputBase/InputBase.stories.tsx @@ -103,7 +103,7 @@ WithIconLeft.args = { viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" - style={{ width: '1.5rem', height: '1.5rem' }} + style={{ width: '1rem', height: '1rem' }} > = ({ value: propValue, @@ -134,7 +135,7 @@ const InputBase: React.FC = ({ const changeEvent = new Event('change', { bubbles: true }); inputRef.current?.dispatchEvent(changeEvent); if (propValue !== undefined) { - onChange?.(event as ChangeEvent); + onChange?.(event as unknown as ChangeEvent); } else { setInternalValue(''); } @@ -142,7 +143,6 @@ const InputBase: React.FC = ({ setErrorMessage(null); onClear?.(); }; - useEffect(() => { // Perform validation on mount if validationTiming is 'onChange' // Or if there's an external validation status @@ -160,14 +160,14 @@ const InputBase: React.FC = ({ ]); // Include dependencies return ( - - + + {iconLeft && ( {iconLeft} @@ -194,8 +194,8 @@ const InputBase: React.FC = ({ required={required} pattern={pattern} validationStatus={validationStatus} - $hasIconLeft={hasIconLeft} - $hasIconRight={hasIconRight} + $hasIconLeft={!!hasIconLeft} // Changed to boolean + $hasIconRight={!!hasIconRight} // Changed to boolean aria-label={ariaLabel} aria-describedby={ariaDescribedBy} aria-invalid={ @@ -211,56 +211,16 @@ const InputBase: React.FC = ({ $position="right" $validationStatus={validationStatus} $hasIconLeft={hasIconLeft} - $hasIconRight={hasIconRight} + $hasIconRight={!!hasIconRight} > {loading ? ( - - - - - + ) : clearButton && value ? ( customClearButton ? ( {customClearButton} ) : ( - {/* Replace with your own icon */} - - - + ) ) : ( From 94f5ee05b574ab7a99c726f25099ed104b4162d0 Mon Sep 17 00:00:00 2001 From: Matthew Ngo Date: Tue, 14 Jan 2025 15:05:40 +0700 Subject: [PATCH 05/43] wip: add template component --- src/components/Thing/Thing.stories.tsx | 0 src/components/Thing/Thing.tsx | 0 src/components/Thing/hooks.ts | 0 src/components/Thing/index.ts | 0 src/components/Thing/styled.ts | 0 src/components/Thing/types.ts | 0 src/components/Thing/utils.ts | 0 7 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/components/Thing/Thing.stories.tsx create mode 100644 src/components/Thing/Thing.tsx create mode 100644 src/components/Thing/hooks.ts create mode 100644 src/components/Thing/index.ts create mode 100644 src/components/Thing/styled.ts create mode 100644 src/components/Thing/types.ts create mode 100644 src/components/Thing/utils.ts diff --git a/src/components/Thing/Thing.stories.tsx b/src/components/Thing/Thing.stories.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/Thing/Thing.tsx b/src/components/Thing/Thing.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/Thing/hooks.ts b/src/components/Thing/hooks.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/components/Thing/index.ts b/src/components/Thing/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/components/Thing/styled.ts b/src/components/Thing/styled.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/components/Thing/types.ts b/src/components/Thing/types.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/components/Thing/utils.ts b/src/components/Thing/utils.ts new file mode 100644 index 00000000..e69de29b From e9b22d55fe30f891d3c566e57fdd68304837a702 Mon Sep 17 00:00:00 2001 From: Matthew Ngo Date: Tue, 14 Jan 2025 15:05:54 +0700 Subject: [PATCH 06/43] chore: add todos --- src/components/todo.md | 420 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 420 insertions(+) create mode 100644 src/components/todo.md diff --git a/src/components/todo.md b/src/components/todo.md new file mode 100644 index 00000000..c1d0c526 --- /dev/null +++ b/src/components/todo.md @@ -0,0 +1,420 @@ +# COMPONENTS + +### 1. Components Input Cơ bản + +- InputBase: Component nền tảng cho tất cả các loại input +- InputLabel: Label cho các trường input +- InputControl: Wrapper cho input và các elements liên quan +- InputIcon: Component icon có thể tái sử dụng cho các input +- InputHelperText: Text hỗ trợ/mô tả cho input +- InputErrorMessage: Hiển thị thông báo lỗi validation +- InputGroup: Nhóm các input liên quan +- TextAreaControl: Cho phép nhập text nhiều dòng +- NumberInput: Input với validation và format số +- DatePicker: Chọn ngày tháng +- TimePicker: Chọn thời gian +- FileUpload: Upload file với preview và validation +- ColorPicker: Chọn màu sắc + +### 2. Components Select & Options + +- SelectBase: Component cơ bản cho các loại select +- MultiSelect: Cho phép chọn nhiều options +- Combobox: Select có khả năng search và filter +- AutoComplete: Gợi ý khi nhập liệu +- CheckboxGroup: Nhóm các checkbox liên quan +- RadioGroup: Nhóm các radio buttons +- ToggleSwitch: Công tắc bật/tắt + +### 3. Components Hiển thị & Tương tác + +- ScrollArea: Khu vực có thể scroll khi nội dung vượt quá +- Badge: Hiển thị trạng thái hoặc số lượng +- Pill: Hiển thị tags hoặc filters +- Tooltip: Hiển thị thông tin bổ sung khi hover +- Popover: Container cho các menu dropdown/select +- ValidationSummary: Tổng hợp các lỗi validation +- ConfirmDialog: Xác nhận các thao tác quan trọng +- FormActions: Container cho các nút submit/reset +- ProgressIndicator: Hiển thị tiến trình form +- LoadingSpinner: Chỉ thị trạng thái loading +- ValidationIcon: Icons cho trạng thái validation + +### 4. Components Cấu trúc Form + +- FormGroup: Wrapper cho nhóm các form controls +- FormRow: Layout cho một hàng trong form +- FormDivider: Phân tách các sections trong form +- FormMessage: Hiển thị thông báo chung của form +- FormSection: Phân chia form thành các phần +- FormGrid: Layout grid cho các fields +- FormStepper: Hiển thị các bước trong form +- FormAccordion: Mở rộng/thu gọn các phần form +- FormTabs: Chia form thành các tab + +### 5. Components Xử lý Dữ liệu Động + +- FieldArray: Quản lý mảng các fields +- DynamicField: Field có thể thêm/xóa động + +--- + +# IMPLEMENTATIONS + +### 1. Components Input Cơ bản + +**InputBase** + +- Kích thước linh hoạt (sm, md, lg) +- Hỗ trợ custom styles và themes +- Xử lý states: focus, hover, disabled, readonly +- Validation states: error, success, warning +- Tích hợp với keyboard events +- Hỗ trợ accessibility (ARIA labels) +- Placeholder text rõ ràng +- Clear text button (tùy chọn) + +**InputLabel** + +- Typography rõ ràng, dễ đọc +- Màu sắc tương phản phù hợp +- Optional/Required indicator +- Tooltip support cho help text +- Liên kết với input field (htmlFor) +- Căn chỉnh label position (top, left) +- Khoảng cách hợp lý với input + +**InputControl** + +- Layout wrapper chuẩn +- Spacing giữa các elements +- Xử lý responsive +- Grouping related elements +- Consistent styling +- Error/Success states display +- Helper text placement + +**TextAreaControl** + +- Auto-resize theo nội dung +- Minimum/Maximum height +- Character count +- Line wrapping +- Placeholder text +- Scroll behavior +- Resize handles (optional) + +**NumberInput** + +- Step controls (increment/decrement) +- Format số (decimal, thousand separator) +- Range validation (min/max) +- Custom step sizes +- Prevent invalid input +- Currency format option +- Percentage format option + +**DatePicker/TimePicker** + +- Calendar/Time selection UI +- Format options +- Range selection +- Disabled dates/times +- Custom validation +- Timezone support +- Keyboard navigation +- Clear selection option + +### 2. Components Select & Options + +**SelectBase/MultiSelect** + +- Dropdown animation +- Search/Filter functionality +- Custom option rendering +- Group options +- Clear selection +- Placeholder text +- Loading states +- Virtual scrolling cho large datasets +- Keyboard navigation +- Selected item(s) display + +**Combobox/AutoComplete** + +- Instant search +- Debounce input +- Custom matching logic +- Highlight matching text +- Loading states +- No results message +- Recent selections +- Keyboard navigation +- Clear input button + +**CheckboxGroup/RadioGroup** + +- Layout options (vertical/horizontal) +- Indeterminate state +- Group selection +- Custom styles +- Disabled states +- Error states +- Spacing controls +- Label placement options + +### 3. Components Hiển thị & Tương tác + +**ValidationSummary/ErrorMessage** + +- Clear error descriptions +- Link to related fields +- Icon indicators +- Color coding +- Animation effects +- Dismissible messages +- Priority sorting +- Grouped by section + +**ScrollArea/Popover** + +- Smooth scrolling +- Custom scrollbar styling +- Overflow handling +- Position calculation +- Animation effects +- Click outside handling +- Focus management +- Z-index handling + +### 4. Components Cấu trúc Form + +**FormGroup/FormSection** + +- Consistent spacing +- Clear visual hierarchy +- Collapsible sections +- Section navigation +- Progress tracking +- Error state indication +- Responsive layout +- Accessibility support + +**FormGrid/FormRow** + +- Responsive breakpoints +- Gap control +- Column sizing +- Alignment options +- Nesting support +- Order control +- Equal height rows +- Auto-layout options + +### 5. Components Xử lý Dữ liệu Động + +**FieldArray/DynamicField** + +- Add/Remove controls +- Reorder functionality +- Validation per item +- Default values +- Bulk actions +- Copy/Paste support +- Performance optimization +- Error handling +- Undo/Redo support + +### Các tiêu chí chung cho mọi component: + +**Accessibility** + +- ARIA labels và roles +- Keyboard navigation +- Screen reader support +- Focus management +- Color contrast +- Error announcements + +**Performance** + +- Lazy loading +- Memoization +- Event debouncing +- Virtual scrolling +- Optimized re-renders + +**Responsive Design** + +- Mobile-first approach +- Breakpoint handling +- Touch interactions +- Flexible layouts +- Adaptive spacing + +**Customization** + +- Theme support +- Style override +- Component composition +- Prop customization +- Event handlers +- Custom renderers + +Mỗi component nên được thiết kế theo module hóa, có thể tái sử dụng và mở rộng. Documentation rõ ràng và các examples cụ thể sẽ giúp người dùng dễ dàng tích hợp và tùy chỉnh theo nhu cầu. + +--- + +# DEMO InputBase + +Để xây dựng một InputBase 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:** + +- value/defaultValue: Giá trị của input +- onChange/onBlur/onFocus: Event handlers +- placeholder: Text placeholder +- disabled/readOnly: Trạng thái input +- type: Loại input (text, password, email...) +- name/id: Định danh của input +- autoComplete: Cấu hình autocomplete +- maxLength/minLength: Giới hạn ký tự +- size: Kích thước (sm, md, lg) +- className/style: Custom styling +- ref: React ref cho DOM access + +### 2. Xử lý States & Validation + +**Visual States:** + +- Default state +- Hover state +- Focus state +- Active state +- Disabled state +- Read-only state +- Error state +- Success state +- Warning state +- Loading state + +**Validation:** + +- Required field +- Pattern matching +- Custom validation rules +- Error message display +- Success message display +- Validation timing (onBlur/onChange) + +### 3. Styling & UI + +**Visual Elements:** + +- Border styles & radius +- Background colors +- Text colors & typography +- Padding & margin +- Icon placement (left/right) +- Clear button (optional) +- Focus ring +- Error/Success indicators +- Cursor styles +- Transitions & animations + +**Responsive Design:** + +- Mobile-friendly sizing +- Touch targets +- Viewport adaptations +- Font scaling +- Spacing adjustments + +### 4. Accessibility (A11y) + +**ARIA Attributes:** + +- aria-label +- aria-describedby +- aria-invalid +- aria-required +- aria-disabled +- role attributes + +**Keyboard Navigation:** + +- Tab index +- Focus management +- Keyboard shortcuts +- Clear focus indicators + +### 5. Performance & Optimization + +**Rendering:** + +- Controlled vs Uncontrolled handling +- Memoization +- Event debouncing +- Re-render optimization +- Props memorization + +### 6. Integration & Extensibility + +**Form Integration:** + +- Form context support +- Field validation integration +- Error handling +- Submit handling +- Reset capability + +**Customization:** + +- Theme support +- Style overrides +- Custom components injection +- Event handler extension +- Composition flexibility + +### 7. Error Handling & Documentation + +**Error Management:** + +- Input validation errors +- Props validation +- Error boundaries +- Console warnings +- User feedback + +**Documentation:** + +- PropTypes/TypeScript definitions +- Usage examples +- API documentation +- Best practices +- Performance guidelines + +### 8. Testing + +**Test Coverage:** + +- Unit tests +- Integration tests +- Browser compatibility +- Mobile compatibility +- Accessibility tests +- Performance tests +- State management tests +- Event handling tests + +### 9. Browser & Device Support + +**Compatibility:** + +- Cross-browser support +- Mobile devices support +- Different OS support +- Input method support (touch, stylus) +- Screen reader compatibility From ed36cad57b66818f70430c338a826eb43c80fde8 Mon Sep 17 00:00:00 2001 From: Matthew Ngo Date: Tue, 14 Jan 2025 16:14:13 +0700 Subject: [PATCH 07/43] chore: add tooltip --- example/.npmignore | 3 - example/index.html | 14 -- example/index.tsx | 14 -- example/package.json | 24 --- example/tsconfig.json | 18 -- package.json | 3 + .../InputLabel/InputLabel.stories.tsx | 90 +++++++++ src/components/InputLabel/InputLabel.tsx | 88 +++++++++ src/components/InputLabel/index.ts | 2 + src/components/InputLabel/styled.ts | 51 +++++ src/components/InputLabel/types.ts | 34 ++++ src/components/Thing/Thing.stories.tsx | 0 src/components/Tooltip/Tooltip.stories.tsx | 157 +++++++++++++++ src/components/Tooltip/Tooltip.tsx | 186 ++++++++++++++++++ src/components/Tooltip/index.ts | 2 + src/components/Tooltip/styled.ts | 71 +++++++ src/components/Tooltip/types.ts | 18 ++ stories/Thing.stories.tsx | 28 --- yarn.lock | 51 ++++- 19 files changed, 752 insertions(+), 102 deletions(-) delete mode 100644 example/.npmignore delete mode 100644 example/index.html delete mode 100644 example/index.tsx delete mode 100644 example/package.json delete mode 100644 example/tsconfig.json create mode 100644 src/components/InputLabel/InputLabel.stories.tsx create mode 100644 src/components/InputLabel/InputLabel.tsx create mode 100644 src/components/InputLabel/index.ts create mode 100644 src/components/InputLabel/styled.ts create mode 100644 src/components/InputLabel/types.ts delete mode 100644 src/components/Thing/Thing.stories.tsx create mode 100644 src/components/Tooltip/Tooltip.stories.tsx create mode 100644 src/components/Tooltip/Tooltip.tsx create mode 100644 src/components/Tooltip/index.ts create mode 100644 src/components/Tooltip/styled.ts create mode 100644 src/components/Tooltip/types.ts delete mode 100644 stories/Thing.stories.tsx diff --git a/example/.npmignore b/example/.npmignore deleted file mode 100644 index 587e4ec7..00000000 --- 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 41d7811b..00000000 --- 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 73387c60..00000000 --- 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 a50960f5..00000000 --- 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 1e2e4fd9..00000000 --- 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 111dbb6a..3421d2e3 100644 --- a/package.json +++ b/package.json @@ -149,10 +149,13 @@ }, "dependencies": { "@hookform/resolvers": "^3.9.1", + "@popperjs/core": "^2.11.8", "lodash": "^4.17.21", + "lucide-react": "^0.471.1", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-hook-form": "^7.49.3", + "react-popper": "^2.3.0", "styled-components": "^6.1.13", "yup": "^1.6.1" }, diff --git a/src/components/InputLabel/InputLabel.stories.tsx b/src/components/InputLabel/InputLabel.stories.tsx new file mode 100644 index 00000000..58b3cebf --- /dev/null +++ b/src/components/InputLabel/InputLabel.stories.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { Meta, StoryFn } from '@storybook/react'; +import InputLabel, { InputLabelProps } from './'; +import InputBase from '../InputBase'; + +export default { + title: 'Components/InputLabel', + component: InputLabel, + argTypes: { + position: { + options: ['top', 'left'], + control: { type: 'radio' }, + }, + tooltipPlacement: { + options: [ + 'top', + 'right', + 'bottom', + 'left', + 'top-start', + 'top-end', + 'bottom-start', + 'bottom-end', + 'right-start', + 'right-end', + 'left-start', + 'left-end', + ], + control: { type: 'select' }, + }, + }, +} as Meta; + +const Template: StoryFn = (args) => ( +
+
+ + +
+
+); +// Default story +export const Default = Template.bind({}); +Default.args = { + label: 'Label Text', + htmlFor: 'input-1', +}; +// Required story +export const Required = Template.bind({}); +Required.args = { + label: 'Label Text', + htmlFor: 'input-2', + required: true, +}; +// Optional story +export const Optional = Template.bind({}); +Optional.args = { + label: 'Label Text', + htmlFor: 'input-3', + optional: true, +}; +// Disabled story +export const Disabled = Template.bind({}); +Disabled.args = { + label: 'Label Text', + htmlFor: 'input-4', + disabled: true, +}; +// Position left story +export const PositionLeft = Template.bind({}); +PositionLeft.args = { + label: 'Label Text', + htmlFor: 'input-5', + position: 'left', +}; +// Tooltip story +export const TooltipStory = Template.bind({}); +TooltipStory.args = { + label: 'Label Text', + htmlFor: 'input-6', + tooltip: 'This is a tooltip', +}; +// Tooltip placement story +export const TooltipPlacementStory = Template.bind({}); +TooltipPlacementStory.args = { + label: 'Label Text', + htmlFor: 'input-7', + tooltip: 'This is a tooltip with placement', + tooltipPlacement: 'bottom-end', +}; diff --git a/src/components/InputLabel/InputLabel.tsx b/src/components/InputLabel/InputLabel.tsx new file mode 100644 index 00000000..1bdf3e6a --- /dev/null +++ b/src/components/InputLabel/InputLabel.tsx @@ -0,0 +1,88 @@ +import React, { forwardRef } from 'react'; +import { InputLabelProps } from './types'; +import { + LabelWrapper, + LabelText, + RequiredIndicator, + OptionalIndicator, +} from './styled'; +import { Tooltip } from '../Tooltip'; +import { ThemeProvider } from 'styled-components'; +import theme from '../../theme'; + +const InputLabel = forwardRef( + ( + { + htmlFor, + label, + required = false, + optional = false, + disabled = false, + position = 'top', + tooltip, + tooltipPlacement = 'top', + className, + style, + customStyles, + ...rest + }, + ref + ) => { + if (!htmlFor) { + console.error('InputLabel: htmlFor prop is required.'); + return null; // Or render a placeholder, based on your error handling strategy + } + + if (position !== 'top' && position !== 'left') { + console.error( + "InputLabel: position prop must be either 'top' or 'left'." + ); + position = 'top'; // Fallback to default value + } + const hasTooltip = !!tooltip; + return ( + + + {tooltip ? ( + + + {label} + {required && !optional && ( + * + )} + {optional && !required && ( + (optional) + )} + + + ) : ( + + {label} + {required && !optional && ( + * + )} + {optional && !required && ( + (optional) + )} + + )} + + {disabled && } + + + ); + } +); + +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 00000000..af38bfcf --- /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/styled.ts b/src/components/InputLabel/styled.ts new file mode 100644 index 00000000..32e66ce4 --- /dev/null +++ b/src/components/InputLabel/styled.ts @@ -0,0 +1,51 @@ +import styled from 'styled-components'; +import { InputLabelPosition } from './types'; + +interface LabelWrapperProps { + $position: InputLabelPosition; + disabled?: boolean; + $hasTooltip: boolean; +} + +interface LabelTextProps { + disabled?: boolean; + $position: InputLabelPosition; +} + +export const LabelWrapper = styled.label` + display: flex; + flex-direction: ${({ $position }) => + $position === 'left' ? 'row' : 'column'}; + align-items: ${({ $position }) => + $position === 'left' ? 'center' : 'flex-start'}; + margin-bottom: ${({ theme, $position }) => + $position === 'top' ? theme.space.xs : '0'}; + margin-right: ${({ theme, $position }) => + $position === 'left' ? theme.space.sm : '0'}; + opacity: ${({ disabled }) => (disabled ? 0.5 : 1)}; + cursor: ${({ $hasTooltip }) => ($hasTooltip ? 'help' : 'default')}; +`; + +export const LabelText = styled.span` + font-family: ${({ theme }) => theme.typography.primaryFont}; + font-size: ${({ theme }) => theme.fontSizes.sm}; + font-weight: ${({ theme }) => theme.fontWeights.medium}; + line-height: 1.5; + color: ${({ theme, disabled }) => + disabled ? theme.colors['secondary-500'] : theme.colors.text}; + margin-right: ${({ theme, $position }) => + $position === 'left' ? theme.space.xs : '0'}; + margin-bottom: ${({ theme, $position }) => + $position === 'top' ? theme.space.xs : '0'}; +`; + +export const RequiredIndicator = styled.span` + color: ${({ theme }) => theme.colors.danger}; + margin-left: ${({ theme }) => theme.space['2xs']}; +`; + +export const OptionalIndicator = styled.span` + color: ${({ theme }) => theme.colors['secondary-500']}; + font-weight: ${({ theme }) => theme.fontWeights.normal}; + margin-left: ${({ theme }) => theme.space['2xs']}; +`; diff --git a/src/components/InputLabel/types.ts b/src/components/InputLabel/types.ts new file mode 100644 index 00000000..f4924341 --- /dev/null +++ b/src/components/InputLabel/types.ts @@ -0,0 +1,34 @@ +import { ReactNode, CSSProperties, Ref } from 'react'; + +export type InputLabelPosition = 'top' | 'left'; +export type TooltipPlacement = + | 'top' + | 'right' + | 'bottom' + | 'left' + | 'top-start' + | 'top-end' + | 'bottom-start' + | 'bottom-end' + | 'right-start' + | 'right-end' + | 'left-start' + | 'left-end'; + +export interface InputLabelProps { + htmlFor: string; // Required + label: ReactNode; // Required + required?: boolean; + optional?: boolean; + disabled?: boolean; + position?: InputLabelPosition; + tooltip?: ReactNode; + tooltipPlacement?: TooltipPlacement; + className?: string; + style?: CSSProperties; + ref?: Ref; + /** + * @deprecated Use `style` instead. + */ + customStyles?: CSSProperties; // Allow custom styling via customStyles prop +} diff --git a/src/components/Thing/Thing.stories.tsx b/src/components/Thing/Thing.stories.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/src/components/Tooltip/Tooltip.stories.tsx b/src/components/Tooltip/Tooltip.stories.tsx new file mode 100644 index 00000000..2adfdfce --- /dev/null +++ b/src/components/Tooltip/Tooltip.stories.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { Meta, StoryFn } from '@storybook/react'; +import Tooltip from './Tooltip'; +import { TooltipProps } from './types'; + +export default { + title: 'Components/Tooltip', + component: Tooltip, + argTypes: { + placement: { + control: { + type: 'select', + }, + options: [ + 'top', + 'top-start', + 'top-end', + 'right', + 'right-start', + 'right-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'left', + 'left-start', + 'left-end', + ], + }, + trigger: { + control: { + type: 'check', + }, + options: ['hover', 'focus', 'click', 'contextMenu'], + }, + delay: { + control: { + type: 'object', + }, + }, + arrow: { + control: { + type: 'boolean', + }, + }, + disabled: { + control: { + type: 'boolean', + }, + }, + open: { + control: { + type: 'boolean', + }, + }, + }, +} as Meta; + +const Template: StoryFn = (args) => ( +
+ + + +
+); + +export const Default = Template.bind({}); +Default.args = { + title: 'This is a default tooltip!', + placement: 'top', +}; + +export const WithDifferentPlacements = Template.bind({}); +WithDifferentPlacements.args = { + title: 'Tooltip on the right', + placement: 'right', +}; + +export const ClickTrigger = Template.bind({}); +ClickTrigger.args = { + title: 'Tooltip on click', + placement: 'bottom', + trigger: 'click', +}; + +export const FocusTrigger = Template.bind({}); +FocusTrigger.args = { + title: 'Tooltip on focus', + placement: 'left', + trigger: 'focus', +}; + +export const HoverAndFocusTrigger = Template.bind({}); +HoverAndFocusTrigger.args = { + title: 'Tooltip on hover and focus', + placement: 'top-start', + trigger: ['hover', 'focus'], +}; + +export const Disabled = Template.bind({}); +Disabled.args = { + title: 'This tooltip is disabled', + placement: 'top', + disabled: true, + children: Disabled Tooltip, +}; + +export const ControlledTooltip = Template.bind({}); +ControlledTooltip.args = { + title: 'Controlled tooltip', + placement: 'bottom-end', + open: true, // Control the open state externally + children: Controlled Tooltip, +}; + +export const CustomDelay = Template.bind({}); +CustomDelay.args = { + title: 'Tooltip with custom delay', + placement: 'top', + delay: { show: 500, hide: 100 }, +}; + +export const NoArrow = Template.bind({}); +NoArrow.args = { + title: 'Tooltip without arrow', + placement: 'top', + arrow: false, +}; + +export const CustomStyling: StoryFn = (args) => ( +
+ + + +
+); + +CustomStyling.args = { + title: 'This tooltip has custom styling!', + placement: 'bottom', + arrow: true, +}; diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx new file mode 100644 index 00000000..265b779a --- /dev/null +++ b/src/components/Tooltip/Tooltip.tsx @@ -0,0 +1,186 @@ +import React, { + useState, + useRef, + useEffect, + ReactNode, + CSSProperties, +} from 'react'; +import { usePopper } from 'react-popper'; +import { Placement } from '@popperjs/core'; +import { createPortal } from 'react-dom'; +import { ThemeProvider } from 'styled-components'; +import theme from '../../theme'; +import { + StyledTooltipContainer, + StyledTooltipOverlay, + StyledTooltipArrow, +} from './styled'; + +interface TooltipProps { + title: ReactNode; + placement?: Placement; + children: ReactNode; + open?: boolean; + trigger?: 'hover' | 'focus' | 'click' | 'contextMenu' | string[]; + delay?: number | { show: number; hide: number }; + disabled?: boolean; + arrow?: boolean; + offset?: number; + className?: string; + style?: CSSProperties; + overlayClassName?: string; + overlayStyle?: CSSProperties; +} + +const Tooltip: React.FC = ({ + title, + placement = 'top', + children, + open: controlledOpen, + trigger = 'hover', + delay = { show: 100, hide: 100 }, + disabled = false, + arrow = true, + offset = 4, + className, + style, + overlayClassName, + overlayStyle, +}) => { + const [isOpen, setIsOpen] = useState(false); + const referenceElementRef = useRef(null); + const popperElementRef = useRef(null); + const arrowRef = useRef(null); + + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : isOpen; + + const { styles, attributes, update } = usePopper( + referenceElementRef.current, + popperElementRef.current, + { + placement, + modifiers: [ + { name: 'offset', options: { offset: [0, offset] } }, + { + name: 'arrow', + options: { element: arrowRef.current }, + }, + { + name: 'flip', + enabled: true, + }, + { + name: 'preventOverflow', + options: { + boundary: 'viewport' as any, + }, + }, + ], + } + ); + + useEffect(() => { + if (referenceElementRef.current && popperElementRef.current && update) { + update(); + } + }, [update]); + + const showTooltip = () => { + if (!isControlled) { + setIsOpen(true); + } + }; + + const hideTooltip = () => { + if (!isControlled) { + setIsOpen(false); + } + }; + + const getDelay = (type: 'show' | 'hide') => { + return typeof delay === 'number' ? delay : delay[type]; + }; + + const getTriggerEvents = (triggerType: string) => { + switch (triggerType) { + case 'hover': + return { + onMouseEnter: () => setTimeout(showTooltip, getDelay('show')), + onMouseLeave: () => setTimeout(hideTooltip, getDelay('hide')), + }; + case 'focus': + return { + onFocus: () => setTimeout(showTooltip, getDelay('show')), + onBlur: () => setTimeout(hideTooltip, getDelay('hide')), + }; + case 'click': + return { + onClick: () => (open ? hideTooltip() : showTooltip()), + }; + case 'contextMenu': + return { + onContextMenu: (e: React.MouseEvent) => { + e.preventDefault(); + const trigger = open ? hideTooltip : showTooltip; + trigger(); + }, + }; + default: + return {}; + } + }; + + const getAllTriggerEvents = () => { + const triggerArray = Array.isArray(trigger) ? trigger : [trigger]; + return triggerArray.reduce( + (acc, triggerType) => ({ + ...acc, + ...getTriggerEvents(triggerType), + }), + {} + ); + }; + + return ( + + <> +
+ {children} +
+ {createPortal( + + + {title} + {arrow && ( + + )} + + , + document.body + )} + +
+ ); +}; + +export default Tooltip; diff --git a/src/components/Tooltip/index.ts b/src/components/Tooltip/index.ts new file mode 100644 index 00000000..1a738144 --- /dev/null +++ b/src/components/Tooltip/index.ts @@ -0,0 +1,2 @@ +export { default } from './Tooltip'; +export * from './types'; diff --git a/src/components/Tooltip/styled.ts b/src/components/Tooltip/styled.ts new file mode 100644 index 00000000..116083ec --- /dev/null +++ b/src/components/Tooltip/styled.ts @@ -0,0 +1,71 @@ +import styled from 'styled-components'; +import { Placement } from '@popperjs/core'; +import theme from '../../theme'; + +interface ArrowProps { + $placement?: Placement; +} + +const getArrowStyles = (placement: Placement) => { + switch (placement) { + case 'top': + return { + bottom: '-3px', + left: '50%', + transform: 'translateX(-50%)', + borderLeft: '4px solid transparent', + borderRight: '4px solid transparent', + borderTop: `4px solid ${theme.colors.black}`, + }; + case 'bottom': + return { + top: '-3px', + left: '50%', + transform: 'translateX(-50%)', + borderLeft: '4px solid transparent', + borderRight: '4px solid transparent', + borderBottom: `4px solid ${theme.colors.black}`, + }; + case 'left': + return { + right: '-3px', + top: '50%', + transform: 'translateY(-50%)', + borderTop: '4px solid transparent', + borderBottom: '4px solid transparent', + borderLeft: `4px solid ${theme.colors.black}`, + }; + case 'right': + return { + left: '-3px', + top: '50%', + transform: 'translateY(-50%)', + borderTop: '4px solid transparent', + borderBottom: '4px solid transparent', + borderRight: `4px solid ${theme.colors.black}`, + }; + default: + return {}; + } +}; + +export const StyledTooltipContainer = styled.div` + position: relative; +`; + +export const StyledTooltipOverlay = styled.div` + position: relative; + background-color: ${(props) => props.theme.colors.black}; + color: ${(props) => props.theme.colors.white}; + padding: ${(props) => props.theme.space.sm} ${(props) => props.theme.space.md}; + border-radius: ${(props) => props.theme.radius.sm}; + font-size: ${(props) => props.theme.fontSizes.small}; + z-index: 2000; +`; + +export const StyledTooltipArrow = styled.div` + position: absolute; + width: 0; + height: 0; + ${(props) => props.$placement && getArrowStyles(props.$placement)} +`; diff --git a/src/components/Tooltip/types.ts b/src/components/Tooltip/types.ts new file mode 100644 index 00000000..9d7b2996 --- /dev/null +++ b/src/components/Tooltip/types.ts @@ -0,0 +1,18 @@ +import { Placement } from '@popperjs/core'; +import { CSSProperties, ReactNode } from 'react'; + +export interface TooltipProps { + title: ReactNode; + placement?: Placement; + children: ReactNode; + open?: boolean; + trigger?: 'hover' | 'focus' | 'click' | 'contextMenu' | string[]; + delay?: number | { show: number; hide: number }; + disabled?: boolean; + arrow?: boolean; + offset?: number; + className?: string; + style?: CSSProperties; + overlayClassName?: string; + overlayStyle?: CSSProperties; +} diff --git a/stories/Thing.stories.tsx b/stories/Thing.stories.tsx deleted file mode 100644 index 3fe0401e..00000000 --- a/stories/Thing.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import { Meta, Story } from '@storybook/react'; -import { Thing, Props } from '../src'; - -const meta: Meta = { - title: 'Welcome', - component: Thing, - argTypes: { - children: { - control: { - type: 'text', - }, - }, - }, - parameters: { - controls: { expanded: true }, - }, -}; - -export default meta; - -const Template: Story = (args) => ; - -// By passing using the Args format for exported stories, you can control the props for a component for reuse in a test -// https://storybook.js.org/docs/react/workflows/unit-testing -export const Default = Template.bind({}); - -Default.args = {}; diff --git a/yarn.lock b/yarn.lock index 50476d97..ee6598bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2527,6 +2527,7 @@ __metadata: "@chromatic-com/storybook": "npm:^3.2.3" "@eslint/compat": "npm:^1.2.4" "@hookform/resolvers": "npm:^3.9.1" + "@popperjs/core": "npm:^2.11.8" "@semantic-release/changelog": "npm:^6.0.3" "@semantic-release/git": "npm:^10.0.1" "@semantic-release/github": "npm:^11.0.1" @@ -2563,6 +2564,7 @@ __metadata: jest: "npm:^29.7.0" lint-staged: "npm:^15.3.0" lodash: "npm:^4.17.21" + lucide-react: "npm:^0.471.1" prettier: "npm:^3.4.2" react: "npm:^19.0.0" react-dnd: "npm:^16.0.1" @@ -2570,6 +2572,7 @@ __metadata: react-dom: "npm:^19.0.0" react-hook-form: "npm:^7.49.3" react-is: "npm:^19.0.0" + react-popper: "npm:^2.3.0" semantic-release: "npm:^24.2.0" size-limit: "npm:^11.1.6" storybook: "npm:^8.4.7" @@ -2991,6 +2994,13 @@ __metadata: languageName: node linkType: hard +"@popperjs/core@npm:^2.11.8": + version: 2.11.8 + resolution: "@popperjs/core@npm:2.11.8" + checksum: 10c0/4681e682abc006d25eb380d0cf3efc7557043f53b6aea7a5057d0d1e7df849a00e281cd8ea79c902a35a414d7919621fc2ba293ecec05f413598e0b23d5a1e63 + languageName: node + linkType: hard + "@react-dnd/asap@npm:^5.0.1": version: 5.0.2 resolution: "@react-dnd/asap@npm:5.0.2" @@ -12361,7 +12371,7 @@ __metadata: languageName: node linkType: hard -"loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0": +"loose-envify@npm:^1.0.0, loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" dependencies: @@ -12404,6 +12414,15 @@ __metadata: languageName: node linkType: hard +"lucide-react@npm:^0.471.1": + version: 0.471.1 + resolution: "lucide-react@npm:0.471.1" + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/d80499f1670b6c371a8c2d3f545dab6161bcb58101a1112811bbe88aa782ff1a467df298074101cf30d7bcad5d29fa8008ac48ce1bb646f1767b48dd603d300a + languageName: node + linkType: hard + "lz-string@npm:^1.5.0": version: 1.5.0 resolution: "lz-string@npm:1.5.0" @@ -14699,6 +14718,13 @@ __metadata: languageName: node linkType: hard +"react-fast-compare@npm:^3.0.1": + version: 3.2.2 + resolution: "react-fast-compare@npm:3.2.2" + checksum: 10c0/0bbd2f3eb41ab2ff7380daaa55105db698d965c396df73e6874831dbafec8c4b5b08ba36ff09df01526caa3c61595247e3269558c284e37646241cba2b90a367 + languageName: node + linkType: hard + "react-hook-form@npm:^7.49.3": version: 7.54.2 resolution: "react-hook-form@npm:7.54.2" @@ -14736,6 +14762,20 @@ __metadata: languageName: node linkType: hard +"react-popper@npm:^2.3.0": + version: 2.3.0 + resolution: "react-popper@npm:2.3.0" + dependencies: + react-fast-compare: "npm:^3.0.1" + warning: "npm:^4.0.2" + peerDependencies: + "@popperjs/core": ^2.0.0 + react: ^16.8.0 || ^17 || ^18 + react-dom: ^16.8.0 || ^17 || ^18 + checksum: 10c0/23f93540537ca4c035425bb8d5e51b11131fbc921d7ac1d041d0ae557feac8c877f3a012d36b94df8787803f52ed81e6df9257ac9e58719875f7805518d6db3f + languageName: node + linkType: hard + "react@npm:^16.8.0 || ^17.0.0 || ^18.0.0": version: 18.3.1 resolution: "react@npm:18.3.1" @@ -17869,6 +17909,15 @@ __metadata: languageName: node linkType: hard +"warning@npm:^4.0.2": + version: 4.0.3 + resolution: "warning@npm:4.0.3" + dependencies: + loose-envify: "npm:^1.0.0" + checksum: 10c0/aebab445129f3e104c271f1637fa38e55eb25f968593e3825bd2f7a12bd58dc3738bb70dc8ec85826621d80b4acfed5a29ebc9da17397c6125864d72301b937e + languageName: node + linkType: hard + "watchpack@npm:^2.4.1": version: 2.4.2 resolution: "watchpack@npm:2.4.2" From 080d4ab1740a759a06e91287067d4d203282a75e Mon Sep 17 00:00:00 2001 From: Matthew Ngo Date: Tue, 14 Jan 2025 16:18:28 +0700 Subject: [PATCH 08/43] chore: update InputLabel, InputBase --- .storybook/main.js | 7 +- .../InputBase/InputBase.stories.tsx | 17 ++++ .../InputLabel/InputLabel.stories.tsx | 76 +++++++--------- src/components/InputLabel/InputLabel.tsx | 89 ++++++++----------- src/components/InputLabel/styled.ts | 68 +++++++------- src/components/InputLabel/types.ts | 32 ++----- 6 files changed, 126 insertions(+), 163 deletions(-) diff --git a/.storybook/main.js b/.storybook/main.js index bd9dcac0..4f8e06a4 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,12 +1,7 @@ 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', diff --git a/src/components/InputBase/InputBase.stories.tsx b/src/components/InputBase/InputBase.stories.tsx index db576061..53f4d591 100644 --- a/src/components/InputBase/InputBase.stories.tsx +++ b/src/components/InputBase/InputBase.stories.tsx @@ -2,6 +2,7 @@ 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', @@ -203,3 +204,19 @@ SmallSize.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/InputLabel/InputLabel.stories.tsx b/src/components/InputLabel/InputLabel.stories.tsx index 58b3cebf..26733f15 100644 --- a/src/components/InputLabel/InputLabel.stories.tsx +++ b/src/components/InputLabel/InputLabel.stories.tsx @@ -1,90 +1,80 @@ import React from 'react'; import { Meta, StoryFn } from '@storybook/react'; -import InputLabel, { InputLabelProps } from './'; -import InputBase from '../InputBase'; +import InputLabel from './InputLabel'; +import { InputLabelProps } from './types'; export default { title: 'Components/InputLabel', component: InputLabel, argTypes: { position: { - options: ['top', 'left'], control: { type: 'radio' }, + options: ['top', 'left'], }, tooltipPlacement: { + control: { type: 'select' }, options: [ 'top', - 'right', - 'bottom', - 'left', 'top-start', 'top-end', - 'bottom-start', - 'bottom-end', + 'right', 'right-start', 'right-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'left', 'left-start', 'left-end', ], - control: { type: 'select' }, }, }, } as Meta; const Template: StoryFn = (args) => ( -
-
- - -
+
+
); -// Default story + export const Default = Template.bind({}); Default.args = { - label: 'Label Text', - htmlFor: 'input-1', + htmlFor: 'input-id', + label: 'Input Label', }; -// Required story + export const Required = Template.bind({}); Required.args = { - label: 'Label Text', - htmlFor: 'input-2', + htmlFor: 'input-id', + label: 'Required Input Label', required: true, }; -// Optional story + export const Optional = Template.bind({}); Optional.args = { - label: 'Label Text', - htmlFor: 'input-3', + htmlFor: 'input-id', + label: 'Optional Input Label', optional: true, }; -// Disabled story + export const Disabled = Template.bind({}); Disabled.args = { - label: 'Label Text', - htmlFor: 'input-4', + htmlFor: 'input-id', + label: 'Disabled Input Label', disabled: true, }; -// Position left story + export const PositionLeft = Template.bind({}); PositionLeft.args = { - label: 'Label Text', - htmlFor: 'input-5', + htmlFor: 'input-id', + label: 'Left Positioned Label', position: 'left', }; -// Tooltip story -export const TooltipStory = Template.bind({}); -TooltipStory.args = { - label: 'Label Text', - htmlFor: 'input-6', - tooltip: 'This is a tooltip', -}; -// Tooltip placement story -export const TooltipPlacementStory = Template.bind({}); -TooltipPlacementStory.args = { - label: 'Label Text', - htmlFor: 'input-7', - tooltip: 'This is a tooltip with placement', - tooltipPlacement: 'bottom-end', + +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 index 1bdf3e6a..48c5e5b7 100644 --- a/src/components/InputLabel/InputLabel.tsx +++ b/src/components/InputLabel/InputLabel.tsx @@ -1,14 +1,15 @@ import React, { forwardRef } from 'react'; -import { InputLabelProps } from './types'; -import { - LabelWrapper, - LabelText, - RequiredIndicator, - OptionalIndicator, -} from './styled'; -import { Tooltip } from '../Tooltip'; 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( ( @@ -19,65 +20,47 @@ const InputLabel = forwardRef( optional = false, disabled = false, position = 'top', - tooltip, + tooltip = null, tooltipPlacement = 'top', className, style, - customStyles, - ...rest }, ref ) => { if (!htmlFor) { - console.error('InputLabel: htmlFor prop is required.'); - return null; // Or render a placeholder, based on your error handling strategy + console.error('InputLabel requires htmlFor prop'); } - if (position !== 'top' && position !== 'left') { - console.error( - "InputLabel: position prop must be either 'top' or 'left'." - ); - position = 'top'; // Fallback to default value - } - const hasTooltip = !!tooltip; + const RequiredIndicator = *; + + const OptionalIndicator = (optional); + return ( - - {tooltip ? ( - - - {label} - {required && !optional && ( - * - )} - {optional && !required && ( - (optional) - )} - - - ) : ( - - {label} - {required && !optional && ( - * - )} - {optional && !required && ( - (optional) - )} - - )} - - {disabled && } - + + {tooltip ? ( + + {label} + + ) : ( + label + )} + {required && RequiredIndicator} + {optional && !required && OptionalIndicator} + + ); } diff --git a/src/components/InputLabel/styled.ts b/src/components/InputLabel/styled.ts index 32e66ce4..d1e5cb17 100644 --- a/src/components/InputLabel/styled.ts +++ b/src/components/InputLabel/styled.ts @@ -1,51 +1,47 @@ import styled from 'styled-components'; -import { InputLabelPosition } from './types'; -interface LabelWrapperProps { - $position: InputLabelPosition; - disabled?: boolean; - $hasTooltip: boolean; +interface StyledLabelContainerProps { + $position: 'top' | 'left'; } -interface LabelTextProps { - disabled?: boolean; - $position: InputLabelPosition; +interface StyledLabelProps { + $disabled?: boolean; + $position?: 'top' | 'left'; } -export const LabelWrapper = styled.label` +export const StyledLabelContainer = styled.div` display: flex; - flex-direction: ${({ $position }) => - $position === 'left' ? 'row' : 'column'}; - align-items: ${({ $position }) => - $position === 'left' ? 'center' : 'flex-start'}; - margin-bottom: ${({ theme, $position }) => - $position === 'top' ? theme.space.xs : '0'}; - margin-right: ${({ theme, $position }) => - $position === 'left' ? theme.space.sm : '0'}; - opacity: ${({ disabled }) => (disabled ? 0.5 : 1)}; - cursor: ${({ $hasTooltip }) => ($hasTooltip ? 'help' : 'default')}; + flex-direction: ${(props) => (props.$position === 'left' ? 'row' : 'column')}; + align-items: ${(props) => + props.$position === 'left' ? 'center' : 'flex-start'}; `; -export const LabelText = styled.span` - font-family: ${({ theme }) => theme.typography.primaryFont}; - font-size: ${({ theme }) => theme.fontSizes.sm}; - font-weight: ${({ theme }) => theme.fontWeights.medium}; +export const StyledLabel = styled.label` + font-family: ${(props) => props.theme.typography.primaryFont}; + font-size: ${(props) => props.theme.fontSizes.small}; + font-weight: ${(props) => props.theme.fontWeights.medium}; line-height: 1.5; - color: ${({ theme, disabled }) => - disabled ? theme.colors['secondary-500'] : theme.colors.text}; - margin-right: ${({ theme, $position }) => - $position === 'left' ? theme.space.xs : '0'}; - margin-bottom: ${({ theme, $position }) => - $position === 'top' ? theme.space.xs : '0'}; + color: ${(props) => + props.$disabled + ? props.theme.colors['text-muted'] + : props.theme.colors.text}; + margin-bottom: ${(props) => + props.$position === 'top' ? props.theme.space.xs : '0'}; + margin-right: ${(props) => + props.$position === 'left' ? props.theme.space.md : '0'}; + display: inline-block; // Ensures consistent spacing around the label + cursor: ${(props) => (props.$disabled ? 'default' : 'pointer')}; `; -export const RequiredIndicator = styled.span` - color: ${({ theme }) => theme.colors.danger}; - margin-left: ${({ theme }) => theme.space['2xs']}; +export const StyledIndicator = styled.span``; + +export const StyledRequired = styled.span` + color: ${(props) => props.theme.colors.danger}; + margin-left: ${(props) => props.theme.space.xs}; `; -export const OptionalIndicator = styled.span` - color: ${({ theme }) => theme.colors['secondary-500']}; - font-weight: ${({ theme }) => theme.fontWeights.normal}; - margin-left: ${({ theme }) => theme.space['2xs']}; +export const StyledOptional = styled.span` + color: ${(props) => props.theme.colors['text-muted']}; + font-weight: ${(props) => props.theme.fontWeights.normal}; + margin-left: ${(props) => props.theme.space.xs}; `; diff --git a/src/components/InputLabel/types.ts b/src/components/InputLabel/types.ts index f4924341..9b81af55 100644 --- a/src/components/InputLabel/types.ts +++ b/src/components/InputLabel/types.ts @@ -1,34 +1,16 @@ -import { ReactNode, CSSProperties, Ref } from 'react'; - -export type InputLabelPosition = 'top' | 'left'; -export type TooltipPlacement = - | 'top' - | 'right' - | 'bottom' - | 'left' - | 'top-start' - | 'top-end' - | 'bottom-start' - | 'bottom-end' - | 'right-start' - | 'right-end' - | 'left-start' - | 'left-end'; +import { CSSProperties, ReactNode } from 'react'; +import { Placement } from '@popperjs/core'; export interface InputLabelProps { - htmlFor: string; // Required - label: ReactNode; // Required + htmlFor: string; + label: ReactNode; required?: boolean; optional?: boolean; disabled?: boolean; - position?: InputLabelPosition; + position?: 'top' | 'left'; tooltip?: ReactNode; - tooltipPlacement?: TooltipPlacement; + tooltipPlacement?: Placement; className?: string; style?: CSSProperties; - ref?: Ref; - /** - * @deprecated Use `style` instead. - */ - customStyles?: CSSProperties; // Allow custom styling via customStyles prop + ref?: React.Ref; } From 60890d6baa35346dca7339fa1c4ef5ce0b50ea54 Mon Sep 17 00:00:00 2001 From: Matthew Ngo Date: Mon, 20 Jan 2025 11:02:22 +0700 Subject: [PATCH 09/43] chore: wip --- .storybook/main.js | 4 + create_components.sh | 42 + package.json | 7 +- src/DynamicForm.stories.tsx | 12 +- src/components/InputBase/InputBase.tsx | 68 -- src/components/InputLabel/plan.md | 96 ++ .../inputs/components/ComboBox/ComboBox.tsx | 284 ++--- tsconfig.json | 15 +- yarn.lock | 1007 ++++++++++------- 9 files changed, 827 insertions(+), 708 deletions(-) create mode 100755 create_components.sh create mode 100644 src/components/InputLabel/plan.md diff --git a/.storybook/main.js b/.storybook/main.js index 4f8e06a4..c27976f8 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,3 +1,4 @@ +const { TsconfigPathsPlugin } = require('tsconfig-paths-webpack-plugin'); const path = require('path'); module.exports = { @@ -15,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 00000000..b03a217c --- /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/package.json b/package.json index 3421d2e3..01cff2ec 100644 --- a/package.json +++ b/package.json @@ -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,13 +150,9 @@ }, "dependencies": { "@hookform/resolvers": "^3.9.1", - "@popperjs/core": "^2.11.8", + "@matthew.ngo/react-form-kit": "^0.0.6", "lodash": "^4.17.21", - "lucide-react": "^0.471.1", - "react-dnd": "^16.0.1", - "react-dnd-html5-backend": "^16.0.1", "react-hook-form": "^7.49.3", - "react-popper": "^2.3.0", "styled-components": "^6.1.13", "yup": "^1.6.1" }, diff --git a/src/DynamicForm.stories.tsx b/src/DynamicForm.stories.tsx index 2f98c649..dee945f7 100644 --- a/src/DynamicForm.stories.tsx +++ b/src/DynamicForm.stories.tsx @@ -906,9 +906,17 @@ ComboBoxInput.args = { disabled: false, required: true, }, + defaultValue: [ + { id: 'a', label: 'Apple' }, + { id: 'b', label: 'Banana' }, + ], 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'; } diff --git a/src/components/InputBase/InputBase.tsx b/src/components/InputBase/InputBase.tsx index 86d89435..758de2fc 100644 --- a/src/components/InputBase/InputBase.tsx +++ b/src/components/InputBase/InputBase.tsx @@ -161,74 +161,6 @@ const InputBase: React.FC = ({ return ( - - {iconLeft && ( - - {iconLeft} - - )} - - {(iconRight || clearButton || loading) && ( - - {loading ? ( - - ) : clearButton && value ? ( - customClearButton ? ( - {customClearButton} - ) : ( - - - - ) - ) : ( - iconRight - )} - - )} - {errorMessage && {errorMessage}} ); diff --git a/src/components/InputLabel/plan.md b/src/components/InputLabel/plan.md new file mode 100644 index 00000000..3a2b6d9c --- /dev/null +++ b/src/components/InputLabel/plan.md @@ -0,0 +1,96 @@ +# 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ó `