Skip to content

Commit 61f0089

Browse files
hasparusdimaMachinagithub-actions[bot]
authored
Add VersionDropdown component (#1888)
Co-authored-by: Dimitri POSTOLOV <[email protected]> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 59f55ac commit 61f0089

File tree

6 files changed

+286
-6
lines changed

6 files changed

+286
-6
lines changed

.changeset/thick-plants-vanish.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@theguild/components": minor
3+
---
4+
5+
Add VersionDropdown component
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
'use client';
2+
3+
import { createContext, useContext, useEffect, useId, useRef, useState } from 'react';
4+
import { cn } from '../cn';
5+
6+
interface DropdownContextValue {
7+
isOpen: boolean;
8+
setIsOpen: (value: boolean) => void;
9+
isHovering: boolean;
10+
setIsHovering: (value: boolean) => void;
11+
buttonId: string;
12+
buttonRef: React.RefObject<HTMLButtonElement>;
13+
menuId: string;
14+
menuRef: React.RefObject<HTMLDivElement>;
15+
}
16+
17+
const DropdownContext = createContext<DropdownContextValue | null>(null);
18+
19+
function useDropdownContext() {
20+
const context = useContext(DropdownContext);
21+
if (!context) {
22+
throw new Error('Dropdown components must be used within a Dropdown');
23+
}
24+
return context;
25+
}
26+
27+
interface DropdownProps extends React.ComponentPropsWithoutRef<'div'> {
28+
children: React.ReactNode;
29+
type: 'hover' | 'click';
30+
}
31+
32+
export function Dropdown({ children, className, type, ...props }: DropdownProps) {
33+
const [isOpen, setIsOpen] = useState(false);
34+
const [isHovering, setIsHovering] = useState(false);
35+
36+
const buttonId = useId();
37+
const menuId = useId();
38+
const menuRef = useRef<HTMLDivElement>(null);
39+
const buttonRef = useRef<HTMLButtonElement>(null);
40+
41+
useEffect(() => {
42+
const handleClickOutside = (event: MouseEvent) => {
43+
if (
44+
!menuRef.current?.contains(event.target as Node) &&
45+
!buttonRef.current?.contains(event.target as Node)
46+
) {
47+
setIsOpen(false);
48+
}
49+
};
50+
51+
const handleEscape = (event: KeyboardEvent) => {
52+
if (event.key === 'Escape') {
53+
setIsOpen(false);
54+
buttonRef.current?.focus();
55+
}
56+
};
57+
58+
const handleFocusElsewhere = (event: FocusEvent) => {
59+
if (
60+
!menuRef.current?.contains(event.target as Node) &&
61+
!buttonRef.current?.contains(event.target as Node)
62+
) {
63+
setIsOpen(false);
64+
}
65+
};
66+
67+
if (isOpen) {
68+
document.addEventListener('mousedown', handleClickOutside);
69+
document.addEventListener('keydown', handleEscape);
70+
document.addEventListener('focus', handleFocusElsewhere);
71+
}
72+
73+
return () => {
74+
document.removeEventListener('mousedown', handleClickOutside);
75+
document.removeEventListener('keydown', handleEscape);
76+
document.removeEventListener('focus', handleFocusElsewhere);
77+
};
78+
}, [isOpen]);
79+
80+
const dismissDelayMs = 200;
81+
const isHoveringRef = useRef(isHovering);
82+
isHoveringRef.current = isHovering;
83+
84+
return (
85+
<DropdownContext.Provider
86+
value={{ isOpen, setIsOpen, isHovering, setIsHovering, buttonId, menuId, buttonRef, menuRef }}
87+
>
88+
<div
89+
className={cn('relative', className)}
90+
{...(type === 'hover' && {
91+
onPointerEnter: () => {
92+
setIsOpen(true);
93+
setIsHovering(true);
94+
},
95+
onPointerLeave: () => {
96+
if (isHovering) {
97+
setIsHovering(false);
98+
setTimeout(() => {
99+
if (!isHoveringRef.current) {
100+
setIsOpen(false);
101+
}
102+
}, dismissDelayMs);
103+
}
104+
},
105+
})}
106+
{...props}
107+
>
108+
{children}
109+
</div>
110+
</DropdownContext.Provider>
111+
);
112+
}
113+
114+
interface DropdownTriggerProps extends React.ComponentPropsWithoutRef<'button'> {
115+
children: React.ReactNode;
116+
}
117+
118+
export function DropdownTrigger({ children, className, ...props }: DropdownTriggerProps) {
119+
const { isOpen, setIsOpen, buttonId, menuId, buttonRef, setIsHovering } = useDropdownContext();
120+
121+
return (
122+
<button
123+
ref={buttonRef}
124+
id={buttonId}
125+
aria-expanded={isOpen}
126+
aria-controls={menuId}
127+
aria-haspopup="true"
128+
onClick={() => {
129+
setIsOpen(true);
130+
setIsHovering(false);
131+
}}
132+
className={cn('cursor-pointer', className)}
133+
{...props}
134+
>
135+
{children}
136+
</button>
137+
);
138+
}
139+
140+
interface DropdownContentProps extends React.ComponentPropsWithoutRef<'div'> {
141+
children: React.ReactNode;
142+
}
143+
144+
export function DropdownContent({ children, className, ...props }: DropdownContentProps) {
145+
const { isOpen, buttonId, menuId, menuRef } = useDropdownContext();
146+
147+
return (
148+
<div
149+
ref={menuRef}
150+
id={menuId}
151+
role="menu"
152+
aria-labelledby={buttonId}
153+
tabIndex={-1}
154+
className={cn(className)}
155+
data-state={isOpen ? 'open' : 'closed'}
156+
{...props}
157+
>
158+
{children}
159+
</div>
160+
);
161+
}
162+
163+
interface DropdownItemProps extends React.HTMLAttributes<HTMLElement> {
164+
children: React.ReactNode;
165+
onClick?: () => void;
166+
href?: string;
167+
}
168+
169+
export function DropdownItem({ children, onClick, className, href, ...props }: DropdownItemProps) {
170+
if (href) {
171+
return (
172+
<a role="menuitem" href={href} className={className} onClick={onClick} {...props}>
173+
{children}
174+
</a>
175+
);
176+
}
177+
178+
return (
179+
<button
180+
role="menuitem"
181+
onClick={onClick}
182+
className={className}
183+
onKeyDown={e => {
184+
if (e.key === 'Enter' || e.key === 'Space') {
185+
onClick?.();
186+
}
187+
}}
188+
{...props}
189+
>
190+
{children}
191+
</button>
192+
);
193+
}

packages/components/src/components/hive-navigation/index.stories.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Meta, StoryContext, StoryObj } from '@storybook/react';
1+
import { Meta, StoryObj } from '@storybook/react';
22
import { hiveThemeDecorator } from '../../../../../.storybook/hive-theme-decorator';
33
import { siteOrigin } from '../../constants';
44
import { PRODUCTS } from '../../products';
@@ -11,6 +11,7 @@ import {
1111
RightCornerIcon,
1212
TargetIcon,
1313
} from '../icons';
14+
import { VersionDropdown } from '../version-dropdown';
1415
import { GraphQLConfCard } from './graphql-conf-card';
1516
import {
1617
CompanyMenu,
@@ -52,7 +53,7 @@ const HIVE_DEVELOPER_MENU: HiveNavigationProps['developerMenu'] = [
5253
export default {
5354
title: 'Hive/HiveNavigation',
5455
component: HiveNavigation,
55-
decorators: [hiveThemeDecorator, nextraThemeDocsCtxDecorator],
56+
decorators: [hiveThemeDecorator],
5657
args: {
5758
productName: 'Hive',
5859
developerMenu: HIVE_DEVELOPER_MENU,
@@ -71,6 +72,15 @@ export const Default: StoryObj = {
7172
],
7273
args: {
7374
developerMenu: HIVE_DEVELOPER_MENU,
75+
children: (
76+
<VersionDropdown
77+
currentVersion="1.x"
78+
versions={[
79+
{ label: 'Hive v1 Docs', href: '#v1', value: '1.x' },
80+
{ label: 'Hive v2 Docs', href: '#v2', value: '2.x' },
81+
]}
82+
/>
83+
),
7484
},
7585
};
7686

@@ -215,7 +225,3 @@ export const CodegenNavmenu: StoryObj<HiveNavigationProps> = {
215225
),
216226
},
217227
};
218-
219-
function nextraThemeDocsCtxDecorator(Story: () => React.ReactNode, _ctx: StoryContext) {
220-
return <Story />;
221-
}

packages/components/src/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,5 @@ export * from './explore-main-product-cards';
2929
export * from './text-link';
3030
export * from './contact-us';
3131
export { Giscus } from './giscus';
32+
export * from './version-dropdown';
33+
export * from './dropdown';
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Meta, StoryObj } from '@storybook/react';
2+
import { hiveThemeDecorator } from '../../../../.storybook/hive-theme-decorator';
3+
import { VersionDropdown, VersionDropdownProps } from './version-dropdown';
4+
5+
const decorator = (Story: React.FC<object>) => (
6+
<div className="flex items-center justify-center">
7+
<Story />
8+
</div>
9+
);
10+
11+
export default {
12+
title: 'Components/VersionDropdown',
13+
component: VersionDropdown,
14+
decorators: [hiveThemeDecorator, decorator],
15+
} satisfies Meta<VersionDropdownProps>;
16+
17+
export const Default: StoryObj<VersionDropdownProps> = {
18+
name: 'VersionDropdown',
19+
args: {
20+
currentVersion: '1.0.0',
21+
versions: [
22+
{ value: '1.0.0', label: 'Hive Docs 1.0.0', href: '/1.0.0' },
23+
{ value: '1.1.0', label: 'Hive Docs 1.1.0', href: '/1.1.0' },
24+
],
25+
},
26+
};
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
'use client';
2+
3+
import { cn } from '../cn';
4+
import { Dropdown, DropdownContent, DropdownItem, DropdownTrigger } from './dropdown';
5+
import { CaretSlimIcon, CheckIcon } from './icons';
6+
7+
export interface VersionDropdownProps {
8+
chevronPosition?: 'left' | 'right';
9+
currentVersion: string;
10+
versions: {
11+
label?: string;
12+
value: string;
13+
href?: string;
14+
onClick?: () => void;
15+
}[];
16+
}
17+
export function VersionDropdown({
18+
currentVersion,
19+
versions,
20+
chevronPosition = 'left',
21+
}: VersionDropdownProps) {
22+
return (
23+
<Dropdown type="hover" className="relative">
24+
<DropdownTrigger className="hive-focus flex cursor-default items-center gap-1 py-2 font-medium leading-normal text-green-800 aria-expanded:text-green-1000 dark:text-neutral-300 dark:aria-expanded:text-neutral-100">
25+
{chevronPosition === 'left' && <CaretSlimIcon className="size-3.5" />}
26+
{currentVersion}
27+
{chevronPosition === 'right' && <CaretSlimIcon className="size-3.5" />}
28+
</DropdownTrigger>
29+
30+
<DropdownContent className="absolute left-full min-w-16 -translate-x-full translate-y-2 rounded-xl border border-beige-200 bg-white p-1 shadow-[0px_16px_32px_-12px_rgba(14,18,27,0.10)] transition ease-in-out data-[state=closed]:pointer-events-none data-[state=closed]:translate-y-0 data-[state=closed]:scale-95 data-[state=closed]:opacity-0 data-[state=open]:fade-in-90 dark:border-neutral-800 dark:bg-neutral-900">
31+
{versions.map(version => (
32+
<DropdownItem
33+
key={version.value}
34+
href={version.href}
35+
onClick={version.onClick}
36+
className={cn(
37+
'flex items-center justify-between gap-1 whitespace-nowrap rounded p-2 text-green-800 transition-colors hover:bg-beige-100 hover:text-green-1000 dark:text-neutral-300 dark:hover:bg-neutral-800/50 dark:hover:text-neutral-100',
38+
version.value === currentVersion && 'pointer-events-none font-medium',
39+
)}
40+
>
41+
{version.label ?? version.value}{' '}
42+
{version.value === currentVersion && <CheckIcon className="size-3.5" />}
43+
</DropdownItem>
44+
))}
45+
</DropdownContent>
46+
</Dropdown>
47+
);
48+
}

0 commit comments

Comments
 (0)