Skip to content

Commit fe3af52

Browse files
feat(c): dropdown direction added
1 parent 7fdc7a5 commit fe3af52

File tree

8 files changed

+235
-81
lines changed

8 files changed

+235
-81
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
dist
22
node_modules
3+
tmp

src/components/checkbox/Checkbox.tsx

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,39 @@ export interface CheckboxProps
1010

1111
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
1212
({ className, label, id, ...props }, ref) => {
13+
const checkboxId = id || React.useId();
14+
1315
return (
14-
<div className="flex items-center gap-2">
15-
<div className="relative inline-flex items-center">
16+
<div className="flex items-center">
17+
<div className="relative flex items-center">
1618
<input
1719
type="checkbox"
20+
id={checkboxId}
1821
ref={ref}
19-
id={id}
2022
className={cn(
21-
"peer h-4 w-4 appearance-none rounded border border-gray-700 bg-gray-800 transition-colors",
22-
"checked:border-blue-500 checked:bg-blue-500",
23-
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500",
23+
"peer h-4 w-4 appearance-none rounded border",
24+
"border-gray-300 dark:border-gray-600",
25+
"bg-white dark:bg-gray-800",
26+
"checked:bg-blue-500 dark:checked:bg-blue-600",
27+
"checked:border-blue-500 dark:checked:border-blue-600",
28+
"focus:outline-none focus:ring-2 focus:ring-blue-500/30 dark:focus:ring-blue-600/30",
2429
"disabled:cursor-not-allowed disabled:opacity-50",
2530
className
2631
)}
2732
{...props}
2833
/>
29-
<Check className="absolute h-3 w-3 text-white opacity-0 peer-checked:opacity-100" />
34+
<Check
35+
className={cn(
36+
"absolute left-0 top-0 h-4 w-4 stroke-[3] text-white opacity-0 transition-opacity",
37+
"peer-checked:opacity-100",
38+
"pointer-events-none"
39+
)}
40+
/>
3041
</div>
3142
{label && (
3243
<label
33-
htmlFor={id}
34-
className="text-sm text-gray-300 cursor-pointer select-none"
44+
htmlFor={checkboxId}
45+
className="ml-2 text-sm text-gray-900 dark:text-gray-100"
3546
>
3647
{label}
3748
</label>

src/components/dropdown/Dropdown.tsx

Lines changed: 80 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
11
import * as React from "react";
2+
import { createPortal } from "react-dom";
23
import { cn } from "../../utils/cn";
34
import { ZINDEX } from "../../utils/z-index";
45

56
export interface DropdownProps {
67
trigger: React.ReactNode;
78
children: React.ReactNode;
89
align?: "left" | "right";
10+
direction?: "down" | "up";
11+
variant?: "default" | "nav";
912
className?: string;
1013
}
1114

1215
export const Dropdown: React.FC<DropdownProps> = ({
1316
trigger,
1417
children,
1518
align = "left",
19+
direction = "down",
20+
variant = "default",
1621
className,
1722
}) => {
1823
const [isOpen, setIsOpen] = React.useState(false);
1924
const dropdownRef = React.useRef<HTMLDivElement>(null);
25+
const triggerRef = React.useRef<HTMLDivElement>(null);
2026

2127
React.useEffect(() => {
2228
const handleClickOutside = (event: MouseEvent) => {
@@ -25,30 +31,87 @@ export const Dropdown: React.FC<DropdownProps> = ({
2531
}
2632
};
2733

34+
const handleEscape = (event: KeyboardEvent) => {
35+
if (event.key === 'Escape') {
36+
setIsOpen(false);
37+
}
38+
};
39+
2840
document.addEventListener("mousedown", handleClickOutside);
29-
return () => document.removeEventListener("mousedown", handleClickOutside);
30-
}, []);
41+
if (variant === "nav") {
42+
document.addEventListener("keydown", handleEscape);
43+
}
44+
45+
return () => {
46+
document.removeEventListener("mousedown", handleClickOutside);
47+
if (variant === "nav") {
48+
document.removeEventListener("keydown", handleEscape);
49+
}
50+
};
51+
}, [variant]);
52+
53+
const triggerClasses = cn({
54+
"cursor-pointer": true,
55+
"flex items-center gap-1 text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200 transition-colors":
56+
variant === "nav",
57+
});
58+
59+
const menuClasses = cn(
60+
"absolute rounded-lg shadow-lg min-w-[8rem] py-1",
61+
variant === "default" && [
62+
"bg-white dark:bg-gray-900",
63+
"border border-gray-200 dark:border-gray-800",
64+
],
65+
variant === "nav" && [
66+
"bg-white dark:bg-gray-800",
67+
"border border-gray-200 dark:border-gray-700",
68+
"w-48 rounded-md",
69+
],
70+
{
71+
"left-0": align === "left",
72+
"right-0": align === "right",
73+
"top-full mt-2": direction === "down",
74+
"bottom-full mb-2": direction === "up",
75+
},
76+
className
77+
);
78+
79+
// Get position for the dropdown menu
80+
const getDropdownPosition = () => {
81+
if (!triggerRef.current) return {};
82+
const rect = triggerRef.current.getBoundingClientRect();
83+
84+
return {
85+
position: 'fixed' as const,
86+
left: rect.left,
87+
right: window.innerWidth - rect.right,
88+
...(direction === 'down'
89+
? { top: rect.bottom + 8 }
90+
: { bottom: window.innerHeight - rect.top + 8 }
91+
),
92+
};
93+
};
3194

3295
return (
3396
<div className="relative inline-block" ref={dropdownRef}>
34-
<div onClick={() => setIsOpen(!isOpen)}>{trigger}</div>
35-
{isOpen && (
97+
<div
98+
ref={triggerRef}
99+
onClick={() => setIsOpen(!isOpen)}
100+
className={triggerClasses}
101+
>
102+
{trigger}
103+
</div>
104+
{isOpen && createPortal(
36105
<div
37-
className={cn(
38-
"absolute mt-2 rounded-lg shadow-lg",
39-
"bg-white dark:bg-gray-900",
40-
"border border-gray-200 dark:border-gray-800",
41-
"min-w-[8rem] py-1",
42-
{
43-
"left-0": align === "left",
44-
"right-0": align === "right",
45-
},
46-
className
47-
)}
48-
style={{ zIndex: ZINDEX.dropdown }}
106+
className={menuClasses}
107+
style={{
108+
...getDropdownPosition(),
109+
zIndex: ZINDEX.dropdown,
110+
}}
49111
>
50112
{children}
51-
</div>
113+
</div>,
114+
document.body
52115
)}
53116
</div>
54117
);

src/components/label/Label.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
88
<label
99
ref={ref}
1010
className={cn(
11-
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-gray-300",
11+
"text-sm font-medium text-gray-900 dark:text-gray-100",
1212
className
1313
)}
1414
{...props}

src/components/navigation/NavMenu.tsx

Lines changed: 25 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,37 @@
11
import * as React from "react";
22
import { cn } from "../../utils/cn";
33
import { ChevronDown } from "lucide-react";
4-
import { ZINDEX } from "../../utils/z-index";
4+
import { Dropdown, DropdownProps } from "../dropdown/Dropdown";
55

6-
export interface NavMenuProps {
6+
export interface NavMenuProps extends Omit<DropdownProps, 'variant' | 'trigger'> {
77
trigger: React.ReactNode;
8-
children: React.ReactNode;
9-
className?: string;
108
}
119

1210
export const NavMenu: React.FC<NavMenuProps> = ({
1311
trigger,
1412
children,
15-
className,
13+
direction = "down",
14+
...props
1615
}) => {
17-
const [isOpen, setIsOpen] = React.useState(false);
18-
const menuRef = React.useRef<HTMLDivElement>(null);
19-
20-
React.useEffect(() => {
21-
const handleClickOutside = (event: MouseEvent) => {
22-
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
23-
setIsOpen(false);
24-
}
25-
};
26-
27-
// Close menu when pressing escape
28-
const handleEscape = (event: KeyboardEvent) => {
29-
if (event.key === 'Escape') {
30-
setIsOpen(false);
31-
}
32-
};
33-
34-
document.addEventListener("mousedown", handleClickOutside);
35-
document.addEventListener("keydown", handleEscape);
36-
37-
return () => {
38-
document.removeEventListener("mousedown", handleClickOutside);
39-
document.removeEventListener("keydown", handleEscape);
40-
setIsOpen(false); // Ensure menu is closed on unmount
41-
};
42-
}, []);
43-
4416
return (
45-
<div className="relative" ref={menuRef}>
46-
<button
47-
onClick={() => setIsOpen(!isOpen)}
48-
className="flex items-center gap-1 text-sm font-medium text-gray-400 hover:text-gray-200 transition-colors"
49-
>
50-
{trigger}
51-
<ChevronDown className="h-4 w-4" />
52-
</button>
53-
{isOpen && (
54-
<div
55-
className={cn(
56-
"absolute right-0 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-black ring-opacity-5",
57-
className
58-
)}
59-
style={{ zIndex: ZINDEX.dropdown }}
60-
>
61-
{children}
62-
</div>
63-
)}
64-
</div>
17+
<Dropdown
18+
trigger={
19+
<>
20+
{trigger}
21+
<ChevronDown
22+
className={cn(
23+
"h-4 w-4 transition-transform duration-200",
24+
direction === "up" && "rotate-180"
25+
)}
26+
/>
27+
</>
28+
}
29+
variant="nav"
30+
direction={direction}
31+
{...props}
32+
>
33+
{children}
34+
</Dropdown>
6535
);
6636
};
6737

@@ -72,7 +42,9 @@ export const NavMenuItem = React.forwardRef<
7242
<button
7343
ref={ref}
7444
className={cn(
75-
"flex w-full items-center rounded-md px-2 py-1.5 text-sm text-gray-300 hover:bg-gray-700 focus:outline-none",
45+
"flex w-full items-center rounded-md px-2 py-1.5 text-sm",
46+
"text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700",
47+
"focus:outline-none",
7648
className
7749
)}
7850
{...props}

src/docs/App.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,37 @@ import { FeaturesPage } from "./pages/FeaturesPage";
3333
import { Toaster } from 'react-hot-toast';
3434

3535
export const App = () => {
36-
const [currentPage, setCurrentPage] = React.useState("getting-started");
36+
const [currentPage, setCurrentPage] = React.useState(() => {
37+
// First try to get page from URL hash
38+
const hashPage = window.location.hash.slice(1);
39+
if (hashPage) return hashPage;
40+
41+
// Then try localStorage
42+
const savedPage = localStorage.getItem('currentPage');
43+
if (savedPage) return savedPage;
44+
45+
// Default to a starting page
46+
return 'getting-started';
47+
});
48+
49+
// Update hash and localStorage when page changes
50+
React.useEffect(() => {
51+
window.location.hash = currentPage;
52+
localStorage.setItem('currentPage', currentPage);
53+
}, [currentPage]);
54+
55+
// Handle hash changes from browser back/forward
56+
React.useEffect(() => {
57+
const handleHashChange = () => {
58+
const page = window.location.hash.slice(1);
59+
if (page) {
60+
setCurrentPage(page);
61+
}
62+
};
63+
64+
window.addEventListener('hashchange', handleHashChange);
65+
return () => window.removeEventListener('hashchange', handleHashChange);
66+
}, []);
3767

3868
const renderPage = () => {
3969
switch (currentPage) {

src/docs/pages/components/DropdownPage.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ export const DropdownPage = () => {
1919
defaultValue: "left",
2020
description: "The alignment of the dropdown menu",
2121
},
22+
{
23+
name: "direction",
24+
type: "'down' | 'up'",
25+
defaultValue: "down",
26+
description: "Direction for the menu to expand",
27+
},
2228
{
2329
name: "className",
2430
type: "string",
@@ -108,6 +114,28 @@ const MyComponent = () => (
108114
<DropdownItem>Action</DropdownItem>
109115
</Dropdown>
110116
</ComponentDemo>
117+
118+
<ComponentDemo
119+
title="Upward Direction"
120+
description="Dropdown menu that expands upward"
121+
code={`<Dropdown
122+
trigger={<Button>Upward Menu</Button>}
123+
direction="up"
124+
>
125+
<DropdownItem>Option 1</DropdownItem>
126+
<DropdownItem>Option 2</DropdownItem>
127+
</Dropdown>`}
128+
>
129+
<div className="text-center mt-16">
130+
<Dropdown
131+
trigger={<Button>Upward Menu</Button>}
132+
direction="up"
133+
>
134+
<DropdownItem>Option 1</DropdownItem>
135+
<DropdownItem>Option 2</DropdownItem>
136+
</Dropdown>
137+
</div>
138+
</ComponentDemo>
111139
</div>
112140
</section>
113141
</div>

0 commit comments

Comments
 (0)