@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,158 @@
1
+ import React, { useRef } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { DropdownProps } from '../types';
4
+ import { useDropdown } from '../hooks/useDropdown';
5
+ import { useClickOutside } from '../hooks/useClickOutside';
6
+ import { useKeyboardNavigation } from '../hooks/useKeyboardNavigation';
7
+ import { DropdownMenu } from './DropdownMenu';
8
+ import { DropdownItem } from './DropdownItem';
9
+ import { DropdownTrigger } from './DropdownTrigger';
10
+
11
+ export const Dropdown: React.FC<DropdownProps> = ({
12
+ children,
13
+ items = [],
14
+ isOpen: controlledIsOpen,
15
+ onToggle,
16
+ placement = 'bottom-start',
17
+ size = 'md',
18
+ variant = 'primary',
19
+ disabled = false,
20
+ closeOnSelect = true,
21
+ showChevron = true,
22
+ className = '',
23
+ 'data-testid': testId
24
+ }) => {
25
+ const containerRef = useRef<HTMLDivElement>(null);
26
+
27
+ const {
28
+ isOpen: internalIsOpen,
29
+ position,
30
+ triggerRef,
31
+ menuRef,
32
+ toggle,
33
+ close,
34
+ handleItemClick
35
+ } = useDropdown({
36
+ placement,
37
+ closeOnSelect
38
+ });
39
+
40
+ // Use controlled or internal state
41
+ const isOpen = controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen;
42
+
43
+ const handleToggle = () => {
44
+ if (disabled) return;
45
+
46
+ if (onToggle) {
47
+ onToggle(!isOpen);
48
+ } else {
49
+ toggle();
50
+ }
51
+ };
52
+
53
+ const handleClose = () => {
54
+ if (onToggle) {
55
+ onToggle(false);
56
+ } else {
57
+ close();
58
+ }
59
+ };
60
+
61
+ const handleItemSelect = (index: number) => {
62
+ const item = items[index];
63
+ if (item && !item.disabled && item.onClick) {
64
+ item.onClick({} as React.MouseEvent<HTMLButtonElement>);
65
+ }
66
+ handleItemClick();
67
+ };
68
+
69
+ // Click outside to close
70
+ useClickOutside({
71
+ ref: containerRef,
72
+ handler: handleClose,
73
+ enabled: isOpen
74
+ });
75
+
76
+ // Keyboard navigation
77
+ useKeyboardNavigation({
78
+ isOpen,
79
+ menuRef,
80
+ onClose: handleClose,
81
+ onSelect: handleItemSelect
82
+ });
83
+
84
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
85
+ if (event.key === 'Enter' || event.key === ' ') {
86
+ event.preventDefault();
87
+ handleToggle();
88
+ } else if (event.key === 'ArrowDown') {
89
+ event.preventDefault();
90
+ if (!isOpen) {
91
+ handleToggle();
92
+ }
93
+ }
94
+ };
95
+
96
+ return (
97
+ <div
98
+ ref={containerRef}
99
+ className={`asm-dropdown ${className}`}
100
+ data-testid={testId}
101
+ >
102
+ <DropdownTrigger
103
+ ref={triggerRef}
104
+ onClick={handleToggle}
105
+ onKeyDown={handleKeyDown}
106
+ disabled={disabled}
107
+ variant={variant}
108
+ size={size}
109
+ >
110
+ {children}
111
+ {showChevron && (
112
+ <svg
113
+ width="12"
114
+ height="12"
115
+ viewBox="0 0 12 12"
116
+ fill="currentColor"
117
+ style={{ marginLeft: 'var(--asm-space-1)' }}
118
+ >
119
+ <path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
120
+ </svg>
121
+ )}
122
+ </DropdownTrigger>
123
+
124
+ {isOpen && createPortal(
125
+ <DropdownMenu
126
+ ref={menuRef}
127
+ isOpen={isOpen}
128
+ position={position}
129
+ size={size}
130
+ data-testid={testId ? `${testId}-menu` : undefined}
131
+ >
132
+ {items.map((item, index) => (
133
+ <React.Fragment key={item.id || index}>
134
+ {item.divider ? (
135
+ <div className="asm-dropdown-divider" />
136
+ ) : (
137
+ <DropdownItem
138
+ label={item.label}
139
+ icon={item.icon}
140
+ disabled={item.disabled}
141
+ danger={item.danger}
142
+ onClick={(event) => {
143
+ if (item.onClick) {
144
+ item.onClick(event);
145
+ }
146
+ handleItemClick();
147
+ }}
148
+ data-testid={item.id ? `${testId}-item-${item.id}` : undefined}
149
+ />
150
+ )}
151
+ </React.Fragment>
152
+ ))}
153
+ </DropdownMenu>,
154
+ document.body
155
+ )}
156
+ </div>
157
+ );
158
+ };
@@ -0,0 +1,54 @@
1
+ import React from 'react';
2
+ import { DropdownItemProps } from '../types';
3
+
4
+ export const DropdownItem: React.FC<DropdownItemProps> = ({
5
+ children,
6
+ label,
7
+ icon,
8
+ disabled = false,
9
+ danger = false,
10
+ divider = false,
11
+ onClick,
12
+ className = '',
13
+ 'data-testid': testId
14
+ }) => {
15
+ if (divider) {
16
+ return <div className="asm-dropdown-divider" data-testid={testId} />;
17
+ }
18
+
19
+ const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
20
+ if (disabled) return;
21
+ onClick?.(event);
22
+ };
23
+
24
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
25
+ if (event.key === 'Enter' || event.key === ' ') {
26
+ event.preventDefault();
27
+ if (!disabled && onClick) {
28
+ onClick(event as any);
29
+ }
30
+ }
31
+ };
32
+
33
+ return (
34
+ <button
35
+ type="button"
36
+ className={`asm-dropdown-item ${danger ? 'asm-dropdown-item--danger' : ''} ${disabled ? 'asm-dropdown-item--disabled' : ''} ${className}`}
37
+ onClick={handleClick}
38
+ onKeyDown={handleKeyDown}
39
+ disabled={disabled}
40
+ role="menuitem"
41
+ tabIndex={-1}
42
+ data-testid={testId}
43
+ >
44
+ {icon && (
45
+ <span className="asm-dropdown-item__icon">
46
+ {icon}
47
+ </span>
48
+ )}
49
+ <span className="asm-dropdown-item__label">
50
+ {label || children}
51
+ </span>
52
+ </button>
53
+ );
54
+ };
@@ -0,0 +1,34 @@
1
+ import React, { forwardRef } from 'react';
2
+ import { DropdownMenuProps } from '../types';
3
+
4
+ export const DropdownMenu = forwardRef<HTMLDivElement, DropdownMenuProps>(({
5
+ children,
6
+ isOpen,
7
+ position,
8
+ size = 'md',
9
+ className = '',
10
+ 'data-testid': testId
11
+ }, ref) => {
12
+ if (!isOpen) return null;
13
+
14
+ const positionStyles: React.CSSProperties = {
15
+ position: 'fixed',
16
+ zIndex: 1000,
17
+ ...position
18
+ };
19
+
20
+ return (
21
+ <div
22
+ ref={ref}
23
+ className={`asm-dropdown-menu asm-dropdown-menu--${size} ${className}`}
24
+ style={positionStyles}
25
+ role="menu"
26
+ aria-orientation="vertical"
27
+ data-testid={testId}
28
+ >
29
+ {children}
30
+ </div>
31
+ );
32
+ });
33
+
34
+ DropdownMenu.displayName = 'DropdownMenu';
@@ -0,0 +1,204 @@
1
+ @import "@asafarim/design-tokens/css/index.css";
2
+
3
+ .trigger {
4
+ font-family: var(--asm-font-family-primary);
5
+ font-size: var(--asm-font-size-md);
6
+ font-weight: var(--asm-font-weight-500);
7
+ border: none;
8
+ border-radius: var(--asm-radius-md);
9
+ cursor: pointer;
10
+ transition: all var(--asm-motion-duration-normal) var(--asm-motion-easing-standard);
11
+ display: inline-flex;
12
+ align-items: center;
13
+ justify-content: center;
14
+ gap: var(--asm-space-2);
15
+ white-space: nowrap;
16
+ }
17
+
18
+ .trigger:disabled {
19
+ cursor: not-allowed;
20
+ opacity: 0.6;
21
+ }
22
+
23
+ .trigger:focus-visible {
24
+ outline: 2px solid var(--asm-color-focus-ring);
25
+ outline-offset: 2px;
26
+ }
27
+
28
+ /* Size variants */
29
+ .sm {
30
+ padding: calc(var(--asm-space-control-padding-y) * 0.75) calc(var(--asm-space-control-padding-x) * 0.75);
31
+ font-size: var(--asm-font-size-sm);
32
+ }
33
+
34
+ .md {
35
+ padding: var(--asm-space-control-padding-y) var(--asm-space-control-padding-x);
36
+ font-size: var(--asm-font-size-md);
37
+ }
38
+
39
+ .lg {
40
+ padding: calc(var(--asm-space-control-padding-y) * 1.25) calc(var(--asm-space-control-padding-x) * 1.25);
41
+ font-size: var(--asm-font-size-lg);
42
+ }
43
+
44
+ /* Primary variant */
45
+ .primary {
46
+ background-color: var(--asm-color-button-primary-bg);
47
+ color: var(--asm-color-button-primary-text);
48
+ border: 1px solid transparent;
49
+ }
50
+
51
+ .primary:hover:not(:disabled) {
52
+ background-color: var(--asm-color-button-primary-bg-hover);
53
+ }
54
+
55
+ .primary:active:not(:disabled) {
56
+ background-color: var(--asm-color-button-primary-bg-active);
57
+ }
58
+
59
+ /* Secondary variant */
60
+ .secondary {
61
+ background-color: var(--asm-color-button-secondary-bg);
62
+ color: var(--asm-color-button-secondary-text);
63
+ border: 1px solid var(--asm-color-border);
64
+ }
65
+
66
+ .secondary:hover:not(:disabled) {
67
+ border-color: var(--asm-color-text);
68
+ background-color: var(--asm-color-surface-muted);
69
+ }
70
+
71
+ .secondary:active:not(:disabled) {
72
+ background-color: var(--asm-color-surface-muted);
73
+ }
74
+
75
+ /* Success variant */
76
+ .success {
77
+ background-color: var(--asm-color-success-400);
78
+ color: #ffffff;
79
+ border: 1px solid transparent;
80
+ }
81
+
82
+ .success:hover:not(:disabled) {
83
+ background-color: var(--asm-color-success-500);
84
+ }
85
+
86
+ .success:active:not(:disabled) {
87
+ background-color: var(--asm-color-success-600);
88
+ }
89
+
90
+ /* Warning variant */
91
+ .warning {
92
+ background-color: var(--asm-color-warning-400);
93
+ color: #ffffff;
94
+ border: 1px solid transparent;
95
+ }
96
+
97
+ .warning:hover:not(:disabled) {
98
+ background-color: var(--asm-color-warning-500);
99
+ }
100
+
101
+ .warning:active:not(:disabled) {
102
+ background-color: var(--asm-color-warning-600);
103
+ }
104
+
105
+ /* Danger variant */
106
+ .danger {
107
+ background-color: var(--asm-color-button-destructive-bg);
108
+ color: var(--asm-color-button-destructive-text);
109
+ border: 1px solid transparent;
110
+ }
111
+
112
+ .danger:hover:not(:disabled) {
113
+ background-color: var(--asm-color-danger-500);
114
+ }
115
+
116
+ .danger:active:not(:disabled) {
117
+ background-color: var(--asm-color-danger-600);
118
+ }
119
+
120
+ /* Info variant */
121
+ .info {
122
+ background-color: var(--asm-color-info-400);
123
+ color: #ffffff;
124
+ border: 1px solid transparent;
125
+ }
126
+
127
+ .info:hover:not(:disabled) {
128
+ background-color: var(--asm-color-info-500);
129
+ }
130
+
131
+ .info:active:not(:disabled) {
132
+ background-color: var(--asm-color-info-600);
133
+ }
134
+
135
+ /* Ghost variant */
136
+ .ghost {
137
+ background-color: transparent;
138
+ color: var(--asm-color-text);
139
+ border: 1px solid transparent;
140
+ }
141
+
142
+ .ghost:hover:not(:disabled) {
143
+ background-color: var(--asm-color-button-ghost-bg-hover);
144
+ }
145
+
146
+ .ghost:active:not(:disabled) {
147
+ background-color: var(--asm-color-button-ghost-bg-hover);
148
+ }
149
+
150
+ /* Outline variant */
151
+ .outline {
152
+ background-color: transparent;
153
+ color: var(--asm-color-text);
154
+ border: 1px solid var(--asm-color-border);
155
+ }
156
+
157
+ .outline:hover:not(:disabled) {
158
+ border-color: var(--asm-color-text);
159
+ background-color: var(--asm-color-surface-muted);
160
+ }
161
+
162
+ .outline:active:not(:disabled) {
163
+ background-color: var(--asm-color-surface-muted);
164
+ }
165
+
166
+ /* Link variant */
167
+ .link {
168
+ background-color: transparent;
169
+ color: var(--asm-color-primary-500);
170
+ border: none;
171
+ text-decoration: none;
172
+ padding: 0;
173
+ }
174
+
175
+ .link:hover:not(:disabled) {
176
+ text-decoration: underline;
177
+ color: var(--asm-color-primary-600);
178
+ }
179
+
180
+ .link:active:not(:disabled) {
181
+ color: var(--asm-color-primary-700);
182
+ }
183
+
184
+ /* Brand variant */
185
+ .brand {
186
+ background-color: var(--asm-color-brand-primary-500);
187
+ color: #ffffff;
188
+ border: 1px solid transparent;
189
+ }
190
+
191
+ .brand:hover:not(:disabled) {
192
+ background-color: var(--asm-color-brand-primary-600);
193
+ }
194
+
195
+ .brand:active:not(:disabled) {
196
+ background-color: var(--asm-color-brand-primary-700);
197
+ }
198
+
199
+ /* Disabled state */
200
+ .trigger:disabled {
201
+ background-color: var(--asm-color-button-disabled-bg);
202
+ color: var(--asm-color-button-disabled-text);
203
+ border-color: transparent;
204
+ }
@@ -0,0 +1,14 @@
1
+ export const trigger: string;
2
+ export const sm: string;
3
+ export const md: string;
4
+ export const lg: string;
5
+ export const primary: string;
6
+ export const secondary: string;
7
+ export const success: string;
8
+ export const warning: string;
9
+ export const danger: string;
10
+ export const info: string;
11
+ export const ghost: string;
12
+ export const outline: string;
13
+ export const link: string;
14
+ export const brand: string;
@@ -0,0 +1,60 @@
1
+ import React from 'react';
2
+ import { DropdownTriggerProps } from '../types';
3
+ import styles from './DropdownTrigger.module.css';
4
+
5
+ export const DropdownTrigger = React.forwardRef<HTMLElement, DropdownTriggerProps>(({
6
+ children,
7
+ onClick,
8
+ onKeyDown,
9
+ disabled = false,
10
+ variant = 'primary',
11
+ size = 'md',
12
+ className = '',
13
+ 'data-testid': testId
14
+ }, ref) => {
15
+ const variantClass = styles[variant] || styles.primary;
16
+ const sizeClass = styles[size] || styles.md;
17
+ const triggerClass = `${styles.trigger} ${variantClass} ${sizeClass} ${className}`.trim();
18
+
19
+ // Check if children is already a button element
20
+ const isButtonChild = React.isValidElement(children) &&
21
+ (children.type === 'button' ||
22
+ (typeof children.type === 'string' && children.type === 'button'));
23
+
24
+ if (isButtonChild) {
25
+ // Clone the button and add our props
26
+ return React.cloneElement(children as React.ReactElement<any>, {
27
+ className: `${(children as any).props.className || ''} ${triggerClass}`.trim(),
28
+ onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
29
+ onClick?.(e);
30
+ (children as any).props.onClick?.(e);
31
+ },
32
+ onKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => {
33
+ onKeyDown?.(e);
34
+ (children as any).props.onKeyDown?.(e);
35
+ },
36
+ disabled: disabled || (children as any).props.disabled,
37
+ 'aria-haspopup': 'menu',
38
+ 'aria-expanded': false,
39
+ 'data-testid': testId || (children as any).props['data-testid'],
40
+ ref: ref
41
+ });
42
+ }
43
+
44
+ // For non-button children, wrap in a button
45
+ return (
46
+ <button
47
+ ref={ref as React.RefObject<HTMLButtonElement>}
48
+ type="button"
49
+ className={triggerClass}
50
+ onClick={onClick}
51
+ onKeyDown={onKeyDown}
52
+ disabled={disabled}
53
+ aria-haspopup="menu"
54
+ aria-expanded={false}
55
+ data-testid={testId}
56
+ >
57
+ {children}
58
+ </button>
59
+ );
60
+ });
@@ -0,0 +1,34 @@
1
+ import { useEffect, RefObject } from 'react';
2
+
3
+ interface UseClickOutsideProps {
4
+ ref: RefObject<HTMLDivElement>;
5
+ handler: () => void;
6
+ enabled?: boolean;
7
+ }
8
+
9
+ export const useClickOutside = ({ ref, handler, enabled = true }: UseClickOutsideProps) => {
10
+ useEffect(() => {
11
+ if (!enabled) return;
12
+
13
+ const handleMouseDown = (event: MouseEvent) => {
14
+ if (ref.current && !ref.current.contains(event.target as Node)) {
15
+ handler();
16
+ }
17
+ };
18
+
19
+ const handleTouchStart = (event: TouchEvent) => {
20
+ if (ref.current && !ref.current.contains(event.target as Node)) {
21
+ handler();
22
+ }
23
+ };
24
+
25
+ // Add event listeners
26
+ document.addEventListener('mousedown', handleMouseDown);
27
+ document.addEventListener('touchstart', handleTouchStart);
28
+
29
+ return () => {
30
+ document.removeEventListener('mousedown', handleMouseDown);
31
+ document.removeEventListener('touchstart', handleTouchStart);
32
+ };
33
+ }, [ref, handler, enabled]);
34
+ };