@asafarim/react-dropdowns 1.0.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.
@@ -0,0 +1,195 @@
1
+ import { useState, useRef, useCallback, useEffect } from 'react';
2
+ import { DropdownPlacement, DropdownPosition } from '../types';
3
+
4
+ interface UseDropdownProps {
5
+ placement?: DropdownPlacement;
6
+ offset?: number;
7
+ closeOnSelect?: boolean;
8
+ }
9
+
10
+ export const useDropdown = ({
11
+ placement = 'bottom-start',
12
+ offset = 8,
13
+ closeOnSelect = true
14
+ }: UseDropdownProps = {}) => {
15
+ const [isOpen, setIsOpen] = useState(false);
16
+ const [position, setPosition] = useState<DropdownPosition>({});
17
+ const triggerRef = useRef<HTMLDivElement>(null);
18
+ const menuRef = useRef<HTMLDivElement>(null);
19
+
20
+ const calculatePosition = useCallback(() => {
21
+ if (!triggerRef.current || !menuRef.current) return;
22
+
23
+ const triggerRect = triggerRef.current.getBoundingClientRect();
24
+ const menuRect = menuRef.current.getBoundingClientRect();
25
+ const viewport = {
26
+ width: window.innerWidth,
27
+ height: window.innerHeight
28
+ };
29
+
30
+ let newPosition: DropdownPosition = {};
31
+
32
+ // Calculate base position based on placement
33
+ switch (placement) {
34
+ case 'top':
35
+ newPosition = {
36
+ bottom: viewport.height - triggerRect.top + offset,
37
+ left: triggerRect.left + (triggerRect.width - menuRect.width) / 2
38
+ };
39
+ break;
40
+ case 'top-start':
41
+ newPosition = {
42
+ bottom: viewport.height - triggerRect.top + offset,
43
+ left: triggerRect.left
44
+ };
45
+ break;
46
+ case 'top-end':
47
+ newPosition = {
48
+ bottom: viewport.height - triggerRect.top + offset,
49
+ right: viewport.width - triggerRect.right
50
+ };
51
+ break;
52
+ case 'bottom':
53
+ newPosition = {
54
+ top: triggerRect.bottom + offset,
55
+ left: triggerRect.left + (triggerRect.width - menuRect.width) / 2
56
+ };
57
+ break;
58
+ case 'bottom-start':
59
+ newPosition = {
60
+ top: triggerRect.bottom + offset,
61
+ left: triggerRect.left
62
+ };
63
+ break;
64
+ case 'bottom-end':
65
+ newPosition = {
66
+ top: triggerRect.bottom + offset,
67
+ right: viewport.width - triggerRect.right
68
+ };
69
+ break;
70
+ case 'left':
71
+ newPosition = {
72
+ top: triggerRect.top + (triggerRect.height - menuRect.height) / 2,
73
+ right: viewport.width - triggerRect.left + offset
74
+ };
75
+ break;
76
+ case 'left-start':
77
+ newPosition = {
78
+ top: triggerRect.top,
79
+ right: viewport.width - triggerRect.left + offset
80
+ };
81
+ break;
82
+ case 'left-end':
83
+ newPosition = {
84
+ bottom: viewport.height - triggerRect.bottom,
85
+ right: viewport.width - triggerRect.left + offset
86
+ };
87
+ break;
88
+ case 'right':
89
+ newPosition = {
90
+ top: triggerRect.top + (triggerRect.height - menuRect.height) / 2,
91
+ left: triggerRect.right + offset
92
+ };
93
+ break;
94
+ case 'right-start':
95
+ newPosition = {
96
+ top: triggerRect.top,
97
+ left: triggerRect.right + offset
98
+ };
99
+ break;
100
+ case 'right-end':
101
+ newPosition = {
102
+ bottom: viewport.height - triggerRect.bottom,
103
+ left: triggerRect.right + offset
104
+ };
105
+ break;
106
+ }
107
+
108
+ // Adjust for viewport boundaries
109
+ if (newPosition.left !== undefined) {
110
+ if (newPosition.left < 0) {
111
+ newPosition.left = 8;
112
+ } else if (newPosition.left + menuRect.width > viewport.width) {
113
+ newPosition.left = viewport.width - menuRect.width - 8;
114
+ }
115
+ }
116
+
117
+ if (newPosition.right !== undefined) {
118
+ if (newPosition.right < 0) {
119
+ newPosition.right = 8;
120
+ }
121
+ }
122
+
123
+ if (newPosition.top !== undefined) {
124
+ if (newPosition.top < 0) {
125
+ newPosition.top = 8;
126
+ } else if (newPosition.top + menuRect.height > viewport.height) {
127
+ newPosition.top = viewport.height - menuRect.height - 8;
128
+ }
129
+ }
130
+
131
+ if (newPosition.bottom !== undefined) {
132
+ if (newPosition.bottom < 0) {
133
+ newPosition.bottom = 8;
134
+ }
135
+ }
136
+
137
+ setPosition(newPosition);
138
+ }, [placement, offset]);
139
+
140
+ const toggle = useCallback(() => {
141
+ setIsOpen(prev => !prev);
142
+ }, []);
143
+
144
+ const open = useCallback(() => {
145
+ setIsOpen(true);
146
+ }, []);
147
+
148
+ const close = useCallback(() => {
149
+ setIsOpen(false);
150
+ }, []);
151
+
152
+ const handleItemClick = useCallback(() => {
153
+ if (closeOnSelect) {
154
+ close();
155
+ }
156
+ }, [closeOnSelect, close]);
157
+
158
+ // Recalculate position when opened
159
+ useEffect(() => {
160
+ if (isOpen) {
161
+ // Small delay to ensure DOM is updated
162
+ const timer = setTimeout(calculatePosition, 0);
163
+ return () => clearTimeout(timer);
164
+ }
165
+ }, [isOpen, calculatePosition]);
166
+
167
+ // Recalculate position on window resize and scroll
168
+ useEffect(() => {
169
+ if (!isOpen) return;
170
+
171
+ const handleResize = () => calculatePosition();
172
+ const handleScroll = () => calculatePosition();
173
+
174
+ window.addEventListener('resize', handleResize);
175
+ window.addEventListener('scroll', handleScroll, { passive: true });
176
+ document.addEventListener('scroll', handleScroll, { passive: true });
177
+
178
+ return () => {
179
+ window.removeEventListener('resize', handleResize);
180
+ window.removeEventListener('scroll', handleScroll);
181
+ document.removeEventListener('scroll', handleScroll);
182
+ };
183
+ }, [isOpen, calculatePosition]);
184
+
185
+ return {
186
+ isOpen,
187
+ position,
188
+ triggerRef,
189
+ menuRef,
190
+ toggle,
191
+ open,
192
+ close,
193
+ handleItemClick
194
+ };
195
+ };
@@ -0,0 +1,108 @@
1
+ import { useEffect, useCallback, RefObject } from 'react';
2
+
3
+ interface UseKeyboardNavigationProps {
4
+ isOpen: boolean;
5
+ menuRef: RefObject<HTMLElement>;
6
+ onClose: () => void;
7
+ onSelect?: (index: number) => void;
8
+ }
9
+
10
+ export const useKeyboardNavigation = ({
11
+ isOpen,
12
+ menuRef,
13
+ onClose,
14
+ onSelect
15
+ }: UseKeyboardNavigationProps) => {
16
+ const getMenuItems = useCallback(() => {
17
+ if (!menuRef.current) return [];
18
+ return Array.from(
19
+ menuRef.current.querySelectorAll('[role="menuitem"]:not([disabled])')
20
+ ) as HTMLElement[];
21
+ }, [menuRef]);
22
+
23
+ const focusItem = useCallback((index: number) => {
24
+ const items = getMenuItems();
25
+ if (items[index]) {
26
+ items[index].focus();
27
+ }
28
+ }, [getMenuItems]);
29
+
30
+ const getCurrentIndex = useCallback(() => {
31
+ const items = getMenuItems();
32
+ const activeElement = document.activeElement as HTMLElement;
33
+ return items.indexOf(activeElement);
34
+ }, [getMenuItems]);
35
+
36
+ const handleKeyDown = useCallback((event: KeyboardEvent) => {
37
+ if (!isOpen) return;
38
+
39
+ const items = getMenuItems();
40
+ const currentIndex = getCurrentIndex();
41
+
42
+ switch (event.key) {
43
+ case 'Escape':
44
+ event.preventDefault();
45
+ onClose();
46
+ break;
47
+
48
+ case 'ArrowDown':
49
+ event.preventDefault();
50
+ if (items.length > 0) {
51
+ const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
52
+ focusItem(nextIndex);
53
+ }
54
+ break;
55
+
56
+ case 'ArrowUp':
57
+ event.preventDefault();
58
+ if (items.length > 0) {
59
+ const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
60
+ focusItem(prevIndex);
61
+ }
62
+ break;
63
+
64
+ case 'Home':
65
+ event.preventDefault();
66
+ if (items.length > 0) {
67
+ focusItem(0);
68
+ }
69
+ break;
70
+
71
+ case 'End':
72
+ event.preventDefault();
73
+ if (items.length > 0) {
74
+ focusItem(items.length - 1);
75
+ }
76
+ break;
77
+
78
+ case 'Enter':
79
+ case ' ':
80
+ event.preventDefault();
81
+ if (currentIndex >= 0 && onSelect) {
82
+ onSelect(currentIndex);
83
+ }
84
+ break;
85
+
86
+ case 'Tab':
87
+ onClose();
88
+ break;
89
+ }
90
+ }, [isOpen, getMenuItems, getCurrentIndex, focusItem, onClose, onSelect]);
91
+
92
+ useEffect(() => {
93
+ if (!isOpen) return;
94
+
95
+ document.addEventListener('keydown', handleKeyDown);
96
+ return () => document.removeEventListener('keydown', handleKeyDown);
97
+ }, [isOpen, handleKeyDown]);
98
+
99
+ // Focus first item when menu opens
100
+ useEffect(() => {
101
+ if (isOpen) {
102
+ const timer = setTimeout(() => {
103
+ focusItem(0);
104
+ }, 0);
105
+ return () => clearTimeout(timer);
106
+ }
107
+ }, [isOpen, focusItem]);
108
+ };
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ // Import styles
2
+ import './styles/dropdown.css';
3
+
4
+ // Export components
5
+ export { Dropdown } from './components/Dropdown';
6
+ export { DropdownItem } from './components/DropdownItem';
7
+ export { DropdownMenu } from './components/DropdownMenu';
8
+ export { DropdownTrigger } from './components/DropdownTrigger';
9
+
10
+ // Export hooks
11
+ export { useDropdown } from './hooks/useDropdown';
12
+ export { useClickOutside } from './hooks/useClickOutside';
13
+ export { useKeyboardNavigation } from './hooks/useKeyboardNavigation';
14
+
15
+ // Export types
16
+ export type {
17
+ DropdownProps,
18
+ DropdownItemProps,
19
+ DropdownMenuProps,
20
+ DropdownTriggerProps,
21
+ DropdownPosition,
22
+ DropdownPlacement,
23
+ DropdownSize,
24
+ DropdownVariant,
25
+ DropdownItemData
26
+ } from './types';
@@ -0,0 +1,231 @@
1
+ /* Import design tokens */
2
+ @import '@asafarim/design-tokens/css/index.css';
3
+
4
+ /* Dropdown Container */
5
+ .asm-dropdown {
6
+ position: relative;
7
+ display: inline-block;
8
+ }
9
+
10
+ /* Dropdown Trigger */
11
+ .asm-dropdown-trigger {
12
+ background: none;
13
+ border: none;
14
+ padding: 0;
15
+ margin: 0;
16
+ cursor: pointer;
17
+ display: inline-flex;
18
+ align-items: center;
19
+ justify-content: center;
20
+ }
21
+
22
+ .asm-dropdown-trigger:disabled {
23
+ cursor: not-allowed;
24
+ opacity: 0.6;
25
+ }
26
+
27
+ .asm-dropdown-trigger:focus-visible {
28
+ outline: 2px solid var(--asm-color-primary-500);
29
+ outline-offset: 2px;
30
+ border-radius: var(--asm-radius-sm);
31
+ }
32
+
33
+ /* Dropdown Menu */
34
+ .asm-dropdown-menu {
35
+ background-color: var(--asm-color-surface);
36
+ border: 1px solid var(--asm-color-border);
37
+ border-radius: var(--asm-radius-lg);
38
+ box-shadow: var(--asm-effect-shadow-lg);
39
+ padding: var(--asm-space-1);
40
+ min-width: 160px;
41
+ max-width: 320px;
42
+ max-height: 400px;
43
+ overflow-y: auto;
44
+ z-index: var(--asm-z-dropdown);
45
+
46
+ /* Animation */
47
+ animation: asm-dropdown-enter 0.15s ease-out;
48
+ transform-origin: top;
49
+ }
50
+
51
+ @keyframes asm-dropdown-enter {
52
+ from {
53
+ opacity: 0;
54
+ transform: scale(0.95) translateY(-8px);
55
+ }
56
+ to {
57
+ opacity: 1;
58
+ transform: scale(1) translateY(0);
59
+ }
60
+ }
61
+
62
+ /* Size variants */
63
+ .asm-dropdown-menu--sm {
64
+ min-width: 120px;
65
+ padding: var(--asm-space-0-5);
66
+ }
67
+
68
+ .asm-dropdown-menu--md {
69
+ min-width: 160px;
70
+ padding: var(--asm-space-1);
71
+ }
72
+
73
+ .asm-dropdown-menu--lg {
74
+ min-width: 200px;
75
+ padding: var(--asm-space-1-5);
76
+ }
77
+
78
+ /* Dropdown Item */
79
+ .asm-dropdown-item {
80
+ display: flex;
81
+ align-items: center;
82
+ gap: var(--asm-space-2);
83
+ width: 100%;
84
+ padding: var(--asm-space-2) var(--asm-space-3);
85
+ border: none;
86
+ background: transparent;
87
+ color: var(--asm-color-text);
88
+ font-size: var(--asm-font-size-sm);
89
+ font-weight: var(--asm-font-weight-400);
90
+ text-align: left;
91
+ cursor: pointer;
92
+ border-radius: var(--asm-radius-md);
93
+ transition: var(--asm-transition-fade);
94
+ min-height: 36px;
95
+ }
96
+
97
+ .asm-dropdown-item:hover:not(:disabled) {
98
+ background-color: var(--asm-color-button-ghost-bg-hover);
99
+ color: var(--asm-color-text);
100
+ }
101
+
102
+ .asm-dropdown-item:focus {
103
+ outline: none;
104
+ background-color: var(--asm-color-button-ghost-bg-hover);
105
+ color: var(--asm-color-text);
106
+ }
107
+
108
+ .asm-dropdown-item:active:not(:disabled) {
109
+ background-color: var(--asm-color-button-ghost-bg-hover);
110
+ }
111
+
112
+ /* Dropdown Item States */
113
+ .asm-dropdown-item--disabled {
114
+ color: var(--asm-color-text-muted);
115
+ cursor: not-allowed;
116
+ opacity: 0.6;
117
+ }
118
+
119
+ .asm-dropdown-item--danger {
120
+ color: var(--asm-color-danger-600);
121
+ }
122
+
123
+ .asm-dropdown-item--danger:hover:not(:disabled) {
124
+ background-color: var(--asm-color-danger-50);
125
+ color: var(--asm-color-danger-700);
126
+ }
127
+
128
+ .asm-dropdown-item--danger:focus {
129
+ background-color: var(--asm-color-danger-50);
130
+ color: var(--asm-color-danger-700);
131
+ }
132
+
133
+ /* Dropdown Item Elements */
134
+ .asm-dropdown-item__icon {
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: center;
138
+ flex-shrink: 0;
139
+ width: 16px;
140
+ height: 16px;
141
+ }
142
+
143
+ .asm-dropdown-item__label {
144
+ flex: 1;
145
+ min-width: 0;
146
+ white-space: nowrap;
147
+ overflow: hidden;
148
+ text-overflow: ellipsis;
149
+ }
150
+
151
+ /* Dropdown Divider */
152
+ .asm-dropdown-divider {
153
+ height: 1px;
154
+ background-color: var(--asm-color-border);
155
+ margin: var(--asm-space-1) 0;
156
+ }
157
+
158
+ /* Dark theme support */
159
+ [data-theme="dark"] .asm-dropdown-menu {
160
+ background-color: var(--asm-color-surface);
161
+ border-color: var(--asm-color-border);
162
+ box-shadow: var(--asm-effect-shadow-lg);
163
+ }
164
+
165
+ [data-theme="dark"] .asm-dropdown-item {
166
+ color: var(--asm-color-text);
167
+ }
168
+
169
+ [data-theme="dark"] .asm-dropdown-item:hover:not(:disabled) {
170
+ background-color: var(--asm-color-button-ghost-bg-hover);
171
+ }
172
+
173
+ [data-theme="dark"] .asm-dropdown-item--danger {
174
+ color: var(--asm-color-danger-400);
175
+ }
176
+
177
+ [data-theme="dark"] .asm-dropdown-item--danger:hover:not(:disabled) {
178
+ background-color: var(--asm-color-danger-900);
179
+ color: var(--asm-color-danger-300);
180
+ }
181
+
182
+ /* Mobile optimizations */
183
+ @media (max-width: 640px) {
184
+ .asm-dropdown-menu {
185
+ min-width: 200px;
186
+ max-width: calc(100vw - 32px);
187
+ max-height: 60vh;
188
+ }
189
+
190
+ .asm-dropdown-item {
191
+ padding: var(--asm-space-3) var(--asm-space-4);
192
+ min-height: 44px;
193
+ font-size: var(--asm-font-size-md);
194
+ }
195
+
196
+ .asm-dropdown-item__icon {
197
+ width: 20px;
198
+ height: 20px;
199
+ }
200
+ }
201
+
202
+ /* Touch device optimizations */
203
+ @media (hover: none) and (pointer: coarse) {
204
+ .asm-dropdown-item {
205
+ min-height: 44px;
206
+ padding: var(--asm-space-3) var(--asm-space-4);
207
+ }
208
+ }
209
+
210
+ /* Reduced motion */
211
+ @media (prefers-reduced-motion: reduce) {
212
+ .asm-dropdown-menu {
213
+ animation: none;
214
+ }
215
+
216
+ .asm-dropdown-item {
217
+ transition: none;
218
+ }
219
+ }
220
+
221
+ /* High contrast mode */
222
+ @media (prefers-contrast: high) {
223
+ .asm-dropdown-menu {
224
+ border-width: 2px;
225
+ }
226
+
227
+ .asm-dropdown-item:focus {
228
+ outline: 2px solid currentColor;
229
+ outline-offset: -2px;
230
+ }
231
+ }
@@ -0,0 +1,14 @@
1
+ declare module '*.module.css' {
2
+ const classes: { [key: string]: string };
3
+ export default classes;
4
+ }
5
+
6
+ declare module '*.module.scss' {
7
+ const classes: { [key: string]: string };
8
+ export default classes;
9
+ }
10
+
11
+ declare module '*.module.sass' {
12
+ const classes: { [key: string]: string };
13
+ export default classes;
14
+ }
@@ -0,0 +1,86 @@
1
+ import { ReactNode, MouseEvent, KeyboardEvent } from 'react';
2
+
3
+ export type DropdownPlacement =
4
+ | 'top' | 'top-start' | 'top-end'
5
+ | 'bottom' | 'bottom-start' | 'bottom-end'
6
+ | 'left' | 'left-start' | 'left-end'
7
+ | 'right' | 'right-start' | 'right-end';
8
+
9
+ export type DropdownSize = 'sm' | 'md' | 'lg';
10
+ export type DropdownVariant =
11
+ | 'primary'
12
+ | 'secondary'
13
+ | 'success'
14
+ | 'warning'
15
+ | 'danger'
16
+ | 'info'
17
+ | 'ghost'
18
+ | 'outline'
19
+ | 'link'
20
+ | 'brand';
21
+
22
+ export interface DropdownPosition {
23
+ top?: number;
24
+ left?: number;
25
+ right?: number;
26
+ bottom?: number;
27
+ transform?: string;
28
+ }
29
+
30
+ export interface DropdownItemData {
31
+ id?: string;
32
+ label: string;
33
+ value?: string;
34
+ icon?: ReactNode;
35
+ disabled?: boolean;
36
+ danger?: boolean;
37
+ divider?: boolean;
38
+ onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
39
+ }
40
+
41
+ export interface DropdownProps {
42
+ children: ReactNode;
43
+ items?: DropdownItemData[];
44
+ isOpen?: boolean;
45
+ onToggle?: (isOpen: boolean) => void;
46
+ placement?: DropdownPlacement;
47
+ size?: DropdownSize;
48
+ variant?: DropdownVariant;
49
+ disabled?: boolean;
50
+ closeOnSelect?: boolean;
51
+ showChevron?: boolean;
52
+ className?: string;
53
+ 'data-testid'?: string;
54
+ }
55
+
56
+ export interface DropdownItemProps {
57
+ children?: ReactNode;
58
+ label?: string;
59
+ icon?: ReactNode;
60
+ disabled?: boolean;
61
+ danger?: boolean;
62
+ divider?: boolean;
63
+ onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
64
+ className?: string;
65
+ 'data-testid'?: string;
66
+ }
67
+
68
+ export interface DropdownMenuProps {
69
+ children: ReactNode;
70
+ isOpen: boolean;
71
+ position: DropdownPosition;
72
+ size?: DropdownSize;
73
+ className?: string;
74
+ 'data-testid'?: string;
75
+ }
76
+
77
+ export interface DropdownTriggerProps {
78
+ children: ReactNode;
79
+ onClick: (event: MouseEvent<HTMLButtonElement>) => void;
80
+ onKeyDown?: (event: KeyboardEvent<HTMLButtonElement>) => void;
81
+ disabled?: boolean;
82
+ variant?: DropdownVariant;
83
+ size?: DropdownSize;
84
+ className?: string;
85
+ 'data-testid'?: string;
86
+ }