Skip to content

Commit

Permalink
feat(c): dropdown direction added
Browse files Browse the repository at this point in the history
  • Loading branch information
brianorwhatever committed Jan 30, 2025
1 parent 7fdc7a5 commit fe3af52
Show file tree
Hide file tree
Showing 8 changed files with 235 additions and 81 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
dist
node_modules
tmp
29 changes: 20 additions & 9 deletions src/components/checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,39 @@ export interface CheckboxProps

export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
({ className, label, id, ...props }, ref) => {
const checkboxId = id || React.useId();

return (
<div className="flex items-center gap-2">
<div className="relative inline-flex items-center">
<div className="flex items-center">
<div className="relative flex items-center">
<input
type="checkbox"
id={checkboxId}
ref={ref}
id={id}
className={cn(
"peer h-4 w-4 appearance-none rounded border border-gray-700 bg-gray-800 transition-colors",
"checked:border-blue-500 checked:bg-blue-500",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500",
"peer h-4 w-4 appearance-none rounded border",
"border-gray-300 dark:border-gray-600",
"bg-white dark:bg-gray-800",
"checked:bg-blue-500 dark:checked:bg-blue-600",
"checked:border-blue-500 dark:checked:border-blue-600",
"focus:outline-none focus:ring-2 focus:ring-blue-500/30 dark:focus:ring-blue-600/30",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<Check className="absolute h-3 w-3 text-white opacity-0 peer-checked:opacity-100" />
<Check
className={cn(
"absolute left-0 top-0 h-4 w-4 stroke-[3] text-white opacity-0 transition-opacity",
"peer-checked:opacity-100",
"pointer-events-none"
)}
/>
</div>
{label && (
<label
htmlFor={id}
className="text-sm text-gray-300 cursor-pointer select-none"
htmlFor={checkboxId}
className="ml-2 text-sm text-gray-900 dark:text-gray-100"
>
{label}
</label>
Expand Down
97 changes: 80 additions & 17 deletions src/components/dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
import * as React from "react";
import { createPortal } from "react-dom";
import { cn } from "../../utils/cn";
import { ZINDEX } from "../../utils/z-index";

export interface DropdownProps {
trigger: React.ReactNode;
children: React.ReactNode;
align?: "left" | "right";
direction?: "down" | "up";
variant?: "default" | "nav";
className?: string;
}

export const Dropdown: React.FC<DropdownProps> = ({
trigger,
children,
align = "left",
direction = "down",
variant = "default",
className,
}) => {
const [isOpen, setIsOpen] = React.useState(false);
const dropdownRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLDivElement>(null);

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

const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};

document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
if (variant === "nav") {
document.addEventListener("keydown", handleEscape);
}

return () => {
document.removeEventListener("mousedown", handleClickOutside);
if (variant === "nav") {
document.removeEventListener("keydown", handleEscape);
}
};
}, [variant]);

const triggerClasses = cn({
"cursor-pointer": true,
"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":
variant === "nav",
});

const menuClasses = cn(
"absolute rounded-lg shadow-lg min-w-[8rem] py-1",
variant === "default" && [
"bg-white dark:bg-gray-900",
"border border-gray-200 dark:border-gray-800",
],
variant === "nav" && [
"bg-white dark:bg-gray-800",
"border border-gray-200 dark:border-gray-700",
"w-48 rounded-md",
],
{
"left-0": align === "left",
"right-0": align === "right",
"top-full mt-2": direction === "down",
"bottom-full mb-2": direction === "up",
},
className
);

// Get position for the dropdown menu
const getDropdownPosition = () => {
if (!triggerRef.current) return {};
const rect = triggerRef.current.getBoundingClientRect();

return {
position: 'fixed' as const,
left: rect.left,
right: window.innerWidth - rect.right,
...(direction === 'down'
? { top: rect.bottom + 8 }
: { bottom: window.innerHeight - rect.top + 8 }
),
};
};

return (
<div className="relative inline-block" ref={dropdownRef}>
<div onClick={() => setIsOpen(!isOpen)}>{trigger}</div>
{isOpen && (
<div
ref={triggerRef}
onClick={() => setIsOpen(!isOpen)}
className={triggerClasses}
>
{trigger}
</div>
{isOpen && createPortal(
<div
className={cn(
"absolute mt-2 rounded-lg shadow-lg",
"bg-white dark:bg-gray-900",
"border border-gray-200 dark:border-gray-800",
"min-w-[8rem] py-1",
{
"left-0": align === "left",
"right-0": align === "right",
},
className
)}
style={{ zIndex: ZINDEX.dropdown }}
className={menuClasses}
style={{
...getDropdownPosition(),
zIndex: ZINDEX.dropdown,
}}
>
{children}
</div>
</div>,
document.body
)}
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion src/components/label/Label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-gray-300",
"text-sm font-medium text-gray-900 dark:text-gray-100",
className
)}
{...props}
Expand Down
78 changes: 25 additions & 53 deletions src/components/navigation/NavMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,67 +1,37 @@
import * as React from "react";
import { cn } from "../../utils/cn";
import { ChevronDown } from "lucide-react";
import { ZINDEX } from "../../utils/z-index";
import { Dropdown, DropdownProps } from "../dropdown/Dropdown";

export interface NavMenuProps {
export interface NavMenuProps extends Omit<DropdownProps, 'variant' | 'trigger'> {
trigger: React.ReactNode;
children: React.ReactNode;
className?: string;
}

export const NavMenu: React.FC<NavMenuProps> = ({
trigger,
children,
className,
direction = "down",
...props
}) => {
const [isOpen, setIsOpen] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);

React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};

// Close menu when pressing escape
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};

document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);

return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
setIsOpen(false); // Ensure menu is closed on unmount
};
}, []);

