@papernote/ui
Version:
A modern React component library with a paper notebook aesthetic - minimal, professional, and expressive
1,018 lines (1,012 loc) • 2.28 MB
JavaScript
import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
import * as React from 'react';
import React__default, { forwardRef, useState, useEffect, useCallback, useRef, useId, useImperativeHandle, useMemo, Children, isValidElement, cloneElement, Component, createContext as createContext$1, useLayoutEffect, createElement, useContext, useReducer } from 'react';
import { Loader2, X, EyeOff, Eye, AlertTriangle, CheckCircle, AlertCircle, ChevronDown, Search, Check, Minus, Star, Calendar as Calendar$1, ChevronLeft, ChevronRight, Clock, ChevronUp, Plus, TrendingUp, TrendingDown, Info, Trash2, ChevronsLeft, ChevronsRight, Circle, MoreVertical, GripVertical, Upload, Bold, Italic, Underline, List, ListOrdered, Code, Link, Home, FileText, Image, File as File$1, Menu as Menu$1, ArrowDown, User, Settings, LogOut, Moon, Sun, Bell, Edit, Trash, Pin, PinOff, Download, Save, ArrowUpDown, Filter, XCircle, BarChart3, MessageSquare } from 'lucide-react';
import { createPortal } from 'react-dom';
import { useInRouterContext, useNavigate, useLocation, Link as Link$1 } from 'react-router-dom';
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);
}
/**
* 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 = 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 = (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 && (jsx(Loader2, { className: `${iconSize[size]} animate-spin` })), !loading && icon && iconPosition === 'left' && (jsx("span", { className: iconSize[size], children: icon })), !iconOnly && children, !loading && icon && iconPosition === 'right' && !iconOnly && (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 (jsxs("div", { className: "relative inline-block", children: [buttonElement, 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 (jsxs("div", { className: `${className}`, children: [label && (jsx("label", { className: "block text-sm font-medium text-ink-700 mb-2", children: label })), 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 (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 && 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 = 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] = 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 jsx(AlertCircle, { className: "h-5 w-5 text-error-500" });
case 'success':
return jsx(CheckCircle, { className: "h-5 w-5 text-success-500" });
case 'warning':
return jsx(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 (jsxs("div", { className: "w-full", children: [label && (jsxs("label", { htmlFor: inputId, className: "label", children: [label, props.required && jsx("span", { className: "text-error-500 ml-1", children: "*" })] })), jsxs("div", { className: "relative", children: [prefix && (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 && (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 && (jsx("div", { className: "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-ink-400", children: icon })), 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 && (jsx("div", { className: "absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none text-ink-500 text-sm", children: suffix })), jsxs("div", { className: "absolute inset-y-0 right-0 pr-3 flex items-center gap-1", children: [loading && (jsx("div", { className: "pointer-events-none text-ink-400", children: jsx(Loader2, { className: "h-5 w-5 animate-spin" }) })), suffixIcon && !suffix && !validationState && !showPasswordToggle && !showClearButton && !loading && (jsx("div", { className: "pointer-events-none text-ink-400", children: suffixIcon })), showClearButton && (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: jsx(X, { className: "h-4 w-4" }) })), type === 'password' && showPasswordToggle && (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 ? jsx(EyeOff, { className: "h-5 w-5" }) : jsx(Eye, { className: "h-5 w-5" }) })), validationState && (jsx("div", { className: "pointer-events-none", children: getValidationIcon() })), icon && iconPosition === 'right' && !suffix && !suffixIcon && !validationState && (jsx("div", { className: "pointer-events-none text-ink-400", children: icon }))] })] }), jsxs("div", { className: "flex justify-between items-center mt-2", children: [(helperText || validationMessage) && (jsx("p", { className: `text-xs ${validationMessage ? getValidationMessageColor() : 'text-ink-600'}`, children: validationMessage || helperText })), showCounter && (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] = useState(getInitialViewportSize);
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] = useState(() => {
if (!isBrowser)
return false;
return window.matchMedia(query).matches;
});
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] = useState(() => {
if (!isBrowser)
return false;
return ('ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
window.matchMedia('(pointer: coarse)').matches);
});
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 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] = useState({
top: 0,
right: 0,
bottom: 0,
left: 0,
});
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 = 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] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [scrollTop, setScrollTop] = useState(0);
const [activeDescendant] = useState(undefined);
const [dropdownPosition, setDropdownPosition] = useState(null);
const selectRef = useRef(null);
const buttonRef = useRef(null);
const dropdownRef = useRef(null);
const searchInputRef = useRef(null);
const mobileSearchInputRef = useRef(null);
const listRef = useRef(null);
const nativeSelectRef = 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 = useId();
const listboxId = useId();
const errorId = useId();
const helperTextId = useId();
// Expose methods via ref
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)
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
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
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
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
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) => (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: [jsxs("span", { className: "flex items-center gap-2", children: [option.icon && jsx("span", { children: option.icon }), option.label] }), isSelected && jsx(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 (jsxs("div", { className: "px-4 py-8 flex items-center justify-center", role: "status", "aria-live": "polite", children: [jsx(Loader2, { className: "h-5 w-5 animate-spin text-ink-500" }), jsx("span", { className: "ml-2 text-sm text-ink-500", children: "Loading..." })] }));
}
if (filteredOptions.length === 0 && filteredGroups.length === 0 && !showCreateOption) {
return (jsx("div", { className: "px-4 py-3 text-sm text-ink-500 text-center", role: "status", "aria-live": "polite", children: "No options found" }));
}
return (jsxs(Fragment, { children: [showCreateOption && (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: jsxs("span", { className: "font-medium", children: ["Create \"", searchQuery, "\""] }) })), useVirtualScrolling ? (jsx("div", { style: { height: totalHeight, position: 'relative' }, children: 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 (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: [jsxs("span", { className: "flex items-center gap-2", children: [option.icon && jsx("span", { children: option.icon }), option.label] }), isSelected && jsx(Check, { className: `${mobile ? 'h-5 w-5' : 'h-4 w-4'} text-accent-600` })] }, key));
}) }) })) : (jsxs(Fragment, { children: [filteredOptions.map((option) => renderOption(option, option.value === value, mobile)), filteredGroups.map((group) => (jsxs("div", { children: [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 (jsxs("div", { className: "w-full", children: [label && (jsx("label", { id: labelId, className: "label", children: label })), jsxs("div", { className: "relative", children: [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: [jsx("option", { value: "", disabled: true, children: placeholder }), options.map((opt) => (jsx("option", { value: opt.value, disabled: opt.disabled, children: opt.label }, opt.value))), groups.map((group) => (jsx("optgroup", { label: group.label, children: group.options.map((opt) => (jsx("option", { value: opt.value, disabled: opt.disabled, children: opt.label }, opt.value))) }, group.label)))] }), jsx(ChevronDown, { className: "absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-ink-500 pointer-events-none" })] }), error && (jsx("p", { id: errorId, className: "mt-2 text-xs text-error-600", role: "alert", "aria-live": "assertive", children: error })), helperText && !error && (jsx("p", { id: helperTextId, className: "mt-2 text-xs text-ink-600", children: helperText }))] }));
}
return (jsxs("div", { className: "w-full", children: [label && (jsx("label", { id: labelId, className: "label", children: label })), jsx("div", { ref: selectRef, className: "relative", children: 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: [jsxs("span", { className: `flex items-center gap-2 ${selectedOption ? 'text-ink-800' : 'text-ink-400'}`, children: [loading && jsx(Loader2, { className: "h-4 w-4 animate-spin text-ink-500" }), !loading && selectedOption?.icon && jsx("span", { children: selectedOption.icon }), selectedOption ? selectedOption.label : placeholder] }), jsxs("div", { className: "flex items-center gap-1", children: [clearable && value && (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: jsx(X, { className: `${effectiveSize === 'lg' ? 'h-5 w-5' : 'h-4 w-4'}` }) })), jsx(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 ? createPortal(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 && (jsx("div", { className: "p-2 border-b border-paper-200", children: jsxs("div", { className: "relative", children: [jsx(Search, { className: "absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-400" }), 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 })] }) })), 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)
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 && (jsx("div", { className: "p-2 border-b border-paper-200", children: jsxs("div", { className: "relative", children: [jsx(Search, { className: "absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-400" }), 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 })] }) })), 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) })] }))), isOpen && useMobileSheet && createPortal(jsxs("div", { className: "fixed inset-0 z-50 flex items-end", onClick: (e) => e.target === e.currentTarget && handleClose(), role: "dialog", "aria-modal": "true", "aria-labelledby": label ? `mobile-${labelId}` : undefined, children: [jsx("div", { className: "absolute inset-0 bg-black/50 animate-fade-in" }), jsxs("div", { className: "relative w-full bg-white rounded-t-2xl shadow-2xl animate-slide-up max-h-[85vh] flex flex-col", style: { paddingBottom: 'env(safe-area-inset-bottom)' }, children: [jsx("div", { className: "py-3 cursor-grab", children: jsx("div", { className: "w-12 h-1.5 bg-ink-300 rounded-full mx-auto" }) }), jsxs("div", { className: "px-4 pb-3 border-b border-paper-200 flex items-center justify-between", children: [label && (jsx("h2", { id: `mobile-${labelId}`, className: "text-lg font-semibold text-ink-900", children: label })), !label && (jsx("h2", { className: "text-lg font-semibold text-ink-900", children: placeholder })), jsx("button", { onC