@papernote/ui 1.3.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/ActionBar.d.ts +112 -0
- package/dist/components/ActionBar.d.ts.map +1 -0
- package/dist/components/BottomNavigation.d.ts +98 -0
- package/dist/components/BottomNavigation.d.ts.map +1 -0
- package/dist/components/Checkbox.d.ts +2 -0
- package/dist/components/Checkbox.d.ts.map +1 -1
- package/dist/components/CheckboxList.d.ts +81 -0
- package/dist/components/CheckboxList.d.ts.map +1 -0
- package/dist/components/Chip.d.ts +92 -1
- package/dist/components/Chip.d.ts.map +1 -1
- package/dist/components/ConfirmDialog.d.ts +43 -1
- package/dist/components/ConfirmDialog.d.ts.map +1 -1
- package/dist/components/DataTable.d.ts +10 -1
- package/dist/components/DataTable.d.ts.map +1 -1
- package/dist/components/DataTableCardView.d.ts +99 -0
- package/dist/components/DataTableCardView.d.ts.map +1 -0
- package/dist/components/ExpandablePanel.d.ts +142 -0
- package/dist/components/ExpandablePanel.d.ts.map +1 -0
- package/dist/components/FloatingActionButton.d.ts +98 -0
- package/dist/components/FloatingActionButton.d.ts.map +1 -0
- package/dist/components/Input.d.ts +45 -1
- package/dist/components/Input.d.ts.map +1 -1
- package/dist/components/MobileHeader.d.ts +98 -0
- package/dist/components/MobileHeader.d.ts.map +1 -0
- package/dist/components/MobileLayout.d.ts +121 -0
- package/dist/components/MobileLayout.d.ts.map +1 -0
- package/dist/components/Modal.d.ts +78 -1
- package/dist/components/Modal.d.ts.map +1 -1
- package/dist/components/PageHeader.d.ts +86 -0
- package/dist/components/PageHeader.d.ts.map +1 -0
- package/dist/components/PullToRefresh.d.ts +87 -0
- package/dist/components/PullToRefresh.d.ts.map +1 -0
- package/dist/components/QueryTransparency.d.ts +1 -1
- package/dist/components/QueryTransparency.d.ts.map +1 -1
- package/dist/components/SearchableList.d.ts +83 -0
- package/dist/components/SearchableList.d.ts.map +1 -0
- package/dist/components/Select.d.ts +16 -2
- package/dist/components/Select.d.ts.map +1 -1
- package/dist/components/Sidebar.d.ts +40 -1
- package/dist/components/Sidebar.d.ts.map +1 -1
- package/dist/components/SwipeActions.d.ts +93 -0
- package/dist/components/SwipeActions.d.ts.map +1 -0
- package/dist/components/Switch.d.ts +1 -0
- package/dist/components/Switch.d.ts.map +1 -1
- package/dist/components/Textarea.d.ts +13 -0
- package/dist/components/Textarea.d.ts.map +1 -1
- package/dist/components/index.d.ts +31 -3
- package/dist/components/index.d.ts.map +1 -1
- package/dist/context/MobileContext.d.ts +168 -0
- package/dist/context/MobileContext.d.ts.map +1 -0
- package/dist/hooks/useResponsive.d.ts +158 -0
- package/dist/hooks/useResponsive.d.ts.map +1 -0
- package/dist/index.d.ts +1871 -51
- package/dist/index.esm.js +3025 -196
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +3063 -194
- package/dist/index.js.map +1 -1
- package/dist/styles.css +434 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/ActionBar.stories.tsx +246 -0
- package/src/components/ActionBar.tsx +242 -0
- package/src/components/BottomNavigation.stories.tsx +142 -0
- package/src/components/BottomNavigation.tsx +225 -0
- package/src/components/Checkbox.stories.tsx +162 -0
- package/src/components/Checkbox.tsx +22 -6
- package/src/components/CheckboxList.stories.tsx +311 -0
- package/src/components/CheckboxList.tsx +433 -0
- package/src/components/Chip.stories.tsx +389 -0
- package/src/components/Chip.tsx +182 -3
- package/src/components/ConfirmDialog.tsx +56 -4
- package/src/components/DataTable.tsx +60 -1
- package/src/components/DataTableCardView.stories.tsx +307 -0
- package/src/components/DataTableCardView.tsx +419 -0
- package/src/components/ExpandablePanel.stories.tsx +620 -0
- package/src/components/ExpandablePanel.tsx +383 -0
- package/src/components/FloatingActionButton.stories.tsx +197 -0
- package/src/components/FloatingActionButton.tsx +301 -0
- package/src/components/Grid.stories.tsx +16 -16
- package/src/components/Input.stories.tsx +214 -0
- package/src/components/Input.tsx +81 -4
- package/src/components/MobileHeader.stories.tsx +205 -0
- package/src/components/MobileHeader.tsx +233 -0
- package/src/components/MobileLayout.stories.tsx +338 -0
- package/src/components/MobileLayout.tsx +313 -0
- package/src/components/Modal.stories.tsx +388 -0
- package/src/components/Modal.tsx +122 -4
- package/src/components/PageHeader.stories.tsx +198 -0
- package/src/components/PageHeader.tsx +217 -0
- package/src/components/PullToRefresh.stories.tsx +321 -0
- package/src/components/PullToRefresh.tsx +294 -0
- package/src/components/QueryTransparency.tsx +1 -1
- package/src/components/SearchableList.stories.tsx +437 -0
- package/src/components/SearchableList.tsx +326 -0
- package/src/components/Select.stories.tsx +190 -0
- package/src/components/Select.tsx +353 -137
- package/src/components/Sidebar.tsx +193 -10
- package/src/components/SwipeActions.stories.tsx +327 -0
- package/src/components/SwipeActions.tsx +387 -0
- package/src/components/Switch.stories.tsx +158 -0
- package/src/components/Switch.tsx +12 -3
- package/src/components/Textarea.tsx +31 -1
- package/src/components/index.ts +69 -3
- package/src/context/MobileContext.tsx +296 -0
- package/src/hooks/useResponsive.ts +360 -0
- package/src/types/index.ts +4 -0
- package/tailwind.config.js +56 -1
package/dist/index.js
CHANGED
|
@@ -226,12 +226,19 @@ function ButtonGroup({ options, value, values = [], onChange, onChangeMultiple,
|
|
|
226
226
|
* A feature-rich text input with support for validation states, character counting,
|
|
227
227
|
* password visibility toggle, prefix/suffix text and icons, and clearable functionality.
|
|
228
228
|
*
|
|
229
|
+
* Mobile optimizations:
|
|
230
|
+
* - inputMode prop for appropriate mobile keyboard
|
|
231
|
+
* - enterKeyHint prop for mobile keyboard action button
|
|
232
|
+
* - Size variants with touch-friendly targets (44px for 'lg')
|
|
233
|
+
*
|
|
229
234
|
* @example Basic input with label
|
|
230
235
|
* ```tsx
|
|
231
236
|
* <Input
|
|
232
237
|
* label="Email"
|
|
233
238
|
* type="email"
|
|
234
239
|
* placeholder="Enter your email"
|
|
240
|
+
* inputMode="email"
|
|
241
|
+
* enterKeyHint="next"
|
|
235
242
|
* />
|
|
236
243
|
* ```
|
|
237
244
|
*
|
|
@@ -257,18 +264,30 @@ function ButtonGroup({ options, value, values = [], onChange, onChangeMultiple,
|
|
|
257
264
|
* />
|
|
258
265
|
* ```
|
|
259
266
|
*
|
|
267
|
+
* @example Mobile-optimized phone input
|
|
268
|
+
* ```tsx
|
|
269
|
+
* <Input
|
|
270
|
+
* label="Phone Number"
|
|
271
|
+
* type="tel"
|
|
272
|
+
* inputMode="tel"
|
|
273
|
+
* enterKeyHint="done"
|
|
274
|
+
* size="lg"
|
|
275
|
+
* />
|
|
276
|
+
* ```
|
|
277
|
+
*
|
|
260
278
|
* @example With prefix/suffix
|
|
261
279
|
* ```tsx
|
|
262
280
|
* <Input
|
|
263
281
|
* label="Amount"
|
|
264
282
|
* type="number"
|
|
283
|
+
* inputMode="decimal"
|
|
265
284
|
* prefixIcon={<DollarSign />}
|
|
266
285
|
* suffix="USD"
|
|
267
286
|
* clearable
|
|
268
287
|
* />
|
|
269
288
|
* ```
|
|
270
289
|
*/
|
|
271
|
-
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, ...props }, ref) => {
|
|
290
|
+
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) => {
|
|
272
291
|
const inputId = id || `input-${Math.random().toString(36).substring(2, 9)}`;
|
|
273
292
|
const [showPassword, setShowPassword] = React.useState(false);
|
|
274
293
|
// Handle clear button click
|
|
@@ -292,6 +311,28 @@ const Input = React.forwardRef(({ label, helperText, validationState, validation
|
|
|
292
311
|
// Calculate character count
|
|
293
312
|
const currentLength = value ? String(value).length : 0;
|
|
294
313
|
const showCounter = showCount && maxLength;
|
|
314
|
+
// Auto-detect inputMode based on type if not specified
|
|
315
|
+
const effectiveInputMode = inputMode || (() => {
|
|
316
|
+
switch (type) {
|
|
317
|
+
case 'email': return 'email';
|
|
318
|
+
case 'tel': return 'tel';
|
|
319
|
+
case 'url': return 'url';
|
|
320
|
+
case 'number': return 'decimal';
|
|
321
|
+
case 'search': return 'search';
|
|
322
|
+
default: return undefined;
|
|
323
|
+
}
|
|
324
|
+
})();
|
|
325
|
+
// Size classes
|
|
326
|
+
const sizeClasses = {
|
|
327
|
+
sm: 'h-8 text-sm',
|
|
328
|
+
md: 'h-10 text-base',
|
|
329
|
+
lg: 'h-12 text-base min-h-touch', // 44px touch target
|
|
330
|
+
};
|
|
331
|
+
const buttonSizeClasses = {
|
|
332
|
+
sm: 'p-1',
|
|
333
|
+
md: 'p-1.5',
|
|
334
|
+
lg: 'p-2 min-w-touch-sm min-h-touch-sm', // 36px touch target for buttons
|
|
335
|
+
};
|
|
295
336
|
const getValidationIcon = () => {
|
|
296
337
|
switch (validationState) {
|
|
297
338
|
case 'error':
|
|
@@ -328,8 +369,9 @@ const Input = React.forwardRef(({ label, helperText, validationState, validation
|
|
|
328
369
|
return 'text-ink-600';
|
|
329
370
|
}
|
|
330
371
|
};
|
|
331
|
-
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, className: `
|
|
372
|
+
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: `
|
|
332
373
|
input
|
|
374
|
+
${sizeClasses[size]}
|
|
333
375
|
${getValidationClasses()}
|
|
334
376
|
${prefix ? 'pl-' + (prefix.length * 8 + 12) : ''}
|
|
335
377
|
${prefixIcon && !prefix ? 'pl-10' : ''}
|
|
@@ -340,15 +382,323 @@ const Input = React.forwardRef(({ label, helperText, validationState, validation
|
|
|
340
382
|
${validationState && !suffix && !suffixIcon && !showPasswordToggle ? 'pr-10' : ''}
|
|
341
383
|
${(showPasswordToggle && type === 'password') || validationState || suffix || suffixIcon ? 'pr-20' : ''}
|
|
342
384
|
${className}
|
|
343
|
-
`, ...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-
|
|
385
|
+
`, ...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] }))] })] }));
|
|
344
386
|
});
|
|
345
387
|
Input.displayName = 'Input';
|
|
346
388
|
|
|
347
389
|
/**
|
|
348
|
-
*
|
|
390
|
+
* Tailwind breakpoint values in pixels
|
|
391
|
+
*/
|
|
392
|
+
const BREAKPOINTS = {
|
|
393
|
+
xs: 0,
|
|
394
|
+
sm: 640,
|
|
395
|
+
md: 768,
|
|
396
|
+
lg: 1024,
|
|
397
|
+
xl: 1280,
|
|
398
|
+
'2xl': 1536,
|
|
399
|
+
};
|
|
400
|
+
/**
|
|
401
|
+
* SSR-safe check for window availability
|
|
402
|
+
*/
|
|
403
|
+
const isBrowser = typeof window !== 'undefined';
|
|
404
|
+
/**
|
|
405
|
+
* Get initial viewport size (SSR-safe)
|
|
406
|
+
*/
|
|
407
|
+
const getInitialViewportSize = () => {
|
|
408
|
+
if (!isBrowser) {
|
|
409
|
+
return { width: 1024, height: 768 }; // Default to desktop for SSR
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
width: window.innerWidth,
|
|
413
|
+
height: window.innerHeight,
|
|
414
|
+
};
|
|
415
|
+
};
|
|
416
|
+
/**
|
|
417
|
+
* useViewportSize - Returns current viewport dimensions
|
|
418
|
+
*
|
|
419
|
+
* Updates on window resize with debouncing for performance.
|
|
420
|
+
* SSR-safe with sensible defaults.
|
|
421
|
+
*
|
|
422
|
+
* @example
|
|
423
|
+
* const { width, height } = useViewportSize();
|
|
424
|
+
* console.log(`Viewport: ${width}x${height}`);
|
|
425
|
+
*/
|
|
426
|
+
function useViewportSize() {
|
|
427
|
+
const [size, setSize] = React.useState(getInitialViewportSize);
|
|
428
|
+
React.useEffect(() => {
|
|
429
|
+
if (!isBrowser)
|
|
430
|
+
return;
|
|
431
|
+
let timeoutId;
|
|
432
|
+
const handleResize = () => {
|
|
433
|
+
clearTimeout(timeoutId);
|
|
434
|
+
timeoutId = setTimeout(() => {
|
|
435
|
+
setSize({
|
|
436
|
+
width: window.innerWidth,
|
|
437
|
+
height: window.innerHeight,
|
|
438
|
+
});
|
|
439
|
+
}, 100); // Debounce 100ms
|
|
440
|
+
};
|
|
441
|
+
window.addEventListener('resize', handleResize);
|
|
442
|
+
// Set initial size on mount (in case SSR default differs)
|
|
443
|
+
handleResize();
|
|
444
|
+
return () => {
|
|
445
|
+
clearTimeout(timeoutId);
|
|
446
|
+
window.removeEventListener('resize', handleResize);
|
|
447
|
+
};
|
|
448
|
+
}, []);
|
|
449
|
+
return size;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* useBreakpoint - Returns the current Tailwind breakpoint
|
|
453
|
+
*
|
|
454
|
+
* Automatically updates when viewport crosses breakpoint thresholds.
|
|
455
|
+
*
|
|
456
|
+
* @example
|
|
457
|
+
* const breakpoint = useBreakpoint();
|
|
458
|
+
* // Returns: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
|
459
|
+
*/
|
|
460
|
+
function useBreakpoint() {
|
|
461
|
+
const { width } = useViewportSize();
|
|
462
|
+
if (width >= BREAKPOINTS['2xl'])
|
|
463
|
+
return '2xl';
|
|
464
|
+
if (width >= BREAKPOINTS.xl)
|
|
465
|
+
return 'xl';
|
|
466
|
+
if (width >= BREAKPOINTS.lg)
|
|
467
|
+
return 'lg';
|
|
468
|
+
if (width >= BREAKPOINTS.md)
|
|
469
|
+
return 'md';
|
|
470
|
+
if (width >= BREAKPOINTS.sm)
|
|
471
|
+
return 'sm';
|
|
472
|
+
return 'xs';
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* useMediaQuery - React hook for CSS media queries
|
|
476
|
+
*
|
|
477
|
+
* SSR-safe implementation that returns false during SSR and
|
|
478
|
+
* updates reactively when media query match state changes.
|
|
479
|
+
*
|
|
480
|
+
* @param query - CSS media query string (e.g., '(max-width: 768px)')
|
|
481
|
+
* @returns boolean indicating if the media query matches
|
|
482
|
+
*
|
|
483
|
+
* @example
|
|
484
|
+
* const isDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
|
485
|
+
* const isReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
|
|
486
|
+
* const isPortrait = useMediaQuery('(orientation: portrait)');
|
|
487
|
+
*/
|
|
488
|
+
function useMediaQuery(query) {
|
|
489
|
+
const [matches, setMatches] = React.useState(() => {
|
|
490
|
+
if (!isBrowser)
|
|
491
|
+
return false;
|
|
492
|
+
return window.matchMedia(query).matches;
|
|
493
|
+
});
|
|
494
|
+
React.useEffect(() => {
|
|
495
|
+
if (!isBrowser)
|
|
496
|
+
return;
|
|
497
|
+
const media = window.matchMedia(query);
|
|
498
|
+
if (media.matches !== matches) {
|
|
499
|
+
setMatches(media.matches);
|
|
500
|
+
}
|
|
501
|
+
const listener = (event) => {
|
|
502
|
+
setMatches(event.matches);
|
|
503
|
+
};
|
|
504
|
+
media.addEventListener('change', listener);
|
|
505
|
+
return () => media.removeEventListener('change', listener);
|
|
506
|
+
}, [query, matches]);
|
|
507
|
+
return matches;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* useIsMobile - Returns true when viewport is mobile-sized (< 768px)
|
|
511
|
+
*
|
|
512
|
+
* @example
|
|
513
|
+
* const isMobile = useIsMobile();
|
|
514
|
+
* return isMobile ? <MobileNav /> : <DesktopNav />;
|
|
515
|
+
*/
|
|
516
|
+
function useIsMobile() {
|
|
517
|
+
return useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`);
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* useIsTablet - Returns true when viewport is tablet-sized (768px - 1023px)
|
|
521
|
+
*
|
|
522
|
+
* @example
|
|
523
|
+
* const isTablet = useIsTablet();
|
|
524
|
+
*/
|
|
525
|
+
function useIsTablet() {
|
|
526
|
+
return useMediaQuery(`(min-width: ${BREAKPOINTS.md}px) and (max-width: ${BREAKPOINTS.lg - 1}px)`);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* useIsDesktop - Returns true when viewport is desktop-sized (>= 1024px)
|
|
530
|
+
*
|
|
531
|
+
* @example
|
|
532
|
+
* const isDesktop = useIsDesktop();
|
|
533
|
+
*/
|
|
534
|
+
function useIsDesktop() {
|
|
535
|
+
return useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* useIsTouchDevice - Detects if the device supports touch input
|
|
539
|
+
*
|
|
540
|
+
* Uses multiple detection methods for reliability:
|
|
541
|
+
* - Touch event support
|
|
542
|
+
* - Pointer coarse media query
|
|
543
|
+
* - Max touch points
|
|
544
|
+
*
|
|
545
|
+
* @example
|
|
546
|
+
* const isTouchDevice = useIsTouchDevice();
|
|
547
|
+
* // Show swipe hints on touch devices
|
|
548
|
+
*/
|
|
549
|
+
function useIsTouchDevice() {
|
|
550
|
+
const [isTouch, setIsTouch] = React.useState(() => {
|
|
551
|
+
if (!isBrowser)
|
|
552
|
+
return false;
|
|
553
|
+
return ('ontouchstart' in window ||
|
|
554
|
+
navigator.maxTouchPoints > 0 ||
|
|
555
|
+
window.matchMedia('(pointer: coarse)').matches);
|
|
556
|
+
});
|
|
557
|
+
React.useEffect(() => {
|
|
558
|
+
if (!isBrowser)
|
|
559
|
+
return;
|
|
560
|
+
// Re-check on mount for accuracy
|
|
561
|
+
const touchSupported = 'ontouchstart' in window ||
|
|
562
|
+
navigator.maxTouchPoints > 0 ||
|
|
563
|
+
window.matchMedia('(pointer: coarse)').matches;
|
|
564
|
+
setIsTouch(touchSupported);
|
|
565
|
+
}, []);
|
|
566
|
+
return isTouch;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* useOrientation - Returns current screen orientation
|
|
570
|
+
*
|
|
571
|
+
* @returns 'portrait' | 'landscape'
|
|
572
|
+
*
|
|
573
|
+
* @example
|
|
574
|
+
* const orientation = useOrientation();
|
|
575
|
+
* // Adjust layout based on orientation
|
|
576
|
+
*/
|
|
577
|
+
function useOrientation() {
|
|
578
|
+
const { width, height } = useViewportSize();
|
|
579
|
+
return height > width ? 'portrait' : 'landscape';
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* useBreakpointValue - Returns different values based on breakpoint
|
|
583
|
+
*
|
|
584
|
+
* Mobile-first: Returns the value for the current breakpoint or the
|
|
585
|
+
* closest smaller breakpoint that has a value defined.
|
|
586
|
+
*
|
|
587
|
+
* @param values - Object mapping breakpoints to values
|
|
588
|
+
* @param defaultValue - Fallback value if no breakpoint matches
|
|
589
|
+
*
|
|
590
|
+
* @example
|
|
591
|
+
* const columns = useBreakpointValue({ xs: 1, sm: 2, lg: 4 }, 1);
|
|
592
|
+
* // Returns 1 on xs, 2 on sm/md, 4 on lg/xl/2xl
|
|
593
|
+
*
|
|
594
|
+
* const padding = useBreakpointValue({ xs: 'p-2', md: 'p-4', xl: 'p-8' });
|
|
595
|
+
*/
|
|
596
|
+
function useBreakpointValue(values, defaultValue) {
|
|
597
|
+
const breakpoint = useBreakpoint();
|
|
598
|
+
// Breakpoints in order from largest to smallest
|
|
599
|
+
const breakpointOrder = ['2xl', 'xl', 'lg', 'md', 'sm', 'xs'];
|
|
600
|
+
// Find the current breakpoint index
|
|
601
|
+
const currentIndex = breakpointOrder.indexOf(breakpoint);
|
|
602
|
+
// Look for value at current breakpoint or smaller (mobile-first)
|
|
603
|
+
for (let i = currentIndex; i < breakpointOrder.length; i++) {
|
|
604
|
+
const bp = breakpointOrder[i];
|
|
605
|
+
if (bp in values && values[bp] !== undefined) {
|
|
606
|
+
return values[bp];
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return defaultValue;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* useResponsiveCallback - Returns a memoized callback that receives responsive info
|
|
613
|
+
*
|
|
614
|
+
* Useful for callbacks that need to behave differently based on viewport.
|
|
615
|
+
*
|
|
616
|
+
* @example
|
|
617
|
+
* const handleClick = useResponsiveCallback((isMobile) => {
|
|
618
|
+
* if (isMobile) {
|
|
619
|
+
* openBottomSheet();
|
|
620
|
+
* } else {
|
|
621
|
+
* openModal();
|
|
622
|
+
* }
|
|
623
|
+
* });
|
|
624
|
+
*/
|
|
625
|
+
function useResponsiveCallback(callback) {
|
|
626
|
+
const isMobile = useIsMobile();
|
|
627
|
+
const isTablet = useIsTablet();
|
|
628
|
+
const isDesktop = useIsDesktop();
|
|
629
|
+
return React.useCallback((...args) => callback(isMobile, isTablet, isDesktop)(...args), [callback, isMobile, isTablet, isDesktop]);
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* useSafeAreaInsets - Returns safe area insets for notched devices
|
|
633
|
+
*
|
|
634
|
+
* Uses CSS environment variables (env(safe-area-inset-*)) to get
|
|
635
|
+
* safe area dimensions for devices with notches or home indicators.
|
|
636
|
+
*
|
|
637
|
+
* @example
|
|
638
|
+
* const { top, bottom } = useSafeAreaInsets();
|
|
639
|
+
* // Add padding-bottom for home indicator
|
|
640
|
+
*/
|
|
641
|
+
function useSafeAreaInsets() {
|
|
642
|
+
const [insets, setInsets] = React.useState({
|
|
643
|
+
top: 0,
|
|
644
|
+
right: 0,
|
|
645
|
+
bottom: 0,
|
|
646
|
+
left: 0,
|
|
647
|
+
});
|
|
648
|
+
React.useEffect(() => {
|
|
649
|
+
if (!isBrowser)
|
|
650
|
+
return;
|
|
651
|
+
// Create a temporary element to read CSS env() values
|
|
652
|
+
const el = document.createElement('div');
|
|
653
|
+
el.style.position = 'fixed';
|
|
654
|
+
el.style.top = 'env(safe-area-inset-top, 0px)';
|
|
655
|
+
el.style.right = 'env(safe-area-inset-right, 0px)';
|
|
656
|
+
el.style.bottom = 'env(safe-area-inset-bottom, 0px)';
|
|
657
|
+
el.style.left = 'env(safe-area-inset-left, 0px)';
|
|
658
|
+
el.style.visibility = 'hidden';
|
|
659
|
+
el.style.pointerEvents = 'none';
|
|
660
|
+
document.body.appendChild(el);
|
|
661
|
+
const computed = getComputedStyle(el);
|
|
662
|
+
setInsets({
|
|
663
|
+
top: parseInt(computed.top, 10) || 0,
|
|
664
|
+
right: parseInt(computed.right, 10) || 0,
|
|
665
|
+
bottom: parseInt(computed.bottom, 10) || 0,
|
|
666
|
+
left: parseInt(computed.left, 10) || 0,
|
|
667
|
+
});
|
|
668
|
+
document.body.removeChild(el);
|
|
669
|
+
}, []);
|
|
670
|
+
return insets;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* usePrefersMobile - Checks if user prefers reduced data/animations (mobile-friendly)
|
|
674
|
+
*
|
|
675
|
+
* Combines multiple preferences that might indicate mobile/low-power usage.
|
|
676
|
+
*/
|
|
677
|
+
function usePrefersMobile() {
|
|
678
|
+
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
|
|
679
|
+
const prefersReducedData = useMediaQuery('(prefers-reduced-data: reduce)');
|
|
680
|
+
const isTouchDevice = useIsTouchDevice();
|
|
681
|
+
const isMobile = useIsMobile();
|
|
682
|
+
return isMobile || isTouchDevice || prefersReducedMotion || prefersReducedData;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Size classes for trigger button
|
|
686
|
+
const sizeClasses$a = {
|
|
687
|
+
sm: 'h-8 text-sm py-1',
|
|
688
|
+
md: 'h-10 text-base py-2',
|
|
689
|
+
lg: 'h-12 text-base py-3 min-h-touch', // 44px touch target
|
|
690
|
+
};
|
|
691
|
+
// Size classes for options
|
|
692
|
+
const optionSizeClasses = {
|
|
693
|
+
sm: 'py-2 text-sm',
|
|
694
|
+
md: 'py-2.5 text-sm',
|
|
695
|
+
lg: 'py-3.5 text-base min-h-touch', // 44px touch target for mobile
|
|
696
|
+
};
|
|
697
|
+
/**
|
|
698
|
+
* Select - Dropdown select component with search, groups, virtual scrolling, and mobile support
|
|
349
699
|
*
|
|
350
700
|
* A feature-rich select component supporting flat or grouped options, search/filter,
|
|
351
|
-
* option creation, virtual scrolling for large lists, and
|
|
701
|
+
* option creation, virtual scrolling for large lists, and mobile-optimized BottomSheet display.
|
|
352
702
|
*
|
|
353
703
|
* @example Basic select
|
|
354
704
|
* ```tsx
|
|
@@ -366,6 +716,16 @@ Input.displayName = 'Input';
|
|
|
366
716
|
* />
|
|
367
717
|
* ```
|
|
368
718
|
*
|
|
719
|
+
* @example Mobile-optimized with large touch targets
|
|
720
|
+
* ```tsx
|
|
721
|
+
* <Select
|
|
722
|
+
* options={options}
|
|
723
|
+
* size="lg"
|
|
724
|
+
* mobileMode="auto"
|
|
725
|
+
* placeholder="Select..."
|
|
726
|
+
* />
|
|
727
|
+
* ```
|
|
728
|
+
*
|
|
369
729
|
* @example Searchable with groups
|
|
370
730
|
* ```tsx
|
|
371
731
|
* const groups = [
|
|
@@ -405,7 +765,7 @@ Input.displayName = 'Input';
|
|
|
405
765
|
* ```
|
|
406
766
|
*/
|
|
407
767
|
const Select = React.forwardRef((props, ref) => {
|
|
408
|
-
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, } = props;
|
|
768
|
+
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', } = props;
|
|
409
769
|
const [isOpen, setIsOpen] = React.useState(false);
|
|
410
770
|
const [searchQuery, setSearchQuery] = React.useState('');
|
|
411
771
|
const [scrollTop, setScrollTop] = React.useState(0);
|
|
@@ -413,7 +773,15 @@ const Select = React.forwardRef((props, ref) => {
|
|
|
413
773
|
const selectRef = React.useRef(null);
|
|
414
774
|
const buttonRef = React.useRef(null);
|
|
415
775
|
const searchInputRef = React.useRef(null);
|
|
776
|
+
const mobileSearchInputRef = React.useRef(null);
|
|
416
777
|
const listRef = React.useRef(null);
|
|
778
|
+
const nativeSelectRef = React.useRef(null);
|
|
779
|
+
// Detect mobile viewport
|
|
780
|
+
const isMobile = useIsMobile();
|
|
781
|
+
const useMobileSheet = mobileMode === 'auto' && isMobile;
|
|
782
|
+
const useNativeSelect = mobileMode === 'native' && isMobile;
|
|
783
|
+
// Auto-size for mobile
|
|
784
|
+
const effectiveSize = isMobile && size === 'md' ? 'lg' : size;
|
|
417
785
|
// Generate unique IDs for ARIA
|
|
418
786
|
const labelId = React.useId();
|
|
419
787
|
const listboxId = React.useId();
|
|
@@ -486,8 +854,10 @@ const Select = React.forwardRef((props, ref) => {
|
|
|
486
854
|
setSearchQuery('');
|
|
487
855
|
setIsOpen(false);
|
|
488
856
|
};
|
|
489
|
-
// Handle click outside
|
|
857
|
+
// Handle click outside (desktop dropdown only)
|
|
490
858
|
React.useEffect(() => {
|
|
859
|
+
if (useMobileSheet)
|
|
860
|
+
return; // Mobile sheet handles its own closing
|
|
491
861
|
const handleClickOutside = (event) => {
|
|
492
862
|
if (selectRef.current && !selectRef.current.contains(event.target)) {
|
|
493
863
|
setIsOpen(false);
|
|
@@ -500,50 +870,105 @@ const Select = React.forwardRef((props, ref) => {
|
|
|
500
870
|
return () => {
|
|
501
871
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
502
872
|
};
|
|
503
|
-
}, [isOpen]);
|
|
873
|
+
}, [isOpen, useMobileSheet]);
|
|
504
874
|
// Focus search input when opened
|
|
505
875
|
React.useEffect(() => {
|
|
506
|
-
if (isOpen && searchable
|
|
507
|
-
|
|
876
|
+
if (isOpen && searchable) {
|
|
877
|
+
if (useMobileSheet && mobileSearchInputRef.current) {
|
|
878
|
+
// Slight delay for mobile sheet animation
|
|
879
|
+
setTimeout(() => mobileSearchInputRef.current?.focus(), 100);
|
|
880
|
+
}
|
|
881
|
+
else if (searchInputRef.current) {
|
|
882
|
+
searchInputRef.current.focus();
|
|
883
|
+
}
|
|
508
884
|
}
|
|
509
|
-
}, [isOpen, searchable]);
|
|
885
|
+
}, [isOpen, searchable, useMobileSheet]);
|
|
886
|
+
// Lock body scroll when mobile sheet is open
|
|
887
|
+
React.useEffect(() => {
|
|
888
|
+
if (useMobileSheet && isOpen) {
|
|
889
|
+
document.body.style.overflow = 'hidden';
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
document.body.style.overflow = '';
|
|
893
|
+
}
|
|
894
|
+
return () => {
|
|
895
|
+
document.body.style.overflow = '';
|
|
896
|
+
};
|
|
897
|
+
}, [isOpen, useMobileSheet]);
|
|
898
|
+
// Handle escape key for mobile sheet
|
|
899
|
+
React.useEffect(() => {
|
|
900
|
+
if (!useMobileSheet || !isOpen)
|
|
901
|
+
return;
|
|
902
|
+
const handleEscape = (e) => {
|
|
903
|
+
if (e.key === 'Escape') {
|
|
904
|
+
setIsOpen(false);
|
|
905
|
+
setSearchQuery('');
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
document.addEventListener('keydown', handleEscape);
|
|
909
|
+
return () => document.removeEventListener('keydown', handleEscape);
|
|
910
|
+
}, [isOpen, useMobileSheet]);
|
|
510
911
|
const handleSelect = (optionValue) => {
|
|
511
912
|
onChange?.(optionValue);
|
|
512
913
|
setIsOpen(false);
|
|
513
914
|
setSearchQuery('');
|
|
514
915
|
};
|
|
916
|
+
const handleClose = () => {
|
|
917
|
+
setIsOpen(false);
|
|
918
|
+
setSearchQuery('');
|
|
919
|
+
};
|
|
920
|
+
// Render option button (shared between desktop and mobile)
|
|
921
|
+
const renderOption = (option, isSelected, mobile = false) => (jsxRuntime.jsxs("button", { type: "button", onClick: () => !option.disabled && handleSelect(option.value), disabled: option.disabled, className: `
|
|
922
|
+
w-full flex items-center justify-between px-4 transition-colors
|
|
923
|
+
${mobile ? optionSizeClasses.lg : optionSizeClasses[effectiveSize]}
|
|
924
|
+
${isSelected ? 'bg-accent-50 text-accent-900' : 'text-ink-700'}
|
|
925
|
+
${option.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-paper-50 active:bg-paper-100 cursor-pointer'}
|
|
926
|
+
`, 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));
|
|
927
|
+
// Render options list content (shared between desktop and mobile)
|
|
928
|
+
const renderOptionsContent = (mobile = false) => {
|
|
929
|
+
if (loading) {
|
|
930
|
+
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..." })] }));
|
|
931
|
+
}
|
|
932
|
+
if (filteredOptions.length === 0 && filteredGroups.length === 0 && !showCreateOption) {
|
|
933
|
+
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" }));
|
|
934
|
+
}
|
|
935
|
+
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [showCreateOption && (jsxRuntime.jsx("button", { type: "button", onClick: handleCreateOption, className: `
|
|
936
|
+
w-full flex items-center px-4 text-accent-700 hover:bg-accent-50 transition-colors border-b border-paper-200
|
|
937
|
+
${mobile ? 'py-3.5 text-base' : 'py-2.5 text-sm'}
|
|
938
|
+
`, 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) => {
|
|
939
|
+
const option = item.option;
|
|
940
|
+
const isSelected = option.value === value;
|
|
941
|
+
const key = `${item.type}-${item.groupIndex}-${item.optionIndex}-${option.value}`;
|
|
942
|
+
return (jsxRuntime.jsxs("button", { type: "button", onClick: () => !option.disabled && handleSelect(option.value), disabled: option.disabled, style: { height: mobile ? '56px' : `${virtualItemHeight}px` }, className: `
|
|
943
|
+
w-full flex items-center justify-between px-4 transition-colors
|
|
944
|
+
${mobile ? 'text-base' : 'text-sm'}
|
|
945
|
+
${isSelected ? 'bg-accent-50 text-accent-900' : 'text-ink-700'}
|
|
946
|
+
${option.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-paper-50 cursor-pointer'}
|
|
947
|
+
`, 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));
|
|
948
|
+
}) }) })) : (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: `
|
|
949
|
+
px-4 font-semibold text-ink-500 uppercase tracking-wider bg-paper-50 border-t border-b border-paper-200
|
|
950
|
+
${mobile ? 'py-2.5 text-xs' : 'py-2 text-xs'}
|
|
951
|
+
`, children: group.label }), group.options.map((option) => renderOption(option, option.value === value, mobile))] }, group.label)))] }))] }));
|
|
952
|
+
};
|
|
953
|
+
// Native select for mobile (optional)
|
|
954
|
+
if (useNativeSelect) {
|
|
955
|
+
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: `
|
|
956
|
+
input w-full appearance-none pr-10
|
|
957
|
+
${sizeClasses$a[effectiveSize]}
|
|
958
|
+
${error ? 'border-error-400 focus:border-error-400 focus:ring-error-400' : ''}
|
|
959
|
+
${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
|
|
960
|
+
`, "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 }))] }));
|
|
961
|
+
}
|
|
515
962
|
return (jsxRuntime.jsxs("div", { className: "w-full", children: [label && (jsxRuntime.jsx("label", { id: labelId, className: "label", children: label })), jsxRuntime.jsxs("div", { ref: selectRef, className: "relative", children: [jsxRuntime.jsxs("button", { ref: buttonRef, type: "button", onClick: () => !disabled && setIsOpen(!isOpen), disabled: disabled, className: `
|
|
516
|
-
input w-full flex items-center justify-between
|
|
963
|
+
input w-full flex items-center justify-between px-3
|
|
964
|
+
${sizeClasses$a[effectiveSize]}
|
|
517
965
|
${error ? 'border-error-400 focus:border-error-400 focus:ring-error-400' : ''}
|
|
518
966
|
${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
|
|
519
967
|
`, 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) => {
|
|
520
968
|
e.stopPropagation();
|
|
521
969
|
onChange?.('');
|
|
522
970
|
setIsOpen(false);
|
|
523
|
-
}, className: "text-ink-400 hover:text-ink-600 transition-colors p-0.5", "aria-label": "Clear selection", children: jsxRuntime.jsx(lucideReact.X, { className:
|
|
524
|
-
const option = item.option;
|
|
525
|
-
const isSelected = option.value === value;
|
|
526
|
-
const key = `${item.type}-${item.groupIndex}-${item.optionIndex}-${option.value}`;
|
|
527
|
-
return (jsxRuntime.jsxs("button", { type: "button", onClick: () => !option.disabled && handleSelect(option.value), disabled: option.disabled, style: { height: `${virtualItemHeight}px` }, className: `
|
|
528
|
-
w-full flex items-center justify-between px-4 text-sm transition-colors
|
|
529
|
-
${isSelected ? 'bg-accent-50 text-accent-900' : 'text-ink-700'}
|
|
530
|
-
${option.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-paper-50 cursor-pointer'}
|
|
531
|
-
`, 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: "h-4 w-4 text-accent-600" })] }, key));
|
|
532
|
-
}) }) })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [filteredOptions.map((option) => {
|
|
533
|
-
const isSelected = option.value === value;
|
|
534
|
-
return (jsxRuntime.jsxs("button", { type: "button", onClick: () => !option.disabled && handleSelect(option.value), disabled: option.disabled, className: `
|
|
535
|
-
w-full flex items-center justify-between px-4 py-2.5 text-sm transition-colors
|
|
536
|
-
${isSelected ? 'bg-accent-50 text-accent-900' : 'text-ink-700'}
|
|
537
|
-
${option.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-paper-50 cursor-pointer'}
|
|
538
|
-
`, 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: "h-4 w-4 text-accent-600" })] }, option.value));
|
|
539
|
-
}), filteredGroups.map((group) => (jsxRuntime.jsxs("div", { children: [jsxRuntime.jsx("div", { className: "px-4 py-2 text-xs font-semibold text-ink-500 uppercase tracking-wider bg-paper-50 border-t border-b border-paper-200", children: group.label }), group.options.map((option) => {
|
|
540
|
-
const isSelected = option.value === value;
|
|
541
|
-
return (jsxRuntime.jsxs("button", { type: "button", onClick: () => !option.disabled && handleSelect(option.value), disabled: option.disabled, className: `
|
|
542
|
-
w-full flex items-center justify-between px-4 py-2.5 text-sm transition-colors
|
|
543
|
-
${isSelected ? 'bg-accent-50 text-accent-900' : 'text-ink-700'}
|
|
544
|
-
${option.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-paper-50 cursor-pointer'}
|
|
545
|
-
`, 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: "h-4 w-4 text-accent-600" })] }, option.value));
|
|
546
|
-
})] }, group.label)))] }))] })) })] }))] }), 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 }))] }));
|
|
971
|
+
}, 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 && (jsxRuntime.jsxs("div", { className: "absolute z-50 w-full mt-2 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", "aria-label": "Available options", "aria-multiselectable": "false", children: renderOptionsContent(false) })] }))] }), isOpen && useMobileSheet && reactDom.createPortal(jsxRuntime.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: [jsxRuntime.jsx("div", { className: "absolute inset-0 bg-black/50 animate-fade-in" }), jsxRuntime.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: [jsxRuntime.jsx("div", { className: "py-3 cursor-grab", children: jsxRuntime.jsx("div", { className: "w-12 h-1.5 bg-ink-300 rounded-full mx-auto" }) }), jsxRuntime.jsxs("div", { className: "px-4 pb-3 border-b border-paper-200 flex items-center justify-between", children: [label && (jsxRuntime.jsx("h2", { id: `mobile-${labelId}`, className: "text-lg font-semibold text-ink-900", children: label })), !label && (jsxRuntime.jsx("h2", { className: "text-lg font-semibold text-ink-900", children: placeholder })), jsxRuntime.jsx("button", { onClick: handleClose, className: "text-ink-400 hover:text-ink-600 transition-colors p-2 -mr-2", "aria-label": "Close", children: jsxRuntime.jsx(lucideReact.X, { className: "h-5 w-5" }) })] }), searchable && (jsxRuntime.jsx("div", { className: "p-3 border-b border-paper-200", children: jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsx(lucideReact.Search, { className: "absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-ink-400" }), jsxRuntime.jsx("input", { ref: mobileSearchInputRef, type: "text", value: searchQuery, onChange: (e) => setSearchQuery(e.target.value), placeholder: "Search...", inputMode: "search", enterKeyHint: "search", className: "w-full pl-12 pr-4 py-3 text-base border border-paper-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent-400 focus:border-accent-400", role: "searchbox", "aria-label": "Search options" })] }) })), jsxRuntime.jsx("div", { id: listboxId, className: "overflow-y-auto flex-1", role: "listbox", "aria-label": "Available options", "aria-multiselectable": "false", children: renderOptionsContent(true) })] })] }), document.body), 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 }))] }));
|
|
547
972
|
});
|
|
548
973
|
Select.displayName = 'Select';
|
|
549
974
|
|
|
@@ -629,6 +1054,9 @@ const Switch = React.forwardRef(({ checked, onChange, label, description, disabl
|
|
|
629
1054
|
const switchId = React.useId();
|
|
630
1055
|
const labelId = label ? `${switchId}-label` : undefined;
|
|
631
1056
|
const descId = description ? `${switchId}-desc` : undefined;
|
|
1057
|
+
// Auto-size for mobile
|
|
1058
|
+
const isMobile = useIsMobile();
|
|
1059
|
+
const effectiveSize = isMobile && size === 'md' ? 'lg' : size;
|
|
632
1060
|
const sizeStyles = {
|
|
633
1061
|
sm: {
|
|
634
1062
|
switch: 'w-9 h-5',
|
|
@@ -649,14 +1077,16 @@ const Switch = React.forwardRef(({ checked, onChange, label, description, disabl
|
|
|
649
1077
|
spinner: 'h-5 w-5',
|
|
650
1078
|
},
|
|
651
1079
|
};
|
|
652
|
-
const styles = sizeStyles[
|
|
1080
|
+
const styles = sizeStyles[effectiveSize];
|
|
653
1081
|
const isDisabled = disabled || loading;
|
|
654
1082
|
const handleChange = () => {
|
|
655
1083
|
if (!isDisabled) {
|
|
656
1084
|
onChange(!checked);
|
|
657
1085
|
}
|
|
658
1086
|
};
|
|
659
|
-
|
|
1087
|
+
// Touch target padding for mobile
|
|
1088
|
+
const touchTargetClass = effectiveSize === 'lg' ? 'min-h-touch py-1' : '';
|
|
1089
|
+
return (jsxRuntime.jsxs("label", { htmlFor: switchId, className: `flex items-center gap-3 ${touchTargetClass} ${isDisabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`, children: [jsxRuntime.jsxs("div", { className: "relative inline-block flex-shrink-0", children: [jsxRuntime.jsx("input", { ref: ref, id: switchId, type: "checkbox", role: "switch", checked: checked, onChange: handleChange, disabled: isDisabled, "aria-checked": checked, "aria-labelledby": labelId, "aria-describedby": descId, "aria-disabled": isDisabled, "aria-busy": loading, className: "sr-only" }), jsxRuntime.jsx("div", { className: `
|
|
660
1090
|
${styles.switch}
|
|
661
1091
|
rounded-full transition-all duration-200
|
|
662
1092
|
${checked ? 'bg-accent-500' : 'bg-paper-300'}
|
|
@@ -665,11 +1095,20 @@ const Switch = React.forwardRef(({ checked, onChange, label, description, disabl
|
|
|
665
1095
|
${styles.slider}
|
|
666
1096
|
absolute left-0.5 top-0.5 bg-white rounded-full shadow-sm transition-transform duration-200 flex items-center justify-center
|
|
667
1097
|
${checked ? styles.translate : ''}
|
|
668
|
-
`, children: loading && jsxRuntime.jsx(lucideReact.Loader2, { className: `${styles.spinner} animate-spin text-accent-600` }) }) })] }), (label || description) && (jsxRuntime.jsxs("div", { className: "flex-1", children: [label && jsxRuntime.jsx("p", { id: labelId, className:
|
|
1098
|
+
`, children: loading && jsxRuntime.jsx(lucideReact.Loader2, { className: `${styles.spinner} animate-spin text-accent-600` }) }) })] }), (label || description) && (jsxRuntime.jsxs("div", { className: "flex-1", children: [label && jsxRuntime.jsx("p", { id: labelId, className: `${effectiveSize === 'lg' ? 'text-base' : 'text-sm'} font-medium text-ink-900`, children: label }), description && jsxRuntime.jsx("p", { id: descId, className: "text-xs text-ink-600 mt-0.5", children: description })] }))] }));
|
|
669
1099
|
});
|
|
670
1100
|
Switch.displayName = 'Switch';
|
|
671
1101
|
|
|
672
|
-
|
|
1102
|
+
// Size classes for textarea
|
|
1103
|
+
const sizeClasses$9 = {
|
|
1104
|
+
sm: 'px-3 py-2 text-sm',
|
|
1105
|
+
md: 'px-4 py-3 text-sm',
|
|
1106
|
+
lg: 'px-4 py-3.5 text-base', // Larger padding and text for mobile
|
|
1107
|
+
};
|
|
1108
|
+
const Textarea = React.forwardRef(({ label, helperText, validationState, validationMessage, maxLength, showCharCount = false, autoExpand = false, minRows = 2, maxRows = 10, resize = 'vertical', loading = false, size = 'md', enterKeyHint, className = '', id, value, rows = 4, ...props }, ref) => {
|
|
1109
|
+
// Detect mobile and auto-size
|
|
1110
|
+
const isMobile = useIsMobile();
|
|
1111
|
+
const effectiveSize = isMobile && size === 'md' ? 'lg' : size;
|
|
673
1112
|
const textareaId = id || `textarea-${Math.random().toString(36).substring(2, 9)}`;
|
|
674
1113
|
const currentLength = typeof value === 'string' ? value.length : 0;
|
|
675
1114
|
const internalRef = React.useRef(null);
|
|
@@ -744,11 +1183,12 @@ const Textarea = React.forwardRef(({ label, helperText, validationState, validat
|
|
|
744
1183
|
return 'text-ink-600';
|
|
745
1184
|
}
|
|
746
1185
|
};
|
|
747
|
-
return (jsxRuntime.jsxs("div", { className: "w-full", children: [label && (jsxRuntime.jsxs("label", { htmlFor: textareaId, className: "label", children: [label, props.required && jsxRuntime.jsx("span", { className: "text-error-500 ml-1", children: "*" })] })), jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsx("textarea", { ref: textareaRef, id: textareaId, value: value, maxLength: maxLength, rows: autoExpand ? minRows : rows, className: `
|
|
748
|
-
block w-full
|
|
1186
|
+
return (jsxRuntime.jsxs("div", { className: "w-full", children: [label && (jsxRuntime.jsxs("label", { htmlFor: textareaId, className: "label", children: [label, props.required && jsxRuntime.jsx("span", { className: "text-error-500 ml-1", children: "*" })] })), jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsx("textarea", { ref: textareaRef, id: textareaId, value: value, maxLength: maxLength, rows: autoExpand ? minRows : rows, enterKeyHint: enterKeyHint, className: `
|
|
1187
|
+
block w-full border rounded-lg text-ink-800 placeholder-ink-400
|
|
749
1188
|
bg-white bg-subtle-grain transition-all duration-200
|
|
750
1189
|
focus:outline-none focus:ring-2 ${getResizeClass()}
|
|
751
1190
|
disabled:bg-paper-100 disabled:text-ink-400 disabled:cursor-not-allowed disabled:opacity-60
|
|
1191
|
+
${sizeClasses$9[effectiveSize]}
|
|
752
1192
|
${getValidationClasses()}
|
|
753
1193
|
${loading ? 'pr-10' : ''}
|
|
754
1194
|
${className}
|
|
@@ -756,10 +1196,20 @@ const Textarea = React.forwardRef(({ label, helperText, validationState, validat
|
|
|
756
1196
|
});
|
|
757
1197
|
Textarea.displayName = 'Textarea';
|
|
758
1198
|
|
|
759
|
-
|
|
1199
|
+
// Size classes for checkbox box and touch target
|
|
1200
|
+
const sizeConfig$4 = {
|
|
1201
|
+
sm: { box: 'w-4 h-4', icon: 'h-3 w-3', text: 'text-sm', gap: 'gap-2' },
|
|
1202
|
+
md: { box: 'w-4 h-4', icon: 'h-3 w-3', text: 'text-sm', gap: 'gap-3' },
|
|
1203
|
+
lg: { box: 'w-5 h-5', icon: 'h-4 w-4', text: 'text-base', gap: 'gap-3', touchTarget: 'min-h-touch py-2' }, // 44px touch target
|
|
1204
|
+
};
|
|
1205
|
+
const Checkbox = React.forwardRef(({ checked, onChange, label, description, disabled = false, indeterminate = false, className = '', id, name, icon, size = 'md', }, ref) => {
|
|
760
1206
|
const generatedId = React.useId();
|
|
761
1207
|
const checkboxId = id || generatedId;
|
|
762
1208
|
const descId = description ? `${checkboxId}-desc` : undefined;
|
|
1209
|
+
// Auto-size for mobile
|
|
1210
|
+
const isMobile = useIsMobile();
|
|
1211
|
+
const effectiveSize = isMobile && size === 'md' ? 'lg' : size;
|
|
1212
|
+
const config = sizeConfig$4[effectiveSize];
|
|
763
1213
|
const handleChange = () => {
|
|
764
1214
|
if (!disabled) {
|
|
765
1215
|
onChange(!checked);
|
|
@@ -771,14 +1221,14 @@ const Checkbox = React.forwardRef(({ checked, onChange, label, description, disa
|
|
|
771
1221
|
onChange(!checked);
|
|
772
1222
|
}
|
|
773
1223
|
};
|
|
774
|
-
return (jsxRuntime.jsxs("label", { htmlFor: checkboxId, className: `flex items-start gap
|
|
775
|
-
|
|
1224
|
+
return (jsxRuntime.jsxs("label", { htmlFor: checkboxId, className: `flex items-start ${config.gap} ${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'} ${'touchTarget' in config ? config.touchTarget : ''} ${className}`, children: [jsxRuntime.jsxs("div", { className: "relative inline-block flex-shrink-0 mt-0.5", children: [jsxRuntime.jsx("input", { ref: ref, type: "checkbox", id: checkboxId, name: name, checked: checked, onChange: handleChange, onKeyDown: handleKeyDown, disabled: disabled, className: "sr-only", "aria-checked": indeterminate ? 'mixed' : checked, "aria-describedby": descId, "aria-disabled": disabled }), jsxRuntime.jsx("div", { className: `
|
|
1225
|
+
${config.box} rounded border transition-all duration-200
|
|
776
1226
|
flex items-center justify-center
|
|
777
1227
|
${checked || indeterminate
|
|
778
1228
|
? 'bg-accent-600 border-accent-600'
|
|
779
1229
|
: 'bg-white border-paper-300 hover:border-paper-400'}
|
|
780
1230
|
${!disabled && 'focus-within:ring-2 focus-within:ring-accent-400 focus-within:ring-offset-2'}
|
|
781
|
-
`, children: indeterminate ? (jsxRuntime.jsx(lucideReact.Minus, { className:
|
|
1231
|
+
`, children: indeterminate ? (jsxRuntime.jsx(lucideReact.Minus, { className: `${config.icon} text-white` })) : checked ? (jsxRuntime.jsx(lucideReact.Check, { className: `${config.icon} text-white` })) : null })] }), (label || description) && (jsxRuntime.jsxs("div", { className: "flex-1", children: [label && (jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [icon && jsxRuntime.jsx("span", { className: "text-ink-700", children: icon }), jsxRuntime.jsx("p", { className: `${config.text} font-medium text-ink-900`, children: label })] })), description && (jsxRuntime.jsx("p", { id: descId, className: "text-xs text-ink-600 mt-0.5", children: description }))] }))] }));
|
|
782
1232
|
});
|
|
783
1233
|
Checkbox.displayName = 'Checkbox';
|
|
784
1234
|
|
|
@@ -1958,7 +2408,7 @@ function Loading({ variant = 'spinner', size = 'md', text }) {
|
|
|
1958
2408
|
function Skeleton({ className = '', ...props }) {
|
|
1959
2409
|
return (jsxRuntime.jsx("div", { className: `animate-pulse bg-paper-200 rounded ${className}`, ...props }));
|
|
1960
2410
|
}
|
|
1961
|
-
function SkeletonCard() {
|
|
2411
|
+
function SkeletonCard$1() {
|
|
1962
2412
|
return (jsxRuntime.jsxs("div", { className: "card", children: [jsxRuntime.jsx(Skeleton, { className: "h-6 w-3/4 mb-4" }), jsxRuntime.jsx(Skeleton, { className: "h-4 w-full mb-2" }), jsxRuntime.jsx(Skeleton, { className: "h-4 w-5/6 mb-2" }), jsxRuntime.jsx(Skeleton, { className: "h-4 w-4/6" })] }));
|
|
1963
2413
|
}
|
|
1964
2414
|
function SkeletonTable({ rows = 5, columns = 4 }) {
|
|
@@ -2610,19 +3060,191 @@ function Alert({ variant = 'info', title, children, onClose, className = '', act
|
|
|
2610
3060
|
return (jsxRuntime.jsx("div", { className: `rounded-lg border p-4 ${styles.container} ${className}`, role: "alert", children: jsxRuntime.jsxs("div", { className: "flex items-start gap-3", children: [jsxRuntime.jsx("div", { className: "flex-shrink-0 mt-0.5", children: styles.icon }), jsxRuntime.jsxs("div", { className: "flex-1 min-w-0", children: [title && jsxRuntime.jsx("h4", { className: "text-sm font-medium mb-1", children: title }), jsxRuntime.jsx("div", { className: "text-sm", children: children }), actions.length > 0 && (jsxRuntime.jsx("div", { className: "flex gap-2 mt-3", children: actions.map((action, index) => (jsxRuntime.jsx("button", { onClick: action.onClick, className: getButtonStyles(action.variant), children: action.label }, index))) }))] }), onClose && (jsxRuntime.jsx("button", { onClick: onClose, className: "flex-shrink-0 text-current opacity-70 hover:opacity-100 transition-opacity", "aria-label": "Close alert", children: jsxRuntime.jsx(lucideReact.X, { className: "h-4 w-4" }) }))] }) }));
|
|
2611
3061
|
}
|
|
2612
3062
|
|
|
2613
|
-
const
|
|
3063
|
+
const heightPresets = {
|
|
3064
|
+
sm: '33vh',
|
|
3065
|
+
md: '50vh',
|
|
3066
|
+
lg: '75vh',
|
|
3067
|
+
full: '90vh',
|
|
3068
|
+
};
|
|
3069
|
+
function BottomSheet({ isOpen, onClose, children, title, height = 'md', showHandle = true, showCloseButton = true, closeOnOverlayClick = true, closeOnEscape = true, className = '', }) {
|
|
3070
|
+
const titleId = React.useId();
|
|
3071
|
+
const [isDragging, setIsDragging] = React.useState(false);
|
|
3072
|
+
const [dragOffset, setDragOffset] = React.useState(0);
|
|
3073
|
+
const [currentHeight] = React.useState(typeof height === 'string' && height in heightPresets
|
|
3074
|
+
? heightPresets[height]
|
|
3075
|
+
: height);
|
|
3076
|
+
const sheetRef = React.useRef(null);
|
|
3077
|
+
const startYRef = React.useRef(0);
|
|
3078
|
+
// Close on Escape
|
|
3079
|
+
React.useEffect(() => {
|
|
3080
|
+
if (!isOpen || !closeOnEscape)
|
|
3081
|
+
return;
|
|
3082
|
+
const handleEscape = (e) => {
|
|
3083
|
+
if (e.key === 'Escape') {
|
|
3084
|
+
onClose();
|
|
3085
|
+
}
|
|
3086
|
+
};
|
|
3087
|
+
document.addEventListener('keydown', handleEscape);
|
|
3088
|
+
return () => document.removeEventListener('keydown', handleEscape);
|
|
3089
|
+
}, [isOpen, closeOnEscape, onClose]);
|
|
3090
|
+
// Prevent body scroll when open
|
|
3091
|
+
React.useEffect(() => {
|
|
3092
|
+
if (isOpen) {
|
|
3093
|
+
document.body.style.overflow = 'hidden';
|
|
3094
|
+
}
|
|
3095
|
+
else {
|
|
3096
|
+
document.body.style.overflow = '';
|
|
3097
|
+
}
|
|
3098
|
+
return () => {
|
|
3099
|
+
document.body.style.overflow = '';
|
|
3100
|
+
};
|
|
3101
|
+
}, [isOpen]);
|
|
3102
|
+
const handleOverlayClick = (e) => {
|
|
3103
|
+
if (closeOnOverlayClick && e.target === e.currentTarget) {
|
|
3104
|
+
onClose();
|
|
3105
|
+
}
|
|
3106
|
+
};
|
|
3107
|
+
const handleDragStart = (e) => {
|
|
3108
|
+
setIsDragging(true);
|
|
3109
|
+
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
|
3110
|
+
startYRef.current = clientY;
|
|
3111
|
+
};
|
|
3112
|
+
const handleDragMove = (e) => {
|
|
3113
|
+
if (!isDragging)
|
|
3114
|
+
return;
|
|
3115
|
+
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
|
3116
|
+
const offset = clientY - startYRef.current;
|
|
3117
|
+
// Only allow dragging down
|
|
3118
|
+
if (offset > 0) {
|
|
3119
|
+
setDragOffset(offset);
|
|
3120
|
+
}
|
|
3121
|
+
};
|
|
3122
|
+
const handleDragEnd = () => {
|
|
3123
|
+
setIsDragging(false);
|
|
3124
|
+
// Close if dragged down more than 150px
|
|
3125
|
+
if (dragOffset > 150) {
|
|
3126
|
+
onClose();
|
|
3127
|
+
}
|
|
3128
|
+
setDragOffset(0);
|
|
3129
|
+
};
|
|
3130
|
+
React.useEffect(() => {
|
|
3131
|
+
if (!isDragging)
|
|
3132
|
+
return;
|
|
3133
|
+
const handleMove = (e) => handleDragMove(e);
|
|
3134
|
+
const handleEnd = () => handleDragEnd();
|
|
3135
|
+
document.addEventListener('touchmove', handleMove);
|
|
3136
|
+
document.addEventListener('mousemove', handleMove);
|
|
3137
|
+
document.addEventListener('touchend', handleEnd);
|
|
3138
|
+
document.addEventListener('mouseup', handleEnd);
|
|
3139
|
+
return () => {
|
|
3140
|
+
document.removeEventListener('touchmove', handleMove);
|
|
3141
|
+
document.removeEventListener('mousemove', handleMove);
|
|
3142
|
+
document.removeEventListener('touchend', handleEnd);
|
|
3143
|
+
document.removeEventListener('mouseup', handleEnd);
|
|
3144
|
+
};
|
|
3145
|
+
}, [isDragging, dragOffset]);
|
|
3146
|
+
if (!isOpen)
|
|
3147
|
+
return null;
|
|
3148
|
+
return (jsxRuntime.jsxs("div", { className: "fixed inset-0 z-50 flex items-end", onClick: handleOverlayClick, children: [jsxRuntime.jsx("div", { className: `
|
|
3149
|
+
absolute inset-0 bg-black/50 transition-opacity duration-300
|
|
3150
|
+
${isOpen ? 'opacity-100' : 'opacity-0'}
|
|
3151
|
+
` }), jsxRuntime.jsxs("div", { ref: sheetRef, className: `
|
|
3152
|
+
relative w-full bg-white rounded-t-2xl shadow-2xl
|
|
3153
|
+
transition-transform duration-300 ease-out
|
|
3154
|
+
${isOpen ? 'translate-y-0' : 'translate-y-full'}
|
|
3155
|
+
${className}
|
|
3156
|
+
`, style: {
|
|
3157
|
+
height: currentHeight,
|
|
3158
|
+
transform: `translateY(${dragOffset}px)`,
|
|
3159
|
+
}, role: "dialog", "aria-modal": "true", "aria-labelledby": title ? titleId : undefined, children: [showHandle && (jsxRuntime.jsx("div", { className: "py-3 cursor-grab active:cursor-grabbing", onTouchStart: handleDragStart, onMouseDown: handleDragStart, children: jsxRuntime.jsx("div", { className: "w-12 h-1.5 bg-ink-300 rounded-full mx-auto" }) })), (title || showCloseButton) && (jsxRuntime.jsxs("div", { className: "px-6 py-4 border-b border-ink-200 flex items-center justify-between", children: [title && (jsxRuntime.jsx("h2", { id: titleId, className: "text-lg font-semibold text-ink-900", children: title })), showCloseButton && (jsxRuntime.jsx("button", { onClick: onClose, className: "text-ink-400 hover:text-ink-600 transition-colors ml-auto", "aria-label": "Close", children: jsxRuntime.jsx(lucideReact.X, { className: "h-5 w-5" }) }))] })), jsxRuntime.jsx("div", { className: "overflow-y-auto flex-1 p-6", children: children })] })] }));
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
const sizeClasses$8 = {
|
|
2614
3163
|
sm: 'max-w-md',
|
|
2615
3164
|
md: 'max-w-lg',
|
|
2616
3165
|
lg: 'max-w-2xl',
|
|
2617
3166
|
xl: 'max-w-4xl',
|
|
2618
3167
|
full: 'max-w-7xl',
|
|
2619
3168
|
};
|
|
2620
|
-
|
|
3169
|
+
/**
|
|
3170
|
+
* Modal - Adaptive dialog component
|
|
3171
|
+
*
|
|
3172
|
+
* On desktop, renders as a centered modal dialog.
|
|
3173
|
+
* On mobile (when mobileMode='auto'), automatically renders as a BottomSheet
|
|
3174
|
+
* for better touch interaction and visibility.
|
|
3175
|
+
*
|
|
3176
|
+
* @example Basic modal
|
|
3177
|
+
* ```tsx
|
|
3178
|
+
* <Modal isOpen={isOpen} onClose={handleClose} title="Edit User">
|
|
3179
|
+
* <form>...</form>
|
|
3180
|
+
* <ModalFooter>
|
|
3181
|
+
* <Button onClick={handleClose}>Cancel</Button>
|
|
3182
|
+
* <Button variant="primary" onClick={handleSave}>Save</Button>
|
|
3183
|
+
* </ModalFooter>
|
|
3184
|
+
* </Modal>
|
|
3185
|
+
* ```
|
|
3186
|
+
*
|
|
3187
|
+
* @example Scrollable modal for long content
|
|
3188
|
+
* ```tsx
|
|
3189
|
+
* <Modal
|
|
3190
|
+
* isOpen={isOpen}
|
|
3191
|
+
* onClose={handleClose}
|
|
3192
|
+
* title="Terms and Conditions"
|
|
3193
|
+
* scrollable
|
|
3194
|
+
* >
|
|
3195
|
+
* {longContent}
|
|
3196
|
+
* </Modal>
|
|
3197
|
+
* ```
|
|
3198
|
+
*
|
|
3199
|
+
* @example Modal with custom max height
|
|
3200
|
+
* ```tsx
|
|
3201
|
+
* <Modal
|
|
3202
|
+
* isOpen={isOpen}
|
|
3203
|
+
* onClose={handleClose}
|
|
3204
|
+
* title="Document Preview"
|
|
3205
|
+
* maxHeight="70vh"
|
|
3206
|
+
* >
|
|
3207
|
+
* {documentContent}
|
|
3208
|
+
* </Modal>
|
|
3209
|
+
* ```
|
|
3210
|
+
*
|
|
3211
|
+
* @example Force modal on mobile
|
|
3212
|
+
* ```tsx
|
|
3213
|
+
* <Modal
|
|
3214
|
+
* isOpen={isOpen}
|
|
3215
|
+
* onClose={handleClose}
|
|
3216
|
+
* title="Settings"
|
|
3217
|
+
* mobileMode="modal"
|
|
3218
|
+
* >
|
|
3219
|
+
* ...
|
|
3220
|
+
* </Modal>
|
|
3221
|
+
* ```
|
|
3222
|
+
*
|
|
3223
|
+
* @example Always use BottomSheet
|
|
3224
|
+
* ```tsx
|
|
3225
|
+
* <Modal
|
|
3226
|
+
* isOpen={isOpen}
|
|
3227
|
+
* onClose={handleClose}
|
|
3228
|
+
* title="Select Option"
|
|
3229
|
+
* mobileMode="sheet"
|
|
3230
|
+
* mobileHeight="md"
|
|
3231
|
+
* >
|
|
3232
|
+
* ...
|
|
3233
|
+
* </Modal>
|
|
3234
|
+
* ```
|
|
3235
|
+
*/
|
|
3236
|
+
function Modal({ isOpen, onClose, title, children, size = 'md', showCloseButton = true, animation = 'scale', scrollable = false, maxHeight, mobileMode = 'auto', mobileHeight = 'lg', mobileShowHandle = true, }) {
|
|
2621
3237
|
const modalRef = React.useRef(null);
|
|
2622
3238
|
const mouseDownOnBackdrop = React.useRef(false);
|
|
2623
3239
|
const titleId = React.useId();
|
|
2624
|
-
|
|
3240
|
+
const isMobile = useIsMobile();
|
|
3241
|
+
// Determine if we should use BottomSheet
|
|
3242
|
+
const useBottomSheet = mobileMode === 'sheet' ||
|
|
3243
|
+
(mobileMode === 'auto' && isMobile);
|
|
3244
|
+
// Handle escape key (only for modal mode, BottomSheet handles its own)
|
|
2625
3245
|
React.useEffect(() => {
|
|
3246
|
+
if (useBottomSheet)
|
|
3247
|
+
return; // BottomSheet handles escape
|
|
2626
3248
|
const handleEscape = (e) => {
|
|
2627
3249
|
if (e.key === 'Escape' && isOpen) {
|
|
2628
3250
|
onClose();
|
|
@@ -2636,7 +3258,7 @@ function Modal({ isOpen, onClose, title, children, size = 'md', showCloseButton
|
|
|
2636
3258
|
document.removeEventListener('keydown', handleEscape);
|
|
2637
3259
|
document.body.style.overflow = 'unset';
|
|
2638
3260
|
};
|
|
2639
|
-
}, [isOpen, onClose]);
|
|
3261
|
+
}, [isOpen, onClose, useBottomSheet]);
|
|
2640
3262
|
// Track if mousedown originated on the backdrop
|
|
2641
3263
|
const handleBackdropMouseDown = (e) => {
|
|
2642
3264
|
if (e.target === e.currentTarget) {
|
|
@@ -2672,13 +3294,20 @@ function Modal({ isOpen, onClose, title, children, size = 'md', showCloseButton
|
|
|
2672
3294
|
};
|
|
2673
3295
|
if (!isOpen)
|
|
2674
3296
|
return null;
|
|
2675
|
-
|
|
3297
|
+
// Render as BottomSheet on mobile
|
|
3298
|
+
if (useBottomSheet) {
|
|
3299
|
+
return (jsxRuntime.jsx(BottomSheet, { isOpen: isOpen, onClose: onClose, title: title, height: mobileHeight, showHandle: mobileShowHandle, showCloseButton: showCloseButton, children: children }));
|
|
3300
|
+
}
|
|
3301
|
+
// Render as standard modal on desktop
|
|
3302
|
+
return (jsxRuntime.jsx("div", { className: "fixed inset-0 z-50 flex items-center justify-center p-4 bg-ink-900 bg-opacity-50 backdrop-blur-sm animate-fade-in", onMouseDown: handleBackdropMouseDown, onClick: handleBackdropClick, children: jsxRuntime.jsxs("div", { ref: modalRef, className: `${sizeClasses$8[size]} w-full bg-white bg-subtle-grain rounded-xl shadow-2xl border border-paper-200 ${getAnimationClass()}`, role: "dialog", "aria-modal": "true", "aria-labelledby": titleId, children: [jsxRuntime.jsxs("div", { className: "flex items-center justify-between px-6 py-4 border-b border-paper-200", children: [jsxRuntime.jsx("h3", { id: titleId, className: "text-lg font-medium text-ink-900", children: title }), showCloseButton && (jsxRuntime.jsx("button", { onClick: onClose, className: "text-ink-400 hover:text-ink-600 transition-colors", "aria-label": "Close modal", children: jsxRuntime.jsx(lucideReact.X, { className: "h-5 w-5" }) }))] }), jsxRuntime.jsx("div", { className: `px-6 py-4 ${scrollable || maxHeight ? 'overflow-y-auto' : ''}`, style: {
|
|
3303
|
+
maxHeight: maxHeight || (scrollable ? 'calc(100vh - 200px)' : undefined),
|
|
3304
|
+
}, children: children })] }) }));
|
|
2676
3305
|
}
|
|
2677
3306
|
function ModalFooter({ children }) {
|
|
2678
|
-
return (jsxRuntime.jsx("div", { className: "flex items-center justify-end gap-3 px-6 py-4 border-t border-paper-200 bg-paper-50", children: children }));
|
|
3307
|
+
return (jsxRuntime.jsx("div", { className: "flex items-center justify-end gap-3 px-6 py-4 border-t border-paper-200 bg-paper-50 -mx-6 -mb-4 mt-4 rounded-b-xl", children: children }));
|
|
2679
3308
|
}
|
|
2680
3309
|
|
|
2681
|
-
const sizeClasses$
|
|
3310
|
+
const sizeClasses$7 = {
|
|
2682
3311
|
left: {
|
|
2683
3312
|
sm: 'w-64',
|
|
2684
3313
|
md: 'w-96',
|
|
@@ -2757,7 +3386,7 @@ function Drawer({ isOpen, onClose, title, children, placement = 'right', size =
|
|
|
2757
3386
|
const isHorizontal = placement === 'left' || placement === 'right';
|
|
2758
3387
|
return (jsxRuntime.jsxs("div", { className: "fixed inset-0 z-50 flex", children: [showOverlay && (jsxRuntime.jsx("div", { className: "fixed inset-0 bg-ink-900 bg-opacity-50 backdrop-blur-sm animate-fade-in", onClick: handleOverlayClick, "aria-hidden": "true" })), jsxRuntime.jsxs("div", { className: `
|
|
2759
3388
|
fixed ${placementClasses[placement]}
|
|
2760
|
-
${sizeClasses$
|
|
3389
|
+
${sizeClasses$7[placement][size]}
|
|
2761
3390
|
bg-white border-paper-200 shadow-2xl
|
|
2762
3391
|
${isHorizontal ? 'border-r' : 'border-b'}
|
|
2763
3392
|
${animationClasses[placement].enter}
|
|
@@ -2789,14 +3418,49 @@ const variantStyles = {
|
|
|
2789
3418
|
button: 'bg-accent-600 hover:bg-accent-700 focus:ring-accent-500',
|
|
2790
3419
|
},
|
|
2791
3420
|
};
|
|
2792
|
-
|
|
3421
|
+
/**
|
|
3422
|
+
* ConfirmDialog - Confirmation dialog with mobile support
|
|
3423
|
+
*
|
|
3424
|
+
* @example Basic usage
|
|
3425
|
+
* ```tsx
|
|
3426
|
+
* <ConfirmDialog
|
|
3427
|
+
* isOpen={isOpen}
|
|
3428
|
+
* onClose={handleClose}
|
|
3429
|
+
* onConfirm={handleDelete}
|
|
3430
|
+
* title="Delete Item"
|
|
3431
|
+
* message="Are you sure you want to delete this item? This action cannot be undone."
|
|
3432
|
+
* variant="danger"
|
|
3433
|
+
* />
|
|
3434
|
+
* ```
|
|
3435
|
+
*
|
|
3436
|
+
* @example With useConfirmDialog hook
|
|
3437
|
+
* ```tsx
|
|
3438
|
+
* const confirmDialog = useConfirmDialog();
|
|
3439
|
+
*
|
|
3440
|
+
* const handleDelete = () => {
|
|
3441
|
+
* confirmDialog.show({
|
|
3442
|
+
* title: 'Delete Item',
|
|
3443
|
+
* message: 'Are you sure?',
|
|
3444
|
+
* onConfirm: async () => await deleteItem(),
|
|
3445
|
+
* });
|
|
3446
|
+
* };
|
|
3447
|
+
*
|
|
3448
|
+
* return (
|
|
3449
|
+
* <>
|
|
3450
|
+
* <button onClick={handleDelete}>Delete</button>
|
|
3451
|
+
* <ConfirmDialog {...confirmDialog.props} />
|
|
3452
|
+
* </>
|
|
3453
|
+
* );
|
|
3454
|
+
* ```
|
|
3455
|
+
*/
|
|
3456
|
+
function ConfirmDialog({ isOpen, onClose, onConfirm, title, message, confirmLabel = 'Confirm', cancelLabel = 'Cancel', variant = 'danger', icon, isLoading = false, mobileMode = 'auto', mobileHeight = 'sm', }) {
|
|
2793
3457
|
const variantStyle = variantStyles[variant];
|
|
2794
3458
|
const IconComponent = icon || variantStyle.icon;
|
|
2795
3459
|
const handleConfirm = async () => {
|
|
2796
3460
|
await onConfirm();
|
|
2797
3461
|
// Note: onClose is called by useConfirmDialog hook after onConfirm completes
|
|
2798
3462
|
};
|
|
2799
|
-
return (jsxRuntime.jsxs(Modal, { isOpen: isOpen, onClose: onClose, title: typeof title === 'string' ? title : String(title), size: "sm", showCloseButton: false, children: [jsxRuntime.jsxs("div", { className: "flex items-start gap-4", children: [jsxRuntime.jsx("div", { className: `flex-shrink-0 flex items-center justify-center w-12 h-12 rounded-full ${variantStyle.iconBg}`, children: typeof IconComponent === 'function' ? (jsxRuntime.jsx(IconComponent, { className: `h-6 w-6 ${variantStyle.iconColor}` })) : React.isValidElement(IconComponent) ? (IconComponent) : null }), jsxRuntime.jsx("div", { className: "flex-1 pt-1", children: jsxRuntime.jsx("p", { className: "text-sm text-ink-700 leading-relaxed whitespace-pre-line", children: typeof message === 'string' ? message : String(message) }) })] }), jsxRuntime.jsxs(ModalFooter, { children: [jsxRuntime.jsx("button", { onClick: onClose, disabled: isLoading, className: "px-4 py-2 text-sm font-medium text-ink-700 bg-white border border-paper-300 rounded-lg hover:bg-paper-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors", children: typeof cancelLabel === 'string' ? cancelLabel : String(cancelLabel) }), jsxRuntime.jsx("button", { onClick: handleConfirm, disabled: isLoading, className: `px-4 py-2 text-sm font-medium text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${variantStyle.button}`, children: isLoading ? (jsxRuntime.jsxs("span", { className: "flex items-center gap-2", children: [jsxRuntime.jsxs("svg", { className: "animate-spin h-4 w-4", xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", children: [jsxRuntime.jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), jsxRuntime.jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }), "Processing..."] })) : (typeof confirmLabel === 'string' ? confirmLabel : String(confirmLabel)) })] })] }));
|
|
3463
|
+
return (jsxRuntime.jsxs(Modal, { isOpen: isOpen, onClose: onClose, title: typeof title === 'string' ? title : String(title), size: "sm", showCloseButton: false, mobileMode: mobileMode, mobileHeight: mobileHeight, mobileShowHandle: false, children: [jsxRuntime.jsxs("div", { className: "flex items-start gap-4", children: [jsxRuntime.jsx("div", { className: `flex-shrink-0 flex items-center justify-center w-12 h-12 rounded-full ${variantStyle.iconBg}`, children: typeof IconComponent === 'function' ? (jsxRuntime.jsx(IconComponent, { className: `h-6 w-6 ${variantStyle.iconColor}` })) : React.isValidElement(IconComponent) ? (IconComponent) : null }), jsxRuntime.jsx("div", { className: "flex-1 pt-1", children: jsxRuntime.jsx("p", { className: "text-sm text-ink-700 leading-relaxed whitespace-pre-line", children: typeof message === 'string' ? message : String(message) }) })] }), jsxRuntime.jsxs(ModalFooter, { children: [jsxRuntime.jsx("button", { onClick: onClose, disabled: isLoading, className: "px-4 py-2 text-sm font-medium text-ink-700 bg-white border border-paper-300 rounded-lg hover:bg-paper-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors min-h-touch-sm", children: typeof cancelLabel === 'string' ? cancelLabel : String(cancelLabel) }), jsxRuntime.jsx("button", { onClick: handleConfirm, disabled: isLoading, className: `px-4 py-2 text-sm font-medium text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors min-h-touch-sm ${variantStyle.button}`, children: isLoading ? (jsxRuntime.jsxs("span", { className: "flex items-center gap-2", children: [jsxRuntime.jsxs("svg", { className: "animate-spin h-4 w-4", xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", children: [jsxRuntime.jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), jsxRuntime.jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }), "Processing..."] })) : (typeof confirmLabel === 'string' ? confirmLabel : String(confirmLabel)) })] })] }));
|
|
2800
3464
|
}
|
|
2801
3465
|
/**
|
|
2802
3466
|
* Hook for managing ConfirmDialog state
|
|
@@ -5127,39 +5791,45 @@ function MenuDivider() {
|
|
|
5127
5791
|
return { divider: true, id: `divider-${Date.now()}`, label: '' };
|
|
5128
5792
|
}
|
|
5129
5793
|
|
|
5130
|
-
const variantClasses = {
|
|
5794
|
+
const variantClasses$4 = {
|
|
5131
5795
|
primary: {
|
|
5132
5796
|
default: 'bg-primary-100 text-primary-700 border-primary-200',
|
|
5133
5797
|
hover: 'hover:bg-primary-200',
|
|
5134
5798
|
close: 'hover:bg-primary-300 text-primary-600',
|
|
5799
|
+
selected: 'bg-primary-200 border-primary-400 ring-2 ring-primary-300',
|
|
5135
5800
|
},
|
|
5136
5801
|
secondary: {
|
|
5137
5802
|
default: 'bg-ink-100 text-ink-700 border-ink-200',
|
|
5138
5803
|
hover: 'hover:bg-ink-200',
|
|
5139
5804
|
close: 'hover:bg-ink-300 text-ink-600',
|
|
5805
|
+
selected: 'bg-ink-200 border-ink-400 ring-2 ring-ink-300',
|
|
5140
5806
|
},
|
|
5141
5807
|
success: {
|
|
5142
5808
|
default: 'bg-success-100 text-success-700 border-success-200',
|
|
5143
5809
|
hover: 'hover:bg-success-200',
|
|
5144
5810
|
close: 'hover:bg-success-300 text-success-600',
|
|
5811
|
+
selected: 'bg-success-200 border-success-400 ring-2 ring-success-300',
|
|
5145
5812
|
},
|
|
5146
5813
|
warning: {
|
|
5147
5814
|
default: 'bg-warning-100 text-warning-700 border-warning-200',
|
|
5148
5815
|
hover: 'hover:bg-warning-200',
|
|
5149
5816
|
close: 'hover:bg-warning-300 text-warning-600',
|
|
5817
|
+
selected: 'bg-warning-200 border-warning-400 ring-2 ring-warning-300',
|
|
5150
5818
|
},
|
|
5151
5819
|
error: {
|
|
5152
5820
|
default: 'bg-error-100 text-error-700 border-error-200',
|
|
5153
5821
|
hover: 'hover:bg-error-200',
|
|
5154
5822
|
close: 'hover:bg-error-300 text-error-600',
|
|
5823
|
+
selected: 'bg-error-200 border-error-400 ring-2 ring-error-300',
|
|
5155
5824
|
},
|
|
5156
5825
|
info: {
|
|
5157
5826
|
default: 'bg-accent-100 text-accent-700 border-accent-200',
|
|
5158
5827
|
hover: 'hover:bg-accent-200',
|
|
5159
5828
|
close: 'hover:bg-accent-300 text-accent-600',
|
|
5829
|
+
selected: 'bg-accent-200 border-accent-400 ring-2 ring-accent-300',
|
|
5160
5830
|
},
|
|
5161
5831
|
};
|
|
5162
|
-
const sizeClasses$
|
|
5832
|
+
const sizeClasses$6 = {
|
|
5163
5833
|
sm: {
|
|
5164
5834
|
container: 'h-6 px-2 text-xs gap-1',
|
|
5165
5835
|
icon: 'h-3 w-3',
|
|
@@ -5176,20 +5846,52 @@ const sizeClasses$2 = {
|
|
|
5176
5846
|
close: 'h-4 w-4 ml-2',
|
|
5177
5847
|
},
|
|
5178
5848
|
};
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5849
|
+
const gapClasses = {
|
|
5850
|
+
xs: 'gap-1',
|
|
5851
|
+
sm: 'gap-1.5',
|
|
5852
|
+
md: 'gap-2',
|
|
5853
|
+
lg: 'gap-3',
|
|
5854
|
+
};
|
|
5855
|
+
/**
|
|
5856
|
+
* Chip - Compact element for displaying values with optional remove functionality
|
|
5857
|
+
*
|
|
5858
|
+
* @example Basic chip
|
|
5859
|
+
* ```tsx
|
|
5860
|
+
* <Chip>Tag Name</Chip>
|
|
5861
|
+
* ```
|
|
5862
|
+
*
|
|
5863
|
+
* @example Removable chip
|
|
5864
|
+
* ```tsx
|
|
5865
|
+
* <Chip onClose={() => removeTag(tag)}>
|
|
5866
|
+
* {tag.name}
|
|
5867
|
+
* </Chip>
|
|
5868
|
+
* ```
|
|
5869
|
+
*
|
|
5870
|
+
* @example With icon and selected state
|
|
5871
|
+
* ```tsx
|
|
5872
|
+
* <Chip
|
|
5873
|
+
* icon={<Star className="h-3 w-3" />}
|
|
5874
|
+
* selected={isSelected}
|
|
5875
|
+
* onClick={() => toggleSelection()}
|
|
5876
|
+
* >
|
|
5877
|
+
* Favorite
|
|
5878
|
+
* </Chip>
|
|
5879
|
+
* ```
|
|
5880
|
+
*/
|
|
5881
|
+
function Chip({ children, variant = 'secondary', size = 'md', onClose, icon, disabled = false, className = '', onClick, selected = false, maxWidth, chipKey, }) {
|
|
5882
|
+
const variantStyle = variantClasses$4[variant];
|
|
5883
|
+
const sizeStyle = sizeClasses$6[size];
|
|
5182
5884
|
const isClickable = !disabled && (onClick || onClose);
|
|
5183
5885
|
return (jsxRuntime.jsxs("div", { className: `
|
|
5184
5886
|
inline-flex items-center rounded-full border font-medium
|
|
5185
5887
|
transition-colors
|
|
5186
|
-
${variantStyle.default}
|
|
5187
|
-
${isClickable && !disabled ? variantStyle.hover : ''}
|
|
5888
|
+
${selected ? variantStyle.selected : variantStyle.default}
|
|
5889
|
+
${isClickable && !disabled && !selected ? variantStyle.hover : ''}
|
|
5188
5890
|
${sizeStyle.container}
|
|
5189
5891
|
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
|
5190
5892
|
${onClick && !disabled ? 'cursor-pointer' : ''}
|
|
5191
5893
|
${className}
|
|
5192
|
-
`, onClick: onClick && !disabled ? onClick : undefined, role: onClick ? 'button' : undefined, "aria-disabled": disabled, children: [icon && (jsxRuntime.jsx("span", { className: `flex-shrink-0 ${sizeStyle.icon}`, children: icon })), jsxRuntime.jsx("span", { className: "truncate", children: children }), onClose && (jsxRuntime.jsx("button", { type: "button", onClick: (e) => {
|
|
5894
|
+
`, onClick: onClick && !disabled ? onClick : undefined, role: onClick ? 'button' : undefined, "aria-disabled": disabled, "aria-pressed": onClick ? selected : undefined, "data-chip-key": chipKey, style: { maxWidth: maxWidth || undefined }, children: [icon && (jsxRuntime.jsx("span", { className: `flex-shrink-0 ${sizeStyle.icon}`, children: icon })), jsxRuntime.jsx("span", { className: "truncate", children: children }), onClose && (jsxRuntime.jsx("button", { type: "button", onClick: (e) => {
|
|
5193
5895
|
e.stopPropagation();
|
|
5194
5896
|
if (!disabled)
|
|
5195
5897
|
onClose();
|
|
@@ -5200,8 +5902,420 @@ function Chip({ children, variant = 'secondary', size = 'md', onClose, icon, dis
|
|
|
5200
5902
|
${sizeStyle.close}
|
|
5201
5903
|
`, "aria-label": "Remove", children: jsxRuntime.jsx(lucideReact.X, { className: "w-full h-full" }) }))] }));
|
|
5202
5904
|
}
|
|
5905
|
+
/**
|
|
5906
|
+
* ChipGroup - Container for multiple chips with layout and selection support
|
|
5907
|
+
*
|
|
5908
|
+
* @example Basic group
|
|
5909
|
+
* ```tsx
|
|
5910
|
+
* <ChipGroup wrap gap="sm">
|
|
5911
|
+
* {tags.map(tag => (
|
|
5912
|
+
* <Chip key={tag.id} onClose={() => removeTag(tag)}>
|
|
5913
|
+
* {tag.name}
|
|
5914
|
+
* </Chip>
|
|
5915
|
+
* ))}
|
|
5916
|
+
* </ChipGroup>
|
|
5917
|
+
* ```
|
|
5918
|
+
*
|
|
5919
|
+
* @example Selectable group (single)
|
|
5920
|
+
* ```tsx
|
|
5921
|
+
* <ChipGroup
|
|
5922
|
+
* selectionMode="single"
|
|
5923
|
+
* selectedKeys={[selectedCategory]}
|
|
5924
|
+
* onSelectionChange={(keys) => setSelectedCategory(keys[0])}
|
|
5925
|
+
* >
|
|
5926
|
+
* <Chip chipKey="all">All</Chip>
|
|
5927
|
+
* <Chip chipKey="active">Active</Chip>
|
|
5928
|
+
* <Chip chipKey="archived">Archived</Chip>
|
|
5929
|
+
* </ChipGroup>
|
|
5930
|
+
* ```
|
|
5931
|
+
*
|
|
5932
|
+
* @example Multi-select group
|
|
5933
|
+
* ```tsx
|
|
5934
|
+
* <ChipGroup
|
|
5935
|
+
* selectionMode="multiple"
|
|
5936
|
+
* selectedKeys={selectedTags}
|
|
5937
|
+
* onSelectionChange={setSelectedTags}
|
|
5938
|
+
* wrap
|
|
5939
|
+
* >
|
|
5940
|
+
* {availableTags.map(tag => (
|
|
5941
|
+
* <Chip key={tag} chipKey={tag}>{tag}</Chip>
|
|
5942
|
+
* ))}
|
|
5943
|
+
* </ChipGroup>
|
|
5944
|
+
* ```
|
|
5945
|
+
*/
|
|
5946
|
+
function ChipGroup({ children, direction = 'horizontal', wrap = false, gap = 'sm', selectionMode = 'none', selectedKeys = [], onSelectionChange, className = '', }) {
|
|
5947
|
+
const handleChipClick = (chipKey) => {
|
|
5948
|
+
if (selectionMode === 'none' || !onSelectionChange)
|
|
5949
|
+
return;
|
|
5950
|
+
if (selectionMode === 'single') {
|
|
5951
|
+
// Toggle single selection
|
|
5952
|
+
if (selectedKeys.includes(chipKey)) {
|
|
5953
|
+
onSelectionChange([]);
|
|
5954
|
+
}
|
|
5955
|
+
else {
|
|
5956
|
+
onSelectionChange([chipKey]);
|
|
5957
|
+
}
|
|
5958
|
+
}
|
|
5959
|
+
else if (selectionMode === 'multiple') {
|
|
5960
|
+
// Toggle in array
|
|
5961
|
+
if (selectedKeys.includes(chipKey)) {
|
|
5962
|
+
onSelectionChange(selectedKeys.filter(k => k !== chipKey));
|
|
5963
|
+
}
|
|
5964
|
+
else {
|
|
5965
|
+
onSelectionChange([...selectedKeys, chipKey]);
|
|
5966
|
+
}
|
|
5967
|
+
}
|
|
5968
|
+
};
|
|
5969
|
+
// Clone children to inject selection props
|
|
5970
|
+
const enhancedChildren = React.Children.map(children, (child) => {
|
|
5971
|
+
if (!React.isValidElement(child))
|
|
5972
|
+
return child;
|
|
5973
|
+
const chipKey = child.props.chipKey;
|
|
5974
|
+
if (!chipKey || selectionMode === 'none')
|
|
5975
|
+
return child;
|
|
5976
|
+
const isSelected = selectedKeys.includes(chipKey);
|
|
5977
|
+
return React.cloneElement(child, {
|
|
5978
|
+
...child.props,
|
|
5979
|
+
selected: isSelected,
|
|
5980
|
+
onClick: () => {
|
|
5981
|
+
// Call original onClick if exists
|
|
5982
|
+
if (child.props.onClick) {
|
|
5983
|
+
child.props.onClick();
|
|
5984
|
+
}
|
|
5985
|
+
handleChipClick(chipKey);
|
|
5986
|
+
},
|
|
5987
|
+
});
|
|
5988
|
+
});
|
|
5989
|
+
return (jsxRuntime.jsx("div", { className: `
|
|
5990
|
+
flex
|
|
5991
|
+
${direction === 'vertical' ? 'flex-col' : 'flex-row'}
|
|
5992
|
+
${wrap ? 'flex-wrap' : ''}
|
|
5993
|
+
${gapClasses[gap]}
|
|
5994
|
+
${className}
|
|
5995
|
+
`, role: selectionMode !== 'none' ? 'group' : undefined, "aria-label": selectionMode !== 'none' ? 'Chip selection group' : undefined, children: enhancedChildren }));
|
|
5996
|
+
}
|
|
5203
5997
|
|
|
5204
|
-
const sizeClasses$
|
|
5998
|
+
const sizeClasses$5 = {
|
|
5999
|
+
sm: {
|
|
6000
|
+
item: 'py-1.5 px-2',
|
|
6001
|
+
text: 'text-sm',
|
|
6002
|
+
description: 'text-xs',
|
|
6003
|
+
groupHeader: 'py-1.5 px-2 text-xs',
|
|
6004
|
+
},
|
|
6005
|
+
md: {
|
|
6006
|
+
item: 'py-2 px-3',
|
|
6007
|
+
text: 'text-sm',
|
|
6008
|
+
description: 'text-xs',
|
|
6009
|
+
groupHeader: 'py-2 px-3 text-xs',
|
|
6010
|
+
},
|
|
6011
|
+
lg: {
|
|
6012
|
+
item: 'py-3 px-4',
|
|
6013
|
+
text: 'text-base',
|
|
6014
|
+
description: 'text-sm',
|
|
6015
|
+
groupHeader: 'py-2.5 px-4 text-sm',
|
|
6016
|
+
},
|
|
6017
|
+
};
|
|
6018
|
+
const variantClasses$3 = {
|
|
6019
|
+
default: 'bg-white',
|
|
6020
|
+
bordered: 'bg-white border border-paper-300 rounded-lg',
|
|
6021
|
+
card: 'bg-white border border-paper-300 rounded-lg shadow-sm',
|
|
6022
|
+
};
|
|
6023
|
+
/**
|
|
6024
|
+
* CheckboxList - Multi-select list with checkboxes, grouping, and search
|
|
6025
|
+
*
|
|
6026
|
+
* @example Basic usage
|
|
6027
|
+
* ```tsx
|
|
6028
|
+
* <CheckboxList
|
|
6029
|
+
* items={[
|
|
6030
|
+
* { key: '1', label: 'Option 1' },
|
|
6031
|
+
* { key: '2', label: 'Option 2' },
|
|
6032
|
+
* ]}
|
|
6033
|
+
* selectedKeys={selected}
|
|
6034
|
+
* onSelectionChange={setSelected}
|
|
6035
|
+
* />
|
|
6036
|
+
* ```
|
|
6037
|
+
*
|
|
6038
|
+
* @example With grouping and search
|
|
6039
|
+
* ```tsx
|
|
6040
|
+
* <CheckboxList
|
|
6041
|
+
* items={fields}
|
|
6042
|
+
* selectedKeys={selectedFields}
|
|
6043
|
+
* onSelectionChange={setSelectedFields}
|
|
6044
|
+
* groupLabels={{ table1: 'Users', table2: 'Orders' }}
|
|
6045
|
+
* searchable
|
|
6046
|
+
* searchPlaceholder="Search fields..."
|
|
6047
|
+
* showSelectAll
|
|
6048
|
+
* maxHeight="300px"
|
|
6049
|
+
* />
|
|
6050
|
+
* ```
|
|
6051
|
+
*/
|
|
6052
|
+
function CheckboxList({ items, selectedKeys, onSelectionChange, groupLabels = {}, expandedGroups: controlledExpandedGroups, defaultExpandedGroups, onGroupToggle, searchable = false, searchPlaceholder = 'Search...', filterFn, debounceMs = 150, maxHeight, showSelectAll = false, selectAllLabel = 'Select All', showSelectedCount = false, emptyMessage = 'No items available', noResultsMessage = 'No items match your search', size = 'md', variant = 'default', className = '', }) {
|
|
6053
|
+
const [searchTerm, setSearchTerm] = React.useState('');
|
|
6054
|
+
const [debouncedSearchTerm, setDebouncedSearchTerm] = React.useState('');
|
|
6055
|
+
const [internalExpandedGroups, setInternalExpandedGroups] = React.useState(new Set(defaultExpandedGroups || []));
|
|
6056
|
+
// Debounce search
|
|
6057
|
+
const handleSearchChange = React.useCallback((value) => {
|
|
6058
|
+
setSearchTerm(value);
|
|
6059
|
+
const timer = setTimeout(() => {
|
|
6060
|
+
setDebouncedSearchTerm(value);
|
|
6061
|
+
}, debounceMs);
|
|
6062
|
+
return () => clearTimeout(timer);
|
|
6063
|
+
}, [debounceMs]);
|
|
6064
|
+
// Filter items based on search
|
|
6065
|
+
const filteredItems = React.useMemo(() => {
|
|
6066
|
+
if (!debouncedSearchTerm)
|
|
6067
|
+
return items;
|
|
6068
|
+
const term = debouncedSearchTerm.toLowerCase();
|
|
6069
|
+
return items.filter(item => {
|
|
6070
|
+
if (filterFn) {
|
|
6071
|
+
return filterFn(item, debouncedSearchTerm);
|
|
6072
|
+
}
|
|
6073
|
+
return (item.label.toLowerCase().includes(term) ||
|
|
6074
|
+
item.description?.toLowerCase().includes(term) ||
|
|
6075
|
+
item.key.toLowerCase().includes(term));
|
|
6076
|
+
});
|
|
6077
|
+
}, [items, debouncedSearchTerm, filterFn]);
|
|
6078
|
+
// Group items
|
|
6079
|
+
const groupedItems = React.useMemo(() => {
|
|
6080
|
+
const groups = new Map();
|
|
6081
|
+
filteredItems.forEach(item => {
|
|
6082
|
+
const groupKey = item.group || null;
|
|
6083
|
+
if (!groups.has(groupKey)) {
|
|
6084
|
+
groups.set(groupKey, []);
|
|
6085
|
+
}
|
|
6086
|
+
groups.get(groupKey).push(item);
|
|
6087
|
+
});
|
|
6088
|
+
return groups;
|
|
6089
|
+
}, [filteredItems]);
|
|
6090
|
+
// Determine expanded groups
|
|
6091
|
+
const expandedGroups = controlledExpandedGroups
|
|
6092
|
+
? new Set(controlledExpandedGroups)
|
|
6093
|
+
: internalExpandedGroups;
|
|
6094
|
+
const handleGroupToggle = (groupKey) => {
|
|
6095
|
+
const newExpanded = !expandedGroups.has(groupKey);
|
|
6096
|
+
if (!controlledExpandedGroups) {
|
|
6097
|
+
setInternalExpandedGroups(prev => {
|
|
6098
|
+
const next = new Set(prev);
|
|
6099
|
+
if (newExpanded) {
|
|
6100
|
+
next.add(groupKey);
|
|
6101
|
+
}
|
|
6102
|
+
else {
|
|
6103
|
+
next.delete(groupKey);
|
|
6104
|
+
}
|
|
6105
|
+
return next;
|
|
6106
|
+
});
|
|
6107
|
+
}
|
|
6108
|
+
onGroupToggle?.(groupKey, newExpanded);
|
|
6109
|
+
};
|
|
6110
|
+
// Selection handlers
|
|
6111
|
+
const handleItemToggle = (key) => {
|
|
6112
|
+
const newSelected = selectedKeys.includes(key)
|
|
6113
|
+
? selectedKeys.filter(k => k !== key)
|
|
6114
|
+
: [...selectedKeys, key];
|
|
6115
|
+
onSelectionChange(newSelected);
|
|
6116
|
+
};
|
|
6117
|
+
const handleSelectAll = () => {
|
|
6118
|
+
const enabledItems = filteredItems.filter(item => !item.disabled);
|
|
6119
|
+
const allSelected = enabledItems.every(item => selectedKeys.includes(item.key));
|
|
6120
|
+
if (allSelected) {
|
|
6121
|
+
// Deselect all filtered items
|
|
6122
|
+
const filteredKeys = new Set(enabledItems.map(item => item.key));
|
|
6123
|
+
onSelectionChange(selectedKeys.filter(key => !filteredKeys.has(key)));
|
|
6124
|
+
}
|
|
6125
|
+
else {
|
|
6126
|
+
// Select all filtered items
|
|
6127
|
+
const newKeys = new Set([...selectedKeys, ...enabledItems.map(item => item.key)]);
|
|
6128
|
+
onSelectionChange(Array.from(newKeys));
|
|
6129
|
+
}
|
|
6130
|
+
};
|
|
6131
|
+
const sizeStyle = sizeClasses$5[size];
|
|
6132
|
+
const enabledItems = filteredItems.filter(item => !item.disabled);
|
|
6133
|
+
const allSelected = enabledItems.length > 0 && enabledItems.every(item => selectedKeys.includes(item.key));
|
|
6134
|
+
const someSelected = enabledItems.some(item => selectedKeys.includes(item.key)) && !allSelected;
|
|
6135
|
+
// Check if we have groups
|
|
6136
|
+
const hasGroups = groupedItems.size > 1 || (groupedItems.size === 1 && !groupedItems.has(null));
|
|
6137
|
+
return (jsxRuntime.jsxs("div", { className: `${variantClasses$3[variant]} ${className}`, children: [searchable && (jsxRuntime.jsx("div", { className: `${sizeStyle.item} border-b border-paper-200`, children: jsxRuntime.jsx(Input, { value: searchTerm, onChange: (e) => handleSearchChange(e.target.value), placeholder: searchPlaceholder, prefixIcon: jsxRuntime.jsx(lucideReact.Search, { className: "h-4 w-4" }), size: size === 'lg' ? 'md' : 'sm', clearable: true, onClear: () => {
|
|
6138
|
+
setSearchTerm('');
|
|
6139
|
+
setDebouncedSearchTerm('');
|
|
6140
|
+
} }) })), (showSelectAll || showSelectedCount) && filteredItems.length > 0 && (jsxRuntime.jsxs("div", { className: `${sizeStyle.item} border-b border-paper-200 flex items-center justify-between`, children: [showSelectAll && (jsxRuntime.jsx(Checkbox, { checked: allSelected, indeterminate: someSelected, onChange: handleSelectAll, label: selectAllLabel, size: size })), showSelectedCount && (jsxRuntime.jsxs("span", { className: `${sizeStyle.description} text-ink-500`, children: [selectedKeys.length, " selected"] }))] })), jsxRuntime.jsxs("div", { className: "overflow-y-auto", style: { maxHeight: maxHeight || undefined }, children: [items.length === 0 && (jsxRuntime.jsx("div", { className: `${sizeStyle.item} text-ink-500 ${sizeStyle.text} text-center`, children: emptyMessage })), items.length > 0 && filteredItems.length === 0 && (jsxRuntime.jsx("div", { className: `${sizeStyle.item} text-ink-500 ${sizeStyle.text} text-center`, children: noResultsMessage })), hasGroups ? (Array.from(groupedItems.entries()).map(([groupKey, groupItems]) => {
|
|
6141
|
+
if (groupKey === null) {
|
|
6142
|
+
// Ungrouped items
|
|
6143
|
+
return groupItems.map(item => (jsxRuntime.jsx(CheckboxListItemRow, { item: item, selected: selectedKeys.includes(item.key), onToggle: () => handleItemToggle(item.key), size: size, sizeStyle: sizeStyle }, item.key)));
|
|
6144
|
+
}
|
|
6145
|
+
const isExpanded = expandedGroups.has(groupKey);
|
|
6146
|
+
const groupLabel = groupLabels[groupKey] || groupKey;
|
|
6147
|
+
const groupSelectedCount = groupItems.filter(item => selectedKeys.includes(item.key)).length;
|
|
6148
|
+
return (jsxRuntime.jsxs("div", { children: [jsxRuntime.jsxs("button", { type: "button", onClick: () => handleGroupToggle(groupKey), className: `
|
|
6149
|
+
w-full flex items-center justify-between
|
|
6150
|
+
${sizeStyle.groupHeader}
|
|
6151
|
+
font-medium text-ink-700 bg-paper-50
|
|
6152
|
+
hover:bg-paper-100 transition-colors
|
|
6153
|
+
border-b border-paper-200
|
|
6154
|
+
`, children: [jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [isExpanded ? (jsxRuntime.jsx(lucideReact.ChevronDown, { className: "h-4 w-4 text-ink-400" })) : (jsxRuntime.jsx(lucideReact.ChevronRight, { className: "h-4 w-4 text-ink-400" })), jsxRuntime.jsx("span", { children: groupLabel })] }), jsxRuntime.jsxs("span", { className: "text-ink-400 font-normal", children: [groupSelectedCount > 0 && `${groupSelectedCount}/`, groupItems.length] })] }), isExpanded && (jsxRuntime.jsx("div", { children: groupItems.map(item => (jsxRuntime.jsx(CheckboxListItemRow, { item: item, selected: selectedKeys.includes(item.key), onToggle: () => handleItemToggle(item.key), size: size, sizeStyle: sizeStyle, indented: true }, item.key))) }))] }, groupKey));
|
|
6155
|
+
})) : (
|
|
6156
|
+
// Flat list (no groups)
|
|
6157
|
+
filteredItems.map(item => (jsxRuntime.jsx(CheckboxListItemRow, { item: item, selected: selectedKeys.includes(item.key), onToggle: () => handleItemToggle(item.key), size: size, sizeStyle: sizeStyle }, item.key))))] })] }));
|
|
6158
|
+
}
|
|
6159
|
+
// Helper component for rendering individual items
|
|
6160
|
+
function CheckboxListItemRow({ item, selected, onToggle, size, sizeStyle, indented = false, }) {
|
|
6161
|
+
return (jsxRuntime.jsx("div", { className: `
|
|
6162
|
+
${sizeStyle.item}
|
|
6163
|
+
${indented ? 'pl-8' : ''}
|
|
6164
|
+
hover:bg-paper-50 transition-colors
|
|
6165
|
+
border-b border-paper-100 last:border-b-0
|
|
6166
|
+
${item.disabled ? 'opacity-50' : ''}
|
|
6167
|
+
`, children: jsxRuntime.jsxs("div", { className: "flex items-start gap-3", children: [jsxRuntime.jsx(Checkbox, { checked: selected, onChange: onToggle, disabled: item.disabled, size: size }), jsxRuntime.jsxs("div", { className: "flex flex-col flex-1 min-w-0", children: [jsxRuntime.jsx("span", { className: sizeStyle.text, children: item.label }), item.description && (jsxRuntime.jsx("span", { className: `${sizeStyle.description} text-ink-500`, children: item.description }))] })] }) }));
|
|
6168
|
+
}
|
|
6169
|
+
|
|
6170
|
+
const sizeClasses$4 = {
|
|
6171
|
+
sm: {
|
|
6172
|
+
container: 'text-sm',
|
|
6173
|
+
item: 'py-1.5 px-2',
|
|
6174
|
+
searchPadding: 'p-2',
|
|
6175
|
+
statusPadding: 'px-2 py-1.5',
|
|
6176
|
+
},
|
|
6177
|
+
md: {
|
|
6178
|
+
container: 'text-sm',
|
|
6179
|
+
item: 'py-2 px-3',
|
|
6180
|
+
searchPadding: 'p-3',
|
|
6181
|
+
statusPadding: 'px-3 py-2',
|
|
6182
|
+
},
|
|
6183
|
+
lg: {
|
|
6184
|
+
container: 'text-base',
|
|
6185
|
+
item: 'py-3 px-4',
|
|
6186
|
+
searchPadding: 'p-4',
|
|
6187
|
+
statusPadding: 'px-4 py-2.5',
|
|
6188
|
+
},
|
|
6189
|
+
};
|
|
6190
|
+
const variantClasses$2 = {
|
|
6191
|
+
default: 'bg-white',
|
|
6192
|
+
bordered: 'bg-white border border-paper-300 rounded-lg',
|
|
6193
|
+
card: 'bg-white border border-paper-300 rounded-lg shadow-sm',
|
|
6194
|
+
};
|
|
6195
|
+
/**
|
|
6196
|
+
* SearchableList - List component with integrated search/filter functionality
|
|
6197
|
+
*
|
|
6198
|
+
* @example Basic usage
|
|
6199
|
+
* ```tsx
|
|
6200
|
+
* <SearchableList
|
|
6201
|
+
* items={users.map(u => ({ key: u.id, data: u }))}
|
|
6202
|
+
* renderItem={(item) => <div>{item.data.name}</div>}
|
|
6203
|
+
* onSelect={(item) => setSelectedUser(item.data)}
|
|
6204
|
+
* searchable
|
|
6205
|
+
* searchPlaceholder="Search users..."
|
|
6206
|
+
* />
|
|
6207
|
+
* ```
|
|
6208
|
+
*
|
|
6209
|
+
* @example With custom filter and loading
|
|
6210
|
+
* ```tsx
|
|
6211
|
+
* <SearchableList
|
|
6212
|
+
* items={products}
|
|
6213
|
+
* renderItem={(item, index, isSelected) => (
|
|
6214
|
+
* <div className={isSelected ? 'bg-accent-50' : ''}>
|
|
6215
|
+
* {item.data.name} - ${item.data.price}
|
|
6216
|
+
* </div>
|
|
6217
|
+
* )}
|
|
6218
|
+
* filterFn={(item, term) =>
|
|
6219
|
+
* item.data.name.toLowerCase().includes(term.toLowerCase())
|
|
6220
|
+
* }
|
|
6221
|
+
* loading={isLoading}
|
|
6222
|
+
* loadingMessage="Fetching products..."
|
|
6223
|
+
* maxHeight="400px"
|
|
6224
|
+
* />
|
|
6225
|
+
* ```
|
|
6226
|
+
*/
|
|
6227
|
+
function SearchableList({ items, searchPlaceholder = 'Search...', searchValue: controlledSearchValue, onSearchChange, filterFn, debounceMs = 150, renderItem, selectedKey, onSelect, maxHeight, showResultCount = false, formatResultCount, emptyMessage = 'No items available', noResultsMessage = 'No items match your search', loading = false, loadingMessage = 'Loading...', size = 'md', variant = 'default', className = '', enableKeyboardNavigation = true, autoFocus = false, }) {
|
|
6228
|
+
const [internalSearchValue, setInternalSearchValue] = React.useState('');
|
|
6229
|
+
const [debouncedSearchTerm, setDebouncedSearchTerm] = React.useState('');
|
|
6230
|
+
const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
|
|
6231
|
+
const listRef = React.useRef(null);
|
|
6232
|
+
const itemRefs = React.useRef(new Map());
|
|
6233
|
+
const searchValue = controlledSearchValue !== undefined ? controlledSearchValue : internalSearchValue;
|
|
6234
|
+
// Debounce search
|
|
6235
|
+
React.useEffect(() => {
|
|
6236
|
+
const timer = setTimeout(() => {
|
|
6237
|
+
setDebouncedSearchTerm(searchValue);
|
|
6238
|
+
}, debounceMs);
|
|
6239
|
+
return () => clearTimeout(timer);
|
|
6240
|
+
}, [searchValue, debounceMs]);
|
|
6241
|
+
const handleSearchChange = React.useCallback((value) => {
|
|
6242
|
+
if (controlledSearchValue === undefined) {
|
|
6243
|
+
setInternalSearchValue(value);
|
|
6244
|
+
}
|
|
6245
|
+
onSearchChange?.(value);
|
|
6246
|
+
setHighlightedIndex(-1);
|
|
6247
|
+
}, [controlledSearchValue, onSearchChange]);
|
|
6248
|
+
// Filter items based on search
|
|
6249
|
+
const filteredItems = React.useMemo(() => {
|
|
6250
|
+
if (!debouncedSearchTerm)
|
|
6251
|
+
return items;
|
|
6252
|
+
return items.filter(item => {
|
|
6253
|
+
if (filterFn) {
|
|
6254
|
+
return filterFn(item, debouncedSearchTerm);
|
|
6255
|
+
}
|
|
6256
|
+
// Default filter: check if key includes search term
|
|
6257
|
+
return item.key.toLowerCase().includes(debouncedSearchTerm.toLowerCase());
|
|
6258
|
+
});
|
|
6259
|
+
}, [items, debouncedSearchTerm, filterFn]);
|
|
6260
|
+
// Keyboard navigation
|
|
6261
|
+
const handleKeyDown = React.useCallback((e) => {
|
|
6262
|
+
if (!enableKeyboardNavigation || filteredItems.length === 0)
|
|
6263
|
+
return;
|
|
6264
|
+
switch (e.key) {
|
|
6265
|
+
case 'ArrowDown':
|
|
6266
|
+
e.preventDefault();
|
|
6267
|
+
setHighlightedIndex(prev => prev < filteredItems.length - 1 ? prev + 1 : 0);
|
|
6268
|
+
break;
|
|
6269
|
+
case 'ArrowUp':
|
|
6270
|
+
e.preventDefault();
|
|
6271
|
+
setHighlightedIndex(prev => prev > 0 ? prev - 1 : filteredItems.length - 1);
|
|
6272
|
+
break;
|
|
6273
|
+
case 'Enter':
|
|
6274
|
+
e.preventDefault();
|
|
6275
|
+
if (highlightedIndex >= 0 && highlightedIndex < filteredItems.length) {
|
|
6276
|
+
onSelect?.(filteredItems[highlightedIndex]);
|
|
6277
|
+
}
|
|
6278
|
+
break;
|
|
6279
|
+
case 'Escape':
|
|
6280
|
+
setHighlightedIndex(-1);
|
|
6281
|
+
break;
|
|
6282
|
+
}
|
|
6283
|
+
}, [enableKeyboardNavigation, filteredItems, highlightedIndex, onSelect]);
|
|
6284
|
+
// Scroll highlighted item into view
|
|
6285
|
+
React.useEffect(() => {
|
|
6286
|
+
if (highlightedIndex >= 0) {
|
|
6287
|
+
const itemEl = itemRefs.current.get(highlightedIndex);
|
|
6288
|
+
if (itemEl) {
|
|
6289
|
+
itemEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
6290
|
+
}
|
|
6291
|
+
}
|
|
6292
|
+
}, [highlightedIndex]);
|
|
6293
|
+
const sizeStyle = sizeClasses$4[size];
|
|
6294
|
+
const resultCountText = formatResultCount
|
|
6295
|
+
? formatResultCount(filteredItems.length, items.length)
|
|
6296
|
+
: `${filteredItems.length} of ${items.length}`;
|
|
6297
|
+
return (jsxRuntime.jsxs("div", { className: `${variantClasses$2[variant]} ${sizeStyle.container} ${className}`, onKeyDown: handleKeyDown, children: [jsxRuntime.jsx("div", { className: `${sizeStyle.searchPadding} border-b border-paper-200`, children: jsxRuntime.jsx(Input, { value: searchValue, onChange: (e) => handleSearchChange(e.target.value), placeholder: searchPlaceholder, prefixIcon: jsxRuntime.jsx(lucideReact.Search, { className: "h-4 w-4" }), size: size === 'lg' ? 'md' : 'sm', clearable: true, onClear: () => handleSearchChange(''), autoFocus: autoFocus }) }), showResultCount && items.length > 0 && !loading && (jsxRuntime.jsx("div", { className: `${sizeStyle.statusPadding} text-ink-500 text-xs border-b border-paper-100`, children: resultCountText })), jsxRuntime.jsxs("div", { ref: listRef, className: "overflow-y-auto", style: { maxHeight: maxHeight || undefined }, role: "listbox", "aria-activedescendant": highlightedIndex >= 0 ? `item-${highlightedIndex}` : undefined, children: [loading && (jsxRuntime.jsxs("div", { className: `${sizeStyle.item} flex items-center justify-center gap-2 text-ink-500`, children: [jsxRuntime.jsx(lucideReact.Loader2, { className: "h-4 w-4 animate-spin" }), jsxRuntime.jsx("span", { children: loadingMessage })] })), !loading && items.length === 0 && (jsxRuntime.jsx("div", { className: `${sizeStyle.item} text-ink-500 text-center`, children: emptyMessage })), !loading && items.length > 0 && filteredItems.length === 0 && (jsxRuntime.jsx("div", { className: `${sizeStyle.item} text-ink-500 text-center`, children: noResultsMessage })), !loading && filteredItems.map((item, index) => {
|
|
6298
|
+
const isSelected = selectedKey === item.key;
|
|
6299
|
+
const isHighlighted = highlightedIndex === index;
|
|
6300
|
+
return (jsxRuntime.jsx("div", { id: `item-${index}`, ref: (el) => {
|
|
6301
|
+
if (el) {
|
|
6302
|
+
itemRefs.current.set(index, el);
|
|
6303
|
+
}
|
|
6304
|
+
else {
|
|
6305
|
+
itemRefs.current.delete(index);
|
|
6306
|
+
}
|
|
6307
|
+
}, role: "option", "aria-selected": isSelected, onClick: () => onSelect?.(item), className: `
|
|
6308
|
+
${sizeStyle.item}
|
|
6309
|
+
cursor-pointer transition-colors
|
|
6310
|
+
${isSelected ? 'bg-accent-50' : ''}
|
|
6311
|
+
${isHighlighted ? 'bg-paper-100' : ''}
|
|
6312
|
+
${!isSelected && !isHighlighted ? 'hover:bg-paper-50' : ''}
|
|
6313
|
+
border-b border-paper-100 last:border-b-0
|
|
6314
|
+
`, children: renderItem(item, index, isSelected, isHighlighted) }, item.key));
|
|
6315
|
+
})] })] }));
|
|
6316
|
+
}
|
|
6317
|
+
|
|
6318
|
+
const sizeClasses$3 = {
|
|
5205
6319
|
sm: {
|
|
5206
6320
|
input: 'h-8 px-2 text-sm',
|
|
5207
6321
|
button: 'h-8 w-8',
|
|
@@ -5225,7 +6339,7 @@ const NumberInput = React.forwardRef((props, ref) => {
|
|
|
5225
6339
|
const { value = 0, onChange, min, max, step = 1, disabled = false, readOnly = false, label, helperText, error, required = false, size = 'md', className = '', placeholder, id, name, precision, formatValue, } = props;
|
|
5226
6340
|
const [internalValue, setInternalValue] = React.useState(String(value));
|
|
5227
6341
|
const [isFocused, setIsFocused] = React.useState(false);
|
|
5228
|
-
const sizeStyle = sizeClasses$
|
|
6342
|
+
const sizeStyle = sizeClasses$3[size];
|
|
5229
6343
|
// Generate unique IDs for ARIA
|
|
5230
6344
|
const uniqueId = React.useId();
|
|
5231
6345
|
const inputId = id || uniqueId;
|
|
@@ -5655,105 +6769,6 @@ class ErrorBoundary extends React.Component {
|
|
|
5655
6769
|
}
|
|
5656
6770
|
}
|
|
5657
6771
|
|
|
5658
|
-
const heightPresets = {
|
|
5659
|
-
sm: '33vh',
|
|
5660
|
-
md: '50vh',
|
|
5661
|
-
lg: '75vh',
|
|
5662
|
-
full: '90vh',
|
|
5663
|
-
};
|
|
5664
|
-
function BottomSheet({ isOpen, onClose, children, title, height = 'md', showHandle = true, showCloseButton = true, closeOnOverlayClick = true, closeOnEscape = true, className = '', }) {
|
|
5665
|
-
const titleId = React.useId();
|
|
5666
|
-
const [isDragging, setIsDragging] = React.useState(false);
|
|
5667
|
-
const [dragOffset, setDragOffset] = React.useState(0);
|
|
5668
|
-
const [currentHeight] = React.useState(typeof height === 'string' && height in heightPresets
|
|
5669
|
-
? heightPresets[height]
|
|
5670
|
-
: height);
|
|
5671
|
-
const sheetRef = React.useRef(null);
|
|
5672
|
-
const startYRef = React.useRef(0);
|
|
5673
|
-
// Close on Escape
|
|
5674
|
-
React.useEffect(() => {
|
|
5675
|
-
if (!isOpen || !closeOnEscape)
|
|
5676
|
-
return;
|
|
5677
|
-
const handleEscape = (e) => {
|
|
5678
|
-
if (e.key === 'Escape') {
|
|
5679
|
-
onClose();
|
|
5680
|
-
}
|
|
5681
|
-
};
|
|
5682
|
-
document.addEventListener('keydown', handleEscape);
|
|
5683
|
-
return () => document.removeEventListener('keydown', handleEscape);
|
|
5684
|
-
}, [isOpen, closeOnEscape, onClose]);
|
|
5685
|
-
// Prevent body scroll when open
|
|
5686
|
-
React.useEffect(() => {
|
|
5687
|
-
if (isOpen) {
|
|
5688
|
-
document.body.style.overflow = 'hidden';
|
|
5689
|
-
}
|
|
5690
|
-
else {
|
|
5691
|
-
document.body.style.overflow = '';
|
|
5692
|
-
}
|
|
5693
|
-
return () => {
|
|
5694
|
-
document.body.style.overflow = '';
|
|
5695
|
-
};
|
|
5696
|
-
}, [isOpen]);
|
|
5697
|
-
const handleOverlayClick = (e) => {
|
|
5698
|
-
if (closeOnOverlayClick && e.target === e.currentTarget) {
|
|
5699
|
-
onClose();
|
|
5700
|
-
}
|
|
5701
|
-
};
|
|
5702
|
-
const handleDragStart = (e) => {
|
|
5703
|
-
setIsDragging(true);
|
|
5704
|
-
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
|
5705
|
-
startYRef.current = clientY;
|
|
5706
|
-
};
|
|
5707
|
-
const handleDragMove = (e) => {
|
|
5708
|
-
if (!isDragging)
|
|
5709
|
-
return;
|
|
5710
|
-
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
|
5711
|
-
const offset = clientY - startYRef.current;
|
|
5712
|
-
// Only allow dragging down
|
|
5713
|
-
if (offset > 0) {
|
|
5714
|
-
setDragOffset(offset);
|
|
5715
|
-
}
|
|
5716
|
-
};
|
|
5717
|
-
const handleDragEnd = () => {
|
|
5718
|
-
setIsDragging(false);
|
|
5719
|
-
// Close if dragged down more than 150px
|
|
5720
|
-
if (dragOffset > 150) {
|
|
5721
|
-
onClose();
|
|
5722
|
-
}
|
|
5723
|
-
setDragOffset(0);
|
|
5724
|
-
};
|
|
5725
|
-
React.useEffect(() => {
|
|
5726
|
-
if (!isDragging)
|
|
5727
|
-
return;
|
|
5728
|
-
const handleMove = (e) => handleDragMove(e);
|
|
5729
|
-
const handleEnd = () => handleDragEnd();
|
|
5730
|
-
document.addEventListener('touchmove', handleMove);
|
|
5731
|
-
document.addEventListener('mousemove', handleMove);
|
|
5732
|
-
document.addEventListener('touchend', handleEnd);
|
|
5733
|
-
document.addEventListener('mouseup', handleEnd);
|
|
5734
|
-
return () => {
|
|
5735
|
-
document.removeEventListener('touchmove', handleMove);
|
|
5736
|
-
document.removeEventListener('mousemove', handleMove);
|
|
5737
|
-
document.removeEventListener('touchend', handleEnd);
|
|
5738
|
-
document.removeEventListener('mouseup', handleEnd);
|
|
5739
|
-
};
|
|
5740
|
-
}, [isDragging, dragOffset]);
|
|
5741
|
-
if (!isOpen)
|
|
5742
|
-
return null;
|
|
5743
|
-
return (jsxRuntime.jsxs("div", { className: "fixed inset-0 z-50 flex items-end", onClick: handleOverlayClick, children: [jsxRuntime.jsx("div", { className: `
|
|
5744
|
-
absolute inset-0 bg-black/50 transition-opacity duration-300
|
|
5745
|
-
${isOpen ? 'opacity-100' : 'opacity-0'}
|
|
5746
|
-
` }), jsxRuntime.jsxs("div", { ref: sheetRef, className: `
|
|
5747
|
-
relative w-full bg-white rounded-t-2xl shadow-2xl
|
|
5748
|
-
transition-transform duration-300 ease-out
|
|
5749
|
-
${isOpen ? 'translate-y-0' : 'translate-y-full'}
|
|
5750
|
-
${className}
|
|
5751
|
-
`, style: {
|
|
5752
|
-
height: currentHeight,
|
|
5753
|
-
transform: `translateY(${dragOffset}px)`,
|
|
5754
|
-
}, role: "dialog", "aria-modal": "true", "aria-labelledby": title ? titleId : undefined, children: [showHandle && (jsxRuntime.jsx("div", { className: "py-3 cursor-grab active:cursor-grabbing", onTouchStart: handleDragStart, onMouseDown: handleDragStart, children: jsxRuntime.jsx("div", { className: "w-12 h-1.5 bg-ink-300 rounded-full mx-auto" }) })), (title || showCloseButton) && (jsxRuntime.jsxs("div", { className: "px-6 py-4 border-b border-ink-200 flex items-center justify-between", children: [title && (jsxRuntime.jsx("h2", { id: titleId, className: "text-lg font-semibold text-ink-900", children: title })), showCloseButton && (jsxRuntime.jsx("button", { onClick: onClose, className: "text-ink-400 hover:text-ink-600 transition-colors ml-auto", "aria-label": "Close", children: jsxRuntime.jsx(lucideReact.X, { className: "h-5 w-5" }) }))] })), jsxRuntime.jsx("div", { className: "overflow-y-auto flex-1 p-6", children: children })] })] }));
|
|
5755
|
-
}
|
|
5756
|
-
|
|
5757
6772
|
function Collapsible({ trigger, children, defaultOpen = false, open: controlledOpen, onOpenChange, disabled = false, showIcon = true, className = '', triggerClassName = '', contentClassName = '', }) {
|
|
5758
6773
|
const isControlled = controlledOpen !== undefined;
|
|
5759
6774
|
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
|
|
@@ -5803,6 +6818,231 @@ function Collapsible({ trigger, children, defaultOpen = false, open: controlledO
|
|
|
5803
6818
|
` }))] }), jsxRuntime.jsx("div", { id: "collapsible-content", ref: contentRef, className: "overflow-hidden transition-all duration-300 ease-in-out", style: { height: `${height}px` }, "aria-hidden": !isOpen, children: jsxRuntime.jsx("div", { className: contentClassName, children: children }) })] }));
|
|
5804
6819
|
}
|
|
5805
6820
|
|
|
6821
|
+
const sizeClasses$2 = {
|
|
6822
|
+
sm: {
|
|
6823
|
+
header: 'h-10 px-3',
|
|
6824
|
+
text: 'text-sm',
|
|
6825
|
+
icon: 'h-4 w-4',
|
|
6826
|
+
},
|
|
6827
|
+
md: {
|
|
6828
|
+
header: 'h-12 px-4',
|
|
6829
|
+
text: 'text-sm',
|
|
6830
|
+
icon: 'h-5 w-5',
|
|
6831
|
+
},
|
|
6832
|
+
lg: {
|
|
6833
|
+
header: 'h-14 px-5',
|
|
6834
|
+
text: 'text-base',
|
|
6835
|
+
icon: 'h-5 w-5',
|
|
6836
|
+
},
|
|
6837
|
+
};
|
|
6838
|
+
const variantClasses$1 = {
|
|
6839
|
+
default: {
|
|
6840
|
+
container: 'bg-white border-ink-200',
|
|
6841
|
+
header: 'bg-paper-50',
|
|
6842
|
+
},
|
|
6843
|
+
elevated: {
|
|
6844
|
+
container: 'bg-white shadow-lg border-ink-200',
|
|
6845
|
+
header: 'bg-white',
|
|
6846
|
+
},
|
|
6847
|
+
bordered: {
|
|
6848
|
+
container: 'bg-white border-2 border-ink-300',
|
|
6849
|
+
header: 'bg-paper-100',
|
|
6850
|
+
},
|
|
6851
|
+
};
|
|
6852
|
+
/**
|
|
6853
|
+
* ExpandablePanel - A panel that sticks to the bottom (or top) and can expand/collapse
|
|
6854
|
+
*
|
|
6855
|
+
* For bottom position: expands UPWARD (content appears above header)
|
|
6856
|
+
* For top position: expands DOWNWARD (content appears below header)
|
|
6857
|
+
*
|
|
6858
|
+
* Two modes of operation:
|
|
6859
|
+
* - `viewport`: Fixed to the viewport (for standalone pages, covers StatusBar)
|
|
6860
|
+
* - `container`: Sticky within its parent container (for use inside Page/AppLayout, respects StatusBar)
|
|
6861
|
+
*
|
|
6862
|
+
* @example Basic usage (viewport mode - full page)
|
|
6863
|
+
* ```tsx
|
|
6864
|
+
* <ExpandablePanel
|
|
6865
|
+
* mode="viewport"
|
|
6866
|
+
* collapsedContent={<Text>3 items selected</Text>}
|
|
6867
|
+
* expandedHeight="300px"
|
|
6868
|
+
* >
|
|
6869
|
+
* {content}
|
|
6870
|
+
* </ExpandablePanel>
|
|
6871
|
+
* ```
|
|
6872
|
+
*
|
|
6873
|
+
* @example Inside Page/AppLayout (container mode - respects StatusBar)
|
|
6874
|
+
* ```tsx
|
|
6875
|
+
* <Page>
|
|
6876
|
+
* <ExpandablePanelContainer>
|
|
6877
|
+
* <div className="flex-1 overflow-auto">
|
|
6878
|
+
* {pageContent}
|
|
6879
|
+
* </div>
|
|
6880
|
+
* <ExpandablePanel
|
|
6881
|
+
* mode="container"
|
|
6882
|
+
* collapsedContent={<Text>3 items selected</Text>}
|
|
6883
|
+
* expandedHeight="300px"
|
|
6884
|
+
* >
|
|
6885
|
+
* {selectedItemsContent}
|
|
6886
|
+
* </ExpandablePanel>
|
|
6887
|
+
* </ExpandablePanelContainer>
|
|
6888
|
+
* </Page>
|
|
6889
|
+
* ```
|
|
6890
|
+
*
|
|
6891
|
+
* @example With maxWidth to match page content
|
|
6892
|
+
* ```tsx
|
|
6893
|
+
* <ExpandablePanel
|
|
6894
|
+
* mode="container"
|
|
6895
|
+
* maxWidth="1400px"
|
|
6896
|
+
* collapsedContent={<Text>Generated SQL</Text>}
|
|
6897
|
+
* expandedHeight="300px"
|
|
6898
|
+
* >
|
|
6899
|
+
* {sqlContent}
|
|
6900
|
+
* </ExpandablePanel>
|
|
6901
|
+
* ```
|
|
6902
|
+
*/
|
|
6903
|
+
function ExpandablePanel({ collapsedContent, children, position = 'bottom', mode = 'viewport', expanded: controlledExpanded, defaultExpanded = false, onExpandedChange, expandedHeight = '300px', maxWidth, showToggle = true, toggleContent, headerActions, closeOnEscape = true, variant = 'elevated', size = 'md', className = '', headerClassName = '', contentClassName = '', zIndex = 40, }) {
|
|
6904
|
+
const [internalExpanded, setInternalExpanded] = React.useState(defaultExpanded);
|
|
6905
|
+
// Determine if controlled or uncontrolled
|
|
6906
|
+
const isControlled = controlledExpanded !== undefined;
|
|
6907
|
+
const expanded = isControlled ? controlledExpanded : internalExpanded;
|
|
6908
|
+
const setExpanded = (value) => {
|
|
6909
|
+
if (!isControlled) {
|
|
6910
|
+
setInternalExpanded(value);
|
|
6911
|
+
}
|
|
6912
|
+
onExpandedChange?.(value);
|
|
6913
|
+
};
|
|
6914
|
+
const toggleExpanded = () => {
|
|
6915
|
+
setExpanded(!expanded);
|
|
6916
|
+
};
|
|
6917
|
+
// Close on Escape
|
|
6918
|
+
React.useEffect(() => {
|
|
6919
|
+
if (!closeOnEscape || !expanded)
|
|
6920
|
+
return;
|
|
6921
|
+
const handleEscape = (e) => {
|
|
6922
|
+
if (e.key === 'Escape') {
|
|
6923
|
+
setExpanded(false);
|
|
6924
|
+
}
|
|
6925
|
+
};
|
|
6926
|
+
document.addEventListener('keydown', handleEscape);
|
|
6927
|
+
return () => document.removeEventListener('keydown', handleEscape);
|
|
6928
|
+
}, [closeOnEscape, expanded]);
|
|
6929
|
+
const sizeStyle = sizeClasses$2[size];
|
|
6930
|
+
const variantStyle = variantClasses$1[variant];
|
|
6931
|
+
const heightValue = typeof expandedHeight === 'number' ? `${expandedHeight}px` : expandedHeight;
|
|
6932
|
+
const maxWidthValue = maxWidth
|
|
6933
|
+
? (typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth)
|
|
6934
|
+
: undefined;
|
|
6935
|
+
// Position classes differ based on mode
|
|
6936
|
+
const getPositionClasses = () => {
|
|
6937
|
+
if (mode === 'viewport') {
|
|
6938
|
+
// Fixed to viewport
|
|
6939
|
+
return position === 'bottom'
|
|
6940
|
+
? 'fixed bottom-0 left-0 right-0'
|
|
6941
|
+
: 'fixed top-0 left-0 right-0';
|
|
6942
|
+
}
|
|
6943
|
+
else {
|
|
6944
|
+
// Absolute positioning within container - snaps to bottom
|
|
6945
|
+
return position === 'bottom'
|
|
6946
|
+
? 'absolute bottom-0 left-0 right-0'
|
|
6947
|
+
: 'absolute top-0 left-0 right-0';
|
|
6948
|
+
}
|
|
6949
|
+
};
|
|
6950
|
+
// For bottom panel, we want chevron up to expand (reveal content above)
|
|
6951
|
+
// For top panel, we want chevron down to expand (reveal content below)
|
|
6952
|
+
const ChevronIcon = position === 'bottom'
|
|
6953
|
+
? (expanded ? lucideReact.ChevronDown : lucideReact.ChevronUp)
|
|
6954
|
+
: (expanded ? lucideReact.ChevronUp : lucideReact.ChevronDown);
|
|
6955
|
+
// Header component
|
|
6956
|
+
const header = (jsxRuntime.jsxs("div", { className: `
|
|
6957
|
+
flex items-center justify-between
|
|
6958
|
+
${sizeStyle.header}
|
|
6959
|
+
${variantStyle.header}
|
|
6960
|
+
border-ink-200
|
|
6961
|
+
flex-shrink-0
|
|
6962
|
+
${headerClassName}
|
|
6963
|
+
`, children: [jsxRuntime.jsx("div", { className: `flex-1 flex items-center ${sizeStyle.text}`, children: collapsedContent }), jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [headerActions, showToggle && (jsxRuntime.jsx("button", { type: "button", onClick: toggleExpanded, className: `
|
|
6964
|
+
flex items-center justify-center
|
|
6965
|
+
p-1.5 rounded-md
|
|
6966
|
+
text-ink-500 hover:text-ink-700
|
|
6967
|
+
hover:bg-ink-100
|
|
6968
|
+
transition-colors
|
|
6969
|
+
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1
|
|
6970
|
+
`, "aria-expanded": expanded, "aria-label": expanded ? 'Collapse panel' : 'Expand panel', children: toggleContent || jsxRuntime.jsx(ChevronIcon, { className: sizeStyle.icon }) }))] })] }));
|
|
6971
|
+
// Content component
|
|
6972
|
+
const content = (jsxRuntime.jsx("div", { className: `
|
|
6973
|
+
overflow-hidden
|
|
6974
|
+
transition-all duration-300 ease-in-out
|
|
6975
|
+
`, style: {
|
|
6976
|
+
maxHeight: expanded ? heightValue : '0px',
|
|
6977
|
+
opacity: expanded ? 1 : 0,
|
|
6978
|
+
}, children: jsxRuntime.jsx("div", { className: `
|
|
6979
|
+
overflow-y-auto p-4
|
|
6980
|
+
${contentClassName}
|
|
6981
|
+
`, style: { maxHeight: heightValue }, children: children }) }));
|
|
6982
|
+
// Build container styles
|
|
6983
|
+
const containerStyle = {
|
|
6984
|
+
...(mode === 'viewport' ? { zIndex } : {}),
|
|
6985
|
+
...(maxWidthValue ? {
|
|
6986
|
+
maxWidth: maxWidthValue,
|
|
6987
|
+
marginLeft: 'auto',
|
|
6988
|
+
marginRight: 'auto'
|
|
6989
|
+
} : {}),
|
|
6990
|
+
};
|
|
6991
|
+
return (jsxRuntime.jsx("div", { className: `
|
|
6992
|
+
${getPositionClasses()}
|
|
6993
|
+
${variantStyle.container}
|
|
6994
|
+
border-t rounded-t-lg
|
|
6995
|
+
transition-all duration-300 ease-in-out
|
|
6996
|
+
flex flex-col
|
|
6997
|
+
${className}
|
|
6998
|
+
`, style: containerStyle, children: position === 'bottom' ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [content, header] })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [header, content] })) }));
|
|
6999
|
+
}
|
|
7000
|
+
/**
|
|
7001
|
+
* ExpandablePanelSpacer - Adds spacing to prevent content from being hidden behind the panel
|
|
7002
|
+
* Only needed in viewport mode. In container mode, the panel is part of the flex layout.
|
|
7003
|
+
*
|
|
7004
|
+
* @example
|
|
7005
|
+
* ```tsx
|
|
7006
|
+
* <div>
|
|
7007
|
+
* <MainContent />
|
|
7008
|
+
* <ExpandablePanelSpacer size="md" />
|
|
7009
|
+
* </div>
|
|
7010
|
+
* <ExpandablePanel mode="viewport" position="bottom" size="md" {...props} />
|
|
7011
|
+
* ```
|
|
7012
|
+
*/
|
|
7013
|
+
function ExpandablePanelSpacer({ size = 'md' }) {
|
|
7014
|
+
const heights = {
|
|
7015
|
+
sm: 'h-10',
|
|
7016
|
+
md: 'h-12',
|
|
7017
|
+
lg: 'h-14',
|
|
7018
|
+
};
|
|
7019
|
+
return jsxRuntime.jsx("div", { className: heights[size] });
|
|
7020
|
+
}
|
|
7021
|
+
/**
|
|
7022
|
+
* ExpandablePanelContainer - Wrapper that sets up proper layout for container mode
|
|
7023
|
+
* Use this to wrap your page content when using ExpandablePanel with mode="container"
|
|
7024
|
+
*
|
|
7025
|
+
* This creates a relative container with full height so the panel can position absolutely
|
|
7026
|
+
* at the bottom while the content scrolls above it.
|
|
7027
|
+
*
|
|
7028
|
+
* @example
|
|
7029
|
+
* ```tsx
|
|
7030
|
+
* <Page>
|
|
7031
|
+
* <ExpandablePanelContainer>
|
|
7032
|
+
* <div className="flex-1 overflow-auto p-4">
|
|
7033
|
+
* {pageContent}
|
|
7034
|
+
* </div>
|
|
7035
|
+
* <ExpandablePanel mode="container" {...props}>
|
|
7036
|
+
* {panelContent}
|
|
7037
|
+
* </ExpandablePanel>
|
|
7038
|
+
* </ExpandablePanelContainer>
|
|
7039
|
+
* </Page>
|
|
7040
|
+
* ```
|
|
7041
|
+
*/
|
|
7042
|
+
function ExpandablePanelContainer({ children, className = '', }) {
|
|
7043
|
+
return (jsxRuntime.jsx("div", { className: `relative h-full overflow-hidden ${className}`, children: children }));
|
|
7044
|
+
}
|
|
7045
|
+
|
|
5806
7046
|
// Tailwind breakpoint mappings
|
|
5807
7047
|
// sm: 640px, md: 768px, lg: 1024px, xl: 1280px, 2xl: 1536px
|
|
5808
7048
|
/**
|
|
@@ -5971,26 +7211,6 @@ function Hide({ children, above, below, only, className = '' }) {
|
|
|
5971
7211
|
}
|
|
5972
7212
|
return (jsxRuntime.jsx("div", { className: `${visibilityClasses} ${className}`, children: children }));
|
|
5973
7213
|
}
|
|
5974
|
-
/**
|
|
5975
|
-
* useMediaQuery hook - React hook for responsive logic in components
|
|
5976
|
-
*
|
|
5977
|
-
* @example
|
|
5978
|
-
* const isMobile = useMediaQuery('(max-width: 768px)');
|
|
5979
|
-
* const isDesktop = useMediaQuery('(min-width: 1024px)');
|
|
5980
|
-
*/
|
|
5981
|
-
function useMediaQuery(query) {
|
|
5982
|
-
const [matches, setMatches] = React.useState(false);
|
|
5983
|
-
React.useEffect(() => {
|
|
5984
|
-
const media = window.matchMedia(query);
|
|
5985
|
-
if (media.matches !== matches) {
|
|
5986
|
-
setMatches(media.matches);
|
|
5987
|
-
}
|
|
5988
|
-
const listener = () => setMatches(media.matches);
|
|
5989
|
-
media.addEventListener('change', listener);
|
|
5990
|
-
return () => media.removeEventListener('change', listener);
|
|
5991
|
-
}, [matches, query]);
|
|
5992
|
-
return matches;
|
|
5993
|
-
}
|
|
5994
7214
|
|
|
5995
7215
|
function Breadcrumbs({ items, showHome = true }) {
|
|
5996
7216
|
return (jsxRuntime.jsxs("nav", { "aria-label": "Breadcrumb", className: "flex items-center space-x-2 text-sm", children: [showHome && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(reactRouterDom.Link, { to: "/", className: "text-ink-500 hover:text-ink-900 transition-colors", "aria-label": "Home", children: jsxRuntime.jsx(lucideReact.Home, { className: "h-4 w-4" }) }), items.length > 0 && jsxRuntime.jsx(lucideReact.ChevronRight, { className: "h-4 w-4 text-ink-400" })] })), items.map((item, index) => {
|
|
@@ -6706,9 +7926,9 @@ function SidebarNavItem({ item, onNavigate, level = 0, currentPath }) {
|
|
|
6706
7926
|
// Auto-detect if this item or any child is active based on currentPath
|
|
6707
7927
|
const isItemActive = currentPath && item.href ? currentPath === item.href : item.active;
|
|
6708
7928
|
const isChildActive = hasChildren && currentPath
|
|
6709
|
-
? item.children?.some(child => currentPath === child.href || currentPath
|
|
7929
|
+
? item.children?.some(child => child.href && (currentPath === child.href || currentPath.startsWith(child.href)))
|
|
6710
7930
|
: false;
|
|
6711
|
-
const shouldExpandByDefault = isChildActive || (hasChildren && currentPath?.startsWith(item.href
|
|
7931
|
+
const shouldExpandByDefault = isChildActive || (hasChildren && item.href && currentPath?.startsWith(item.href));
|
|
6712
7932
|
const [isExpanded, setIsExpanded] = React.useState(shouldExpandByDefault);
|
|
6713
7933
|
const handleClick = () => {
|
|
6714
7934
|
if (hasChildren) {
|
|
@@ -6748,15 +7968,692 @@ function SidebarGroup({ title, items, onNavigate, defaultExpanded = true, curren
|
|
|
6748
7968
|
}
|
|
6749
7969
|
return (jsxRuntime.jsxs("div", { children: [jsxRuntime.jsxs("button", { onClick: () => setIsExpanded(!isExpanded), className: "w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-ink-500 uppercase tracking-wider hover:text-ink-700 transition-colors", children: [jsxRuntime.jsx("span", { children: title }), isExpanded ? (jsxRuntime.jsx(lucideReact.ChevronDown, { className: "h-4 w-4" })) : (jsxRuntime.jsx(lucideReact.ChevronRight, { className: "h-4 w-4" }))] }), isExpanded && (jsxRuntime.jsx("div", { className: "mt-1 space-y-1", children: items.map((item) => (jsxRuntime.jsx(SidebarNavItem, { item: item, onNavigate: onNavigate, currentPath: currentPath }, item.id))) }))] }));
|
|
6750
7970
|
}
|
|
6751
|
-
|
|
6752
|
-
|
|
7971
|
+
/**
|
|
7972
|
+
* Sidebar - Navigation sidebar with mobile drawer support
|
|
7973
|
+
*
|
|
7974
|
+
* On desktop: Renders as a fixed-width sidebar
|
|
7975
|
+
* On mobile: Renders as a drawer overlay when mobileOpen is true
|
|
7976
|
+
*
|
|
7977
|
+
* @example Desktop usage (no mobile props)
|
|
7978
|
+
* ```tsx
|
|
7979
|
+
* <Sidebar
|
|
7980
|
+
* items={navItems}
|
|
7981
|
+
* header={<Logo />}
|
|
7982
|
+
* footer={<UserProfile />}
|
|
7983
|
+
* currentPath={location.pathname}
|
|
7984
|
+
* onNavigate={(href) => navigate(href)}
|
|
7985
|
+
* />
|
|
7986
|
+
* ```
|
|
7987
|
+
*
|
|
7988
|
+
* @example With mobile drawer support
|
|
7989
|
+
* ```tsx
|
|
7990
|
+
* const [mobileOpen, setMobileOpen] = useState(false);
|
|
7991
|
+
*
|
|
7992
|
+
* <Sidebar
|
|
7993
|
+
* items={navItems}
|
|
7994
|
+
* header={<Logo />}
|
|
7995
|
+
* mobileOpen={mobileOpen}
|
|
7996
|
+
* onMobileClose={() => setMobileOpen(false)}
|
|
7997
|
+
* onNavigate={(href) => {
|
|
7998
|
+
* navigate(href);
|
|
7999
|
+
* setMobileOpen(false); // Close drawer on navigation
|
|
8000
|
+
* }}
|
|
8001
|
+
* />
|
|
8002
|
+
* ```
|
|
8003
|
+
*/
|
|
8004
|
+
function Sidebar({ items, onNavigate, className = '', header, footer, currentPath, mobileOpen, onMobileClose, width = 'w-64', }) {
|
|
8005
|
+
const sidebarRef = React.useRef(null);
|
|
8006
|
+
const [isAnimating, setIsAnimating] = React.useState(false);
|
|
8007
|
+
const [shouldRender, setShouldRender] = React.useState(mobileOpen);
|
|
8008
|
+
// Handle animation states for mobile drawer
|
|
8009
|
+
React.useEffect(() => {
|
|
8010
|
+
if (mobileOpen) {
|
|
8011
|
+
setShouldRender(true);
|
|
8012
|
+
// Small delay to trigger animation
|
|
8013
|
+
requestAnimationFrame(() => {
|
|
8014
|
+
setIsAnimating(true);
|
|
8015
|
+
});
|
|
8016
|
+
return; // No cleanup needed when opening
|
|
8017
|
+
}
|
|
8018
|
+
else {
|
|
8019
|
+
setIsAnimating(false);
|
|
8020
|
+
// Wait for animation to complete before unmounting
|
|
8021
|
+
const timer = setTimeout(() => {
|
|
8022
|
+
setShouldRender(false);
|
|
8023
|
+
}, 300);
|
|
8024
|
+
return () => clearTimeout(timer);
|
|
8025
|
+
}
|
|
8026
|
+
}, [mobileOpen]);
|
|
8027
|
+
// Handle escape key for mobile drawer
|
|
8028
|
+
React.useEffect(() => {
|
|
8029
|
+
if (!mobileOpen)
|
|
8030
|
+
return;
|
|
8031
|
+
const handleEscape = (e) => {
|
|
8032
|
+
if (e.key === 'Escape') {
|
|
8033
|
+
onMobileClose?.();
|
|
8034
|
+
}
|
|
8035
|
+
};
|
|
8036
|
+
document.addEventListener('keydown', handleEscape);
|
|
8037
|
+
return () => document.removeEventListener('keydown', handleEscape);
|
|
8038
|
+
}, [mobileOpen, onMobileClose]);
|
|
8039
|
+
// Lock body scroll when mobile drawer is open
|
|
8040
|
+
React.useEffect(() => {
|
|
8041
|
+
if (mobileOpen) {
|
|
8042
|
+
document.body.style.overflow = 'hidden';
|
|
8043
|
+
}
|
|
8044
|
+
else {
|
|
8045
|
+
document.body.style.overflow = '';
|
|
8046
|
+
}
|
|
8047
|
+
return () => {
|
|
8048
|
+
document.body.style.overflow = '';
|
|
8049
|
+
};
|
|
8050
|
+
}, [mobileOpen]);
|
|
8051
|
+
// Handle navigation with auto-close on mobile
|
|
8052
|
+
const handleNavigate = (href, external) => {
|
|
8053
|
+
onNavigate?.(href, external);
|
|
8054
|
+
// Auto-close mobile drawer on navigation
|
|
8055
|
+
if (mobileOpen) {
|
|
8056
|
+
onMobileClose?.();
|
|
8057
|
+
}
|
|
8058
|
+
};
|
|
8059
|
+
// Sidebar content (shared between desktop and mobile)
|
|
8060
|
+
const sidebarContent = (jsxRuntime.jsxs("div", { ref: sidebarRef, className: `flex flex-col h-full bg-white border-r border-paper-300 notebook-binding ${width} ${className}`, children: [mobileOpen !== undefined && (jsxRuntime.jsxs("div", { className: "flex items-center justify-between px-4 pt-4 md:hidden", children: [jsxRuntime.jsx("div", { className: "flex-1", children: header }), jsxRuntime.jsx("button", { onClick: onMobileClose, className: "\n flex items-center justify-center\n w-10 h-10 -mr-2\n text-ink-500 hover:text-ink-700\n hover:bg-paper-100 rounded-full\n transition-colors\n ", "aria-label": "Close sidebar", children: jsxRuntime.jsx(lucideReact.X, { className: "w-5 h-5" }) })] })), header && mobileOpen === undefined && (jsxRuntime.jsx("div", { className: "px-6 pt-6 pb-4", children: header })), header && mobileOpen !== undefined && (jsxRuntime.jsx("div", { className: "px-6 pt-2 pb-4 hidden md:block", children: header })), jsxRuntime.jsx("nav", { className: "flex-1 px-3 py-2 space-y-1 overflow-y-auto", children: items.map((item) => {
|
|
6753
8061
|
// Render separator
|
|
6754
8062
|
if (item.separator) {
|
|
6755
8063
|
return jsxRuntime.jsx("div", { className: "my-4 border-t border-paper-300" }, item.id);
|
|
6756
8064
|
}
|
|
6757
8065
|
// Render nav item
|
|
6758
|
-
return (jsxRuntime.jsx(SidebarNavItem, { item: item, onNavigate:
|
|
8066
|
+
return (jsxRuntime.jsx(SidebarNavItem, { item: item, onNavigate: handleNavigate, currentPath: currentPath }, item.id));
|
|
6759
8067
|
}) }), footer && (jsxRuntime.jsx("div", { className: "border-t border-paper-300 pl-2 pr-6 py-4 overflow-visible", children: footer }))] }));
|
|
8068
|
+
// If mobileOpen is not defined, render as regular sidebar (desktop mode)
|
|
8069
|
+
if (mobileOpen === undefined) {
|
|
8070
|
+
return sidebarContent;
|
|
8071
|
+
}
|
|
8072
|
+
// Mobile drawer mode
|
|
8073
|
+
if (!shouldRender) {
|
|
8074
|
+
return null;
|
|
8075
|
+
}
|
|
8076
|
+
return reactDom.createPortal(jsxRuntime.jsxs("div", { className: "fixed inset-0 z-50 md:hidden", role: "dialog", "aria-modal": "true", "aria-label": "Navigation menu", children: [jsxRuntime.jsx("div", { className: `
|
|
8077
|
+
absolute inset-0 bg-ink-900/50 backdrop-blur-sm
|
|
8078
|
+
transition-opacity duration-300
|
|
8079
|
+
${isAnimating ? 'opacity-100' : 'opacity-0'}
|
|
8080
|
+
`, onClick: onMobileClose, "aria-hidden": "true" }), jsxRuntime.jsx("div", { className: `
|
|
8081
|
+
absolute inset-y-0 left-0 flex max-w-full
|
|
8082
|
+
transition-transform duration-300 ease-out
|
|
8083
|
+
${isAnimating ? 'translate-x-0' : '-translate-x-full'}
|
|
8084
|
+
`, children: sidebarContent })] }), document.body);
|
|
8085
|
+
}
|
|
8086
|
+
|
|
8087
|
+
/**
|
|
8088
|
+
* BottomNavigation - Mobile-style bottom tab bar
|
|
8089
|
+
*
|
|
8090
|
+
* iOS/Android-style fixed bottom navigation with icons, labels, and badges.
|
|
8091
|
+
* Handles safe area insets for notched devices automatically.
|
|
8092
|
+
*
|
|
8093
|
+
* Best practices:
|
|
8094
|
+
* - Use 3-5 items maximum
|
|
8095
|
+
* - Keep labels short (1-2 words)
|
|
8096
|
+
* - Use consistent icon style
|
|
8097
|
+
*
|
|
8098
|
+
* @example Basic usage
|
|
8099
|
+
* ```tsx
|
|
8100
|
+
* import { BottomNavigation } from 'notebook-ui';
|
|
8101
|
+
* import { Home, Search, Bell, User } from 'lucide-react';
|
|
8102
|
+
*
|
|
8103
|
+
* const navItems = [
|
|
8104
|
+
* { id: 'home', label: 'Home', icon: <Home />, href: '/' },
|
|
8105
|
+
* { id: 'search', label: 'Search', icon: <Search />, href: '/search' },
|
|
8106
|
+
* { id: 'notifications', label: 'Alerts', icon: <Bell />, badge: 3 },
|
|
8107
|
+
* { id: 'profile', label: 'Profile', icon: <User />, href: '/profile' },
|
|
8108
|
+
* ];
|
|
8109
|
+
*
|
|
8110
|
+
* <BottomNavigation
|
|
8111
|
+
* items={navItems}
|
|
8112
|
+
* activeId="home"
|
|
8113
|
+
* onNavigate={(id, href) => navigate(href)}
|
|
8114
|
+
* />
|
|
8115
|
+
* ```
|
|
8116
|
+
*
|
|
8117
|
+
* @example With onClick handlers
|
|
8118
|
+
* ```tsx
|
|
8119
|
+
* const navItems = [
|
|
8120
|
+
* { id: 'home', label: 'Home', icon: <Home />, onClick: () => setTab('home') },
|
|
8121
|
+
* { id: 'add', label: 'Add', icon: <Plus />, onClick: openAddModal },
|
|
8122
|
+
* ];
|
|
8123
|
+
*
|
|
8124
|
+
* <BottomNavigation items={navItems} activeId={currentTab} />
|
|
8125
|
+
* ```
|
|
8126
|
+
*/
|
|
8127
|
+
function BottomNavigation({ items, activeId, onNavigate, showLabels = true, className = '', safeArea = true, }) {
|
|
8128
|
+
const handleItemClick = (item) => {
|
|
8129
|
+
if (item.disabled)
|
|
8130
|
+
return;
|
|
8131
|
+
if (item.onClick) {
|
|
8132
|
+
item.onClick();
|
|
8133
|
+
}
|
|
8134
|
+
if (onNavigate) {
|
|
8135
|
+
onNavigate(item.id, item.href);
|
|
8136
|
+
}
|
|
8137
|
+
};
|
|
8138
|
+
return (jsxRuntime.jsx("nav", { className: `
|
|
8139
|
+
fixed bottom-0 left-0 right-0 z-40
|
|
8140
|
+
bg-white border-t border-paper-200 shadow-lg
|
|
8141
|
+
${safeArea ? 'pb-[env(safe-area-inset-bottom)]' : ''}
|
|
8142
|
+
${className}
|
|
8143
|
+
`, role: "navigation", "aria-label": "Bottom navigation", children: jsxRuntime.jsx("div", { className: "flex items-center justify-around h-14 max-w-lg mx-auto px-2", children: items.map((item) => {
|
|
8144
|
+
const isActive = item.id === activeId;
|
|
8145
|
+
return (jsxRuntime.jsxs("button", { onClick: () => handleItemClick(item), disabled: item.disabled, className: `
|
|
8146
|
+
relative flex flex-col items-center justify-center
|
|
8147
|
+
flex-1 h-full min-w-touch-sm
|
|
8148
|
+
transition-colors duration-200
|
|
8149
|
+
${item.disabled
|
|
8150
|
+
? 'opacity-40 cursor-not-allowed'
|
|
8151
|
+
: 'active:bg-paper-100'}
|
|
8152
|
+
${isActive
|
|
8153
|
+
? 'text-accent-600'
|
|
8154
|
+
: 'text-ink-500 hover:text-ink-700'}
|
|
8155
|
+
`, "aria-current": isActive ? 'page' : undefined, "aria-label": item.label, children: [jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsx("div", { className: `
|
|
8156
|
+
w-6 h-6 flex items-center justify-center
|
|
8157
|
+
transition-transform duration-200
|
|
8158
|
+
${isActive ? 'scale-110' : 'scale-100'}
|
|
8159
|
+
`, children: React.isValidElement(item.icon)
|
|
8160
|
+
? React.cloneElement(item.icon, {
|
|
8161
|
+
className: 'w-6 h-6',
|
|
8162
|
+
})
|
|
8163
|
+
: item.icon }), item.badge !== undefined && item.badge > 0 && (jsxRuntime.jsx("span", { className: `
|
|
8164
|
+
absolute -top-1 -right-2.5
|
|
8165
|
+
min-w-[18px] h-[18px] px-1
|
|
8166
|
+
flex items-center justify-center
|
|
8167
|
+
text-[10px] font-bold text-white
|
|
8168
|
+
bg-error-500 rounded-full
|
|
8169
|
+
${item.badge > 99 ? 'text-[8px]' : ''}
|
|
8170
|
+
`, children: item.badge > 99 ? '99+' : item.badge }))] }), showLabels && (jsxRuntime.jsx("span", { className: `
|
|
8171
|
+
mt-1 text-[10px] font-medium leading-none
|
|
8172
|
+
transition-opacity duration-200
|
|
8173
|
+
truncate max-w-full px-1
|
|
8174
|
+
${isActive ? 'opacity-100' : 'opacity-70'}
|
|
8175
|
+
`, children: item.label })), isActive && (jsxRuntime.jsx("div", { className: "\n absolute top-0 left-1/2 -translate-x-1/2\n w-8 h-0.5 bg-accent-500 rounded-full\n " }))] }, item.id));
|
|
8176
|
+
}) }) }));
|
|
8177
|
+
}
|
|
8178
|
+
/**
|
|
8179
|
+
* BottomNavigationSpacer - Spacer to prevent content from being hidden behind BottomNavigation
|
|
8180
|
+
*
|
|
8181
|
+
* Place this at the bottom of your scrollable content when using BottomNavigation.
|
|
8182
|
+
*
|
|
8183
|
+
* @example
|
|
8184
|
+
* ```tsx
|
|
8185
|
+
* <div className="flex flex-col h-screen">
|
|
8186
|
+
* <main className="flex-1 overflow-auto">
|
|
8187
|
+
* {/* Your content *\/}
|
|
8188
|
+
* <BottomNavigationSpacer />
|
|
8189
|
+
* </main>
|
|
8190
|
+
* <BottomNavigation items={navItems} />
|
|
8191
|
+
* </div>
|
|
8192
|
+
* ```
|
|
8193
|
+
*/
|
|
8194
|
+
function BottomNavigationSpacer({ safeArea = true }) {
|
|
8195
|
+
return (jsxRuntime.jsx("div", { className: `h-14 ${safeArea ? 'pb-[env(safe-area-inset-bottom)]' : ''}`, "aria-hidden": "true" }));
|
|
8196
|
+
}
|
|
8197
|
+
|
|
8198
|
+
/**
|
|
8199
|
+
* MobileHeader - Mobile app header with navigation controls
|
|
8200
|
+
*
|
|
8201
|
+
* A flexible mobile header component with support for:
|
|
8202
|
+
* - Hamburger menu button (default)
|
|
8203
|
+
* - Back navigation arrow
|
|
8204
|
+
* - Close button (X)
|
|
8205
|
+
* - Custom left/right actions
|
|
8206
|
+
* - Sticky positioning
|
|
8207
|
+
* - Blur/transparent variants
|
|
8208
|
+
*
|
|
8209
|
+
* @example Basic with menu button
|
|
8210
|
+
* ```tsx
|
|
8211
|
+
* <MobileHeader
|
|
8212
|
+
* title="Dashboard"
|
|
8213
|
+
* onMenuClick={() => setDrawerOpen(true)}
|
|
8214
|
+
* />
|
|
8215
|
+
* ```
|
|
8216
|
+
*
|
|
8217
|
+
* @example With back button
|
|
8218
|
+
* ```tsx
|
|
8219
|
+
* <MobileHeader
|
|
8220
|
+
* title="User Details"
|
|
8221
|
+
* subtitle="Profile"
|
|
8222
|
+
* onBackClick={() => navigate(-1)}
|
|
8223
|
+
* />
|
|
8224
|
+
* ```
|
|
8225
|
+
*
|
|
8226
|
+
* @example With right action
|
|
8227
|
+
* ```tsx
|
|
8228
|
+
* <MobileHeader
|
|
8229
|
+
* title="Settings"
|
|
8230
|
+
* onMenuClick={openMenu}
|
|
8231
|
+
* rightAction={
|
|
8232
|
+
* <Button variant="ghost" iconOnly onClick={save}>
|
|
8233
|
+
* <Check className="w-5 h-5" />
|
|
8234
|
+
* </Button>
|
|
8235
|
+
* }
|
|
8236
|
+
* />
|
|
8237
|
+
* ```
|
|
8238
|
+
*
|
|
8239
|
+
* @example Transparent with blur
|
|
8240
|
+
* ```tsx
|
|
8241
|
+
* <MobileHeader
|
|
8242
|
+
* title="Photo Gallery"
|
|
8243
|
+
* variant="blur"
|
|
8244
|
+
* onBackClick={goBack}
|
|
8245
|
+
* />
|
|
8246
|
+
* ```
|
|
8247
|
+
*/
|
|
8248
|
+
function MobileHeader({ title, subtitle, onMenuClick, onBackClick, onCloseClick, rightAction, leftAction, sticky = true, bordered = true, variant = 'solid', className = '', safeArea = true, }) {
|
|
8249
|
+
// Determine which left button to show
|
|
8250
|
+
const renderLeftButton = () => {
|
|
8251
|
+
// Custom left action takes priority
|
|
8252
|
+
if (leftAction) {
|
|
8253
|
+
return leftAction;
|
|
8254
|
+
}
|
|
8255
|
+
// Close button
|
|
8256
|
+
if (onCloseClick) {
|
|
8257
|
+
return (jsxRuntime.jsx("button", { onClick: onCloseClick, className: "\n flex items-center justify-center\n w-10 h-10 -ml-2\n text-ink-600 hover:text-ink-900\n hover:bg-paper-100 rounded-full\n transition-colors duration-200\n active:bg-paper-200\n ", "aria-label": "Close", children: jsxRuntime.jsx(lucideReact.X, { className: "w-6 h-6" }) }));
|
|
8258
|
+
}
|
|
8259
|
+
// Back button
|
|
8260
|
+
if (onBackClick) {
|
|
8261
|
+
return (jsxRuntime.jsx("button", { onClick: onBackClick, className: "\n flex items-center justify-center\n w-10 h-10 -ml-2\n text-ink-600 hover:text-ink-900\n hover:bg-paper-100 rounded-full\n transition-colors duration-200\n active:bg-paper-200\n ", "aria-label": "Go back", children: jsxRuntime.jsx(lucideReact.ChevronLeft, { className: "w-6 h-6" }) }));
|
|
8262
|
+
}
|
|
8263
|
+
// Menu button (default)
|
|
8264
|
+
if (onMenuClick) {
|
|
8265
|
+
return (jsxRuntime.jsx("button", { onClick: onMenuClick, className: "\n flex items-center justify-center\n w-10 h-10 -ml-2\n text-ink-600 hover:text-ink-900\n hover:bg-paper-100 rounded-full\n transition-colors duration-200\n active:bg-paper-200\n ", "aria-label": "Open menu", children: jsxRuntime.jsx(lucideReact.Menu, { className: "w-6 h-6" }) }));
|
|
8266
|
+
}
|
|
8267
|
+
// No left button
|
|
8268
|
+
return jsxRuntime.jsx("div", { className: "w-10 h-10" });
|
|
8269
|
+
};
|
|
8270
|
+
// Background variant styles
|
|
8271
|
+
const variantStyles = {
|
|
8272
|
+
solid: 'bg-white',
|
|
8273
|
+
transparent: 'bg-transparent',
|
|
8274
|
+
blur: 'bg-white/80 backdrop-blur-md',
|
|
8275
|
+
};
|
|
8276
|
+
return (jsxRuntime.jsx("header", { className: `
|
|
8277
|
+
${sticky ? 'sticky top-0 z-30' : ''}
|
|
8278
|
+
${safeArea ? 'pt-[env(safe-area-inset-top)]' : ''}
|
|
8279
|
+
${variantStyles[variant]}
|
|
8280
|
+
${bordered ? 'border-b border-paper-200' : ''}
|
|
8281
|
+
${className}
|
|
8282
|
+
`, children: jsxRuntime.jsxs("div", { className: "flex items-center justify-between h-14 px-4", children: [jsxRuntime.jsxs("div", { className: "flex items-center gap-2 min-w-0 flex-1", children: [renderLeftButton(), jsxRuntime.jsxs("div", { className: "flex flex-col min-w-0 flex-1", children: [subtitle && (jsxRuntime.jsx("span", { className: "text-xs text-ink-500 truncate", children: subtitle })), jsxRuntime.jsx("h1", { className: "text-lg font-semibold text-ink-900 truncate leading-tight", children: title })] })] }), jsxRuntime.jsx("div", { className: "flex items-center gap-1 ml-2 flex-shrink-0", children: rightAction })] }) }));
|
|
8283
|
+
}
|
|
8284
|
+
/**
|
|
8285
|
+
* MobileHeaderSpacer - Spacer to prevent content from being hidden behind sticky MobileHeader
|
|
8286
|
+
*
|
|
8287
|
+
* Place this at the top of your content when NOT using sticky header
|
|
8288
|
+
* to maintain consistent spacing.
|
|
8289
|
+
*
|
|
8290
|
+
* @example
|
|
8291
|
+
* ```tsx
|
|
8292
|
+
* <MobileHeader title="Page" sticky={false} />
|
|
8293
|
+
* <MobileHeaderSpacer />
|
|
8294
|
+
* <main>Content here</main>
|
|
8295
|
+
* ```
|
|
8296
|
+
*/
|
|
8297
|
+
function MobileHeaderSpacer({ safeArea = true }) {
|
|
8298
|
+
return (jsxRuntime.jsx("div", { className: `h-14 ${safeArea ? 'pt-[env(safe-area-inset-top)]' : ''}`, "aria-hidden": "true" }));
|
|
8299
|
+
}
|
|
8300
|
+
|
|
8301
|
+
const positionClasses = {
|
|
8302
|
+
'bottom-right': 'right-4 bottom-4',
|
|
8303
|
+
'bottom-left': 'left-4 bottom-4',
|
|
8304
|
+
'bottom-center': 'left-1/2 -translate-x-1/2 bottom-4',
|
|
8305
|
+
};
|
|
8306
|
+
const variantClasses = {
|
|
8307
|
+
primary: 'bg-accent-600 hover:bg-accent-700 text-white shadow-lg',
|
|
8308
|
+
secondary: 'bg-white hover:bg-paper-50 text-ink-700 shadow-lg border border-paper-200',
|
|
8309
|
+
accent: 'bg-accent-500 hover:bg-accent-600 text-white shadow-lg',
|
|
8310
|
+
};
|
|
8311
|
+
const sizeClasses$1 = {
|
|
8312
|
+
md: 'w-14 h-14',
|
|
8313
|
+
lg: 'w-16 h-16',
|
|
8314
|
+
};
|
|
8315
|
+
const iconSizeClasses = {
|
|
8316
|
+
md: 'h-6 w-6',
|
|
8317
|
+
lg: 'h-7 w-7',
|
|
8318
|
+
};
|
|
8319
|
+
/**
|
|
8320
|
+
* FloatingActionButton - Material Design style FAB for mobile
|
|
8321
|
+
*
|
|
8322
|
+
* A prominent button for the primary action on a screen.
|
|
8323
|
+
* Supports single action or expandable menu with multiple actions.
|
|
8324
|
+
*
|
|
8325
|
+
* @example Simple FAB
|
|
8326
|
+
* ```tsx
|
|
8327
|
+
* <FloatingActionButton
|
|
8328
|
+
* onClick={() => openCreateModal()}
|
|
8329
|
+
* label="Create new item"
|
|
8330
|
+
* />
|
|
8331
|
+
* ```
|
|
8332
|
+
*
|
|
8333
|
+
* @example FAB with action menu
|
|
8334
|
+
* ```tsx
|
|
8335
|
+
* <FloatingActionButton
|
|
8336
|
+
* actions={[
|
|
8337
|
+
* { id: 'photo', icon: <Camera />, label: 'Take Photo', onClick: takePhoto },
|
|
8338
|
+
* { id: 'upload', icon: <Upload />, label: 'Upload File', onClick: uploadFile },
|
|
8339
|
+
* { id: 'note', icon: <FileText />, label: 'Create Note', onClick: createNote },
|
|
8340
|
+
* ]}
|
|
8341
|
+
* />
|
|
8342
|
+
* ```
|
|
8343
|
+
*
|
|
8344
|
+
* @example Extended FAB
|
|
8345
|
+
* ```tsx
|
|
8346
|
+
* <FloatingActionButton
|
|
8347
|
+
* extended
|
|
8348
|
+
* extendedLabel="New Task"
|
|
8349
|
+
* icon={<Plus />}
|
|
8350
|
+
* onClick={createTask}
|
|
8351
|
+
* />
|
|
8352
|
+
* ```
|
|
8353
|
+
*/
|
|
8354
|
+
function FloatingActionButton({ onClick, icon, actions, position = 'bottom-right', variant = 'primary', size = 'md', label = 'Action button', extended = false, extendedLabel, hidden = false, offset, className = '', }) {
|
|
8355
|
+
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
|
|
8356
|
+
const fabRef = React.useRef(null);
|
|
8357
|
+
const hasMenu = actions && actions.length > 0;
|
|
8358
|
+
// Close menu on escape
|
|
8359
|
+
React.useEffect(() => {
|
|
8360
|
+
if (!isMenuOpen)
|
|
8361
|
+
return;
|
|
8362
|
+
const handleEscape = (e) => {
|
|
8363
|
+
if (e.key === 'Escape') {
|
|
8364
|
+
setIsMenuOpen(false);
|
|
8365
|
+
}
|
|
8366
|
+
};
|
|
8367
|
+
document.addEventListener('keydown', handleEscape);
|
|
8368
|
+
return () => document.removeEventListener('keydown', handleEscape);
|
|
8369
|
+
}, [isMenuOpen]);
|
|
8370
|
+
// Close menu on click outside
|
|
8371
|
+
React.useEffect(() => {
|
|
8372
|
+
if (!isMenuOpen)
|
|
8373
|
+
return;
|
|
8374
|
+
const handleClickOutside = (e) => {
|
|
8375
|
+
if (fabRef.current && !fabRef.current.contains(e.target)) {
|
|
8376
|
+
setIsMenuOpen(false);
|
|
8377
|
+
}
|
|
8378
|
+
};
|
|
8379
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
8380
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
8381
|
+
}, [isMenuOpen]);
|
|
8382
|
+
const handleClick = () => {
|
|
8383
|
+
if (hasMenu) {
|
|
8384
|
+
setIsMenuOpen(!isMenuOpen);
|
|
8385
|
+
}
|
|
8386
|
+
else if (onClick) {
|
|
8387
|
+
onClick();
|
|
8388
|
+
}
|
|
8389
|
+
};
|
|
8390
|
+
const handleActionClick = (action) => {
|
|
8391
|
+
if (!action.disabled) {
|
|
8392
|
+
action.onClick();
|
|
8393
|
+
setIsMenuOpen(false);
|
|
8394
|
+
}
|
|
8395
|
+
};
|
|
8396
|
+
// Custom offset styles
|
|
8397
|
+
const offsetStyle = offset ? {
|
|
8398
|
+
...(offset.x !== undefined && position.includes('right') ? { right: `${offset.x}px` } : {}),
|
|
8399
|
+
...(offset.x !== undefined && position.includes('left') ? { left: `${offset.x}px` } : {}),
|
|
8400
|
+
...(offset.y !== undefined ? { bottom: `${offset.y}px` } : {}),
|
|
8401
|
+
} : {};
|
|
8402
|
+
const fabContent = (jsxRuntime.jsxs("div", { className: `
|
|
8403
|
+
fixed z-40 transition-all duration-300
|
|
8404
|
+
${positionClasses[position]}
|
|
8405
|
+
${hidden ? 'translate-y-20 opacity-0 pointer-events-none' : 'translate-y-0 opacity-100'}
|
|
8406
|
+
${className}
|
|
8407
|
+
`, style: {
|
|
8408
|
+
...offsetStyle,
|
|
8409
|
+
paddingBottom: 'env(safe-area-inset-bottom)',
|
|
8410
|
+
}, children: [hasMenu && isMenuOpen && (jsxRuntime.jsx("div", { className: "absolute bottom-full mb-3 flex flex-col-reverse gap-3 items-center", children: actions.map((action, index) => (jsxRuntime.jsxs("div", { className: "flex items-center gap-3 animate-fade-in", style: { animationDelay: `${index * 50}ms` }, children: [jsxRuntime.jsx("span", { className: "bg-ink-900/80 text-white text-sm px-3 py-1.5 rounded-lg whitespace-nowrap", children: action.label }), jsxRuntime.jsx("button", { onClick: () => handleActionClick(action), disabled: action.disabled, className: `
|
|
8411
|
+
w-12 h-12 rounded-full flex items-center justify-center
|
|
8412
|
+
transition-all duration-200
|
|
8413
|
+
${action.disabled
|
|
8414
|
+
? 'bg-paper-200 text-ink-400 cursor-not-allowed'
|
|
8415
|
+
: 'bg-white text-ink-700 shadow-lg hover:bg-paper-50 active:scale-95'}
|
|
8416
|
+
`, "aria-label": action.label, children: action.icon })] }, action.id))) })), hasMenu && isMenuOpen && (jsxRuntime.jsx("div", { className: "fixed inset-0 bg-black/20 -z-10 animate-fade-in", onClick: () => setIsMenuOpen(false) })), jsxRuntime.jsxs("button", { ref: fabRef, onClick: handleClick, className: `
|
|
8417
|
+
${extended ? 'px-6 rounded-full' : 'rounded-full'}
|
|
8418
|
+
${extended ? 'h-14' : sizeClasses$1[size]}
|
|
8419
|
+
${variantClasses[variant]}
|
|
8420
|
+
flex items-center justify-center gap-2
|
|
8421
|
+
transition-all duration-200
|
|
8422
|
+
active:scale-95
|
|
8423
|
+
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-400
|
|
8424
|
+
`, "aria-label": label, "aria-expanded": hasMenu ? isMenuOpen : undefined, "aria-haspopup": hasMenu ? 'menu' : undefined, children: [hasMenu && isMenuOpen ? (jsxRuntime.jsx(lucideReact.X, { className: iconSizeClasses[size] })) : (icon || jsxRuntime.jsx(lucideReact.Plus, { className: iconSizeClasses[size] })), extended && extendedLabel && (jsxRuntime.jsx("span", { className: "font-medium", children: extendedLabel }))] })] }));
|
|
8425
|
+
// Render via portal to ensure proper stacking
|
|
8426
|
+
return reactDom.createPortal(fabContent, document.body);
|
|
8427
|
+
}
|
|
8428
|
+
/**
|
|
8429
|
+
* Hook for scroll-based FAB visibility
|
|
8430
|
+
*
|
|
8431
|
+
* @example
|
|
8432
|
+
* ```tsx
|
|
8433
|
+
* const { hidden, scrollDirection } = useFABScroll();
|
|
8434
|
+
* <FloatingActionButton hidden={hidden} />
|
|
8435
|
+
* ```
|
|
8436
|
+
*/
|
|
8437
|
+
function useFABScroll(threshold = 10) {
|
|
8438
|
+
const [hidden, setHidden] = React.useState(false);
|
|
8439
|
+
const [scrollDirection, setScrollDirection] = React.useState(null);
|
|
8440
|
+
const lastScrollY = React.useRef(0);
|
|
8441
|
+
React.useEffect(() => {
|
|
8442
|
+
const handleScroll = () => {
|
|
8443
|
+
const currentScrollY = window.scrollY;
|
|
8444
|
+
const diff = currentScrollY - lastScrollY.current;
|
|
8445
|
+
if (Math.abs(diff) > threshold) {
|
|
8446
|
+
if (diff > 0) {
|
|
8447
|
+
setHidden(true);
|
|
8448
|
+
setScrollDirection('down');
|
|
8449
|
+
}
|
|
8450
|
+
else {
|
|
8451
|
+
setHidden(false);
|
|
8452
|
+
setScrollDirection('up');
|
|
8453
|
+
}
|
|
8454
|
+
lastScrollY.current = currentScrollY;
|
|
8455
|
+
}
|
|
8456
|
+
};
|
|
8457
|
+
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
8458
|
+
return () => window.removeEventListener('scroll', handleScroll);
|
|
8459
|
+
}, [threshold]);
|
|
8460
|
+
return { hidden, scrollDirection };
|
|
8461
|
+
}
|
|
8462
|
+
|
|
8463
|
+
/**
|
|
8464
|
+
* PullToRefresh - Mobile pull-to-refresh gesture handler
|
|
8465
|
+
*
|
|
8466
|
+
* Wraps content and provides native-feeling pull-to-refresh functionality.
|
|
8467
|
+
* Only activates when scrolled to top of content.
|
|
8468
|
+
*
|
|
8469
|
+
* @example Basic usage
|
|
8470
|
+
* ```tsx
|
|
8471
|
+
* <PullToRefresh onRefresh={async () => {
|
|
8472
|
+
* await fetchLatestData();
|
|
8473
|
+
* }}>
|
|
8474
|
+
* <div className="min-h-screen">
|
|
8475
|
+
* {content}
|
|
8476
|
+
* </div>
|
|
8477
|
+
* </PullToRefresh>
|
|
8478
|
+
* ```
|
|
8479
|
+
*
|
|
8480
|
+
* @example With custom threshold
|
|
8481
|
+
* ```tsx
|
|
8482
|
+
* <PullToRefresh
|
|
8483
|
+
* onRefresh={handleRefresh}
|
|
8484
|
+
* pullThreshold={100}
|
|
8485
|
+
* maxPull={150}
|
|
8486
|
+
* >
|
|
8487
|
+
* {content}
|
|
8488
|
+
* </PullToRefresh>
|
|
8489
|
+
* ```
|
|
8490
|
+
*/
|
|
8491
|
+
function PullToRefresh({ children, onRefresh, disabled = false, pullThreshold = 80, maxPull = 120, loadingIndicator, pullIndicator, className = '', }) {
|
|
8492
|
+
const [state, setState] = React.useState('idle');
|
|
8493
|
+
const [pullDistance, setPullDistance] = React.useState(0);
|
|
8494
|
+
const containerRef = React.useRef(null);
|
|
8495
|
+
const startY = React.useRef(0);
|
|
8496
|
+
const currentY = React.useRef(0);
|
|
8497
|
+
// Check if at top of scroll container
|
|
8498
|
+
const isAtTop = React.useCallback(() => {
|
|
8499
|
+
const container = containerRef.current;
|
|
8500
|
+
if (!container)
|
|
8501
|
+
return false;
|
|
8502
|
+
return container.scrollTop <= 0;
|
|
8503
|
+
}, []);
|
|
8504
|
+
// Handle touch start
|
|
8505
|
+
const handleTouchStart = React.useCallback((e) => {
|
|
8506
|
+
if (disabled || state === 'refreshing' || !isAtTop())
|
|
8507
|
+
return;
|
|
8508
|
+
startY.current = e.touches[0].clientY;
|
|
8509
|
+
currentY.current = startY.current;
|
|
8510
|
+
}, [disabled, state, isAtTop]);
|
|
8511
|
+
// Handle touch move
|
|
8512
|
+
const handleTouchMove = React.useCallback((e) => {
|
|
8513
|
+
if (disabled || state === 'refreshing')
|
|
8514
|
+
return;
|
|
8515
|
+
if (startY.current === 0)
|
|
8516
|
+
return;
|
|
8517
|
+
currentY.current = e.touches[0].clientY;
|
|
8518
|
+
const diff = currentY.current - startY.current;
|
|
8519
|
+
// Only allow pulling down when at top
|
|
8520
|
+
if (diff > 0 && isAtTop()) {
|
|
8521
|
+
// Apply resistance - pull slows down as distance increases
|
|
8522
|
+
const resistance = 0.5;
|
|
8523
|
+
const adjustedPull = Math.min(diff * resistance, maxPull);
|
|
8524
|
+
setPullDistance(adjustedPull);
|
|
8525
|
+
setState(adjustedPull >= pullThreshold ? 'ready' : 'pulling');
|
|
8526
|
+
// Prevent default scroll when pulling
|
|
8527
|
+
if (adjustedPull > 0) {
|
|
8528
|
+
e.preventDefault();
|
|
8529
|
+
}
|
|
8530
|
+
}
|
|
8531
|
+
}, [disabled, state, isAtTop, maxPull, pullThreshold]);
|
|
8532
|
+
// Handle touch end
|
|
8533
|
+
const handleTouchEnd = React.useCallback(async () => {
|
|
8534
|
+
if (disabled || state === 'refreshing')
|
|
8535
|
+
return;
|
|
8536
|
+
if (state === 'ready') {
|
|
8537
|
+
setState('refreshing');
|
|
8538
|
+
setPullDistance(pullThreshold); // Hold at threshold while refreshing
|
|
8539
|
+
try {
|
|
8540
|
+
await onRefresh();
|
|
8541
|
+
}
|
|
8542
|
+
catch (error) {
|
|
8543
|
+
console.error('Refresh failed:', error);
|
|
8544
|
+
}
|
|
8545
|
+
setState('idle');
|
|
8546
|
+
}
|
|
8547
|
+
setPullDistance(0);
|
|
8548
|
+
startY.current = 0;
|
|
8549
|
+
currentY.current = 0;
|
|
8550
|
+
}, [disabled, state, pullThreshold, onRefresh]);
|
|
8551
|
+
// Attach touch listeners
|
|
8552
|
+
React.useEffect(() => {
|
|
8553
|
+
const container = containerRef.current;
|
|
8554
|
+
if (!container)
|
|
8555
|
+
return;
|
|
8556
|
+
container.addEventListener('touchstart', handleTouchStart, { passive: true });
|
|
8557
|
+
container.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
8558
|
+
container.addEventListener('touchend', handleTouchEnd);
|
|
8559
|
+
return () => {
|
|
8560
|
+
container.removeEventListener('touchstart', handleTouchStart);
|
|
8561
|
+
container.removeEventListener('touchmove', handleTouchMove);
|
|
8562
|
+
container.removeEventListener('touchend', handleTouchEnd);
|
|
8563
|
+
};
|
|
8564
|
+
}, [handleTouchStart, handleTouchMove, handleTouchEnd]);
|
|
8565
|
+
// Calculate indicator opacity and rotation
|
|
8566
|
+
const progress = Math.min(pullDistance / pullThreshold, 1);
|
|
8567
|
+
const rotation = progress * 180;
|
|
8568
|
+
// Default loading indicator
|
|
8569
|
+
const defaultLoadingIndicator = (jsxRuntime.jsx(lucideReact.Loader2, { className: "h-6 w-6 text-accent-600 animate-spin" }));
|
|
8570
|
+
// Default pull indicator
|
|
8571
|
+
const defaultPullIndicator = (jsxRuntime.jsx("div", { className: `
|
|
8572
|
+
transition-transform duration-200
|
|
8573
|
+
${state === 'ready' ? 'text-accent-600' : 'text-ink-400'}
|
|
8574
|
+
`, style: { transform: `rotate(${rotation}deg)` }, children: jsxRuntime.jsx(lucideReact.ArrowDown, { className: "h-6 w-6" }) }));
|
|
8575
|
+
return (jsxRuntime.jsxs("div", { ref: containerRef, className: `relative overflow-auto ${className}`, style: { touchAction: pullDistance > 0 ? 'none' : 'auto' }, children: [jsxRuntime.jsx("div", { className: `
|
|
8576
|
+
absolute left-0 right-0 flex items-center justify-center
|
|
8577
|
+
transition-all duration-200 overflow-hidden
|
|
8578
|
+
${state === 'idle' && pullDistance === 0 ? 'opacity-0' : 'opacity-100'}
|
|
8579
|
+
`, style: {
|
|
8580
|
+
height: `${pullDistance}px`,
|
|
8581
|
+
top: 0,
|
|
8582
|
+
zIndex: 10,
|
|
8583
|
+
}, children: jsxRuntime.jsx("div", { className: `
|
|
8584
|
+
w-10 h-10 rounded-full bg-white shadow-md
|
|
8585
|
+
flex items-center justify-center
|
|
8586
|
+
transition-transform duration-200
|
|
8587
|
+
${state === 'refreshing' ? 'scale-100' : progress < 0.3 ? 'scale-75' : 'scale-100'}
|
|
8588
|
+
`, children: state === 'refreshing'
|
|
8589
|
+
? (loadingIndicator || defaultLoadingIndicator)
|
|
8590
|
+
: (pullIndicator || defaultPullIndicator) }) }), jsxRuntime.jsx("div", { className: "transition-transform duration-200", style: {
|
|
8591
|
+
transform: `translateY(${pullDistance}px)`,
|
|
8592
|
+
}, children: children })] }));
|
|
8593
|
+
}
|
|
8594
|
+
/**
|
|
8595
|
+
* usePullToRefresh - Hook for custom pull-to-refresh implementations
|
|
8596
|
+
*
|
|
8597
|
+
* @example
|
|
8598
|
+
* ```tsx
|
|
8599
|
+
* const { pullDistance, isRefreshing, bind } = usePullToRefresh({
|
|
8600
|
+
* onRefresh: async () => {
|
|
8601
|
+
* await fetchData();
|
|
8602
|
+
* }
|
|
8603
|
+
* });
|
|
8604
|
+
*
|
|
8605
|
+
* return (
|
|
8606
|
+
* <div {...bind}>
|
|
8607
|
+
* {isRefreshing && <Spinner />}
|
|
8608
|
+
* {content}
|
|
8609
|
+
* </div>
|
|
8610
|
+
* );
|
|
8611
|
+
* ```
|
|
8612
|
+
*/
|
|
8613
|
+
function usePullToRefresh({ onRefresh, pullThreshold = 80, maxPull = 120, disabled = false, }) {
|
|
8614
|
+
const [pullDistance, setPullDistance] = React.useState(0);
|
|
8615
|
+
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
|
8616
|
+
const startY = React.useRef(0);
|
|
8617
|
+
const handleTouchStart = React.useCallback((e) => {
|
|
8618
|
+
if (disabled || isRefreshing)
|
|
8619
|
+
return;
|
|
8620
|
+
startY.current = e.touches[0].clientY;
|
|
8621
|
+
}, [disabled, isRefreshing]);
|
|
8622
|
+
const handleTouchMove = React.useCallback((e) => {
|
|
8623
|
+
if (disabled || isRefreshing || startY.current === 0)
|
|
8624
|
+
return;
|
|
8625
|
+
const diff = e.touches[0].clientY - startY.current;
|
|
8626
|
+
if (diff > 0) {
|
|
8627
|
+
const adjustedPull = Math.min(diff * 0.5, maxPull);
|
|
8628
|
+
setPullDistance(adjustedPull);
|
|
8629
|
+
}
|
|
8630
|
+
}, [disabled, isRefreshing, maxPull]);
|
|
8631
|
+
const handleTouchEnd = React.useCallback(async () => {
|
|
8632
|
+
if (disabled || isRefreshing)
|
|
8633
|
+
return;
|
|
8634
|
+
if (pullDistance >= pullThreshold) {
|
|
8635
|
+
setIsRefreshing(true);
|
|
8636
|
+
try {
|
|
8637
|
+
await onRefresh();
|
|
8638
|
+
}
|
|
8639
|
+
finally {
|
|
8640
|
+
setIsRefreshing(false);
|
|
8641
|
+
}
|
|
8642
|
+
}
|
|
8643
|
+
setPullDistance(0);
|
|
8644
|
+
startY.current = 0;
|
|
8645
|
+
}, [disabled, isRefreshing, pullDistance, pullThreshold, onRefresh]);
|
|
8646
|
+
return {
|
|
8647
|
+
pullDistance,
|
|
8648
|
+
isRefreshing,
|
|
8649
|
+
isReady: pullDistance >= pullThreshold,
|
|
8650
|
+
progress: Math.min(pullDistance / pullThreshold, 1),
|
|
8651
|
+
bind: {
|
|
8652
|
+
onTouchStart: handleTouchStart,
|
|
8653
|
+
onTouchMove: handleTouchMove,
|
|
8654
|
+
onTouchEnd: handleTouchEnd,
|
|
8655
|
+
},
|
|
8656
|
+
};
|
|
6760
8657
|
}
|
|
6761
8658
|
|
|
6762
8659
|
function Logo({ size = 'md', showText = true, text = 'Commora', className = '', }) {
|
|
@@ -6872,6 +8769,125 @@ const Layout = ({ sidebar, children, statusBar, className = '', sections }) => {
|
|
|
6872
8769
|
return (jsxRuntime.jsxs("div", { className: `h-screen flex flex-col bg-paper-100 ${className}`, children: [jsxRuntime.jsxs("div", { className: "flex flex-1 overflow-hidden relative", children: [sidebar, jsxRuntime.jsx("div", { className: "w-8 h-full bg-paper-100 flex-shrink-0 relative flex items-center justify-center", children: jsxRuntime.jsx(PageNavigation, { sections: sections }) }), jsxRuntime.jsx("div", { className: "flex-1 overflow-auto", children: children })] }), statusBar] }));
|
|
6873
8770
|
};
|
|
6874
8771
|
|
|
8772
|
+
/**
|
|
8773
|
+
* MobileLayout - Auto-responsive layout that switches between desktop and mobile patterns
|
|
8774
|
+
*
|
|
8775
|
+
* This component automatically detects the viewport size and renders the appropriate layout:
|
|
8776
|
+
* - **Desktop** (≥1024px): Standard Layout with sidebar, gutter, and scrollable content
|
|
8777
|
+
* - **Mobile/Tablet** (<1024px): Mobile header, drawer navigation, bottom tab bar
|
|
8778
|
+
*
|
|
8779
|
+
* The mobile layout features:
|
|
8780
|
+
* - Sticky header with hamburger menu to open drawer
|
|
8781
|
+
* - Sidebar rendered as a slide-in drawer
|
|
8782
|
+
* - Bottom navigation bar for primary navigation
|
|
8783
|
+
* - Safe area support for notched devices
|
|
8784
|
+
*
|
|
8785
|
+
* @example Basic usage
|
|
8786
|
+
* ```tsx
|
|
8787
|
+
* <MobileLayout
|
|
8788
|
+
* sidebarItems={[
|
|
8789
|
+
* { id: 'home', label: 'Home', icon: <Home />, href: '/' },
|
|
8790
|
+
* { id: 'tasks', label: 'Tasks', icon: <CheckSquare />, href: '/tasks' },
|
|
8791
|
+
* { id: 'settings', label: 'Settings', icon: <Settings />, href: '/settings' }
|
|
8792
|
+
* ]}
|
|
8793
|
+
* currentPath={location.pathname}
|
|
8794
|
+
* onNavigate={(href) => navigate(href)}
|
|
8795
|
+
* title="My App"
|
|
8796
|
+
* header={<Logo />}
|
|
8797
|
+
* userProfile={<UserProfileButton user={user} />}
|
|
8798
|
+
* >
|
|
8799
|
+
* <Page>
|
|
8800
|
+
* <h1>Dashboard</h1>
|
|
8801
|
+
* </Page>
|
|
8802
|
+
* </MobileLayout>
|
|
8803
|
+
* ```
|
|
8804
|
+
*
|
|
8805
|
+
* @example With custom bottom nav items
|
|
8806
|
+
* ```tsx
|
|
8807
|
+
* <MobileLayout
|
|
8808
|
+
* sidebarItems={fullNavItems}
|
|
8809
|
+
* bottomNavItems={[
|
|
8810
|
+
* { id: 'home', label: 'Home', icon: <Home />, href: '/' },
|
|
8811
|
+
* { id: 'search', label: 'Search', icon: <Search />, href: '/search' },
|
|
8812
|
+
* { id: 'profile', label: 'Profile', icon: <User />, href: '/profile' }
|
|
8813
|
+
* ]}
|
|
8814
|
+
* currentPath={location.pathname}
|
|
8815
|
+
* title="My App"
|
|
8816
|
+
* >
|
|
8817
|
+
* {children}
|
|
8818
|
+
* </MobileLayout>
|
|
8819
|
+
* ```
|
|
8820
|
+
*
|
|
8821
|
+
* @example Force mobile layout for testing
|
|
8822
|
+
* ```tsx
|
|
8823
|
+
* <MobileLayout
|
|
8824
|
+
* sidebarItems={items}
|
|
8825
|
+
* title="Mobile Preview"
|
|
8826
|
+
* forceMobile
|
|
8827
|
+
* >
|
|
8828
|
+
* {children}
|
|
8829
|
+
* </MobileLayout>
|
|
8830
|
+
* ```
|
|
8831
|
+
*/
|
|
8832
|
+
const MobileLayout = ({ children, sidebarItems, currentPath, onNavigate, header, userProfile, sidebarFooter, title, subtitle, headerRightAction, headerLeftAction, headerVariant = 'solid', bottomNavItems, activeBottomNavId, showBottomNavLabels = true, statusBar, className = '', sections, forceMobile = false, forceDesktop = false, hideBottomNav = false, hideMobileHeader = false, safeArea = true, }) => {
|
|
8833
|
+
const isMobileViewport = useIsMobile();
|
|
8834
|
+
const isTabletViewport = useIsTablet();
|
|
8835
|
+
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
|
8836
|
+
// Determine if we should use mobile layout
|
|
8837
|
+
const useMobileLayout = forceDesktop
|
|
8838
|
+
? false
|
|
8839
|
+
: forceMobile || isMobileViewport || isTabletViewport;
|
|
8840
|
+
// Open/close drawer
|
|
8841
|
+
const openDrawer = React.useCallback(() => setDrawerOpen(true), []);
|
|
8842
|
+
const closeDrawer = React.useCallback(() => setDrawerOpen(false), []);
|
|
8843
|
+
// Handle navigation from drawer - close drawer after navigation
|
|
8844
|
+
const handleDrawerNavigate = React.useCallback((href) => {
|
|
8845
|
+
closeDrawer();
|
|
8846
|
+
onNavigate?.(href);
|
|
8847
|
+
}, [closeDrawer, onNavigate]);
|
|
8848
|
+
// Handle bottom nav navigation - matches BottomNavigation's onNavigate signature
|
|
8849
|
+
const handleBottomNavNavigate = React.useCallback((id, href) => {
|
|
8850
|
+
if (href) {
|
|
8851
|
+
onNavigate?.(href);
|
|
8852
|
+
}
|
|
8853
|
+
// Also check if there's a custom onClick in the bottom nav items
|
|
8854
|
+
const item = bottomNavItems?.find(i => i.id === id);
|
|
8855
|
+
item?.onClick?.();
|
|
8856
|
+
}, [onNavigate, bottomNavItems]);
|
|
8857
|
+
// Convert sidebar items to bottom nav items if not provided
|
|
8858
|
+
const effectiveBottomNavItems = bottomNavItems || sidebarItems
|
|
8859
|
+
.filter(item => !item.children && item.href) // Only top-level items with href
|
|
8860
|
+
.slice(0, 5) // Max 5 items for bottom nav
|
|
8861
|
+
.map(item => ({
|
|
8862
|
+
id: item.id,
|
|
8863
|
+
label: item.label,
|
|
8864
|
+
icon: item.icon,
|
|
8865
|
+
href: item.href,
|
|
8866
|
+
badge: typeof item.badge === 'number' ? item.badge : undefined,
|
|
8867
|
+
}));
|
|
8868
|
+
// Determine active bottom nav ID
|
|
8869
|
+
const effectiveActiveBottomNavId = activeBottomNavId ||
|
|
8870
|
+
effectiveBottomNavItems.find(item => item.href === currentPath)?.id;
|
|
8871
|
+
// Close drawer on escape key
|
|
8872
|
+
React.useEffect(() => {
|
|
8873
|
+
if (!drawerOpen)
|
|
8874
|
+
return;
|
|
8875
|
+
const handleEscape = (e) => {
|
|
8876
|
+
if (e.key === 'Escape') {
|
|
8877
|
+
closeDrawer();
|
|
8878
|
+
}
|
|
8879
|
+
};
|
|
8880
|
+
window.addEventListener('keydown', handleEscape);
|
|
8881
|
+
return () => window.removeEventListener('keydown', handleEscape);
|
|
8882
|
+
}, [drawerOpen, closeDrawer]);
|
|
8883
|
+
// Desktop Layout
|
|
8884
|
+
if (!useMobileLayout) {
|
|
8885
|
+
return (jsxRuntime.jsxs("div", { className: `h-screen flex flex-col bg-paper-100 ${className}`, children: [jsxRuntime.jsxs("div", { className: "flex flex-1 overflow-hidden relative", children: [jsxRuntime.jsx(Sidebar, { items: sidebarItems, currentPath: currentPath, onNavigate: onNavigate, header: header, footer: jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [userProfile, sidebarFooter] }) }), jsxRuntime.jsx("div", { className: "w-8 h-full bg-paper-100 flex-shrink-0 relative flex items-center justify-center", children: jsxRuntime.jsx(PageNavigation, { sections: sections }) }), jsxRuntime.jsx("div", { className: "flex-1 overflow-auto", children: children })] }), statusBar] }));
|
|
8886
|
+
}
|
|
8887
|
+
// Mobile Layout
|
|
8888
|
+
return (jsxRuntime.jsxs("div", { className: `min-h-screen flex flex-col bg-paper-100 ${className}`, children: [!hideMobileHeader && (jsxRuntime.jsx(MobileHeader, { title: title, subtitle: subtitle, onMenuClick: headerLeftAction ? undefined : openDrawer, leftAction: headerLeftAction, rightAction: headerRightAction, variant: headerVariant, sticky: true, bordered: true, safeArea: safeArea })), jsxRuntime.jsx(Sidebar, { items: sidebarItems, currentPath: currentPath, onNavigate: handleDrawerNavigate, header: header, footer: jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [userProfile, sidebarFooter] }), mobileOpen: drawerOpen, onMobileClose: closeDrawer }), jsxRuntime.jsx("div", { className: "flex-1 overflow-auto", children: children }), !hideBottomNav && effectiveBottomNavItems.length > 0 && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(BottomNavigationSpacer, {}), jsxRuntime.jsx(BottomNavigation, { items: effectiveBottomNavItems, activeId: effectiveActiveBottomNavId, onNavigate: handleBottomNavNavigate, showLabels: showBottomNavLabels, safeArea: safeArea })] }))] }));
|
|
8889
|
+
};
|
|
8890
|
+
|
|
6875
8891
|
/**
|
|
6876
8892
|
* Two-column content layout component that provides:
|
|
6877
8893
|
* - Sidebar column on the left (takes 1/3 of width)
|
|
@@ -7125,6 +9141,185 @@ function NotificationIndicator({ count = 0, onClick, className = '', maxCount =
|
|
|
7125
9141
|
return (jsxRuntime.jsxs("button", { onClick: onClick, className: `relative bg-white p-2.5 rounded-lg text-ink-400 hover:text-ink-600 hover:bg-paper-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-400 transition-all shadow-xs dark:bg-slate-800 dark:text-slate-400 dark:hover:text-slate-100 dark:hover:bg-slate-700 ${className}`, "aria-label": "View notifications", children: [jsxRuntime.jsx(lucideReact.Bell, { className: "h-5 w-5" }), showBadge && (jsxRuntime.jsx("span", { className: `absolute -top-1 -right-1 ${variantClasses[variant]} text-white text-xs font-semibold rounded-full h-5 min-w-5 px-1 flex items-center justify-center`, children: displayCount }))] }));
|
|
7126
9142
|
}
|
|
7127
9143
|
|
|
9144
|
+
/**
|
|
9145
|
+
* Get value from item by key path (supports nested keys like 'user.name')
|
|
9146
|
+
*/
|
|
9147
|
+
function getValueByKey(item, key) {
|
|
9148
|
+
if (typeof key !== 'string') {
|
|
9149
|
+
return item[key];
|
|
9150
|
+
}
|
|
9151
|
+
const keys = key.split('.');
|
|
9152
|
+
let value = item;
|
|
9153
|
+
for (const k of keys) {
|
|
9154
|
+
if (value == null)
|
|
9155
|
+
return undefined;
|
|
9156
|
+
value = value[k];
|
|
9157
|
+
}
|
|
9158
|
+
return value;
|
|
9159
|
+
}
|
|
9160
|
+
/**
|
|
9161
|
+
* Skeleton card for loading state
|
|
9162
|
+
*/
|
|
9163
|
+
function SkeletonCard({ className = '' }) {
|
|
9164
|
+
return (jsxRuntime.jsx("div", { className: `bg-white rounded-lg border border-paper-200 p-4 animate-pulse ${className}`, children: jsxRuntime.jsxs("div", { className: "flex items-start gap-3", children: [jsxRuntime.jsx("div", { className: "w-10 h-10 rounded-full bg-paper-200 flex-shrink-0" }), jsxRuntime.jsxs("div", { className: "flex-1 min-w-0", children: [jsxRuntime.jsx("div", { className: "h-5 bg-paper-200 rounded w-3/4 mb-2" }), jsxRuntime.jsx("div", { className: "h-4 bg-paper-100 rounded w-1/2 mb-3" }), jsxRuntime.jsxs("div", { className: "space-y-2", children: [jsxRuntime.jsx("div", { className: "h-3 bg-paper-100 rounded w-2/3" }), jsxRuntime.jsx("div", { className: "h-3 bg-paper-100 rounded w-1/2" })] })] }), jsxRuntime.jsx("div", { className: "h-6 w-16 bg-paper-200 rounded-full" })] }) }));
|
|
9165
|
+
}
|
|
9166
|
+
/**
|
|
9167
|
+
* DataTableCardView - Mobile-friendly card view for data tables
|
|
9168
|
+
*
|
|
9169
|
+
* Renders data as cards instead of table rows, optimized for touch interaction.
|
|
9170
|
+
* Automatically uses column render functions for consistent data display.
|
|
9171
|
+
*
|
|
9172
|
+
* @example Basic usage
|
|
9173
|
+
* ```tsx
|
|
9174
|
+
* <DataTableCardView
|
|
9175
|
+
* data={users}
|
|
9176
|
+
* columns={columns}
|
|
9177
|
+
* cardConfig={{
|
|
9178
|
+
* titleKey: 'name',
|
|
9179
|
+
* subtitleKey: 'email',
|
|
9180
|
+
* metadataKeys: ['department', 'role'],
|
|
9181
|
+
* badgeKey: 'status',
|
|
9182
|
+
* }}
|
|
9183
|
+
* onCardClick={(user) => navigate(`/users/${user.id}`)}
|
|
9184
|
+
* />
|
|
9185
|
+
* ```
|
|
9186
|
+
*
|
|
9187
|
+
* @example With selection
|
|
9188
|
+
* ```tsx
|
|
9189
|
+
* <DataTableCardView
|
|
9190
|
+
* data={orders}
|
|
9191
|
+
* columns={columns}
|
|
9192
|
+
* cardConfig={{
|
|
9193
|
+
* titleKey: 'orderNumber',
|
|
9194
|
+
* subtitleKey: 'customer',
|
|
9195
|
+
* badgeKey: 'status',
|
|
9196
|
+
* }}
|
|
9197
|
+
* selectable
|
|
9198
|
+
* selectedRows={selectedOrders}
|
|
9199
|
+
* onSelectionChange={setSelectedOrders}
|
|
9200
|
+
* />
|
|
9201
|
+
* ```
|
|
9202
|
+
*/
|
|
9203
|
+
function DataTableCardView({ data, columns, cardConfig, loading = false, loadingRows = 5, emptyMessage = 'No items to display', onCardClick, onCardLongPress, selectable = false, selectedRows = new Set(), onSelectionChange, keyExtractor = (row) => String(row.id), actions, onEdit, onDelete, className = '', cardClassName = '', gap = 'md', }) {
|
|
9204
|
+
const gapClasses = {
|
|
9205
|
+
sm: 'gap-2',
|
|
9206
|
+
md: 'gap-3',
|
|
9207
|
+
lg: 'gap-4',
|
|
9208
|
+
};
|
|
9209
|
+
// Find column by key to use its render function
|
|
9210
|
+
const getColumn = (key) => {
|
|
9211
|
+
return columns.find(col => col.key === key);
|
|
9212
|
+
};
|
|
9213
|
+
// Render a value using column's render function if available
|
|
9214
|
+
const renderValue = (item, key) => {
|
|
9215
|
+
const column = getColumn(key);
|
|
9216
|
+
const value = getValueByKey(item, key);
|
|
9217
|
+
if (column?.render) {
|
|
9218
|
+
return column.render(item, value);
|
|
9219
|
+
}
|
|
9220
|
+
if (value == null)
|
|
9221
|
+
return '-';
|
|
9222
|
+
if (typeof value === 'boolean')
|
|
9223
|
+
return value ? 'Yes' : 'No';
|
|
9224
|
+
if (value instanceof Date)
|
|
9225
|
+
return value.toLocaleDateString();
|
|
9226
|
+
return String(value);
|
|
9227
|
+
};
|
|
9228
|
+
// Handle card selection toggle
|
|
9229
|
+
const handleSelectionToggle = (item, event) => {
|
|
9230
|
+
event.stopPropagation();
|
|
9231
|
+
const key = keyExtractor(item);
|
|
9232
|
+
const newSelected = new Set(selectedRows);
|
|
9233
|
+
if (newSelected.has(key)) {
|
|
9234
|
+
newSelected.delete(key);
|
|
9235
|
+
}
|
|
9236
|
+
else {
|
|
9237
|
+
newSelected.add(key);
|
|
9238
|
+
}
|
|
9239
|
+
onSelectionChange?.(Array.from(newSelected));
|
|
9240
|
+
};
|
|
9241
|
+
// Handle card click
|
|
9242
|
+
const handleCardClick = (item) => {
|
|
9243
|
+
if (selectable && selectedRows.size > 0) {
|
|
9244
|
+
// If in selection mode, toggle selection instead
|
|
9245
|
+
const key = keyExtractor(item);
|
|
9246
|
+
const newSelected = new Set(selectedRows);
|
|
9247
|
+
if (newSelected.has(key)) {
|
|
9248
|
+
newSelected.delete(key);
|
|
9249
|
+
}
|
|
9250
|
+
else {
|
|
9251
|
+
newSelected.add(key);
|
|
9252
|
+
}
|
|
9253
|
+
onSelectionChange?.(Array.from(newSelected));
|
|
9254
|
+
}
|
|
9255
|
+
else {
|
|
9256
|
+
onCardClick?.(item);
|
|
9257
|
+
}
|
|
9258
|
+
};
|
|
9259
|
+
// Handle long press for context actions
|
|
9260
|
+
const handleLongPress = (item, event) => {
|
|
9261
|
+
onCardLongPress?.(item, event);
|
|
9262
|
+
};
|
|
9263
|
+
// Loading state
|
|
9264
|
+
if (loading) {
|
|
9265
|
+
return (jsxRuntime.jsx("div", { className: `flex flex-col ${gapClasses[gap]} ${className}`, children: Array.from({ length: loadingRows }).map((_, i) => (jsxRuntime.jsx(SkeletonCard, { className: cardClassName }, i))) }));
|
|
9266
|
+
}
|
|
9267
|
+
// Empty state
|
|
9268
|
+
if (data.length === 0) {
|
|
9269
|
+
return (jsxRuntime.jsx("div", { className: `flex items-center justify-center py-12 px-4 ${className}`, children: jsxRuntime.jsx("p", { className: "text-ink-500 text-center", children: emptyMessage }) }));
|
|
9270
|
+
}
|
|
9271
|
+
// Determine default card config if not provided
|
|
9272
|
+
const config = cardConfig || {
|
|
9273
|
+
titleKey: columns[0]?.key || 'id',
|
|
9274
|
+
subtitleKey: columns[1]?.key,
|
|
9275
|
+
metadataKeys: columns.slice(2, 5).map(c => c.key),
|
|
9276
|
+
};
|
|
9277
|
+
return (jsxRuntime.jsx("div", { className: `flex flex-col ${gapClasses[gap]} ${className}`, children: data.map((item) => {
|
|
9278
|
+
const key = keyExtractor(item);
|
|
9279
|
+
const isSelected = selectedRows.has(key);
|
|
9280
|
+
// Custom card render
|
|
9281
|
+
if (config.renderCard) {
|
|
9282
|
+
return (jsxRuntime.jsx("div", { onClick: () => handleCardClick(item), onContextMenu: (e) => {
|
|
9283
|
+
e.preventDefault();
|
|
9284
|
+
handleLongPress(item, e);
|
|
9285
|
+
}, className: `
|
|
9286
|
+
cursor-pointer transition-all duration-200
|
|
9287
|
+
${isSelected ? 'ring-2 ring-accent-500' : ''}
|
|
9288
|
+
${cardClassName}
|
|
9289
|
+
`, children: config.renderCard(item, columns) }, key));
|
|
9290
|
+
}
|
|
9291
|
+
// Default card layout
|
|
9292
|
+
const titleColumn = getColumn(config.titleKey);
|
|
9293
|
+
const titleValue = getValueByKey(item, config.titleKey);
|
|
9294
|
+
return (jsxRuntime.jsx("div", { onClick: () => handleCardClick(item), onContextMenu: (e) => {
|
|
9295
|
+
e.preventDefault();
|
|
9296
|
+
handleLongPress(item, e);
|
|
9297
|
+
}, className: `
|
|
9298
|
+
bg-white rounded-lg border border-paper-200 p-4
|
|
9299
|
+
transition-all duration-200 cursor-pointer
|
|
9300
|
+
active:scale-[0.98] active:bg-paper-50
|
|
9301
|
+
${isSelected ? 'ring-2 ring-accent-500 bg-accent-50/30' : 'hover:shadow-md hover:border-paper-300'}
|
|
9302
|
+
${cardClassName}
|
|
9303
|
+
`, children: jsxRuntime.jsxs("div", { className: "flex items-start gap-3", children: [selectable && (jsxRuntime.jsx("div", { className: "flex-shrink-0 pt-0.5", onClick: (e) => handleSelectionToggle(item, e), children: jsxRuntime.jsx(Checkbox, { checked: isSelected, onChange: () => { } }) })), config.avatarKey && (jsxRuntime.jsx("div", { className: "flex-shrink-0", children: (() => {
|
|
9304
|
+
const avatarValue = getValueByKey(item, config.avatarKey);
|
|
9305
|
+
if (typeof avatarValue === 'string' && avatarValue.startsWith('http')) {
|
|
9306
|
+
return (jsxRuntime.jsx("img", { src: avatarValue, alt: "", className: "w-10 h-10 rounded-full object-cover" }));
|
|
9307
|
+
}
|
|
9308
|
+
// Render initials or placeholder
|
|
9309
|
+
const initials = String(titleValue || '').slice(0, 2).toUpperCase();
|
|
9310
|
+
return (jsxRuntime.jsx("div", { className: "w-10 h-10 rounded-full bg-accent-100 flex items-center justify-center text-accent-700 font-medium text-sm", children: initials }));
|
|
9311
|
+
})() })), jsxRuntime.jsxs("div", { className: "flex-1 min-w-0", children: [jsxRuntime.jsx("div", { className: "font-medium text-ink-900 truncate", children: titleColumn?.render
|
|
9312
|
+
? titleColumn.render(item, titleValue)
|
|
9313
|
+
: String(titleValue || '-') }), config.subtitleKey && (jsxRuntime.jsx("div", { className: "text-sm text-ink-500 truncate mt-0.5", children: renderValue(item, config.subtitleKey) })), config.metadataKeys && config.metadataKeys.length > 0 && (jsxRuntime.jsx("div", { className: "mt-2 space-y-1", children: config.metadataKeys.map((metaKey) => {
|
|
9314
|
+
const column = getColumn(metaKey);
|
|
9315
|
+
return (jsxRuntime.jsxs("div", { className: "flex items-center text-xs", children: [jsxRuntime.jsxs("span", { className: "text-ink-400 w-20 flex-shrink-0 truncate", children: [column?.header || String(metaKey), ":"] }), jsxRuntime.jsx("span", { className: "text-ink-600 truncate", children: renderValue(item, metaKey) })] }, String(metaKey)));
|
|
9316
|
+
}) }))] }), jsxRuntime.jsxs("div", { className: "flex flex-col items-end gap-2 flex-shrink-0", children: [config.badgeKey && (jsxRuntime.jsx("div", { children: renderValue(item, config.badgeKey) })), config.showChevron && onCardClick && (jsxRuntime.jsx(lucideReact.ChevronRight, { className: "h-5 w-5 text-ink-300" })), (actions || onEdit || onDelete) && (jsxRuntime.jsx("button", { onClick: (e) => {
|
|
9317
|
+
e.stopPropagation();
|
|
9318
|
+
handleLongPress(item, e);
|
|
9319
|
+
}, className: "p-1 rounded hover:bg-paper-100 text-ink-400 hover:text-ink-600 -mr-1", children: jsxRuntime.jsx(lucideReact.MoreVertical, { className: "h-5 w-5" }) }))] })] }) }, key));
|
|
9320
|
+
}) }));
|
|
9321
|
+
}
|
|
9322
|
+
|
|
7128
9323
|
/**
|
|
7129
9324
|
* ActionMenu - Inline dropdown menu for row actions
|
|
7130
9325
|
*/
|
|
@@ -7283,7 +9478,11 @@ function DataTable({ data, columns, loading = false, error = null, emptyMessage
|
|
|
7283
9478
|
// Visual customization props
|
|
7284
9479
|
striped = false, stripedColor, density = 'normal', rowClassName, rowHighlight, highlightedRowId, bordered = false, borderColor = 'border-paper-200', disableHover = false, hiddenColumns = [], headerClassName = '', renderEmptyState: customRenderEmptyState, resizable = false, onColumnResize, reorderable = false, onColumnReorder, virtualized = false, virtualHeight = '600px', virtualRowHeight = 60,
|
|
7285
9480
|
// Pagination props
|
|
7286
|
-
paginated = false, currentPage = 1, pageSize = 10, totalItems, onPageChange, pageSizeOptions = [10, 25, 50, 100], onPageSizeChange, showPageSizeSelector = true,
|
|
9481
|
+
paginated = false, currentPage = 1, pageSize = 10, totalItems, onPageChange, pageSizeOptions = [10, 25, 50, 100], onPageSizeChange, showPageSizeSelector = true,
|
|
9482
|
+
// Mobile view props
|
|
9483
|
+
mobileView = 'auto', cardConfig, cardGap = 'md', cardClassName, }) {
|
|
9484
|
+
// Mobile detection for auto mode
|
|
9485
|
+
const isMobileViewport = useIsMobile();
|
|
7287
9486
|
// Column resizing state
|
|
7288
9487
|
const [columnWidths, setColumnWidths] = React.useState({});
|
|
7289
9488
|
const [resizingColumn, setResizingColumn] = React.useState(null);
|
|
@@ -7902,8 +10101,274 @@ paginated = false, currentPage = 1, pageSize = 10, totalItems, onPageChange, pag
|
|
|
7902
10101
|
return null;
|
|
7903
10102
|
return (jsxRuntime.jsxs("div", { className: "flex items-center justify-between mb-4 px-1", children: [jsxRuntime.jsxs("div", { className: "flex items-center gap-4", children: [showPageSizeSelector && onPageSizeChange && (jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [jsxRuntime.jsx("span", { className: "text-sm text-ink-600", children: "Show:" }), jsxRuntime.jsx(Select, { options: pageSizeSelectOptions, value: String(pageSize), onChange: (value) => onPageSizeChange?.(Number(value)) })] })), jsxRuntime.jsx("span", { className: "text-sm text-ink-600", children: effectiveTotalItems > 0 ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: ["Showing ", ((currentPage - 1) * pageSize) + 1, " - ", Math.min(currentPage * pageSize, effectiveTotalItems), " of ", effectiveTotalItems] })) : ('No items') })] }), totalPages > 1 && onPageChange && (jsxRuntime.jsx(Pagination, { currentPage: currentPage, totalPages: totalPages, onPageChange: onPageChange }))] }));
|
|
7904
10103
|
};
|
|
10104
|
+
// Determine if we should show card view
|
|
10105
|
+
const shouldShowCardView = mobileView === 'card' ||
|
|
10106
|
+
(mobileView === 'auto' && isMobileViewport);
|
|
10107
|
+
// Card view content
|
|
10108
|
+
const cardViewContent = shouldShowCardView ? (jsxRuntime.jsx(DataTableCardView, { data: data, columns: visibleColumns, cardConfig: cardConfig, loading: loading, loadingRows: loadingRows, emptyMessage: emptyMessage, onCardClick: onRowClick, onCardLongPress: (item, event) => {
|
|
10109
|
+
if (enableContextMenu && allActions.length > 0) {
|
|
10110
|
+
event.preventDefault();
|
|
10111
|
+
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
|
|
10112
|
+
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
|
|
10113
|
+
setContextMenuState({
|
|
10114
|
+
isOpen: true,
|
|
10115
|
+
position: { x: clientX, y: clientY },
|
|
10116
|
+
item,
|
|
10117
|
+
});
|
|
10118
|
+
}
|
|
10119
|
+
}, selectable: selectable, selectedRows: selectedRowsSet, onSelectionChange: onRowSelect ? (rows) => onRowSelect(rows) : undefined, keyExtractor: getRowKey, actions: allActions, onEdit: onEdit, onDelete: onDelete, className: className, cardClassName: cardClassName, gap: cardGap })) : null;
|
|
7905
10120
|
// Render with context menu
|
|
7906
|
-
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [renderPaginationControls(), finalContent, contextMenuState.isOpen && contextMenuState.item && (jsxRuntime.jsx(Menu, { items: convertActionsToMenuItems(contextMenuState.item), position: contextMenuState.position, onClose: () => setContextMenuState({ isOpen: false, position: { x: 0, y: 0 }, item: null }) }))] }));
|
|
10121
|
+
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [renderPaginationControls(), shouldShowCardView ? cardViewContent : finalContent, contextMenuState.isOpen && contextMenuState.item && (jsxRuntime.jsx(Menu, { items: convertActionsToMenuItems(contextMenuState.item), position: contextMenuState.position, onClose: () => setContextMenuState({ isOpen: false, position: { x: 0, y: 0 }, item: null }) }))] }));
|
|
10122
|
+
}
|
|
10123
|
+
|
|
10124
|
+
// Color mapping for action buttons
|
|
10125
|
+
const colorClasses = {
|
|
10126
|
+
primary: 'bg-accent-500 text-white',
|
|
10127
|
+
success: 'bg-success-500 text-white',
|
|
10128
|
+
warning: 'bg-warning-500 text-white',
|
|
10129
|
+
error: 'bg-error-500 text-white',
|
|
10130
|
+
default: 'bg-paper-500 text-white',
|
|
10131
|
+
};
|
|
10132
|
+
/**
|
|
10133
|
+
* SwipeActions - Touch-based swipe actions for list items
|
|
10134
|
+
*
|
|
10135
|
+
* Wraps any content with swipe-to-reveal actions, commonly used in mobile
|
|
10136
|
+
* list items for quick actions like delete, archive, edit, etc.
|
|
10137
|
+
*
|
|
10138
|
+
* Features:
|
|
10139
|
+
* - Left and right swipe actions
|
|
10140
|
+
* - Full swipe to trigger primary action
|
|
10141
|
+
* - Spring-back animation
|
|
10142
|
+
* - Touch and mouse support
|
|
10143
|
+
* - Customizable thresholds
|
|
10144
|
+
*
|
|
10145
|
+
* @example Basic delete action
|
|
10146
|
+
* ```tsx
|
|
10147
|
+
* <SwipeActions
|
|
10148
|
+
* leftActions={[
|
|
10149
|
+
* {
|
|
10150
|
+
* id: 'delete',
|
|
10151
|
+
* label: 'Delete',
|
|
10152
|
+
* icon: <Trash className="h-5 w-5" />,
|
|
10153
|
+
* color: 'error',
|
|
10154
|
+
* onClick: () => handleDelete(item),
|
|
10155
|
+
* primary: true,
|
|
10156
|
+
* },
|
|
10157
|
+
* ]}
|
|
10158
|
+
* >
|
|
10159
|
+
* <div className="p-4 bg-white">
|
|
10160
|
+
* List item content
|
|
10161
|
+
* </div>
|
|
10162
|
+
* </SwipeActions>
|
|
10163
|
+
* ```
|
|
10164
|
+
*
|
|
10165
|
+
* @example Multiple actions on both sides
|
|
10166
|
+
* ```tsx
|
|
10167
|
+
* <SwipeActions
|
|
10168
|
+
* leftActions={[
|
|
10169
|
+
* { id: 'delete', label: 'Delete', icon: <Trash />, color: 'error', onClick: handleDelete },
|
|
10170
|
+
* { id: 'archive', label: 'Archive', icon: <Archive />, color: 'warning', onClick: handleArchive },
|
|
10171
|
+
* ]}
|
|
10172
|
+
* rightActions={[
|
|
10173
|
+
* { id: 'edit', label: 'Edit', icon: <Edit />, color: 'primary', onClick: handleEdit },
|
|
10174
|
+
* ]}
|
|
10175
|
+
* fullSwipe
|
|
10176
|
+
* >
|
|
10177
|
+
* <ListItem />
|
|
10178
|
+
* </SwipeActions>
|
|
10179
|
+
* ```
|
|
10180
|
+
*/
|
|
10181
|
+
function SwipeActions({ children, leftActions = [], rightActions = [], threshold = 80, fullSwipeThreshold = 0.5, fullSwipe = false, disabled = false, onSwipeChange, className = '', }) {
|
|
10182
|
+
const containerRef = React.useRef(null);
|
|
10183
|
+
const contentRef = React.useRef(null);
|
|
10184
|
+
// Swipe state
|
|
10185
|
+
const [translateX, setTranslateX] = React.useState(0);
|
|
10186
|
+
const [isDragging, setIsDragging] = React.useState(false);
|
|
10187
|
+
const [activeDirection, setActiveDirection] = React.useState(null);
|
|
10188
|
+
// Touch/mouse tracking
|
|
10189
|
+
const startX = React.useRef(0);
|
|
10190
|
+
const currentX = React.useRef(0);
|
|
10191
|
+
const startTime = React.useRef(0);
|
|
10192
|
+
// Calculate action widths
|
|
10193
|
+
const leftActionsWidth = leftActions.length * 72; // 72px per action
|
|
10194
|
+
const rightActionsWidth = rightActions.length * 72;
|
|
10195
|
+
// Reset position
|
|
10196
|
+
const resetPosition = React.useCallback(() => {
|
|
10197
|
+
setTranslateX(0);
|
|
10198
|
+
setActiveDirection(null);
|
|
10199
|
+
onSwipeChange?.(null);
|
|
10200
|
+
}, [onSwipeChange]);
|
|
10201
|
+
// Handle touch/mouse start
|
|
10202
|
+
const handleStart = React.useCallback((clientX) => {
|
|
10203
|
+
if (disabled)
|
|
10204
|
+
return;
|
|
10205
|
+
startX.current = clientX;
|
|
10206
|
+
currentX.current = clientX;
|
|
10207
|
+
startTime.current = Date.now();
|
|
10208
|
+
setIsDragging(true);
|
|
10209
|
+
}, [disabled]);
|
|
10210
|
+
// Handle touch/mouse move
|
|
10211
|
+
const handleMove = React.useCallback((clientX) => {
|
|
10212
|
+
if (!isDragging || disabled)
|
|
10213
|
+
return;
|
|
10214
|
+
const deltaX = clientX - startX.current;
|
|
10215
|
+
currentX.current = clientX;
|
|
10216
|
+
// Determine direction and apply resistance at boundaries
|
|
10217
|
+
let newTranslateX = deltaX;
|
|
10218
|
+
// Swiping left (reveals left actions on right side)
|
|
10219
|
+
if (deltaX < 0) {
|
|
10220
|
+
if (leftActions.length === 0) {
|
|
10221
|
+
newTranslateX = deltaX * 0.2; // Heavy resistance if no actions
|
|
10222
|
+
}
|
|
10223
|
+
else {
|
|
10224
|
+
const maxSwipe = fullSwipe
|
|
10225
|
+
? -(containerRef.current?.offsetWidth || 300)
|
|
10226
|
+
: -leftActionsWidth;
|
|
10227
|
+
newTranslateX = Math.max(maxSwipe, deltaX);
|
|
10228
|
+
// Apply resistance past the action buttons
|
|
10229
|
+
if (newTranslateX < -leftActionsWidth) {
|
|
10230
|
+
const overSwipe = newTranslateX + leftActionsWidth;
|
|
10231
|
+
newTranslateX = -leftActionsWidth + overSwipe * 0.3;
|
|
10232
|
+
}
|
|
10233
|
+
}
|
|
10234
|
+
setActiveDirection('left');
|
|
10235
|
+
onSwipeChange?.('left');
|
|
10236
|
+
}
|
|
10237
|
+
// Swiping right (reveals right actions on left side)
|
|
10238
|
+
else if (deltaX > 0) {
|
|
10239
|
+
if (rightActions.length === 0) {
|
|
10240
|
+
newTranslateX = deltaX * 0.2; // Heavy resistance if no actions
|
|
10241
|
+
}
|
|
10242
|
+
else {
|
|
10243
|
+
const maxSwipe = fullSwipe
|
|
10244
|
+
? (containerRef.current?.offsetWidth || 300)
|
|
10245
|
+
: rightActionsWidth;
|
|
10246
|
+
newTranslateX = Math.min(maxSwipe, deltaX);
|
|
10247
|
+
// Apply resistance past the action buttons
|
|
10248
|
+
if (newTranslateX > rightActionsWidth) {
|
|
10249
|
+
const overSwipe = newTranslateX - rightActionsWidth;
|
|
10250
|
+
newTranslateX = rightActionsWidth + overSwipe * 0.3;
|
|
10251
|
+
}
|
|
10252
|
+
}
|
|
10253
|
+
setActiveDirection('right');
|
|
10254
|
+
onSwipeChange?.('right');
|
|
10255
|
+
}
|
|
10256
|
+
setTranslateX(newTranslateX);
|
|
10257
|
+
}, [isDragging, disabled, leftActions.length, rightActions.length, leftActionsWidth, rightActionsWidth, fullSwipe, onSwipeChange]);
|
|
10258
|
+
// Handle touch/mouse end
|
|
10259
|
+
const handleEnd = React.useCallback(() => {
|
|
10260
|
+
if (!isDragging)
|
|
10261
|
+
return;
|
|
10262
|
+
setIsDragging(false);
|
|
10263
|
+
const deltaX = currentX.current - startX.current;
|
|
10264
|
+
const velocity = Math.abs(deltaX) / (Date.now() - startTime.current);
|
|
10265
|
+
const containerWidth = containerRef.current?.offsetWidth || 300;
|
|
10266
|
+
// Check for full swipe trigger
|
|
10267
|
+
if (fullSwipe) {
|
|
10268
|
+
const swipePercentage = Math.abs(translateX) / containerWidth;
|
|
10269
|
+
if (swipePercentage >= fullSwipeThreshold || velocity > 0.5) {
|
|
10270
|
+
// Find primary action and trigger it
|
|
10271
|
+
if (translateX < 0 && leftActions.length > 0) {
|
|
10272
|
+
const primaryAction = leftActions.find(a => a.primary) || leftActions[0];
|
|
10273
|
+
primaryAction.onClick();
|
|
10274
|
+
resetPosition();
|
|
10275
|
+
return;
|
|
10276
|
+
}
|
|
10277
|
+
else if (translateX > 0 && rightActions.length > 0) {
|
|
10278
|
+
const primaryAction = rightActions.find(a => a.primary) || rightActions[0];
|
|
10279
|
+
primaryAction.onClick();
|
|
10280
|
+
resetPosition();
|
|
10281
|
+
return;
|
|
10282
|
+
}
|
|
10283
|
+
}
|
|
10284
|
+
}
|
|
10285
|
+
// Snap to open or closed position
|
|
10286
|
+
if (Math.abs(translateX) >= threshold || velocity > 0.3) {
|
|
10287
|
+
// Snap open
|
|
10288
|
+
if (translateX < 0 && leftActions.length > 0) {
|
|
10289
|
+
setTranslateX(-leftActionsWidth);
|
|
10290
|
+
setActiveDirection('left');
|
|
10291
|
+
onSwipeChange?.('left');
|
|
10292
|
+
}
|
|
10293
|
+
else if (translateX > 0 && rightActions.length > 0) {
|
|
10294
|
+
setTranslateX(rightActionsWidth);
|
|
10295
|
+
setActiveDirection('right');
|
|
10296
|
+
onSwipeChange?.('right');
|
|
10297
|
+
}
|
|
10298
|
+
else {
|
|
10299
|
+
resetPosition();
|
|
10300
|
+
}
|
|
10301
|
+
}
|
|
10302
|
+
else {
|
|
10303
|
+
// Snap closed
|
|
10304
|
+
resetPosition();
|
|
10305
|
+
}
|
|
10306
|
+
}, [isDragging, translateX, threshold, fullSwipe, fullSwipeThreshold, leftActions, rightActions, leftActionsWidth, rightActionsWidth, resetPosition, onSwipeChange]);
|
|
10307
|
+
// Touch event handlers
|
|
10308
|
+
const handleTouchStart = (e) => {
|
|
10309
|
+
handleStart(e.touches[0].clientX);
|
|
10310
|
+
};
|
|
10311
|
+
const handleTouchMove = (e) => {
|
|
10312
|
+
handleMove(e.touches[0].clientX);
|
|
10313
|
+
};
|
|
10314
|
+
const handleTouchEnd = () => {
|
|
10315
|
+
handleEnd();
|
|
10316
|
+
};
|
|
10317
|
+
// Mouse event handlers (for testing/desktop)
|
|
10318
|
+
const handleMouseDown = (e) => {
|
|
10319
|
+
handleStart(e.clientX);
|
|
10320
|
+
};
|
|
10321
|
+
const handleMouseMove = (e) => {
|
|
10322
|
+
handleMove(e.clientX);
|
|
10323
|
+
};
|
|
10324
|
+
const handleMouseUp = () => {
|
|
10325
|
+
handleEnd();
|
|
10326
|
+
};
|
|
10327
|
+
// Close on outside click
|
|
10328
|
+
React.useEffect(() => {
|
|
10329
|
+
if (activeDirection === null)
|
|
10330
|
+
return;
|
|
10331
|
+
const handleClickOutside = (e) => {
|
|
10332
|
+
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
10333
|
+
resetPosition();
|
|
10334
|
+
}
|
|
10335
|
+
};
|
|
10336
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
10337
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
10338
|
+
}, [activeDirection, resetPosition]);
|
|
10339
|
+
// Handle mouse leave during drag
|
|
10340
|
+
React.useEffect(() => {
|
|
10341
|
+
if (!isDragging)
|
|
10342
|
+
return;
|
|
10343
|
+
const handleMouseLeave = () => {
|
|
10344
|
+
handleEnd();
|
|
10345
|
+
};
|
|
10346
|
+
document.addEventListener('mouseup', handleMouseLeave);
|
|
10347
|
+
return () => document.removeEventListener('mouseup', handleMouseLeave);
|
|
10348
|
+
}, [isDragging, handleEnd]);
|
|
10349
|
+
// Render action button
|
|
10350
|
+
const renderActionButton = (action) => {
|
|
10351
|
+
const colorClass = colorClasses[action.color || 'default'];
|
|
10352
|
+
return (jsxRuntime.jsxs("button", { onClick: (e) => {
|
|
10353
|
+
e.stopPropagation();
|
|
10354
|
+
action.onClick();
|
|
10355
|
+
resetPosition();
|
|
10356
|
+
}, className: `
|
|
10357
|
+
flex flex-col items-center justify-center
|
|
10358
|
+
w-18 h-full min-w-[72px]
|
|
10359
|
+
${colorClass}
|
|
10360
|
+
transition-transform duration-150
|
|
10361
|
+
`, style: {
|
|
10362
|
+
transform: isDragging ? 'scale(1)' : 'scale(1)',
|
|
10363
|
+
}, children: [action.icon && (jsxRuntime.jsx("div", { className: "mb-1", children: action.icon })), jsxRuntime.jsx("span", { className: "text-xs font-medium", children: action.label })] }, action.id));
|
|
10364
|
+
};
|
|
10365
|
+
return (jsxRuntime.jsxs("div", { ref: containerRef, className: `relative overflow-hidden ${className}`, onTouchStart: handleTouchStart, onTouchMove: handleTouchMove, onTouchEnd: handleTouchEnd, onMouseDown: handleMouseDown, onMouseMove: isDragging ? handleMouseMove : undefined, onMouseUp: handleMouseUp, onMouseLeave: isDragging ? handleEnd : undefined, children: [rightActions.length > 0 && (jsxRuntime.jsx("div", { className: "absolute left-0 top-0 bottom-0 flex", style: { width: rightActionsWidth }, children: rightActions.map((action) => renderActionButton(action)) })), leftActions.length > 0 && (jsxRuntime.jsx("div", { className: "absolute right-0 top-0 bottom-0 flex", style: { width: leftActionsWidth }, children: leftActions.map((action) => renderActionButton(action)) })), jsxRuntime.jsx("div", { ref: contentRef, className: `
|
|
10366
|
+
relative bg-white
|
|
10367
|
+
${isDragging ? '' : 'transition-transform duration-200 ease-out'}
|
|
10368
|
+
`, style: {
|
|
10369
|
+
transform: `translateX(${translateX}px)`,
|
|
10370
|
+
touchAction: 'pan-y', // Allow vertical scrolling
|
|
10371
|
+
}, children: children })] }));
|
|
7907
10372
|
}
|
|
7908
10373
|
|
|
7909
10374
|
var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
|
|
@@ -51391,6 +53856,171 @@ function PageLayout({ title, description, children, className = '', headerConten
|
|
|
51391
53856
|
return (jsxRuntime.jsxs(Page, { padding: "none", maxWidth: maxWidth, fixed: fixed, children: [headerContent, jsxRuntime.jsxs("div", { className: `${paddingClasses} ${maxWidthClasses[maxWidth]} mx-auto ${className}`, children: [jsxRuntime.jsxs("div", { className: "mb-8", children: [jsxRuntime.jsx("h1", { className: "text-3xl font-bold text-ink-900 mb-2", children: title }), description && (jsxRuntime.jsx("p", { className: "text-ink-600", children: description }))] }), children] })] }));
|
|
51392
53857
|
}
|
|
51393
53858
|
|
|
53859
|
+
/**
|
|
53860
|
+
* PageHeader - Standard page header with title, breadcrumbs, and actions
|
|
53861
|
+
*
|
|
53862
|
+
* A consistent header component for pages that provides:
|
|
53863
|
+
* - Page title and optional subtitle
|
|
53864
|
+
* - Breadcrumb navigation
|
|
53865
|
+
* - Action buttons (Create, Export, etc.)
|
|
53866
|
+
* - Optional back button
|
|
53867
|
+
* - Sticky positioning option
|
|
53868
|
+
*
|
|
53869
|
+
* @example Basic usage
|
|
53870
|
+
* ```tsx
|
|
53871
|
+
* <PageHeader
|
|
53872
|
+
* title="Products"
|
|
53873
|
+
* subtitle="Manage your product catalog"
|
|
53874
|
+
* breadcrumbs={[{ label: 'Inventory' }, { label: 'Products' }]}
|
|
53875
|
+
* actions={[
|
|
53876
|
+
* { id: 'export', label: 'Export', icon: <Download />, onClick: handleExport, variant: 'ghost' },
|
|
53877
|
+
* { id: 'add', label: 'Add Product', icon: <Plus />, onClick: handleAdd, variant: 'primary' },
|
|
53878
|
+
* ]}
|
|
53879
|
+
* />
|
|
53880
|
+
* ```
|
|
53881
|
+
*
|
|
53882
|
+
* @example With back button
|
|
53883
|
+
* ```tsx
|
|
53884
|
+
* <PageHeader
|
|
53885
|
+
* title="Edit Product"
|
|
53886
|
+
* backButton={{ label: 'Back to Products', onClick: () => navigate('/products') }}
|
|
53887
|
+
* />
|
|
53888
|
+
* ```
|
|
53889
|
+
*
|
|
53890
|
+
* @example With custom right content
|
|
53891
|
+
* ```tsx
|
|
53892
|
+
* <PageHeader
|
|
53893
|
+
* title="Dashboard"
|
|
53894
|
+
* rightContent={<DateRangePicker value={range} onChange={setRange} />}
|
|
53895
|
+
* />
|
|
53896
|
+
* ```
|
|
53897
|
+
*/
|
|
53898
|
+
function PageHeader({ title, subtitle, breadcrumbs, showHomeBreadcrumb = true, actions, rightContent, belowTitle, className = '', sticky = false, backButton, }) {
|
|
53899
|
+
const variantStyles = {
|
|
53900
|
+
primary: 'bg-accent-500 text-white border-accent-500 hover:bg-accent-600 hover:shadow-sm',
|
|
53901
|
+
secondary: 'bg-white text-ink-700 border-paper-300 hover:bg-paper-50 hover:border-paper-400 shadow-xs hover:shadow-sm',
|
|
53902
|
+
ghost: 'bg-transparent text-ink-600 border-transparent hover:text-ink-800 hover:bg-paper-100',
|
|
53903
|
+
danger: 'bg-error-500 text-white border-error-500 hover:bg-error-600 hover:shadow-sm',
|
|
53904
|
+
outline: 'bg-transparent text-ink-700 border-paper-300 hover:bg-paper-50 hover:border-ink-400',
|
|
53905
|
+
};
|
|
53906
|
+
return (jsxRuntime.jsx("div", { className: `
|
|
53907
|
+
bg-white border-b border-paper-200
|
|
53908
|
+
${sticky ? 'sticky top-0 z-40' : ''}
|
|
53909
|
+
${className}
|
|
53910
|
+
`, children: jsxRuntime.jsxs("div", { className: "px-6 py-4", children: [breadcrumbs && breadcrumbs.length > 0 && (jsxRuntime.jsx("div", { className: "mb-3", children: jsxRuntime.jsx(Breadcrumbs, { items: breadcrumbs, showHome: showHomeBreadcrumb }) })), backButton && (jsxRuntime.jsx("div", { className: "mb-3", children: jsxRuntime.jsxs("button", { onClick: backButton.onClick, className: "inline-flex items-center gap-1.5 text-sm text-ink-500 hover:text-ink-700 transition-colors", children: [jsxRuntime.jsx("svg", { className: "h-4 w-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: jsxRuntime.jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 19l-7-7 7-7" }) }), jsxRuntime.jsx("span", { children: backButton.label || 'Back' })] }) })), jsxRuntime.jsxs("div", { className: "flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4", children: [jsxRuntime.jsxs("div", { className: "min-w-0 flex-1", children: [jsxRuntime.jsx("h1", { className: "text-2xl font-bold text-ink-900 truncate", children: title }), subtitle && (jsxRuntime.jsx("p", { className: "mt-1 text-sm text-ink-500", children: subtitle }))] }), (actions || rightContent) && (jsxRuntime.jsxs("div", { className: "flex items-center gap-2 flex-shrink-0", children: [rightContent, actions && actions.map((action) => (jsxRuntime.jsxs("button", { onClick: action.onClick, disabled: action.disabled || action.loading, className: `
|
|
53911
|
+
inline-flex items-center justify-center gap-2
|
|
53912
|
+
px-4 py-2 text-sm font-medium rounded-lg border
|
|
53913
|
+
transition-all duration-200
|
|
53914
|
+
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-400
|
|
53915
|
+
disabled:opacity-40 disabled:cursor-not-allowed
|
|
53916
|
+
${variantStyles[action.variant || 'secondary']}
|
|
53917
|
+
${action.hideOnMobile ? 'hidden sm:inline-flex' : ''}
|
|
53918
|
+
`, children: [action.loading ? (jsxRuntime.jsxs("svg", { className: "h-4 w-4 animate-spin", fill: "none", viewBox: "0 0 24 24", children: [jsxRuntime.jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), jsxRuntime.jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] })) : action.icon ? (jsxRuntime.jsx("span", { className: "h-4 w-4", children: action.icon })) : null, jsxRuntime.jsx("span", { children: action.label })] }, action.id)))] }))] }), belowTitle && (jsxRuntime.jsx("div", { className: "mt-4", children: belowTitle }))] }) }));
|
|
53919
|
+
}
|
|
53920
|
+
|
|
53921
|
+
/**
|
|
53922
|
+
* ActionBar - Flexible toolbar for page-level and contextual actions
|
|
53923
|
+
*
|
|
53924
|
+
* A versatile action container that can be used for:
|
|
53925
|
+
* - Bulk actions when rows are selected
|
|
53926
|
+
* - Page-level actions and controls
|
|
53927
|
+
* - Form action buttons (Save/Cancel)
|
|
53928
|
+
* - Contextual toolbars
|
|
53929
|
+
*
|
|
53930
|
+
* @example Basic bulk actions bar
|
|
53931
|
+
* ```tsx
|
|
53932
|
+
* <ActionBar
|
|
53933
|
+
* visible={selectedIds.length > 0}
|
|
53934
|
+
* leftContent={<Text weight="medium">{selectedIds.length} selected</Text>}
|
|
53935
|
+
* actions={[
|
|
53936
|
+
* { id: 'export', label: 'Export', icon: <Download />, onClick: handleExport },
|
|
53937
|
+
* { id: 'delete', label: 'Delete', icon: <Trash />, onClick: handleDelete, variant: 'danger' },
|
|
53938
|
+
* ]}
|
|
53939
|
+
* onDismiss={() => setSelectedIds([])}
|
|
53940
|
+
* showDismiss
|
|
53941
|
+
* />
|
|
53942
|
+
* ```
|
|
53943
|
+
*
|
|
53944
|
+
* @example Sticky bottom form actions
|
|
53945
|
+
* ```tsx
|
|
53946
|
+
* <ActionBar
|
|
53947
|
+
* position="bottom"
|
|
53948
|
+
* sticky
|
|
53949
|
+
* rightContent={
|
|
53950
|
+
* <>
|
|
53951
|
+
* <Button variant="ghost" onClick={handleCancel}>Cancel</Button>
|
|
53952
|
+
* <Button variant="primary" onClick={handleSave} loading={isSaving}>Save Changes</Button>
|
|
53953
|
+
* </>
|
|
53954
|
+
* }
|
|
53955
|
+
* />
|
|
53956
|
+
* ```
|
|
53957
|
+
*
|
|
53958
|
+
* @example Info bar with center content
|
|
53959
|
+
* ```tsx
|
|
53960
|
+
* <ActionBar
|
|
53961
|
+
* variant="info"
|
|
53962
|
+
* centerContent={
|
|
53963
|
+
* <Text size="sm">Showing results for "search term" - 42 items found</Text>
|
|
53964
|
+
* }
|
|
53965
|
+
* onDismiss={clearSearch}
|
|
53966
|
+
* showDismiss
|
|
53967
|
+
* />
|
|
53968
|
+
* ```
|
|
53969
|
+
*/
|
|
53970
|
+
function ActionBar({ leftContent, centerContent, rightContent, actions, position = 'top', sticky = false, visible = true, onDismiss, showDismiss = false, className = '', variant = 'default', compact = false, }) {
|
|
53971
|
+
if (!visible) {
|
|
53972
|
+
return null;
|
|
53973
|
+
}
|
|
53974
|
+
const variantStyles = {
|
|
53975
|
+
default: 'bg-white border-paper-200',
|
|
53976
|
+
primary: 'bg-accent-50 border-accent-200',
|
|
53977
|
+
warning: 'bg-warning-50 border-warning-200',
|
|
53978
|
+
info: 'bg-blue-50 border-blue-200',
|
|
53979
|
+
};
|
|
53980
|
+
const buttonVariantStyles = {
|
|
53981
|
+
primary: 'bg-accent-500 text-white border-accent-500 hover:bg-accent-600 hover:shadow-sm',
|
|
53982
|
+
secondary: 'bg-white text-ink-700 border-paper-300 hover:bg-paper-50 hover:border-paper-400 shadow-xs hover:shadow-sm',
|
|
53983
|
+
ghost: 'bg-transparent text-ink-600 border-transparent hover:text-ink-800 hover:bg-paper-100',
|
|
53984
|
+
danger: 'bg-error-500 text-white border-error-500 hover:bg-error-600 hover:shadow-sm',
|
|
53985
|
+
outline: 'bg-transparent text-ink-700 border-paper-300 hover:bg-paper-50 hover:border-ink-400',
|
|
53986
|
+
};
|
|
53987
|
+
const positionStyles = {
|
|
53988
|
+
top: sticky ? 'sticky top-0 z-40 border-b' : 'border-b',
|
|
53989
|
+
bottom: sticky ? 'sticky bottom-0 z-40 border-t' : 'border-t',
|
|
53990
|
+
};
|
|
53991
|
+
return (jsxRuntime.jsx("div", { className: `
|
|
53992
|
+
${variantStyles[variant]}
|
|
53993
|
+
${positionStyles[position]}
|
|
53994
|
+
${compact ? 'px-4 py-2' : 'px-6 py-3'}
|
|
53995
|
+
${className}
|
|
53996
|
+
`, role: "toolbar", "aria-label": "Action bar", children: jsxRuntime.jsxs("div", { className: "flex items-center justify-between gap-4", children: [jsxRuntime.jsxs("div", { className: "flex items-center gap-3 min-w-0 flex-shrink-0", children: [showDismiss && onDismiss && (jsxRuntime.jsx("button", { onClick: onDismiss, className: "\n flex items-center justify-center\n w-8 h-8 rounded-full\n text-ink-400 hover:text-ink-600\n hover:bg-paper-100\n transition-colors\n ", "aria-label": "Dismiss", children: jsxRuntime.jsx(lucideReact.X, { className: "h-4 w-4" }) })), leftContent] }), centerContent && (jsxRuntime.jsx("div", { className: "flex-1 flex items-center justify-center min-w-0", children: centerContent })), jsxRuntime.jsxs("div", { className: "flex items-center gap-2 flex-shrink-0", children: [rightContent, actions && actions.map((action) => (jsxRuntime.jsxs("button", { onClick: action.onClick, disabled: action.disabled || action.loading, className: `
|
|
53997
|
+
inline-flex items-center justify-center gap-2
|
|
53998
|
+
px-3 py-1.5 text-sm font-medium rounded-lg border
|
|
53999
|
+
transition-all duration-200
|
|
54000
|
+
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-400
|
|
54001
|
+
disabled:opacity-40 disabled:cursor-not-allowed
|
|
54002
|
+
${buttonVariantStyles[action.variant || 'secondary']}
|
|
54003
|
+
`, children: [action.loading ? (jsxRuntime.jsxs("svg", { className: "h-4 w-4 animate-spin", fill: "none", viewBox: "0 0 24 24", children: [jsxRuntime.jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), jsxRuntime.jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] })) : action.icon ? (jsxRuntime.jsx("span", { className: "h-4 w-4", children: action.icon })) : null, jsxRuntime.jsx("span", { children: action.label })] }, action.id)))] })] }) }));
|
|
54004
|
+
}
|
|
54005
|
+
/**
|
|
54006
|
+
* ActionBar.Left - Semantic wrapper for left content
|
|
54007
|
+
*/
|
|
54008
|
+
function ActionBarLeft({ children }) {
|
|
54009
|
+
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
|
|
54010
|
+
}
|
|
54011
|
+
/**
|
|
54012
|
+
* ActionBar.Center - Semantic wrapper for center content
|
|
54013
|
+
*/
|
|
54014
|
+
function ActionBarCenter({ children }) {
|
|
54015
|
+
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
|
|
54016
|
+
}
|
|
54017
|
+
/**
|
|
54018
|
+
* ActionBar.Right - Semantic wrapper for right content
|
|
54019
|
+
*/
|
|
54020
|
+
function ActionBarRight({ children }) {
|
|
54021
|
+
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
|
|
54022
|
+
}
|
|
54023
|
+
|
|
51394
54024
|
const sizeClasses = {
|
|
51395
54025
|
sm: 'max-w-sm',
|
|
51396
54026
|
md: 'max-w-md',
|
|
@@ -51983,7 +54613,210 @@ function useColumnReorder(initialColumns, options = {}) {
|
|
|
51983
54613
|
};
|
|
51984
54614
|
}
|
|
51985
54615
|
|
|
54616
|
+
/**
|
|
54617
|
+
* Default context value (SSR-safe defaults)
|
|
54618
|
+
*/
|
|
54619
|
+
const defaultContextValue = {
|
|
54620
|
+
isMobile: false,
|
|
54621
|
+
isTablet: false,
|
|
54622
|
+
isDesktop: true,
|
|
54623
|
+
isTouchDevice: false,
|
|
54624
|
+
breakpoint: 'lg',
|
|
54625
|
+
orientation: 'landscape',
|
|
54626
|
+
viewport: { width: 1024, height: 768 },
|
|
54627
|
+
safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
54628
|
+
useMobileUI: false,
|
|
54629
|
+
};
|
|
54630
|
+
/**
|
|
54631
|
+
* Mobile context
|
|
54632
|
+
*/
|
|
54633
|
+
const MobileContext = React.createContext(defaultContextValue);
|
|
54634
|
+
/**
|
|
54635
|
+
* MobileProvider - Provides responsive state to the entire application
|
|
54636
|
+
*
|
|
54637
|
+
* Wrap your application with MobileProvider to enable auto-responsive
|
|
54638
|
+
* behavior in notebook-ui components.
|
|
54639
|
+
*
|
|
54640
|
+
* @example Basic usage
|
|
54641
|
+
* ```tsx
|
|
54642
|
+
* import { MobileProvider } from 'notebook-ui';
|
|
54643
|
+
*
|
|
54644
|
+
* function App() {
|
|
54645
|
+
* return (
|
|
54646
|
+
* <MobileProvider>
|
|
54647
|
+
* <YourApplication />
|
|
54648
|
+
* </MobileProvider>
|
|
54649
|
+
* );
|
|
54650
|
+
* }
|
|
54651
|
+
* ```
|
|
54652
|
+
*
|
|
54653
|
+
* @example Force mobile UI for testing
|
|
54654
|
+
* ```tsx
|
|
54655
|
+
* <MobileProvider forceMobileUI>
|
|
54656
|
+
* <YourApplication />
|
|
54657
|
+
* </MobileProvider>
|
|
54658
|
+
* ```
|
|
54659
|
+
*/
|
|
54660
|
+
function MobileProvider({ children, forceMobileUI = false, forceDesktopUI = false, }) {
|
|
54661
|
+
const isMobile = useIsMobile();
|
|
54662
|
+
const isTablet = useIsTablet();
|
|
54663
|
+
const isDesktop = useIsDesktop();
|
|
54664
|
+
const isTouchDevice = useIsTouchDevice();
|
|
54665
|
+
const breakpoint = useBreakpoint();
|
|
54666
|
+
const orientation = useOrientation();
|
|
54667
|
+
const viewport = useViewportSize();
|
|
54668
|
+
const safeAreaInsets = useSafeAreaInsets();
|
|
54669
|
+
const value = React.useMemo(() => {
|
|
54670
|
+
// Calculate effective mobile UI state
|
|
54671
|
+
let useMobileUI = isMobile || isTouchDevice;
|
|
54672
|
+
// Apply force overrides
|
|
54673
|
+
if (forceMobileUI) {
|
|
54674
|
+
useMobileUI = true;
|
|
54675
|
+
}
|
|
54676
|
+
else if (forceDesktopUI) {
|
|
54677
|
+
useMobileUI = false;
|
|
54678
|
+
}
|
|
54679
|
+
return {
|
|
54680
|
+
isMobile: forceMobileUI ? true : forceDesktopUI ? false : isMobile,
|
|
54681
|
+
isTablet: forceMobileUI || forceDesktopUI ? false : isTablet,
|
|
54682
|
+
isDesktop: forceDesktopUI ? true : forceMobileUI ? false : isDesktop,
|
|
54683
|
+
isTouchDevice,
|
|
54684
|
+
breakpoint: forceMobileUI ? 'xs' : forceDesktopUI ? 'lg' : breakpoint,
|
|
54685
|
+
orientation,
|
|
54686
|
+
viewport,
|
|
54687
|
+
safeAreaInsets,
|
|
54688
|
+
useMobileUI,
|
|
54689
|
+
};
|
|
54690
|
+
}, [
|
|
54691
|
+
isMobile,
|
|
54692
|
+
isTablet,
|
|
54693
|
+
isDesktop,
|
|
54694
|
+
isTouchDevice,
|
|
54695
|
+
breakpoint,
|
|
54696
|
+
orientation,
|
|
54697
|
+
viewport,
|
|
54698
|
+
safeAreaInsets,
|
|
54699
|
+
forceMobileUI,
|
|
54700
|
+
forceDesktopUI,
|
|
54701
|
+
]);
|
|
54702
|
+
return (jsxRuntime.jsx(MobileContext.Provider, { value: value, children: children }));
|
|
54703
|
+
}
|
|
54704
|
+
/**
|
|
54705
|
+
* useMobileContext - Hook to access mobile responsive state
|
|
54706
|
+
*
|
|
54707
|
+
* Must be used within a MobileProvider. Returns comprehensive
|
|
54708
|
+
* responsive state for making UI decisions.
|
|
54709
|
+
*
|
|
54710
|
+
* @example
|
|
54711
|
+
* ```tsx
|
|
54712
|
+
* function MyComponent() {
|
|
54713
|
+
* const { isMobile, useMobileUI, breakpoint } = useMobileContext();
|
|
54714
|
+
*
|
|
54715
|
+
* return useMobileUI ? <MobileView /> : <DesktopView />;
|
|
54716
|
+
* }
|
|
54717
|
+
* ```
|
|
54718
|
+
*/
|
|
54719
|
+
function useMobileContext() {
|
|
54720
|
+
const context = React.useContext(MobileContext);
|
|
54721
|
+
if (context === undefined) {
|
|
54722
|
+
// Return default value if used outside provider (graceful degradation)
|
|
54723
|
+
console.warn('useMobileContext was used outside of MobileProvider. ' +
|
|
54724
|
+
'Wrap your app with <MobileProvider> for full mobile support.');
|
|
54725
|
+
return defaultContextValue;
|
|
54726
|
+
}
|
|
54727
|
+
return context;
|
|
54728
|
+
}
|
|
54729
|
+
/**
|
|
54730
|
+
* withMobileContext - HOC to inject mobile context as props
|
|
54731
|
+
*
|
|
54732
|
+
* For class components or when you prefer props over hooks.
|
|
54733
|
+
*
|
|
54734
|
+
* @example
|
|
54735
|
+
* ```tsx
|
|
54736
|
+
* interface Props {
|
|
54737
|
+
* mobile: MobileContextValue;
|
|
54738
|
+
* }
|
|
54739
|
+
*
|
|
54740
|
+
* class MyComponent extends React.Component<Props> {
|
|
54741
|
+
* render() {
|
|
54742
|
+
* const { isMobile } = this.props.mobile;
|
|
54743
|
+
* return isMobile ? <Mobile /> : <Desktop />;
|
|
54744
|
+
* }
|
|
54745
|
+
* }
|
|
54746
|
+
*
|
|
54747
|
+
* export default withMobileContext(MyComponent);
|
|
54748
|
+
* ```
|
|
54749
|
+
*/
|
|
54750
|
+
function withMobileContext(Component) {
|
|
54751
|
+
const displayName = Component.displayName || Component.name || 'Component';
|
|
54752
|
+
const WrappedComponent = (props) => {
|
|
54753
|
+
const mobile = useMobileContext();
|
|
54754
|
+
return jsxRuntime.jsx(Component, { ...props, mobile: mobile });
|
|
54755
|
+
};
|
|
54756
|
+
WrappedComponent.displayName = `withMobileContext(${displayName})`;
|
|
54757
|
+
return WrappedComponent;
|
|
54758
|
+
}
|
|
54759
|
+
/**
|
|
54760
|
+
* MobileOnly - Renders children only on mobile devices
|
|
54761
|
+
*
|
|
54762
|
+
* @example
|
|
54763
|
+
* ```tsx
|
|
54764
|
+
* <MobileOnly>
|
|
54765
|
+
* <BottomNavigation items={navItems} />
|
|
54766
|
+
* </MobileOnly>
|
|
54767
|
+
* ```
|
|
54768
|
+
*/
|
|
54769
|
+
function MobileOnly({ children }) {
|
|
54770
|
+
const { useMobileUI } = useMobileContext();
|
|
54771
|
+
return useMobileUI ? jsxRuntime.jsx(jsxRuntime.Fragment, { children: children }) : null;
|
|
54772
|
+
}
|
|
54773
|
+
/**
|
|
54774
|
+
* DesktopOnly - Renders children only on desktop devices
|
|
54775
|
+
*
|
|
54776
|
+
* @example
|
|
54777
|
+
* ```tsx
|
|
54778
|
+
* <DesktopOnly>
|
|
54779
|
+
* <Sidebar items={navItems} />
|
|
54780
|
+
* </DesktopOnly>
|
|
54781
|
+
* ```
|
|
54782
|
+
*/
|
|
54783
|
+
function DesktopOnly({ children }) {
|
|
54784
|
+
const { useMobileUI } = useMobileContext();
|
|
54785
|
+
return useMobileUI ? null : jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
|
|
54786
|
+
}
|
|
54787
|
+
/**
|
|
54788
|
+
* Responsive - Renders different content based on device type
|
|
54789
|
+
*
|
|
54790
|
+
* @example
|
|
54791
|
+
* ```tsx
|
|
54792
|
+
* <Responsive
|
|
54793
|
+
* mobile={<MobileNavigation />}
|
|
54794
|
+
* tablet={<TabletNavigation />}
|
|
54795
|
+
* desktop={<DesktopNavigation />}
|
|
54796
|
+
* />
|
|
54797
|
+
* ```
|
|
54798
|
+
*/
|
|
54799
|
+
function Responsive({ mobile, tablet, desktop, }) {
|
|
54800
|
+
const { isMobile, isTablet, isDesktop } = useMobileContext();
|
|
54801
|
+
if (isMobile && mobile)
|
|
54802
|
+
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: mobile });
|
|
54803
|
+
if (isTablet && tablet)
|
|
54804
|
+
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: tablet });
|
|
54805
|
+
if (isDesktop && desktop)
|
|
54806
|
+
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: desktop });
|
|
54807
|
+
// Fallback: desktop -> tablet -> mobile
|
|
54808
|
+
if (isDesktop)
|
|
54809
|
+
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: desktop || tablet || mobile });
|
|
54810
|
+
if (isTablet)
|
|
54811
|
+
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: tablet || mobile || desktop });
|
|
54812
|
+
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: mobile || tablet || desktop });
|
|
54813
|
+
}
|
|
54814
|
+
|
|
51986
54815
|
exports.Accordion = Accordion;
|
|
54816
|
+
exports.ActionBar = ActionBar;
|
|
54817
|
+
exports.ActionBarCenter = ActionBarCenter;
|
|
54818
|
+
exports.ActionBarLeft = ActionBarLeft;
|
|
54819
|
+
exports.ActionBarRight = ActionBarRight;
|
|
51987
54820
|
exports.ActionButton = ActionButton;
|
|
51988
54821
|
exports.AdminModal = AdminModal;
|
|
51989
54822
|
exports.Alert = Alert;
|
|
@@ -51991,7 +54824,10 @@ exports.AlertDialog = AlertDialog;
|
|
|
51991
54824
|
exports.AppLayout = AppLayout;
|
|
51992
54825
|
exports.Autocomplete = Autocomplete;
|
|
51993
54826
|
exports.Avatar = Avatar;
|
|
54827
|
+
exports.BREAKPOINTS = BREAKPOINTS;
|
|
51994
54828
|
exports.Badge = Badge;
|
|
54829
|
+
exports.BottomNavigation = BottomNavigation;
|
|
54830
|
+
exports.BottomNavigationSpacer = BottomNavigationSpacer;
|
|
51995
54831
|
exports.BottomSheet = BottomSheet;
|
|
51996
54832
|
exports.Box = Box;
|
|
51997
54833
|
exports.Breadcrumbs = Breadcrumbs;
|
|
@@ -52007,7 +54843,9 @@ exports.CardTitle = CardTitle;
|
|
|
52007
54843
|
exports.CardView = CardView;
|
|
52008
54844
|
exports.Carousel = Carousel;
|
|
52009
54845
|
exports.Checkbox = Checkbox;
|
|
54846
|
+
exports.CheckboxList = CheckboxList;
|
|
52010
54847
|
exports.Chip = Chip;
|
|
54848
|
+
exports.ChipGroup = ChipGroup;
|
|
52011
54849
|
exports.Collapsible = Collapsible;
|
|
52012
54850
|
exports.ColorPicker = ColorPicker;
|
|
52013
54851
|
exports.Combobox = Combobox;
|
|
@@ -52022,10 +54860,12 @@ exports.Dashboard = Dashboard;
|
|
|
52022
54860
|
exports.DashboardContent = DashboardContent;
|
|
52023
54861
|
exports.DashboardHeader = DashboardHeader;
|
|
52024
54862
|
exports.DataTable = DataTable;
|
|
54863
|
+
exports.DataTableCardView = DataTableCardView;
|
|
52025
54864
|
exports.DateDisplay = DateDisplay;
|
|
52026
54865
|
exports.DatePicker = DatePicker;
|
|
52027
54866
|
exports.DateRangePicker = DateRangePicker;
|
|
52028
54867
|
exports.DateTimePicker = DateTimePicker;
|
|
54868
|
+
exports.DesktopOnly = DesktopOnly;
|
|
52029
54869
|
exports.Drawer = Drawer;
|
|
52030
54870
|
exports.DrawerFooter = DrawerFooter;
|
|
52031
54871
|
exports.DropZone = DropZone;
|
|
@@ -52033,6 +54873,9 @@ exports.Dropdown = Dropdown;
|
|
|
52033
54873
|
exports.DropdownTrigger = DropdownTrigger;
|
|
52034
54874
|
exports.EmptyState = EmptyState;
|
|
52035
54875
|
exports.ErrorBoundary = ErrorBoundary;
|
|
54876
|
+
exports.ExpandablePanel = ExpandablePanel;
|
|
54877
|
+
exports.ExpandablePanelContainer = ExpandablePanelContainer;
|
|
54878
|
+
exports.ExpandablePanelSpacer = ExpandablePanelSpacer;
|
|
52036
54879
|
exports.ExpandableRowButton = ExpandableRowButton;
|
|
52037
54880
|
exports.ExpandableToolbar = ExpandableToolbar;
|
|
52038
54881
|
exports.ExpandedRowEditForm = ExpandedRowEditForm;
|
|
@@ -52042,6 +54885,7 @@ exports.FileUpload = FileUpload;
|
|
|
52042
54885
|
exports.FilterBar = FilterBar;
|
|
52043
54886
|
exports.FilterControls = FilterControls;
|
|
52044
54887
|
exports.FilterStatusBanner = FilterStatusBanner;
|
|
54888
|
+
exports.FloatingActionButton = FloatingActionButton;
|
|
52045
54889
|
exports.Form = Form;
|
|
52046
54890
|
exports.FormContext = FormContext;
|
|
52047
54891
|
exports.FormControl = FormControl;
|
|
@@ -52061,6 +54905,11 @@ exports.MarkdownEditor = MarkdownEditor;
|
|
|
52061
54905
|
exports.MaskedInput = MaskedInput;
|
|
52062
54906
|
exports.Menu = Menu;
|
|
52063
54907
|
exports.MenuDivider = MenuDivider;
|
|
54908
|
+
exports.MobileHeader = MobileHeader;
|
|
54909
|
+
exports.MobileHeaderSpacer = MobileHeaderSpacer;
|
|
54910
|
+
exports.MobileLayout = MobileLayout;
|
|
54911
|
+
exports.MobileOnly = MobileOnly;
|
|
54912
|
+
exports.MobileProvider = MobileProvider;
|
|
52064
54913
|
exports.Modal = Modal;
|
|
52065
54914
|
exports.ModalFooter = ModalFooter;
|
|
52066
54915
|
exports.MultiSelect = MultiSelect;
|
|
@@ -52068,24 +54917,28 @@ exports.NotificationBar = NotificationBar;
|
|
|
52068
54917
|
exports.NotificationIndicator = NotificationIndicator;
|
|
52069
54918
|
exports.NumberInput = NumberInput;
|
|
52070
54919
|
exports.Page = Page;
|
|
54920
|
+
exports.PageHeader = PageHeader;
|
|
52071
54921
|
exports.PageLayout = PageLayout;
|
|
52072
54922
|
exports.PageNavigation = PageNavigation;
|
|
52073
54923
|
exports.Pagination = Pagination;
|
|
52074
54924
|
exports.PasswordInput = PasswordInput;
|
|
52075
54925
|
exports.Popover = Popover;
|
|
52076
54926
|
exports.Progress = Progress;
|
|
54927
|
+
exports.PullToRefresh = PullToRefresh;
|
|
52077
54928
|
exports.QueryTransparency = QueryTransparency;
|
|
52078
54929
|
exports.RadioGroup = RadioGroup;
|
|
52079
54930
|
exports.Rating = Rating;
|
|
54931
|
+
exports.Responsive = Responsive;
|
|
52080
54932
|
exports.RichTextEditor = RichTextEditor;
|
|
52081
54933
|
exports.SearchBar = SearchBar;
|
|
54934
|
+
exports.SearchableList = SearchableList;
|
|
52082
54935
|
exports.Select = Select;
|
|
52083
54936
|
exports.Separator = Separator;
|
|
52084
54937
|
exports.Show = Show;
|
|
52085
54938
|
exports.Sidebar = Sidebar;
|
|
52086
54939
|
exports.SidebarGroup = SidebarGroup;
|
|
52087
54940
|
exports.Skeleton = Skeleton;
|
|
52088
|
-
exports.SkeletonCard = SkeletonCard;
|
|
54941
|
+
exports.SkeletonCard = SkeletonCard$1;
|
|
52089
54942
|
exports.SkeletonTable = SkeletonTable;
|
|
52090
54943
|
exports.Slider = Slider;
|
|
52091
54944
|
exports.Spreadsheet = Spreadsheet;
|
|
@@ -52099,6 +54952,7 @@ exports.StatusBadge = StatusBadge;
|
|
|
52099
54952
|
exports.StatusBar = StatusBar;
|
|
52100
54953
|
exports.StepIndicator = StepIndicator;
|
|
52101
54954
|
exports.Stepper = Stepper;
|
|
54955
|
+
exports.SwipeActions = SwipeActions;
|
|
52102
54956
|
exports.Switch = Switch;
|
|
52103
54957
|
exports.Tabs = Tabs;
|
|
52104
54958
|
exports.Text = Text;
|
|
@@ -52133,10 +54987,25 @@ exports.reorderArray = reorderArray;
|
|
|
52133
54987
|
exports.saveColumnOrder = saveColumnOrder;
|
|
52134
54988
|
exports.saveColumnWidths = saveColumnWidths;
|
|
52135
54989
|
exports.statusManager = statusManager;
|
|
54990
|
+
exports.useBreakpoint = useBreakpoint;
|
|
54991
|
+
exports.useBreakpointValue = useBreakpointValue;
|
|
52136
54992
|
exports.useColumnReorder = useColumnReorder;
|
|
52137
54993
|
exports.useColumnResize = useColumnResize;
|
|
52138
54994
|
exports.useCommandPalette = useCommandPalette;
|
|
52139
54995
|
exports.useConfirmDialog = useConfirmDialog;
|
|
54996
|
+
exports.useFABScroll = useFABScroll;
|
|
52140
54997
|
exports.useFormContext = useFormContext;
|
|
54998
|
+
exports.useIsDesktop = useIsDesktop;
|
|
54999
|
+
exports.useIsMobile = useIsMobile;
|
|
55000
|
+
exports.useIsTablet = useIsTablet;
|
|
55001
|
+
exports.useIsTouchDevice = useIsTouchDevice;
|
|
52141
55002
|
exports.useMediaQuery = useMediaQuery;
|
|
55003
|
+
exports.useMobileContext = useMobileContext;
|
|
55004
|
+
exports.useOrientation = useOrientation;
|
|
55005
|
+
exports.usePrefersMobile = usePrefersMobile;
|
|
55006
|
+
exports.usePullToRefresh = usePullToRefresh;
|
|
55007
|
+
exports.useResponsiveCallback = useResponsiveCallback;
|
|
55008
|
+
exports.useSafeAreaInsets = useSafeAreaInsets;
|
|
55009
|
+
exports.useViewportSize = useViewportSize;
|
|
55010
|
+
exports.withMobileContext = withMobileContext;
|
|
52142
55011
|
//# sourceMappingURL=index.js.map
|