return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1 text-sm font-medium text-gray-400 hover:text-gray-200 transition-colors"
>
{trigger}
<ChevronDown className="h-4 w-4" />
</button>
{isOpen && (
<div
className={cn(
"absolute right-0 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-black ring-opacity-5",
className
)}
style={{ zIndex: ZINDEX.dropdown }}
>
{children}
</div>
)}
</div>
<Dropdown
trigger={
<>
{trigger}
<ChevronDown
className={cn(
"h-4 w-4 transition-transform duration-200",
direction === "up" && "rotate-180"
)}
/>
</>
}
variant="nav"
direction={direction}
{...props}
>
{children}
</Dropdown>
);
};

Expand All @@ -72,7 +42,9 @@ export const NavMenuItem = React.forwardRef<
<button
ref={ref}
className={cn(
"flex w-full items-center rounded-md px-2 py-1.5 text-sm text-gray-300 hover:bg-gray-700 focus:outline-none",
"flex w-full items-center rounded-md px-2 py-1.5 text-sm",
"text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700",
"focus:outline-none",
className
)}
{...props}
Expand Down
32 changes: 31 additions & 1 deletion src/docs/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,37 @@ import { FeaturesPage } from "./pages/FeaturesPage";
import { Toaster } from 'react-hot-toast';

export const App = () => {
const [currentPage, setCurrentPage] = React.useState("getting-started");
const [currentPage, setCurrentPage] = React.useState(() => {
// First try to get page from URL hash
const hashPage = window.location.hash.slice(1);
if (hashPage) return hashPage;

// Then try localStorage
const savedPage = localStorage.getItem('currentPage');
if (savedPage) return savedPage;

// Default to a starting page
return 'getting-started';
});

// Update hash and localStorage when page changes
React.useEffect(() => {
window.location.hash = currentPage;
localStorage.setItem('currentPage', currentPage);
}, [currentPage]);

// Handle hash changes from browser back/forward
React.useEffect(() => {
const handleHashChange = () => {
const page = window.location.hash.slice(1);
if (page) {
setCurrentPage(page);
}
};

window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);

const renderPage = () => {
switch (currentPage) {
Expand Down
28 changes: 28 additions & 0 deletions src/docs/pages/components/DropdownPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export const DropdownPage = () => {
defaultValue: "left",
description: "The alignment of the dropdown menu",
},
{
name: "direction",
type: "'down' | 'up'",
defaultValue: "down",
description: "Direction for the menu to expand",
},
{
name: "className",
type: "string",
Expand Down Expand Up @@ -108,6 +114,28 @@ const MyComponent = () => (
<DropdownItem>Action</DropdownItem>
</Dropdown>
</ComponentDemo>

<ComponentDemo
title="Upward Direction"
description="Dropdown menu that expands upward"
code={`<Dropdown
trigger={<Button>Upward Menu</Button>}
direction="up"
>
<DropdownItem>Option 1</DropdownItem>
<DropdownItem>Option 2</DropdownItem>
</Dropdown>`}
>
<div className="text-center mt-16">
<Dropdown
trigger={<Button>Upward Menu</Button>}
direction="up"
>
<DropdownItem>Option 1</DropdownItem>
<DropdownItem>Option 2</DropdownItem>
</Dropdown>
</div>
</ComponentDemo>
</div>
</section>
</div>
Expand Down
Loading

0 comments on commit fe3af52

Please sign in to comment.