@galyan/ui 0.0.1

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.
Files changed (95) hide show
  1. package/.turbo/turbo-build.log +20 -0
  2. package/dist/index.cjs +2291 -0
  3. package/dist/index.css +2558 -0
  4. package/dist/index.d.cts +498 -0
  5. package/dist/index.d.ts +498 -0
  6. package/dist/index.js +2235 -0
  7. package/eslint.config.mjs +4 -0
  8. package/package.json +43 -0
  9. package/src/accordion/Accordion.tsx +88 -0
  10. package/src/accordion/accordion.css +78 -0
  11. package/src/accordion/index.ts +2 -0
  12. package/src/animatednumber/AnimatedNumber.tsx +71 -0
  13. package/src/animatednumber/index.ts +2 -0
  14. package/src/banner/Banner.tsx +84 -0
  15. package/src/banner/banner.css +56 -0
  16. package/src/banner/index.ts +2 -0
  17. package/src/breadcrumb/Breadcrumb.tsx +94 -0
  18. package/src/breadcrumb/breadcrumb.css +58 -0
  19. package/src/breadcrumb/index.ts +2 -0
  20. package/src/button/Button.tsx +98 -0
  21. package/src/button/button.css +190 -0
  22. package/src/button/index.ts +2 -0
  23. package/src/button.tsx +20 -0
  24. package/src/calendar/Calendar.tsx +190 -0
  25. package/src/calendar/calendar.css +142 -0
  26. package/src/calendar/index.ts +2 -0
  27. package/src/card/Card.tsx +88 -0
  28. package/src/card/card.css +115 -0
  29. package/src/card/index.ts +2 -0
  30. package/src/card.tsx +27 -0
  31. package/src/chart/Chart.tsx +186 -0
  32. package/src/chart/index.ts +2 -0
  33. package/src/checkbox/Checkbox.tsx +101 -0
  34. package/src/checkbox/checkbox.css +76 -0
  35. package/src/checkbox/index.ts +2 -0
  36. package/src/chips/Chips.tsx +146 -0
  37. package/src/chips/chips.css +90 -0
  38. package/src/chips/index.ts +2 -0
  39. package/src/code.tsx +11 -0
  40. package/src/datepicker/DatePicker.tsx +104 -0
  41. package/src/datepicker/datepicker.css +22 -0
  42. package/src/datepicker/index.ts +2 -0
  43. package/src/dragdrop/DragDrop.tsx +168 -0
  44. package/src/dragdrop/dragdrop.css +122 -0
  45. package/src/dragdrop/index.ts +2 -0
  46. package/src/dropdown/Dropdown.tsx +176 -0
  47. package/src/dropdown/dropdown.css +118 -0
  48. package/src/dropdown/index.ts +2 -0
  49. package/src/fileupload/FileUpload.tsx +144 -0
  50. package/src/fileupload/fileupload.css +118 -0
  51. package/src/fileupload/index.ts +2 -0
  52. package/src/index.ts +28 -0
  53. package/src/input/Input.tsx +121 -0
  54. package/src/input/index.ts +2 -0
  55. package/src/input/input.css +120 -0
  56. package/src/menu/Menu.tsx +118 -0
  57. package/src/menu/index.ts +2 -0
  58. package/src/menu/menu.css +117 -0
  59. package/src/modal/Modal.tsx +103 -0
  60. package/src/modal/index.ts +2 -0
  61. package/src/modal/modal.css +79 -0
  62. package/src/progressbar/ProgressBar.tsx +68 -0
  63. package/src/progressbar/index.ts +2 -0
  64. package/src/progressbar/progressbar.css +73 -0
  65. package/src/radiogroup/RadioGroup.tsx +73 -0
  66. package/src/radiogroup/index.ts +2 -0
  67. package/src/radiogroup/radiogroup.css +80 -0
  68. package/src/skeleton/Skeleton.tsx +61 -0
  69. package/src/skeleton/index.ts +2 -0
  70. package/src/skeleton/skeleton.css +46 -0
  71. package/src/spinner/Spinner.tsx +29 -0
  72. package/src/spinner/index.ts +2 -0
  73. package/src/spinner/spinner.css +39 -0
  74. package/src/stepper/Stepper.tsx +127 -0
  75. package/src/stepper/index.ts +2 -0
  76. package/src/stepper/stepper.css +141 -0
  77. package/src/tab/Tabs.tsx +71 -0
  78. package/src/tab/index.ts +2 -0
  79. package/src/tab/tab.css +92 -0
  80. package/src/table/Table.tsx +205 -0
  81. package/src/table/index.ts +2 -0
  82. package/src/table/table.css +119 -0
  83. package/src/textarea/Textarea.tsx +102 -0
  84. package/src/textarea/index.ts +2 -0
  85. package/src/textarea/textarea.css +85 -0
  86. package/src/toaster/Toaster.tsx +108 -0
  87. package/src/toaster/index.ts +2 -0
  88. package/src/toaster/toaster.css +113 -0
  89. package/src/toggle/Toggle.tsx +88 -0
  90. package/src/toggle/index.ts +2 -0
  91. package/src/toggle/toggle.css +63 -0
  92. package/src/typography/Typography.tsx +58 -0
  93. package/src/typography/index.ts +2 -0
  94. package/src/typography/typography.css +102 -0
  95. package/tsconfig.json +13 -0
