UNPKG

@papernote/ui

Version:

A modern React component library with a paper notebook aesthetic - minimal, professional, and expressive

1,038 lines (1,029 loc) 2.31 MB
'use strict'; var jsxRuntime = require('react/jsx-runtime'); var React = require('react'); var lucideReact = require('lucide-react'); var reactDom = require('react-dom'); var reactRouterDom = require('react-router-dom'); function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } function _mergeNamespaces(n, m) { m.forEach(function (e) { e && typeof e !== 'string' && !Array.isArray(e) && Object.keys(e).forEach(function (k) { if (k !== 'default' && !(k in n)) { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); }); return Object.freeze(n); } var React__namespace = /*#__PURE__*/_interopNamespaceDefault(React); /** * Button - Interactive button component with variants, sizes, and loading states * * A versatile button component that supports multiple visual styles, sizes, icons, * loading states, and notification badges. * * Supports ref forwarding for DOM access. * * @example Basic usage * ```tsx * <Button variant="primary">Click me</Button> * ``` * * @example With icon and loading * ```tsx * <Button * variant="secondary" * icon={<Save />} * loading={isSaving} * > * Save Changes * </Button> * ``` * * @example Icon-only with badge * ```tsx * <Button * iconOnly * badge={5} * badgeVariant="error" * > * <Bell /> * </Button> * ``` * * @example With ref * ```tsx * const buttonRef = useRef<HTMLButtonElement>(null); * <Button ref={buttonRef}>Focusable</Button> * ``` */ const Button = React.forwardRef(({ variant = 'primary', size = 'md', loading = false, icon, iconPosition = 'left', fullWidth = false, iconOnly = false, badge, badgeVariant = 'error', children, disabled, className = '', ...props }, ref) => { const baseStyles = 'inline-flex items-center justify-center font-medium rounded-lg border transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-400 disabled:opacity-40 disabled:cursor-not-allowed'; const variantStyles = { primary: 'bg-accent-500 text-white border-accent-500 hover:bg-accent-600 hover:shadow-sm active:scale-[0.98]', secondary: 'bg-white text-ink-700 border-paper-300 hover:bg-paper-50 hover:border-paper-400 shadow-xs hover:shadow-sm', ghost: 'bg-transparent text-ink-600 border-transparent hover:text-ink-800 hover:bg-paper-100 active:bg-paper-200', danger: 'bg-error-500 text-white border-error-500 hover:bg-error-600 hover:shadow-sm active:scale-[0.98]', outline: 'bg-transparent text-ink-700 border-paper-300 hover:bg-paper-50 hover:border-ink-400', }; // Icon-only buttons are square const sizeStyles = { sm: iconOnly ? 'p-1.5' : 'px-3 py-1.5 text-xs gap-1.5', md: iconOnly ? 'p-2.5' : 'px-4 py-2.5 text-sm gap-2', lg: iconOnly ? 'p-3' : 'px-6 py-3 text-base gap-2.5', }; const iconSize = { sm: 'h-3.5 w-3.5', md: 'h-4 w-4', lg: 'h-5 w-5', }; const badgeColorStyles = { primary: 'bg-accent-500', success: 'bg-success-500', warning: 'bg-warning-500', error: 'bg-error-500', }; const badgeSizeStyles = { sm: 'min-w-[16px] h-4 text-[10px] px-1', md: 'min-w-[18px] h-[18px] text-[11px] px-1.5', lg: 'min-w-[20px] h-5 text-xs px-1.5', }; const buttonElement = (jsxRuntime.jsxs("button", { ref: ref, className: ` ${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${fullWidth && !iconOnly ? 'w-full' : ''} ${className} `, disabled: disabled || loading, "aria-label": iconOnly && typeof children === 'string' ? children : props['aria-label'], ...props, children: [loading && (jsxRuntime.jsx(lucideReact.Loader2, { className: `${iconSize[size]} animate-spin` })), !loading && icon && iconPosition === 'left' && (jsxRuntime.jsx("span", { className: iconSize[size], children: icon })), !iconOnly && children, !loading && icon && iconPosition === 'right' && !iconOnly && (jsxRuntime.jsx("span", { className: iconSize[size], children: icon }))] })); // If no badge, return button directly if (!badge && badge !== 0) { return buttonElement; } // Format badge content (limit to 99+) const badgeContent = typeof badge === 'number' && badge > 99 ? '99+' : String(badge); // Wrap button with badge return (jsxRuntime.jsxs("div", { className: "relative inline-block", children: [buttonElement, jsxRuntime.jsx("span", { className: ` absolute -top-1 -right-1 flex items-center justify-center rounded-full text-white font-semibold ${badgeColorStyles[badgeVariant]} ${badgeSizeStyles[size]} shadow-sm pointer-events-none `, "aria-label": `${badgeContent} notifications`, children: badgeContent })] })); }); Button.displayName = 'Button'; /** * ButtonGroup component - Toggle button group for single or multiple selection. * * Features: * - Single or multiple selection modes * - Icon support * - Full width option * - Disabled states * - Accessible keyboard navigation * * @example * ```tsx * // Single select * <ButtonGroup * label="Text Alignment" * options={[ * { value: 'left', label: 'Left', icon: AlignLeft }, * { value: 'center', label: 'Center', icon: AlignCenter }, * { value: 'right', label: 'Right', icon: AlignRight }, * ]} * value={alignment} * onChange={setAlignment} * /> * * // Multi select * <ButtonGroup * label="Text Formatting" * options={[ * { value: 'bold', label: 'Bold', icon: Bold }, * { value: 'italic', label: 'Italic', icon: Italic }, * { value: 'underline', label: 'Underline', icon: Underline }, * ]} * values={formatting} * onChangeMultiple={setFormatting} * multiple * /> * ``` */ function ButtonGroup({ options, value, values = [], onChange, onChangeMultiple, multiple = false, label, size = 'md', fullWidth = false, disabled = false, className = '', }) { // Handle single select const handleSingleSelect = (optionValue) => { if (disabled) return; onChange?.(optionValue); }; // Handle multiple select const handleMultipleSelect = (optionValue) => { if (disabled) return; const newValues = values.includes(optionValue) ? values.filter(v => v !== optionValue) : [...values, optionValue]; onChangeMultiple?.(newValues); }; // Check if option is selected const isSelected = (optionValue) => { if (multiple) { return values.includes(optionValue); } return value === optionValue; }; // Size classes const sizeClasses = { sm: 'text-xs px-3 py-1.5', md: 'text-sm px-4 py-2', lg: 'text-base px-5 py-2.5', }; const iconSizeClasses = { sm: 'h-3.5 w-3.5', md: 'h-4 w-4', lg: 'h-5 w-5', }; return (jsxRuntime.jsxs("div", { className: `${className}`, children: [label && (jsxRuntime.jsx("label", { className: "block text-sm font-medium text-ink-700 mb-2", children: label })), jsxRuntime.jsx("div", { className: `inline-flex ${fullWidth ? 'w-full' : ''}`, role: multiple ? 'group' : 'radiogroup', "aria-label": label, children: options.map((option, index) => { const Icon = option.icon; const selected = isSelected(option.value); const isDisabled = disabled || option.disabled; const isFirst = index === 0; const isLast = index === options.length - 1; return (jsxRuntime.jsxs("button", { type: "button", onClick: () => multiple ? handleMultipleSelect(option.value) : handleSingleSelect(option.value), disabled: isDisabled, title: option.tooltip, role: multiple ? 'checkbox' : 'radio', "aria-checked": selected, "aria-disabled": isDisabled, className: ` ${sizeClasses[size]} ${fullWidth ? 'flex-1' : ''} font-medium transition-colors border border-paper-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:z-10 ${isFirst ? 'rounded-l-md' : '-ml-px'} ${isLast ? 'rounded-r-md' : ''} ${selected ? 'bg-primary-500 text-white border-primary-500 z-10' : 'bg-white text-ink-700 hover:bg-paper-50'} ${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'} ${Icon && option.label ? 'inline-flex items-center gap-2' : 'inline-flex items-center justify-center'} `, children: [Icon && jsxRuntime.jsx(Icon, { className: iconSizeClasses[size] }), option.label] }, option.value)); }) })] })); } /** * Input - Text input component with validation, icons, and prefixes/suffixes * * A feature-rich text input with support for validation states, character counting, * password visibility toggle, prefix/suffix text and icons, and clearable functionality. * * Mobile optimizations: * - inputMode prop for appropriate mobile keyboard * - enterKeyHint prop for mobile keyboard action button * - Size variants with touch-friendly targets (44px for 'lg') * * @example Basic input with label * ```tsx * <Input * label="Email" * type="email" * placeholder="Enter your email" * inputMode="email" * enterKeyHint="next" * /> * ``` * * @example With validation * ```tsx * <Input * label="Username" * value={username} * onChange={(e) => setUsername(e.target.value)} * validationState={error ? 'error' : 'success'} * validationMessage={error || 'Username is available'} * /> * ``` * * @example Password with toggle and character count * ```tsx * <Input * type="password" * label="Password" * showPasswordToggle * showCount * maxLength={50} * /> * ``` * * @example Mobile-optimized phone input * ```tsx * <Input * label="Phone Number" * type="tel" * inputMode="tel" * enterKeyHint="done" * size="lg" * /> * ``` * * @example With prefix/suffix * ```tsx * <Input * label="Amount" * type="number" * inputMode="decimal" * prefixIcon={<DollarSign />} * suffix="USD" * clearable * /> * ``` */ const Input = React.forwardRef(({ label, helperText, validationState, validationMessage, icon, iconPosition = 'left', showCount = false, prefix, suffix, prefixIcon, suffixIcon, showPasswordToggle = false, clearable = false, onClear, loading = false, className = '', id, type = 'text', value, maxLength, inputMode, enterKeyHint, size = 'md', ...props }, ref) => { const inputId = id || `input-${Math.random().toString(36).substring(2, 9)}`; const [showPassword, setShowPassword] = React.useState(false); // Handle clear button click const handleClear = () => { if (onClear) { onClear(); } else if (props.onChange) { // Create a synthetic event to trigger onChange const syntheticEvent = { target: { value: '' }, currentTarget: { value: '' }, }; props.onChange(syntheticEvent); } }; // Show clear button if clearable and has value const showClearButton = clearable && value && String(value).length > 0; // Determine actual input type (handle password toggle) const actualType = type === 'password' && showPassword ? 'text' : type; // Calculate character count const currentLength = value ? String(value).length : 0; const showCounter = showCount && maxLength; // Auto-detect inputMode based on type if not specified const effectiveInputMode = inputMode || (() => { switch (type) { case 'email': return 'email'; case 'tel': return 'tel'; case 'url': return 'url'; case 'number': return 'decimal'; case 'search': return 'search'; default: return undefined; } })(); // Size classes const sizeClasses = { sm: 'h-8 text-sm', md: 'h-10 text-base', lg: 'h-12 text-base min-h-touch', // 44px touch target }; const buttonSizeClasses = { sm: 'p-1', md: 'p-1.5', lg: 'p-2 min-w-touch-sm min-h-touch-sm', // 36px touch target for buttons }; const getValidationIcon = () => { switch (validationState) { case 'error': return jsxRuntime.jsx(lucideReact.AlertCircle, { className: "h-5 w-5 text-error-500" }); case 'success': return jsxRuntime.jsx(lucideReact.CheckCircle, { className: "h-5 w-5 text-success-500" }); case 'warning': return jsxRuntime.jsx(lucideReact.AlertTriangle, { className: "h-5 w-5 text-warning-500" }); default: return null; } }; const getValidationClasses = () => { switch (validationState) { case 'error': return 'border-error-400 focus:border-error-400 focus:ring-error-400'; case 'success': return 'border-success-400 focus:border-success-400 focus:ring-success-400'; case 'warning': return 'border-warning-400 focus:border-warning-400 focus:ring-warning-400'; default: return 'border-paper-300 focus:border-accent-400 focus:ring-accent-400 hover:border-paper-400'; } }; const getValidationMessageColor = () => { switch (validationState) { case 'error': return 'text-error-600'; case 'success': return 'text-success-600'; case 'warning': return 'text-warning-600'; default: return 'text-ink-600'; } }; return (jsxRuntime.jsxs("div", { className: "w-full", children: [label && (jsxRuntime.jsxs("label", { htmlFor: inputId, className: "label", children: [label, props.required && jsxRuntime.jsx("span", { className: "text-error-500 ml-1", children: "*" })] })), jsxRuntime.jsxs("div", { className: "relative", children: [prefix && (jsxRuntime.jsx("div", { className: "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-ink-500 text-sm", children: prefix })), prefixIcon && !prefix && (jsxRuntime.jsx("div", { className: "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-ink-400", children: prefixIcon })), icon && iconPosition === 'left' && !prefix && !prefixIcon && (jsxRuntime.jsx("div", { className: "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-ink-400", children: icon })), jsxRuntime.jsx("input", { ref: ref, id: inputId, type: actualType, value: value, maxLength: maxLength, inputMode: effectiveInputMode, enterKeyHint: enterKeyHint, className: ` input ${sizeClasses[size]} ${getValidationClasses()} ${prefix ? 'pl-' + (prefix.length * 8 + 12) : ''} ${prefixIcon && !prefix ? 'pl-10' : ''} ${icon && iconPosition === 'left' && !prefix && !prefixIcon ? 'pl-10' : ''} ${suffix ? 'pr-' + (suffix.length * 8 + 12) : ''} ${suffixIcon && !suffix ? 'pr-10' : ''} ${icon && iconPosition === 'right' && !suffix && !suffixIcon ? 'pr-10' : ''} ${validationState && !suffix && !suffixIcon && !showPasswordToggle ? 'pr-10' : ''} ${(showPasswordToggle && type === 'password') || validationState || suffix || suffixIcon ? 'pr-20' : ''} ${className} `, ...props }), suffix && (jsxRuntime.jsx("div", { className: "absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none text-ink-500 text-sm", children: suffix })), jsxRuntime.jsxs("div", { className: "absolute inset-y-0 right-0 pr-3 flex items-center gap-1", children: [loading && (jsxRuntime.jsx("div", { className: "pointer-events-none text-ink-400", children: jsxRuntime.jsx(lucideReact.Loader2, { className: "h-5 w-5 animate-spin" }) })), suffixIcon && !suffix && !validationState && !showPasswordToggle && !showClearButton && !loading && (jsxRuntime.jsx("div", { className: "pointer-events-none text-ink-400", children: suffixIcon })), showClearButton && (jsxRuntime.jsx("button", { type: "button", onClick: handleClear, className: `text-ink-400 hover:text-ink-600 focus:outline-none cursor-pointer pointer-events-auto rounded-full hover:bg-paper-100 flex items-center justify-center ${buttonSizeClasses[size]}`, "aria-label": "Clear input", children: jsxRuntime.jsx(lucideReact.X, { className: "h-4 w-4" }) })), type === 'password' && showPasswordToggle && (jsxRuntime.jsx("button", { type: "button", onClick: () => setShowPassword(!showPassword), className: `text-ink-400 hover:text-ink-600 focus:outline-none cursor-pointer pointer-events-auto rounded-full hover:bg-paper-100 flex items-center justify-center ${buttonSizeClasses[size]}`, "aria-label": showPassword ? 'Hide password' : 'Show password', children: showPassword ? jsxRuntime.jsx(lucideReact.EyeOff, { className: "h-5 w-5" }) : jsxRuntime.jsx(lucideReact.Eye, { className: "h-5 w-5" }) })), validationState && (jsxRuntime.jsx("div", { className: "pointer-events-none", children: getValidationIcon() })), icon && iconPosition === 'right' && !suffix && !suffixIcon && !validationState && (jsxRuntime.jsx("div", { className: "pointer-events-none text-ink-400", children: icon }))] })] }), jsxRuntime.jsxs("div", { className: "flex justify-between items-center mt-2", children: [(helperText || validationMessage) && (jsxRuntime.jsx("p", { className: `text-xs ${validationMessage ? getValidationMessageColor() : 'text-ink-600'}`, children: validationMessage || helperText })), showCounter && (jsxRuntime.jsxs("p", { className: `text-xs ml-auto ${currentLength > maxLength ? 'text-error-600' : 'text-ink-500'}`, children: [currentLength, " / ", maxLength] }))] })] })); }); Input.displayName = 'Input'; /** * Tailwind breakpoint values in pixels */ const BREAKPOINTS = { xs: 0, sm: 640, md: 768, lg: 1024, xl: 1280, '2xl': 1536, }; /** * SSR-safe check for window availability */ const isBrowser = typeof window !== 'undefined'; /** * Get initial viewport size (SSR-safe) */ const getInitialViewportSize = () => { if (!isBrowser) { return { width: 1024, height: 768 }; // Default to desktop for SSR } return { width: window.innerWidth, height: window.innerHeight, }; }; /** * useViewportSize - Returns current viewport dimensions * * Updates on window resize with debouncing for performance. * SSR-safe with sensible defaults. * * @example * const { width, height } = useViewportSize(); * console.log(`Viewport: ${width}x${height}`); */ function useViewportSize() { const [size, setSize] = React.useState(getInitialViewportSize); React.useEffect(() => { if (!isBrowser) return; let timeoutId; const handleResize = () => { clearTimeout(timeoutId); timeoutId = setTimeout(() => { setSize({ width: window.innerWidth, height: window.innerHeight, }); }, 100); // Debounce 100ms }; window.addEventListener('resize', handleResize); // Set initial size on mount (in case SSR default differs) handleResize(); return () => { clearTimeout(timeoutId); window.removeEventListener('resize', handleResize); }; }, []); return size; } /** * useBreakpoint - Returns the current Tailwind breakpoint * * Automatically updates when viewport crosses breakpoint thresholds. * * @example * const breakpoint = useBreakpoint(); * // Returns: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' */ function useBreakpoint() { const { width } = useViewportSize(); if (width >= BREAKPOINTS['2xl']) return '2xl'; if (width >= BREAKPOINTS.xl) return 'xl'; if (width >= BREAKPOINTS.lg) return 'lg'; if (width >= BREAKPOINTS.md) return 'md'; if (width >= BREAKPOINTS.sm) return 'sm'; return 'xs'; } /** * useMediaQuery - React hook for CSS media queries * * SSR-safe implementation that returns false during SSR and * updates reactively when media query match state changes. * * @param query - CSS media query string (e.g., '(max-width: 768px)') * @returns boolean indicating if the media query matches * * @example * const isDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); * const isReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); * const isPortrait = useMediaQuery('(orientation: portrait)'); */ function useMediaQuery(query) { const [matches, setMatches] = React.useState(() => { if (!isBrowser) return false; return window.matchMedia(query).matches; }); React.useEffect(() => { if (!isBrowser) return; const media = window.matchMedia(query); if (media.matches !== matches) { setMatches(media.matches); } const listener = (event) => { setMatches(event.matches); }; media.addEventListener('change', listener); return () => media.removeEventListener('change', listener); }, [query, matches]); return matches; } /** * useIsMobile - Returns true when viewport is mobile-sized (< 768px) * * @example * const isMobile = useIsMobile(); * return isMobile ? <MobileNav /> : <DesktopNav />; */ function useIsMobile() { return useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`); } /** * useIsTablet - Returns true when viewport is tablet-sized (768px - 1023px) * * @example * const isTablet = useIsTablet(); */ function useIsTablet() { return useMediaQuery(`(min-width: ${BREAKPOINTS.md}px) and (max-width: ${BREAKPOINTS.lg - 1}px)`); } /** * useIsDesktop - Returns true when viewport is desktop-sized (>= 1024px) * * @example * const isDesktop = useIsDesktop(); */ function useIsDesktop() { return useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`); } /** * useIsTouchDevice - Detects if the device supports touch input * * Uses multiple detection methods for reliability: * - Touch event support * - Pointer coarse media query * - Max touch points * * @example * const isTouchDevice = useIsTouchDevice(); * // Show swipe hints on touch devices */ function useIsTouchDevice() { const [isTouch, setIsTouch] = React.useState(() => { if (!isBrowser) return false; return ('ontouchstart' in window || navigator.maxTouchPoints > 0 || window.matchMedia('(pointer: coarse)').matches); }); React.useEffect(() => { if (!isBrowser) return; // Re-check on mount for accuracy const touchSupported = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || window.matchMedia('(pointer: coarse)').matches; setIsTouch(touchSupported); }, []); return isTouch; } /** * useOrientation - Returns current screen orientation * * @returns 'portrait' | 'landscape' * * @example * const orientation = useOrientation(); * // Adjust layout based on orientation */ function useOrientation() { const { width, height } = useViewportSize(); return height > width ? 'portrait' : 'landscape'; } /** * useBreakpointValue - Returns different values based on breakpoint * * Mobile-first: Returns the value for the current breakpoint or the * closest smaller breakpoint that has a value defined. * * @param values - Object mapping breakpoints to values * @param defaultValue - Fallback value if no breakpoint matches * * @example * const columns = useBreakpointValue({ xs: 1, sm: 2, lg: 4 }, 1); * // Returns 1 on xs, 2 on sm/md, 4 on lg/xl/2xl * * const padding = useBreakpointValue({ xs: 'p-2', md: 'p-4', xl: 'p-8' }); */ function useBreakpointValue(values, defaultValue) { const breakpoint = useBreakpoint(); // Breakpoints in order from largest to smallest const breakpointOrder = ['2xl', 'xl', 'lg', 'md', 'sm', 'xs']; // Find the current breakpoint index const currentIndex = breakpointOrder.indexOf(breakpoint); // Look for value at current breakpoint or smaller (mobile-first) for (let i = currentIndex; i < breakpointOrder.length; i++) { const bp = breakpointOrder[i]; if (bp in values && values[bp] !== undefined) { return values[bp]; } } return defaultValue; } /** * useResponsiveCallback - Returns a memoized callback that receives responsive info * * Useful for callbacks that need to behave differently based on viewport. * * @example * const handleClick = useResponsiveCallback((isMobile) => { * if (isMobile) { * openBottomSheet(); * } else { * openModal(); * } * }); */ function useResponsiveCallback(callback) { const isMobile = useIsMobile(); const isTablet = useIsTablet(); const isDesktop = useIsDesktop(); return React.useCallback((...args) => callback(isMobile, isTablet, isDesktop)(...args), [callback, isMobile, isTablet, isDesktop]); } /** * useSafeAreaInsets - Returns safe area insets for notched devices * * Uses CSS environment variables (env(safe-area-inset-*)) to get * safe area dimensions for devices with notches or home indicators. * * @example * const { top, bottom } = useSafeAreaInsets(); * // Add padding-bottom for home indicator */ function useSafeAreaInsets() { const [insets, setInsets] = React.useState({ top: 0, right: 0, bottom: 0, left: 0, }); React.useEffect(() => { if (!isBrowser) return; // Create a temporary element to read CSS env() values const el = document.createElement('div'); el.style.position = 'fixed'; el.style.top = 'env(safe-area-inset-top, 0px)'; el.style.right = 'env(safe-area-inset-right, 0px)'; el.style.bottom = 'env(safe-area-inset-bottom, 0px)'; el.style.left = 'env(safe-area-inset-left, 0px)'; el.style.visibility = 'hidden'; el.style.pointerEvents = 'none'; document.body.appendChild(el); const computed = getComputedStyle(el); setInsets({ top: parseInt(computed.top, 10) || 0, right: parseInt(computed.right, 10) || 0, bottom: parseInt(computed.bottom, 10) || 0, left: parseInt(computed.left, 10) || 0, }); document.body.removeChild(el); }, []); return insets; } /** * usePrefersMobile - Checks if user prefers reduced data/animations (mobile-friendly) * * Combines multiple preferences that might indicate mobile/low-power usage. */ function usePrefersMobile() { const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); const prefersReducedData = useMediaQuery('(prefers-reduced-data: reduce)'); const isTouchDevice = useIsTouchDevice(); const isMobile = useIsMobile(); return isMobile || isTouchDevice || prefersReducedMotion || prefersReducedData; } // Size classes for trigger button const sizeClasses$a = { sm: 'h-8 text-sm py-1', md: 'h-10 text-base py-2', lg: 'h-12 text-base py-3 min-h-touch', // 44px touch target }; // Size classes for options const optionSizeClasses = { sm: 'py-2 text-sm', md: 'py-2.5 text-sm', lg: 'py-3.5 text-base min-h-touch', // 44px touch target for mobile }; /** * Select - Dropdown select component with search, groups, virtual scrolling, and mobile support * * A feature-rich select component supporting flat or grouped options, search/filter, * option creation, virtual scrolling for large lists, and mobile-optimized BottomSheet display. * * @example Basic select * ```tsx * const options = [ * { value: '1', label: 'Option 1' }, * { value: '2', label: 'Option 2' }, * { value: '3', label: 'Option 3' }, * ]; * * <Select * label="Choose option" * options={options} * value={selected} * onChange={setSelected} * /> * ``` * * @example Mobile-optimized with large touch targets * ```tsx * <Select * options={options} * size="lg" * mobileMode="auto" * placeholder="Select..." * /> * ``` * * @example Searchable with groups * ```tsx * const groups = [ * { * label: 'Fruits', * options: [ * { value: 'apple', label: 'Apple', icon: <Apple /> }, * { value: 'banana', label: 'Banana' }, * ] * }, * { * label: 'Vegetables', * options: [ * { value: 'carrot', label: 'Carrot' }, * ] * }, * ]; * * <Select * groups={groups} * searchable * clearable * placeholder="Search food..." * /> * ``` * * @example Creatable with virtual scrolling * ```tsx * <Select * options={largeOptionList} * searchable * creatable * onCreateOption={handleCreate} * virtualized * virtualHeight="400px" * /> * ``` */ const Select = React.forwardRef((props, ref) => { const { options = [], groups = [], value, onChange, placeholder = 'Select an option', searchable = false, disabled = false, label, helperText, error, loading = false, clearable = false, creatable = false, onCreateOption, virtualized = false, virtualHeight = '300px', virtualItemHeight = 42, size = 'md', mobileMode = 'auto', usePortal = true, } = props; const [isOpen, setIsOpen] = React.useState(false); const [searchQuery, setSearchQuery] = React.useState(''); const [scrollTop, setScrollTop] = React.useState(0); const [activeDescendant] = React.useState(undefined); const [dropdownPosition, setDropdownPosition] = React.useState(null); const selectRef = React.useRef(null); const buttonRef = React.useRef(null); const dropdownRef = React.useRef(null); const searchInputRef = React.useRef(null); const mobileSearchInputRef = React.useRef(null); const listRef = React.useRef(null); const nativeSelectRef = React.useRef(null); // Detect mobile viewport const isMobile = useIsMobile(); const useMobileSheet = mobileMode === 'auto' && isMobile; const useNativeSelect = mobileMode === 'native' && isMobile; // Auto-size for mobile const effectiveSize = isMobile && size === 'md' ? 'lg' : size; // Generate unique IDs for ARIA const labelId = React.useId(); const listboxId = React.useId(); const errorId = React.useId(); const helperTextId = React.useId(); // Expose methods via ref React.useImperativeHandle(ref, () => ({ focus: () => buttonRef.current?.focus(), blur: () => buttonRef.current?.blur(), open: () => setIsOpen(true), close: () => setIsOpen(false), })); // Flatten all options (from both options and groups) const allOptions = [ ...options, ...groups.flatMap(group => group.options) ]; const selectedOption = allOptions.find(opt => opt.value === value); // Filter options/groups based on search const getFilteredData = () => { if (!searchable || !searchQuery) { return { options, groups }; } const query = searchQuery.toLowerCase(); // Filter flat options const filteredOptions = options.filter(opt => opt.label.toLowerCase().includes(query)); // Filter grouped options const filteredGroups = groups .map(group => ({ ...group, options: group.options.filter(opt => opt.label.toLowerCase().includes(query)) })) .filter(group => group.options.length > 0); return { options: filteredOptions, groups: filteredGroups }; }; const { options: filteredOptions, groups: filteredGroups } = getFilteredData(); // Virtual scrolling calculations const totalItems = filteredOptions.length + filteredGroups.flatMap(g => g.options).length; const useVirtualScrolling = virtualized && totalItems > 50; const visibleRangeStart = useVirtualScrolling ? Math.floor(scrollTop / virtualItemHeight) : 0; const visibleRangeEnd = useVirtualScrolling ? Math.min(visibleRangeStart + Math.ceil(parseInt(virtualHeight) / virtualItemHeight) + 5, totalItems) : totalItems; // Flatten all filtered items for virtualization const allFilteredItems = [ ...filteredOptions.map((opt, idx) => ({ type: 'option', option: opt, groupIndex: -1, optionIndex: idx })), ...filteredGroups.flatMap((group, groupIdx) => group.options.map((opt, optIdx) => ({ type: 'grouped', option: opt, groupIndex: groupIdx, optionIndex: optIdx, groupLabel: group.label }))) ]; const visibleItems = useVirtualScrolling ? allFilteredItems.slice(visibleRangeStart, visibleRangeEnd) : allFilteredItems; const offsetY = useVirtualScrolling ? visibleRangeStart * virtualItemHeight : 0; const totalHeight = useVirtualScrolling ? totalItems * virtualItemHeight : 'auto'; // Check if we should show "Create" option const showCreateOption = creatable && searchQuery.trim() !== '' && !filteredOptions.some(opt => opt.label.toLowerCase() === searchQuery.toLowerCase()) && !filteredGroups.some(group => group.options.some(opt => opt.label.toLowerCase() === searchQuery.toLowerCase())); // Handle creating new option const handleCreateOption = () => { if (onCreateOption) { onCreateOption(searchQuery.trim()); } else { // If no callback, just select the typed value onChange?.(searchQuery.trim()); } setSearchQuery(''); setIsOpen(false); }; // Handle click outside (desktop dropdown only) React.useEffect(() => { if (useMobileSheet) return; // Mobile sheet handles its own closing const handleClickOutside = (event) => { const target = event.target; // Check if click is outside both the select trigger and the dropdown portal const isOutsideSelect = selectRef.current && !selectRef.current.contains(target); const isOutsideDropdown = dropdownRef.current && !dropdownRef.current.contains(target); if (isOutsideSelect && isOutsideDropdown) { setIsOpen(false); setSearchQuery(''); } }; if (isOpen) { document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [isOpen, useMobileSheet]); // Focus search input when opened React.useEffect(() => { if (isOpen && searchable) { if (useMobileSheet && mobileSearchInputRef.current) { // Slight delay for mobile sheet animation setTimeout(() => mobileSearchInputRef.current?.focus(), 100); } else if (searchInputRef.current) { searchInputRef.current.focus(); } } }, [isOpen, searchable, useMobileSheet]); // Calculate dropdown position with collision detection and scroll/resize handling React.useEffect(() => { if (!isOpen || useMobileSheet || !usePortal) { setDropdownPosition(null); return; } const updatePosition = () => { if (!buttonRef.current) return; const rect = buttonRef.current.getBoundingClientRect(); const dropdownHeight = 240; // max-h-60 = 15rem = 240px const gap = 2; // Small gap to visually connect to trigger const viewportHeight = window.innerHeight; // Check if there's enough space below const spaceBelow = viewportHeight - rect.bottom; const spaceAbove = rect.top; const hasSpaceBelow = spaceBelow >= dropdownHeight + gap; const hasSpaceAbove = spaceAbove >= dropdownHeight + gap; // Prefer bottom placement, flip to top if not enough space below but enough above const placement = hasSpaceBelow || !hasSpaceAbove ? 'bottom' : 'top'; const top = placement === 'bottom' ? rect.bottom + gap : rect.top - dropdownHeight - gap; setDropdownPosition({ top, left: rect.left, width: rect.width, placement, }); }; // Initial position calculation updatePosition(); // Listen for scroll events on all scrollable ancestors window.addEventListener('scroll', updatePosition, true); window.addEventListener('resize', updatePosition); return () => { window.removeEventListener('scroll', updatePosition, true); window.removeEventListener('resize', updatePosition); }; }, [isOpen, useMobileSheet, usePortal]); // Lock body scroll when mobile sheet is open React.useEffect(() => { if (useMobileSheet && isOpen) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; } return () => { document.body.style.overflow = ''; }; }, [isOpen, useMobileSheet]); // Handle escape key for mobile sheet React.useEffect(() => { if (!useMobileSheet || !isOpen) return; const handleEscape = (e) => { if (e.key === 'Escape') { setIsOpen(false); setSearchQuery(''); } }; document.addEventListener('keydown', handleEscape); return () => document.removeEventListener('keydown', handleEscape); }, [isOpen, useMobileSheet]); const handleSelect = (optionValue) => { onChange?.(optionValue); setIsOpen(false); setSearchQuery(''); }; const handleClose = () => { setIsOpen(false); setSearchQuery(''); }; // Render option button (shared between desktop and mobile) const renderOption = (option, isSelected, mobile = false) => (jsxRuntime.jsxs("button", { type: "button", onClick: () => !option.disabled && handleSelect(option.value), disabled: option.disabled, className: ` w-full flex items-center justify-between px-4 transition-colors ${mobile ? optionSizeClasses.lg : optionSizeClasses[effectiveSize]} ${isSelected ? 'bg-accent-50 text-accent-900' : 'text-ink-700'} ${option.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-paper-50 active:bg-paper-100 cursor-pointer'} `, role: "option", "aria-selected": isSelected, children: [jsxRuntime.jsxs("span", { className: "flex items-center gap-2", children: [option.icon && jsxRuntime.jsx("span", { children: option.icon }), option.label] }), isSelected && jsxRuntime.jsx(lucideReact.Check, { className: `${mobile ? 'h-5 w-5' : 'h-4 w-4'} text-accent-600` })] }, option.value)); // Render options list content (shared between desktop and mobile) const renderOptionsContent = (mobile = false) => { if (loading) { return (jsxRuntime.jsxs("div", { className: "px-4 py-8 flex items-center justify-center", role: "status", "aria-live": "polite", children: [jsxRuntime.jsx(lucideReact.Loader2, { className: "h-5 w-5 animate-spin text-ink-500" }), jsxRuntime.jsx("span", { className: "ml-2 text-sm text-ink-500", children: "Loading..." })] })); } if (filteredOptions.length === 0 && filteredGroups.length === 0 && !showCreateOption) { return (jsxRuntime.jsx("div", { className: "px-4 py-3 text-sm text-ink-500 text-center", role: "status", "aria-live": "polite", children: "No options found" })); } return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [showCreateOption && (jsxRuntime.jsx("button", { type: "button", onClick: handleCreateOption, className: ` w-full flex items-center px-4 text-accent-700 hover:bg-accent-50 transition-colors border-b border-paper-200 ${mobile ? 'py-3.5 text-base' : 'py-2.5 text-sm'} `, children: jsxRuntime.jsxs("span", { className: "font-medium", children: ["Create \"", searchQuery, "\""] }) })), useVirtualScrolling ? (jsxRuntime.jsx("div", { style: { height: totalHeight, position: 'relative' }, children: jsxRuntime.jsx("div", { style: { transform: `translateY(${offsetY}px)` }, children: visibleItems.map((item) => { const option = item.option; const isSelected = option.value === value; const key = `${item.type}-${item.groupIndex}-${item.optionIndex}-${option.value}`; return (jsxRuntime.jsxs("button", { type: "button", onClick: () => !option.disabled && handleSelect(option.value), disabled: option.disabled, style: { height: mobile ? '56px' : `${virtualItemHeight}px` }, className: ` w-full flex items-center justify-between px-4 transition-colors ${mobile ? 'text-base' : 'text-sm'} ${isSelected ? 'bg-accent-50 text-accent-900' : 'text-ink-700'} ${option.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-paper-50 cursor-pointer'} `, role: "option", "aria-selected": isSelected, children: [jsxRuntime.jsxs("span", { className: "flex items-center gap-2", children: [option.icon && jsxRuntime.jsx("span", { children: option.icon }), option.label] }), isSelected && jsxRuntime.jsx(lucideReact.Check, { className: `${mobile ? 'h-5 w-5' : 'h-4 w-4'} text-accent-600` })] }, key)); }) }) })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [filteredOptions.map((option) => renderOption(option, option.value === value, mobile)), filteredGroups.map((group) => (jsxRuntime.jsxs("div", { children: [jsxRuntime.jsx("div", { className: ` px-4 font-semibold text-ink-500 uppercase tracking-wider bg-paper-50 border-t border-b border-paper-200 ${mobile ? 'py-2.5 text-xs' : 'py-2 text-xs'} `, children: group.label }), group.options.map((option) => renderOption(option, option.value === value, mobile))] }, group.label)))] }))] })); }; // Native select for mobile (optional) if (useNativeSelect) { return (jsxRuntime.jsxs("div", { className: "w-full", children: [label && (jsxRuntime.jsx("label", { id: labelId, className: "label", children: label })), jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsxs("select", { ref: nativeSelectRef, value: value || '', onChange: (e) => onChange?.(e.target.value), disabled: disabled, className: ` input w-full appearance-none pr-10 ${sizeClasses$a[effectiveSize]} ${error ? 'border-error-400 focus:border-error-400 focus:ring-error-400' : ''} ${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'} `, "aria-labelledby": label ? labelId : undefined, "aria-invalid": error ? 'true' : undefined, "aria-describedby": error ? errorId : (helperText ? helperTextId : undefined), children: [jsxRuntime.jsx("option", { value: "", disabled: true, children: placeholder }), options.map((opt) => (jsxRuntime.jsx("option", { value: opt.value, disabled: opt.disabled, children: opt.label }, opt.value))), groups.map((group) => (jsxRuntime.jsx("optgroup", { label: group.label, children: group.options.map((opt) => (jsxRuntime.jsx("option", { value: opt.value, disabled: opt.disabled, children: opt.label }, opt.value))) }, group.label)))] }), jsxRuntime.jsx(lucideReact.ChevronDown, { className: "absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-ink-500 pointer-events-none" })] }), error && (jsxRuntime.jsx("p", { id: errorId, className: "mt-2 text-xs text-error-600", role: "alert", "aria-live": "assertive", children: error })), helperText && !error && (jsxRuntime.jsx("p", { id: helperTextId, className: "mt-2 text-xs text-ink-600", children: helperText }))] })); } return (jsxRuntime.jsxs("div", { className: "w-full", children: [label && (jsxRuntime.jsx("label", { id: labelId, className: "label", children: label })), jsxRuntime.jsx("div", { ref: selectRef, className: "relative", children: jsxRuntime.jsxs("button", { ref: buttonRef, type: "button", onClick: () => !disabled && setIsOpen(!isOpen), disabled: disabled, className: ` input w-full flex items-center justify-between px-3 ${sizeClasses$a[effectiveSize]} ${error ? 'border-error-400 focus:border-error-400 focus:ring-error-400' : ''} ${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'} `, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": isOpen, "aria-controls": listboxId, "aria-labelledby": label ? labelId : undefined, "aria-label": !label ? placeholder : undefined, "aria-activedescendant": activeDescendant, "aria-invalid": error ? 'true' : undefined, "aria-describedby": error ? errorId : (helperText ? helperTextId : undefined), "aria-disabled": disabled, children: [jsxRuntime.jsxs("span", { className: `flex items-center gap-2 ${selectedOption ? 'text-ink-800' : 'text-ink-400'}`, children: [loading && jsxRuntime.jsx(lucideReact.Loader2, { className: "h-4 w-4 animate-spin text-ink-500" }), !loading && selectedOption?.icon && jsxRuntime.jsx("span", { children: selectedOption.icon }), selectedOption ? selectedOption.label : placeholder] }), jsxRuntime.jsxs("div", { className: "flex items-center gap-1", children: [clearable && value && (jsxRuntime.jsx("button", { type: "button", onClick: (e) => { e.stopPropagation(); onChange?.(''); setIsOpen(false); }, className: "text-ink-400 hover:text-ink-600 transition-colors p-0.5", "aria-label": "Clear selection", children: jsxRuntime.jsx(lucideReact.X, { className: `${effectiveSize === 'lg' ? 'h-5 w-5' : 'h-4 w-4'}` }) })), jsxRuntime.jsx(lucideReact.ChevronDown, { className: `${effectiveSize === 'lg' ? 'h-5 w-5' : 'h-4 w-4'} text-ink-500 transition-transform ${isOpen ? 'rotate-180' : ''}` })] })] }) }), isOpen && !useMobileSheet && (usePortal ? dropdownPosition : true) && (usePortal ? reactDom.createPortal(jsxRuntime.jsxs("div", { ref: dropdownRef, className: `fixed z-[9999] bg-white bg-subtle-grain rounded-lg shadow-lg border border-paper-200 max-h-60 overflow-hidden animate-fade-in ${dropdownPosition?.placement === 'top' ? 'origin-bottom' : 'origin-top'}`, style: { top: dropdownPosition.top, left: dropdownPosition.left, width: dropdownPosition.width, }, children: [searchable && (jsxRuntime.jsx("div", { className: "p-2 border-b border-paper-200", children: jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsx(lucideReact.Search, { className: "absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-400" }), jsxRuntime.jsx("input", { ref: searchInputRef, type: "text", value: searchQuery, onChange: (e) => setSearchQuery(e.target.value), placeholder: "Search...", className: "w-full pl-9 pr-3 py-2 text-sm border border-paper-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-400 focus:border-accent-400", role: "searchbox", "aria-label": "Search options", "aria-autocomplete": "list", "aria-controls": listboxId })] }) })), jsxRuntime.jsx("div", { ref: listRef, id: listboxId, className: "overflow-y-auto", style: { maxHeight: useVirtualScrolling ? virtualHeight : '12rem' }, onScroll: (e) => useVirtualScrolling && setScrollTop(e.currentTarget.scrollTop), role: "listbox", "aria-label": "Available options", "aria-multiselectable": "false", children: renderOptionsContent(false) })] }), document.body) : ( // Non-portal dropdown (inline, relative positioning) jsxRuntime.jsxs("div", { ref: dropdownRef, className: "absolute z-50 mt-1 w-full bg-white bg-subtle-grain rounded-lg shadow-lg border border-paper-200 max-h-60 overflow-hidden animate-fade-in", children: [searchable && (jsxRuntime.jsx("div", { className: "p-2 border-b border-paper-200", children: jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsx(lucideReact.Search, { className: "absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-400" }), jsxRuntime.jsx("input", { ref: searchInputRef, type: "text", value: searchQuery, onChange: (e) => setSearchQuery(e.target.value), placeholder: "Search...", className: "w-full pl-9 pr-3 py-2 text-sm border border-paper-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-400 focus:border-accent-400", role: "searchbox", "aria-label": "Search options", "aria-autocomplete": "list", "aria-controls": listboxId })] }) })), jsxRuntime.jsx("div", { ref: listRef, id: listboxId, className: "overflow-y-auto", style: { maxHeight: useVirtualScrolling ? virtualHeight : '12rem' }, onScroll: (e) => useVirtualScrolling && setScrollTop(e.currentTarget.scrollTop), role: "listbox