@@ -0,0 +1,121 @@
1
+ 'use client';
2
+
3
+ import React, { forwardRef, useId } from 'react';
4
+ import './input.css';
5
+
6
+ export type InputSize = 'sm' | 'md' | 'lg';
7
+
8
+ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
9
+ label?: string;
10
+ size?: InputSize;
11
+ error?: string;
12
+ helperText?: string;
13
+ leftIcon?: React.ReactNode;
14
+ rightIcon?: React.ReactNode;
15
+ clearable?: boolean;
16
+ onClear?: () => void;
17
+ required?: boolean;
18
+ fullWidth?: boolean;
19
+ }
20
+
21
+ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
22
+ {
23
+ label,
24
+ size = 'md',
25
+ error,
26
+ helperText,
27
+ leftIcon,
28
+ rightIcon,
29
+ clearable,
30
+ onClear,
31
+ required,
32
+ fullWidth = true,
33
+ disabled,
34
+ className = '',
35
+ id,
36
+ value,
37
+ onChange,
38
+ ...rest
39
+ },
40
+ ref
41
+ ) {
42
+ const uid = useId();
43
+ const inputId = id ?? uid;
44
+
45
+ const wrapperClasses = [
46
+ 'gy-input-wrapper',
47
+ `gy-input-wrapper--${size}`,
48
+ error ? 'gy-input-wrapper--error' : '',
49
+ disabled ? 'gy-input-wrapper--disabled' : '',
50
+ ]
51
+ .filter(Boolean)
52
+ .join(' ');
53
+
54
+ const showClear = clearable && value && !disabled;
55
+
56
+ return (
57
+ <div className={`gy-input-root ${fullWidth ? '' : 'gy-input-root--inline'} ${className}`}>
58
+ {label && (
59
+ <label
60
+ className={`gy-input-label ${required ? 'gy-input-label--required' : ''}`}
61
+ htmlFor={inputId}
62
+ >
63
+ {label}
64
+ </label>
65
+ )}
66
+
67
+ <div className={wrapperClasses}>
68
+ {leftIcon && (
69
+ <span className="gy-input-addon gy-input-addon--left" aria-hidden="true">
70
+ {leftIcon}
71
+ </span>
72
+ )}
73
+
74
+ <input
75
+ ref={ref}
76
+ id={inputId}
77
+ className="gy-input"
78
+ disabled={disabled}
79
+ required={required}
80
+ aria-invalid={!!error}
81
+ aria-describedby={error ? `${inputId}-error` : helperText ? `${inputId}-helper` : undefined}
82
+ value={value}
83
+ onChange={onChange}
84
+ {...rest}
85
+ />
86
+
87
+ {showClear && (
88
+ <button
89
+ type="button"
90
+ className="gy-input-clear"
91
+ onClick={onClear}
92
+ aria-label="Clear input"
93
+ tabIndex={-1}
94
+ >
95
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2">
96
+ <line x1="1" y1="1" x2="13" y2="13" />
97
+ <line x1="13" y1="1" x2="1" y2="13" />
98
+ </svg>
99
+ </button>
100
+ )}
101
+
102
+ {rightIcon && !showClear && (
103
+ <span className="gy-input-addon gy-input-addon--right" aria-hidden="true">
104
+ {rightIcon}
105
+ </span>
106
+ )}
107
+ </div>
108
+
109
+ {error && (
110
+ <span id={`${inputId}-error`} className="gy-input-helper gy-input-helper--error" role="alert">
111
+ {error}
112
+ </span>
113
+ )}
114
+ {!error && helperText && (
115
+ <span id={`${inputId}-helper`} className="gy-input-helper">
116
+ {helperText}
117
+ </span>
118
+ )}
119
+ </div>
120
+ );
121
+ });
@@ -0,0 +1,2 @@
1
+ export { Input } from './Input';
2
+ export type { InputProps, InputSize } from './Input';
@@ -0,0 +1,120 @@
1
+ /* ── Input Component ──────────────────────────────────────────────────────── */
2
+ .gy-input-root {
3
+ display: flex;
4
+ flex-direction: column;
5
+ gap: var(--gy-spacing-1-5);
6
+ width: 100%;
7
+ }
8
+
9
+ .gy-input-label {
10
+ font-size: var(--gy-font-size-sm);
11
+ font-weight: var(--gy-font-weight-medium);
12
+ color: var(--gy-text);
13
+ display: flex;
14
+ align-items: center;
15
+ gap: var(--gy-spacing-1);
16
+ }
17
+
18
+ .gy-input-label--required::after {
19
+ content: '*';
20
+ color: var(--gy-danger);
21
+ margin-left: 2px;
22
+ }
23
+
24
+ .gy-input-wrapper {
25
+ position: relative;
26
+ display: flex;
27
+ align-items: center;
28
+ background: var(--gy-surface);
29
+ border: 1.5px solid var(--gy-border);
30
+ border-radius: var(--gy-radius-lg);
31
+ transition:
32
+ border-color var(--gy-duration-fast) var(--gy-ease-in-out),
33
+ box-shadow var(--gy-duration-fast) var(--gy-ease-in-out);
34
+ }
35
+
36
+ .gy-input-wrapper:focus-within {
37
+ border-color: var(--gy-border-focus);
38
+ box-shadow: 0 0 0 3px var(--gy-focus-ring);
39
+ }
40
+
41
+ .gy-input-wrapper--error {
42
+ border-color: var(--gy-danger) !important;
43
+ box-shadow: none !important;
44
+ }
45
+
46
+ .gy-input-wrapper--error:focus-within {
47
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2) !important;
48
+ }
49
+
50
+ .gy-input-wrapper--disabled {
51
+ background: var(--gy-disabled-bg);
52
+ border-color: var(--gy-disabled-border);
53
+ cursor: not-allowed;
54
+ }
55
+
56
+ .gy-input-addon {
57
+ display: flex;
58
+ align-items: center;
59
+ justify-content: center;
60
+ color: var(--gy-text-subtle);
61
+ flex-shrink: 0;
62
+ }
63
+
64
+ .gy-input-addon--left { padding-left: var(--gy-spacing-3); }
65
+ .gy-input-addon--right { padding-right: var(--gy-spacing-3); }
66
+
67
+ .gy-input {
68
+ flex: 1;
69
+ border: none;
70
+ outline: none;
71
+ background: transparent;
72
+ color: var(--gy-text);
73
+ font-family: var(--gy-font-sans);
74
+ width: 100%;
75
+ min-width: 0;
76
+ }
77
+
78
+ .gy-input::placeholder { color: var(--gy-text-subtle); }
79
+
80
+ .gy-input:disabled {
81
+ cursor: not-allowed;
82
+ color: var(--gy-text-disabled);
83
+ }
84
+
85
+ /* Sizes */
86
+ .gy-input-wrapper--sm .gy-input { padding: var(--gy-spacing-1-5) var(--gy-spacing-3); font-size: var(--gy-font-size-sm); }
87
+ .gy-input-wrapper--md .gy-input { padding: var(--gy-spacing-2-5) var(--gy-spacing-3); font-size: var(--gy-font-size-sm); }
88
+ .gy-input-wrapper--lg .gy-input { padding: var(--gy-spacing-3) var(--gy-spacing-4); font-size: var(--gy-font-size-md); }
89
+
90
+ .gy-input-wrapper--sm { min-height: 2rem; }
91
+ .gy-input-wrapper--md { min-height: 2.5rem; }
92
+ .gy-input-wrapper--lg { min-height: 3rem; }
93
+
94
+ /* Left addon spacing fix */
95
+ .gy-input-wrapper .gy-input-addon--left + .gy-input { padding-left: var(--gy-spacing-2); }
96
+ .gy-input-wrapper .gy-input:has(+ .gy-input-addon--right) { padding-right: var(--gy-spacing-2); }
97
+
98
+ /* Clear button */
99
+ .gy-input-clear {
100
+ display: flex;
101
+ align-items: center;
102
+ justify-content: center;
103
+ padding: var(--gy-spacing-1);
104
+ margin-right: var(--gy-spacing-1);
105
+ color: var(--gy-text-subtle);
106
+ border-radius: var(--gy-radius-full);
107
+ cursor: pointer;
108
+ transition: color var(--gy-duration-fast), background var(--gy-duration-fast);
109
+ }
110
+
111
+ .gy-input-clear:hover { color: var(--gy-text); background: var(--gy-background-muted); }
112
+
113
+ /* Helper / Error text */
114
+ .gy-input-helper {
115
+ font-size: var(--gy-font-size-xs);
116
+ color: var(--gy-text-subtle);
117
+ margin-top: var(--gy-spacing-0-5);
118
+ }
119
+
120
+ .gy-input-helper--error { color: var(--gy-danger); }
@@ -0,0 +1,118 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useRef, useState } from 'react';
4
+ import './menu.css';
5
+
6
+ // ── Tooltip ───────────────────────────────────────────────────────────────────
7
+ export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
8
+
9
+ export interface TooltipProps {
10
+ content: React.ReactNode;
11
+ children: React.ReactElement;
12
+ position?: TooltipPosition;
13
+ delay?: number;
14
+ disabled?: boolean;
15
+ }
16
+
17
+ export function Tooltip({ content, children, position = 'top', delay = 300, disabled = false }: TooltipProps) {
18
+ const [visible, setVisible] = useState(false);
19
+ const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
20
+
21
+ const show = () => { timerRef.current = setTimeout(() => setVisible(true), delay); };
22
+ const hide = () => { clearTimeout(timerRef.current); setVisible(false); };
23
+
24
+ return (
25
+ <span className="gy-tooltip-wrapper" onMouseEnter={show} onMouseLeave={hide} onFocus={show} onBlur={hide}>
26
+ {children}
27
+ {visible && !disabled && content && (
28
+ <span className={`gy-tooltip gy-tooltip--${position}`} role="tooltip">
29
+ {content}
30
+ </span>
31
+ )}
32
+ </span>
33
+ );
34
+ }
35
+
36
+ // ── Menu ──────────────────────────────────────────────────────────────────────
37
+ export interface MenuItemDef {
38
+ id: string;
39
+ label: React.ReactNode;
40
+ icon?: React.ReactNode;
41
+ onClick?: () => void;
42
+ disabled?: boolean;
43
+ danger?: boolean;
44
+ badge?: string;
45
+ separator?: never;
46
+ groupLabel?: never;
47
+ }
48
+
49
+ export interface MenuSeparator { separator: true; id: string; }
50
+ export interface MenuGroupLabel { groupLabel: string; id: string; }
51
+
52
+ export type MenuEntry = MenuItemDef | MenuSeparator | MenuGroupLabel;
53
+
54
+ export interface MenuProps {
55
+ items: MenuEntry[];
56
+ trigger: React.ReactElement;
57
+ position?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right';
58
+ className?: string;
59
+ }
60
+
61
+ const posStyle: Record<string, React.CSSProperties> = {
62
+ 'bottom-left': { top: 'calc(100% + 4px)', left: 0 },
63
+ 'bottom-right': { top: 'calc(100% + 4px)', right: 0 },
64
+ 'top-left': { bottom: 'calc(100% + 4px)', left: 0 },
65
+ 'top-right': { bottom: 'calc(100% + 4px)', right: 0 },
66
+ };
67
+
68
+ export function Menu({ items, trigger, position = 'bottom-left', className = '' }: MenuProps) {
69
+ const [open, setOpen] = useState(false);
70
+ const rootRef = useRef<HTMLDivElement>(null);
71
+
72
+ useEffect(() => {
73
+ const handler = (e: MouseEvent) => {
74
+ if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
75
+ };
76
+ document.addEventListener('mousedown', handler);
77
+ return () => document.removeEventListener('mousedown', handler);
78
+ }, []);
79
+
80
+ return (
81
+ <div ref={rootRef} className="gy-menu-trigger" style={{ position: 'relative' }}>
82
+ {React.cloneElement(trigger as React.ReactElement<any>, { onClick: () => setOpen((o) => !o) })}
83
+ {open && (
84
+ <div className={`gy-menu gy-menu--absolute ${className}`} style={posStyle[position]} role="menu">
85
+ {items.map((entry) => {
86
+ if ('separator' in entry && entry.separator) {
87
+ return <div key={entry.id} className="gy-menu-separator" role="separator" />;
88
+ }
89
+ if ('groupLabel' in entry && entry.groupLabel) {
90
+ return <div key={entry.id} className="gy-menu-group-label">{entry.groupLabel}</div>;
91
+ }
92
+ const item = entry as MenuItemDef;
93
+ return (
94
+ <button
95
+ key={item.id}
96
+ type="button"
97
+ className={[
98
+ 'gy-menu-item',
99
+ item.danger ? 'gy-menu-item--danger' : '',
100
+ item.disabled ? 'gy-menu-item--disabled' : '',
101
+ ]
102
+ .filter(Boolean)
103
+ .join(' ')}
104
+ role="menuitem"
105
+ disabled={item.disabled}
106
+ onClick={() => { item.onClick?.(); setOpen(false); }}
107
+ >
108
+ {item.icon && <span className="gy-menu-item__icon" aria-hidden="true">{item.icon}</span>}
109
+ {item.label}
110
+ {item.badge && <span className="gy-menu-item__badge">{item.badge}</span>}
111
+ </button>
112
+ );
113
+ })}
114
+ </div>
115
+ )}
116
+ </div>
117
+ );
118
+ }
@@ -0,0 +1,2 @@
1
+ export { Tooltip, Menu } from './Menu';
2
+ export type { TooltipProps, TooltipPosition, MenuProps, MenuItemDef, MenuEntry } from './Menu';
@@ -0,0 +1,117 @@
1
+ /* ── Tooltip ─────────────────────────────────────────────────────────────── */
2
+ .gy-tooltip-wrapper {
3
+ position: relative;
4
+ display: inline-flex;
5
+ }
6
+
7
+ .gy-tooltip {
8
+ position: absolute;
9
+ z-index: var(--gy-z-tooltip);
10
+ background: var(--gy-text);
11
+ color: var(--gy-text-inverse);
12
+ font-size: var(--gy-font-size-xs);
13
+ font-weight: var(--gy-font-weight-medium);
14
+ padding: var(--gy-spacing-1-5) var(--gy-spacing-2-5);
15
+ border-radius: var(--gy-radius-md);
16
+ white-space: nowrap;
17
+ pointer-events: none;
18
+ box-shadow: var(--gy-shadow-sm);
19
+ animation: gy-fade-in var(--gy-duration-fast) var(--gy-ease-out);
20
+ max-width: 240px;
21
+ word-wrap: break-word;
22
+ white-space: normal;
23
+ text-align: center;
24
+ line-height: 1.4;
25
+ }
26
+
27
+ /* Arrow */
28
+ .gy-tooltip::after {
29
+ content: '';
30
+ position: absolute;
31
+ border: 5px solid transparent;
32
+ }
33
+
34
+ .gy-tooltip--top { bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); }
35
+ .gy-tooltip--top::after { top: 100%; left: 50%; transform: translateX(-50%); border-top-color: var(--gy-text); }
36
+
37
+ .gy-tooltip--bottom { top: calc(100% + 8px); left: 50%; transform: translateX(-50%); }
38
+ .gy-tooltip--bottom::after { bottom: 100%; left: 50%; transform: translateX(-50%); border-bottom-color: var(--gy-text); }
39
+
40
+ .gy-tooltip--left { right: calc(100% + 8px); top: 50%; transform: translateY(-50%); }
41
+ .gy-tooltip--left::after { left: 100%; top: 50%; transform: translateY(-50%); border-left-color: var(--gy-text); }
42
+
43
+ .gy-tooltip--right { left: calc(100% + 8px); top: 50%; transform: translateY(-50%); }
44
+ .gy-tooltip--right::after { right: 100%; top: 50%; transform: translateY(-50%); border-right-color: var(--gy-text); }
45
+
46
+ /* ── Menu ────────────────────────────────────────────────────────────────── */
47
+ .gy-menu {
48
+ background: var(--gy-surface);
49
+ border: 1px solid var(--gy-border);
50
+ border-radius: var(--gy-radius-xl);
51
+ box-shadow: var(--gy-shadow-dropdown);
52
+ padding: var(--gy-spacing-1) 0;
53
+ min-width: 180px;
54
+ z-index: var(--gy-z-dropdown);
55
+ animation: gy-slide-in-up var(--gy-duration-fast) var(--gy-ease-out);
56
+ }
57
+
58
+ .gy-menu--absolute {
59
+ position: absolute;
60
+ }
61
+
62
+ .gy-menu-item {
63
+ display: flex;
64
+ align-items: center;
65
+ gap: var(--gy-spacing-2-5);
66
+ padding: var(--gy-spacing-2) var(--gy-spacing-4);
67
+ font-size: var(--gy-font-size-sm);
68
+ color: var(--gy-text);
69
+ cursor: pointer;
70
+ transition: background-color var(--gy-duration-fast);
71
+ border: none;
72
+ background: none;
73
+ width: 100%;
74
+ text-align: left;
75
+ border-radius: 0;
76
+ }
77
+
78
+ .gy-menu-item:hover { background: var(--gy-background-subtle); }
79
+ .gy-menu-item:focus-visible { outline: none; background: var(--gy-background-muted); }
80
+
81
+ .gy-menu-item--danger { color: var(--gy-danger); }
82
+ .gy-menu-item--danger:hover { background: var(--gy-danger-subtle); }
83
+
84
+ .gy-menu-item--disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
85
+
86
+ .gy-menu-item__icon {
87
+ display: flex;
88
+ align-items: center;
89
+ flex-shrink: 0;
90
+ color: var(--gy-text-subtle);
91
+ }
92
+
93
+ .gy-menu-item--danger .gy-menu-item__icon { color: var(--gy-danger); }
94
+
95
+ .gy-menu-item__badge {
96
+ margin-left: auto;
97
+ font-size: var(--gy-font-size-xs);
98
+ color: var(--gy-text-subtle);
99
+ }
100
+
101
+ .gy-menu-separator {
102
+ height: 1px;
103
+ background: var(--gy-divider);
104
+ margin: var(--gy-spacing-1) 0;
105
+ }
106
+
107
+ .gy-menu-group-label {
108
+ padding: var(--gy-spacing-2) var(--gy-spacing-4) var(--gy-spacing-1);
109
+ font-size: var(--gy-font-size-xs);
110
+ font-weight: var(--gy-font-weight-semibold);
111
+ color: var(--gy-text-subtle);
112
+ letter-spacing: var(--gy-letter-spacing-wider);
113
+ text-transform: uppercase;
114
+ }
115
+
116
+ /* Context menu trigger */
117
+ .gy-menu-trigger { position: relative; display: inline-flex; }
@@ -0,0 +1,103 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useRef } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import './modal.css';
6
+
7
+ export type ModalSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
8
+
9
+ export interface ModalProps {
10
+ open: boolean;
11
+ onClose: () => void;
12
+ title?: React.ReactNode;
13
+ size?: ModalSize;
14
+ children?: React.ReactNode;
15
+ footer?: React.ReactNode;
16
+ closeOnBackdrop?: boolean;
17
+ closeOnEscape?: boolean;
18
+ className?: string;
19
+ }
20
+
21
+ export function Modal({
22
+ open,
23
+ onClose,
24
+ title,
25
+ size = 'md',
26
+ children,
27
+ footer,
28
+ closeOnBackdrop = true,
29
+ closeOnEscape = true,
30
+ className = '',
31
+ }: ModalProps) {
32
+ const dialogRef = useRef<HTMLDivElement>(null);
33
+
34
+ // Focus trap and keyboard handling
35
+ useEffect(() => {
36
+ if (!open) return;
37
+
38
+ const previous = document.activeElement as HTMLElement;
39
+ dialogRef.current?.focus();
40
+
41
+ const handleKey = (e: KeyboardEvent) => {
42
+ if (e.key === 'Escape' && closeOnEscape) onClose();
43
+ if (e.key === 'Tab') {
44
+ const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
45
+ 'a, button:not(:disabled), textarea, input, select, [tabindex]:not([tabindex="-1"])'
46
+ );
47
+ if (!focusable?.length) return;
48
+ const first = focusable[0]!;
49
+ const last = focusable[focusable.length - 1]!;
50
+ if (e.shiftKey && document.activeElement === first) {
51
+ e.preventDefault();
52
+ last.focus();
53
+ } else if (!e.shiftKey && document.activeElement === last) {
54
+ e.preventDefault();
55
+ first.focus();
56
+ }
57
+ }
58
+ };
59
+
60
+ document.addEventListener('keydown', handleKey);
61
+ document.body.style.overflow = 'hidden';
62
+
63
+ return () => {
64
+ document.removeEventListener('keydown', handleKey);
65
+ document.body.style.overflow = '';
66
+ previous?.focus();
67
+ };
68
+ }, [open, onClose, closeOnEscape]);
69
+
70
+ if (!open) return null;
71
+
72
+ const content = (
73
+ <div
74
+ className="gy-modal-backdrop"
75
+ onClick={closeOnBackdrop ? (e) => { if (e.target === e.currentTarget) onClose(); } : undefined}
76
+ aria-modal="true"
77
+ >
78
+ <div
79
+ ref={dialogRef}
80
+ role="dialog"
81
+ aria-labelledby={title ? 'gy-modal-title' : undefined}
82
+ className={`gy-modal gy-modal--${size} ${className}`}
83
+ tabIndex={-1}
84
+ >
85
+ {title && (
86
+ <div className="gy-modal-header">
87
+ <h2 id="gy-modal-title" className="gy-modal-title">{title}</h2>
88
+ <button className="gy-modal-close" onClick={onClose} aria-label="Close dialog">
89
+ <svg width="16" height="16" viewBox="0 0 16 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
90
+ <line x1="1" y1="1" x2="15" y2="15" />
91
+ <line x1="15" y1="1" x2="1" y2="15" />
92
+ </svg>
93
+ </button>
94
+ </div>
95
+ )}
96
+ <div className="gy-modal-body">{children}</div>
97
+ {footer && <div className="gy-modal-footer">{footer}</div>}
98
+ </div>
99
+ </div>
100
+ );
101
+
102
+ return createPortal(content, document.body);
103
+ }
@@ -0,0 +1,2 @@
1
+ export { Modal } from './Modal';
2
+ export type { ModalProps, ModalSize } from './Modal';
@@ -0,0 +1,79 @@
1
+ /* ── Modal ────────────────────────────────────────────────────────────────── */
2
+ .gy-modal-backdrop {
3
+ position: fixed;
4
+ inset: 0;
5
+ background: var(--gy-overlay);
6
+ backdrop-filter: blur(4px);
7
+ z-index: var(--gy-z-modal);
8
+ display: flex;
9
+ align-items: center;
10
+ justify-content: center;
11
+ padding: var(--gy-spacing-4);
12
+ animation: gy-fade-in var(--gy-duration-normal) var(--gy-ease-out);
13
+ }
14
+
15
+ .gy-modal {
16
+ background: var(--gy-surface);
17
+ border-radius: var(--gy-radius-2xl);
18
+ box-shadow: var(--gy-shadow-modal);
19
+ display: flex;
20
+ flex-direction: column;
21
+ max-height: calc(100vh - 2rem);
22
+ width: 100%;
23
+ animation: gy-zoom-in var(--gy-duration-normal) var(--gy-ease-spring);
24
+ outline: none;
25
+ }
26
+
27
+ .gy-modal--xs { max-width: 20rem; }
28
+ .gy-modal--sm { max-width: 28rem; }
29
+ .gy-modal--md { max-width: 36rem; }
30
+ .gy-modal--lg { max-width: 48rem; }
31
+ .gy-modal--xl { max-width: 64rem; }
32
+ .gy-modal--full { max-width: calc(100vw - 2rem); max-height: calc(100vh - 2rem); }
33
+
34
+ .gy-modal-header {
35
+ display: flex;
36
+ align-items: center;
37
+ justify-content: space-between;
38
+ padding: var(--gy-spacing-5) var(--gy-spacing-6);
39
+ border-bottom: 1px solid var(--gy-border);
40
+ flex-shrink: 0;
41
+ }
42
+
43
+ .gy-modal-title {
44
+ font-size: var(--gy-font-size-lg);
45
+ font-weight: var(--gy-font-weight-semibold);
46
+ color: var(--gy-text);
47
+ }
48
+
49
+ .gy-modal-close {
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ width: 2rem;
54
+ height: 2rem;
55
+ border-radius: var(--gy-radius-md);
56
+ color: var(--gy-text-subtle);
57
+ cursor: pointer;
58
+ background: none;
59
+ border: none;
60
+ transition: color var(--gy-duration-fast), background var(--gy-duration-fast);
61
+ }
62
+
63
+ .gy-modal-close:hover { color: var(--gy-text); background: var(--gy-background-muted); }
64
+
65
+ .gy-modal-body {
66
+ flex: 1;
67
+ overflow-y: auto;
68
+ padding: var(--gy-spacing-6);
69
+ }
70
+
71
+ .gy-modal-footer {
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: flex-end;
75
+ gap: var(--gy-spacing-3);
76
+ padding: var(--gy-spacing-4) var(--gy-spacing-6);
77
+ border-top: 1px solid var(--gy-border);
78
+ flex-shrink: 0;
79
+ }