mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-19 05:45:01 -04:00
Merge pull request #193 from spacedriveapp/feature/eng-40-implement-context-menu
Add context menu
This commit is contained in:
@@ -21,13 +21,11 @@
|
||||
"shortDescription": "Your personal virtual cloud.",
|
||||
"longDescription": "Spacedrive is an open source virtual filesystem, a personal cloud powered by your everyday devices. Feature-rich benefits of the cloud, only its owned and hosted by you with security, privacy and ownership as a foundation. Spacedrive makes it possible to create a limitless directory of your digital life that will stand the test of time.",
|
||||
"deb": {
|
||||
"depends": [],
|
||||
"useBootstrapper": false
|
||||
"depends": []
|
||||
},
|
||||
"macOS": {
|
||||
"frameworks": [],
|
||||
"minimumSystemVersion": "",
|
||||
"useBootstrapper": false,
|
||||
"exceptionDomain": "",
|
||||
"signingIdentity": null,
|
||||
"entitlements": null
|
||||
|
||||
@@ -2,7 +2,7 @@ import '@fontsource/inter/variable.css';
|
||||
import { BaseTransport, ClientProvider, setTransport } from '@sd/client';
|
||||
import { Button } from '@sd/ui';
|
||||
import clsx from 'clsx';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import {
|
||||
@@ -34,7 +34,6 @@ import KeysSettings from './screens/settings/KeysSetting';
|
||||
import LibrarySettings from './screens/settings/LibrarySettings';
|
||||
import LocationSettings from './screens/settings/LocationSettings';
|
||||
import SecuritySettings from './screens/settings/SecuritySettings';
|
||||
import ContactsSettings from './screens/settings/SharingSettings';
|
||||
import SharingSettings from './screens/settings/SharingSettings';
|
||||
import SyncSettings from './screens/settings/SyncSettings';
|
||||
import TagsSettings from './screens/settings/TagsSettings';
|
||||
@@ -64,10 +63,17 @@ function AppLayout() {
|
||||
const appPropsContext = useContext(AppPropsContext);
|
||||
|
||||
const isWindowRounded = appPropsContext?.platform === 'macOS';
|
||||
const hasWindowBorder = appPropsContext?.platform !== 'browser' && appPropsContext?.platform !== 'windows';
|
||||
const hasWindowBorder =
|
||||
appPropsContext?.platform !== 'browser' && appPropsContext?.platform !== 'windows';
|
||||
|
||||
return (
|
||||
<div
|
||||
onContextMenu={(e) => {
|
||||
// TODO: allow this on some UI text at least
|
||||
// disable default browser context menu
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
className={clsx(
|
||||
'flex flex-row h-screen overflow-hidden text-gray-900 bg-white select-none dark:text-white dark:bg-gray-650',
|
||||
isWindowRounded && 'rounded-xl',
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { KeyIcon } from '@heroicons/react/outline';
|
||||
import { CogIcon, LockClosedIcon } from '@heroicons/react/solid';
|
||||
import { Button } from '@sd/ui';
|
||||
import {
|
||||
Cloud,
|
||||
Desktop,
|
||||
DeviceMobileCamera,
|
||||
DotsSixVertical,
|
||||
Laptop,
|
||||
Phone,
|
||||
PhoneX
|
||||
} from 'phosphor-react';
|
||||
import { Cloud, Desktop, DeviceMobileCamera, DotsSixVertical, Laptop } from 'phosphor-react';
|
||||
import React, { useState } from 'react';
|
||||
import LoadingIcons, { Rings } from 'react-loading-icons';
|
||||
import { Rings } from 'react-loading-icons';
|
||||
|
||||
import FileItem from '../file/FileItem';
|
||||
import ProgressBar from '../primitive/ProgressBar';
|
||||
@@ -20,9 +12,8 @@ export interface DeviceProps {
|
||||
name: string;
|
||||
size: string;
|
||||
type: 'laptop' | 'desktop' | 'phone' | 'server';
|
||||
locations: { name: string }[];
|
||||
locations: { name: string; folder?: boolean; format?: string; icon?: string }[];
|
||||
runningJob?: { amount: number; task: string };
|
||||
removeThisSoon?: boolean;
|
||||
}
|
||||
|
||||
export function Device(props: DeviceProps) {
|
||||
@@ -32,8 +23,9 @@ export function Device(props: DeviceProps) {
|
||||
if (selectedFile === key) setSelectedFile(null);
|
||||
else setSelectedFile(key);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full bg-gray-600 border rounded-md border-gray-550 ">
|
||||
<div className="w-full bg-gray-50 dark:bg-gray-600 border rounded-md border-gray-100 dark:border-gray-550">
|
||||
<div className="flex flex-row items-center px-4 pt-2 pb-2">
|
||||
<DotsSixVertical weight="bold" className="mr-3 opacity-30" />
|
||||
{props.type === 'phone' && <DeviceMobileCamera weight="fill" size={20} className="mr-2" />}
|
||||
@@ -42,17 +34,17 @@ export function Device(props: DeviceProps) {
|
||||
{props.type === 'server' && <Cloud weight="fill" size={20} className="mr-2" />}
|
||||
<h3 className="font-semibold text-md">{props.name || 'Unnamed Device'}</h3>
|
||||
<div className="flex flex-row space-x-1.5 mt-0.5">
|
||||
<span className="font-semibold flex flex-row h-[19px] -mt-0.5 ml-3 py-0.5 px-1.5 text-[10px] rounded bg-gray-500 text-gray-400">
|
||||
<span className="font-semibold flex flex-row h-[19px] -mt-0.5 ml-3 py-0.5 px-1.5 text-[10px] rounded bg-gray-250 text-gray-500 dark:bg-gray-500 dark:text-gray-400">
|
||||
<LockClosedIcon className="w-3 h-3 mr-1 -ml-0.5 m-[1px]" />
|
||||
P2P
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-semibold py-0.5 px-1.5 text-sm ml-2 text-gray-400 ">
|
||||
<span className="font-semibold py-0.5 px-1.5 text-sm ml-2 text-gray-400 ">
|
||||
{props.size}
|
||||
</span>
|
||||
<div className="flex flex-grow" />
|
||||
{props.runningJob && (
|
||||
<div className="flex flex-row ml-5 bg-opacity-50 rounded-md bg-gray-550 ">
|
||||
<div className="flex flex-row ml-5 bg-opacity-50 rounded-md bg-gray-300 dark:bg-gray-550">
|
||||
<Rings
|
||||
stroke="#2599FF"
|
||||
strokeOpacity={4}
|
||||
@@ -83,30 +75,14 @@ export function Device(props: DeviceProps) {
|
||||
{props.locations.map((location, key) => (
|
||||
<FileItem
|
||||
key={key}
|
||||
selected={selectedFile == location.name}
|
||||
selected={selectedFile === location.name}
|
||||
onClick={() => handleSelect(location.name)}
|
||||
fileName={location.name}
|
||||
folder
|
||||
folder={location.folder}
|
||||
format={location.format}
|
||||
iconName={location.icon}
|
||||
/>
|
||||
))}
|
||||
{props.removeThisSoon && (
|
||||
<>
|
||||
<FileItem
|
||||
selected={selectedFile == 'tsx'}
|
||||
onClick={() => handleSelect('tsx')}
|
||||
fileName="App.tsx"
|
||||
format="tsx"
|
||||
iconName="reactts"
|
||||
/>
|
||||
<FileItem
|
||||
selected={selectedFile == 'vite'}
|
||||
onClick={() => handleSelect('vite')}
|
||||
fileName="vite.config.js"
|
||||
format="vite"
|
||||
iconName="vite"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { ArrowLineRight, FilePlus, FileText, Plus, Share, Trash } from 'phosphor-react';
|
||||
import React, { MouseEventHandler } from 'react';
|
||||
|
||||
import icons from '../../assets/icons';
|
||||
import { ReactComponent as Folder } from '../../assets/svg/folder.svg';
|
||||
import { WithContextMenu } from '../layout/MenuOverlay';
|
||||
import { DefaultProps } from '../primitive/types';
|
||||
|
||||
interface Props extends DefaultProps {
|
||||
@@ -11,7 +13,7 @@ interface Props extends DefaultProps {
|
||||
format?: string;
|
||||
folder?: boolean;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export default function FileItem(props: Props) {
|
||||
@@ -26,78 +28,128 @@ export default function FileItem(props: Props) {
|
||||
// );
|
||||
// };
|
||||
return (
|
||||
<div onClick={props.onClick} className="inline-block w-[100px] mb-3" draggable>
|
||||
<div
|
||||
className={clsx(
|
||||
'border-2 border-transparent rounded-lg text-center w-[100px] h-[100px] mb-1',
|
||||
<WithContextMenu
|
||||
menu={[
|
||||
[
|
||||
{
|
||||
'bg-gray-50 dark:bg-gray-650': props.selected
|
||||
label: 'Details',
|
||||
icon: FileText,
|
||||
onClick() {
|
||||
alert('There are no details ඞ');
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Share',
|
||||
icon: Share,
|
||||
onClick() {
|
||||
navigator.share?.({
|
||||
title: 'Spacedrive',
|
||||
text: 'Check out this cool app',
|
||||
url: 'https://spacedrive.com'
|
||||
});
|
||||
}
|
||||
}
|
||||
)}
|
||||
>
|
||||
{props.folder ? (
|
||||
<div className="flex items-center justify-center w-full h-full active:translate-y-[1px]">
|
||||
<div className="w-[70px]">
|
||||
<Folder className="" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={clsx(
|
||||
'w-[64px] mt-1.5 m-auto transition duration-200 rounded-lg h-[90px] relative active:translate-y-[1px]',
|
||||
{
|
||||
'': props.selected
|
||||
}
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="absolute top-0 left-0 pointer-events-none fill-gray-150 dark:fill-gray-550"
|
||||
width="65"
|
||||
height="85"
|
||||
viewBox="0 0 65 81"
|
||||
>
|
||||
<path d="M0 8C0 3.58172 3.58172 0 8 0H39.6863C41.808 0 43.8429 0.842855 45.3431 2.34315L53.5 10.5L62.6569 19.6569C64.1571 21.1571 65 23.192 65 25.3137V73C65 77.4183 61.4183 81 57 81H8C3.58172 81 0 77.4183 0 73V8Z" />
|
||||
</svg>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
className="absolute top-1 -right-[1px] z-10 fill-gray-50 dark:fill-gray-500 pointer-events-none"
|
||||
viewBox="0 0 41 41"
|
||||
>
|
||||
<path d="M41.4116 40.5577H11.234C5.02962 40.5577 0 35.5281 0 29.3238V0L41.4116 40.5577Z" />
|
||||
</svg>
|
||||
<div className="absolute flex flex-col items-center justify-center w-full h-full">
|
||||
{/* @ts-ignore */}
|
||||
{props.iconName && icons[props.iconName] ? (
|
||||
(() => {
|
||||
// @ts-ignore
|
||||
let Icon = icons[props.iconName];
|
||||
return (
|
||||
<Icon className="mt-2 pointer-events-none margin-auto w-[40px] h-[40px]" />
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<span className="mt-1 text-xs font-bold text-center uppercase cursor-default text-gray-450">
|
||||
{props.format}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<span
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'More More More',
|
||||
icon: Plus,
|
||||
onClick() {},
|
||||
children: [
|
||||
[
|
||||
{
|
||||
label: 'Super cool nested item',
|
||||
onClick() {
|
||||
alert("That's right, you clicked the cool item");
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: Trash,
|
||||
danger: true,
|
||||
onClick() {}
|
||||
}
|
||||
]
|
||||
]}
|
||||
>
|
||||
<div onClick={props.onClick} className="inline-block w-[100px] mb-3" draggable>
|
||||
<div
|
||||
className={clsx(
|
||||
'px-1.5 py-[1px] rounded-md text-sm font-medium text-gray-300 cursor-default',
|
||||
'border-2 border-transparent rounded-lg text-center w-[100px] h-[100px] mb-1',
|
||||
{
|
||||
'bg-primary !text-white': props.selected
|
||||
'bg-gray-50 dark:bg-gray-650': props.selected
|
||||
}
|
||||
)}
|
||||
>
|
||||
{props.fileName}
|
||||
</span>
|
||||
{props.folder ? (
|
||||
<div className="flex items-center justify-center w-full h-full active:translate-y-[1px]">
|
||||
<div className="w-[70px]">
|
||||
<Folder className="" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={clsx(
|
||||
'w-[64px] mt-1.5 m-auto transition duration-200 rounded-lg h-[90px] relative active:translate-y-[1px]',
|
||||
{
|
||||
'': props.selected
|
||||
}
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="absolute top-0 left-0 pointer-events-none fill-gray-150 dark:fill-gray-550"
|
||||
width="65"
|
||||
height="85"
|
||||
viewBox="0 0 65 81"
|
||||
>
|
||||
<path d="M0 8C0 3.58172 3.58172 0 8 0H39.6863C41.808 0 43.8429 0.842855 45.3431 2.34315L53.5 10.5L62.6569 19.6569C64.1571 21.1571 65 23.192 65 25.3137V73C65 77.4183 61.4183 81 57 81H8C3.58172 81 0 77.4183 0 73V8Z" />
|
||||
</svg>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
className="absolute top-1 -right-[1px] z-10 fill-gray-50 dark:fill-gray-500 pointer-events-none"
|
||||
viewBox="0 0 41 41"
|
||||
>
|
||||
<path d="M41.4116 40.5577H11.234C5.02962 40.5577 0 35.5281 0 29.3238V0L41.4116 40.5577Z" />
|
||||
</svg>
|
||||
<div className="absolute flex flex-col items-center justify-center w-full h-full">
|
||||
{/* @ts-ignore */}
|
||||
{props.iconName && icons[props.iconName] ? (
|
||||
(() => {
|
||||
// @ts-ignore
|
||||
let Icon = icons[props.iconName];
|
||||
return (
|
||||
<Icon className="mt-2 pointer-events-none margin-auto w-[40px] h-[40px]" />
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<span className="mt-1 text-xs font-bold text-center uppercase cursor-default text-gray-450">
|
||||
{props.format}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<span
|
||||
className={clsx(
|
||||
'px-1.5 py-[1px] rounded-md text-sm font-medium text-gray-550 dark:text-gray-300 cursor-default',
|
||||
{
|
||||
'bg-primary !text-white': props.selected
|
||||
}
|
||||
)}
|
||||
>
|
||||
{props.fileName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WithContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { ShareIcon } from '@heroicons/react/solid';
|
||||
import { FilePath } from '@sd/core';
|
||||
import { Button } from '@sd/ui';
|
||||
import { Input, TextArea } from '@sd/ui';
|
||||
import { Button, TextArea } from '@sd/ui';
|
||||
import moment from 'moment';
|
||||
import { Heart, Link } from 'phosphor-react';
|
||||
import React from 'react';
|
||||
|
||||
import { default as types } from '../../constants/file-types.json';
|
||||
import { useExplorerState } from './FileList';
|
||||
import FileThumb from './FileThumb';
|
||||
|
||||
interface MetaItemProps {
|
||||
|
||||
17
packages/interface/src/components/layout/MenuOverlay.tsx
Normal file
17
packages/interface/src/components/layout/MenuOverlay.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ContextMenu, ContextMenuProps, Root, Trigger } from '@sd/ui';
|
||||
import React, { ComponentProps } from 'react';
|
||||
|
||||
export const WithContextMenu: React.FC<{
|
||||
menu: ContextMenuProps['items'];
|
||||
children: ComponentProps<typeof Trigger>['children'];
|
||||
}> = (props) => {
|
||||
const { menu: sections = [], children } = props;
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<Trigger>{children}</Trigger>
|
||||
|
||||
<ContextMenu items={sections} />
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
@@ -5,8 +5,6 @@ import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { MacWindowControls } from '../file/Sidebar';
|
||||
|
||||
export interface ModalProps {
|
||||
full?: boolean;
|
||||
children: React.ReactNode;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { MenuIcon, PlusIcon } from '@heroicons/react/solid';
|
||||
import { PlusIcon } from '@heroicons/react/solid';
|
||||
import { useBridgeQuery } from '@sd/client';
|
||||
import { Statistics } from '@sd/core';
|
||||
import { Button } from '@sd/ui';
|
||||
import { Input } from '@sd/ui';
|
||||
import { Button, Input } from '@sd/ui';
|
||||
import byteSize from 'byte-size';
|
||||
import clsx from 'clsx';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
@@ -171,24 +170,38 @@ export const OverviewScreen: React.FC<{}> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col pb-4 space-y-4">
|
||||
<Device
|
||||
name="James' MacBook Pro"
|
||||
size="1.4TB"
|
||||
runningJob={{ amount: 65, task: 'Generating preview media' }}
|
||||
locations={[{ name: 'Pictures' }, { name: 'Downloads' }, { name: 'Minecraft' }]}
|
||||
type="laptop"
|
||||
/>
|
||||
{clientState && (
|
||||
<Device
|
||||
name={clientState?.node_name ?? 'This Device'}
|
||||
size="1.4TB"
|
||||
runningJob={{ amount: 65, task: 'Generating preview media' }}
|
||||
locations={[
|
||||
{ name: 'Pictures', folder: true },
|
||||
{ name: 'Downloads', folder: true },
|
||||
{ name: 'Minecraft', folder: true }
|
||||
]}
|
||||
type="laptop"
|
||||
/>
|
||||
)}
|
||||
<Device
|
||||
name={`James' iPhone 12`}
|
||||
size="47.7GB"
|
||||
locations={[{ name: 'Camera Roll' }, { name: 'Notes' }]}
|
||||
locations={[
|
||||
{ name: 'Camera Roll', folder: true },
|
||||
{ name: 'Notes', folder: true },
|
||||
{ name: 'App.tsx', format: 'tsx', icon: 'reactts' },
|
||||
{ name: 'vite.config.js', format: 'js', icon: 'vite' }
|
||||
]}
|
||||
type="phone"
|
||||
removeThisSoon
|
||||
/>
|
||||
<Device
|
||||
name={`Spacedrive Server`}
|
||||
size="5GB"
|
||||
locations={[{ name: 'Cached' }, { name: 'Photos' }, { name: 'Documents' }]}
|
||||
locations={[
|
||||
{ name: 'Cached', folder: true },
|
||||
{ name: 'Photos', folder: true },
|
||||
{ name: 'Documents', folder: true }
|
||||
]}
|
||||
type="server"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,8 @@ module.exports = {
|
||||
implementation: require('postcss')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'storybook-tailwind-dark-mode'
|
||||
],
|
||||
webpackFinal: async (config) => {
|
||||
config.module.rules.push({
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.5.0",
|
||||
"@heroicons/react": "^1.0.6",
|
||||
"@radix-ui/react-context-menu": "^0.1.6",
|
||||
"clsx": "^1.1.1",
|
||||
"phosphor-react": "^1.4.1",
|
||||
"postcss": "^8.4.12",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
@@ -45,6 +47,7 @@
|
||||
"postcss-loader": "^7.0.0",
|
||||
"sass": "^1.50.0",
|
||||
"sass-loader": "^13.0.0",
|
||||
"storybook-tailwind-dark-mode": "^1.0.12",
|
||||
"style-loader": "^3.3.1",
|
||||
"typescript": "^4.6.3"
|
||||
}
|
||||
|
||||
41
packages/ui/src/ContextMenu.stories.tsx
Normal file
41
packages/ui/src/ContextMenu.stories.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
import { FileText, Plus, Trash } from 'phosphor-react';
|
||||
import React from 'react';
|
||||
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
|
||||
export default {
|
||||
title: 'UI/Context Menu',
|
||||
component: ContextMenu,
|
||||
argTypes: {},
|
||||
parameters: {},
|
||||
args: {}
|
||||
} as ComponentMeta<typeof ContextMenu>;
|
||||
|
||||
const Template: ComponentStory<typeof ContextMenu> = (args) => <ContextMenu {...args} />;
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
items: [
|
||||
[
|
||||
{
|
||||
label: 'New Item',
|
||||
icon: Plus,
|
||||
onClick: () => {}
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'View Info',
|
||||
icon: FileText,
|
||||
onClick: () => {}
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: Trash,
|
||||
danger: true,
|
||||
onClick: () => {}
|
||||
}
|
||||
]
|
||||
]
|
||||
};
|
||||
103
packages/ui/src/ContextMenu.tsx
Normal file
103
packages/ui/src/ContextMenu.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
|
||||
import { Root, Trigger } from '@radix-ui/react-context-menu';
|
||||
import clsx from 'clsx';
|
||||
import { CaretRight, Icon } from 'phosphor-react';
|
||||
import { Question } from 'phosphor-react';
|
||||
import React, { ComponentPropsWithRef } from 'react';
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
icon?: Icon;
|
||||
danger?: boolean;
|
||||
onClick: () => void;
|
||||
|
||||
children?: ContextMenuSection[];
|
||||
}
|
||||
|
||||
export type ContextMenuSection = (ContextMenuItem | string)[];
|
||||
|
||||
export interface ContextMenuProps {
|
||||
items?: ContextMenuSection[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
const { items: sections = [], className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<ContextMenuPrimitive.Content
|
||||
className={clsx(
|
||||
'shadow-2xl min-w-[15rem] shadow-gray-300 dark:shadow-gray-750 flex flex-col select-none cursor-default bg-gray-50 text-gray-800 border-gray-200 dark:bg-gray-650 dark:text-gray-100 dark:border-gray-550 text-left text-sm rounded gap-1.5 border py-1.5',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{sections.map((sec, i) => (
|
||||
<>
|
||||
{i !== 0 && (
|
||||
<ContextMenuPrimitive.Separator className="border-0 border-b border-b-gray-300 dark:border-b-gray-550 mx-2" />
|
||||
)}
|
||||
|
||||
<ContextMenuPrimitive.Group key={i} className="flex items-stretch flex-col gap-0.5">
|
||||
{sec.map((item) => {
|
||||
if (typeof item === 'string')
|
||||
return (
|
||||
<ContextMenuPrimitive.Label className="text-xs ml-2 mt-1 uppercase text-gray-400">
|
||||
{item}
|
||||
</ContextMenuPrimitive.Label>
|
||||
);
|
||||
|
||||
const { icon: ItemIcon = Question } = item;
|
||||
|
||||
let ItemComponent:
|
||||
| typeof ContextMenuPrimitive.Item
|
||||
| typeof ContextMenuPrimitive.TriggerItem = ContextMenuPrimitive.Item;
|
||||
|
||||
if ((item.children?.length ?? 0) > 0)
|
||||
ItemComponent = ((props) => (
|
||||
<ContextMenuPrimitive.Root>
|
||||
<ContextMenuPrimitive.TriggerItem {...props}>
|
||||
{props.children}
|
||||
</ContextMenuPrimitive.TriggerItem>
|
||||
|
||||
<ContextMenu items={item.children} className="relative -left-1 -top-2" />
|
||||
</ContextMenuPrimitive.Root>
|
||||
)) as typeof ContextMenuPrimitive.TriggerItem;
|
||||
|
||||
return (
|
||||
<ItemComponent
|
||||
style={{
|
||||
font: 'inherit',
|
||||
textAlign: 'inherit'
|
||||
}}
|
||||
className={clsx(
|
||||
'focus:outline-none group cursor-default flex-1 px-1.5 py-0 group-first:pt-1.5',
|
||||
{
|
||||
'text-red-600 dark:text-red-400': item.danger
|
||||
}
|
||||
)}
|
||||
onClick={item.onClick}
|
||||
key={item.label}
|
||||
>
|
||||
<div className="px-1.5 py-[0.4em] group-focus:bg-gray-150 group-hover:bg-gray-150 dark:group-focus:bg-gray-550 dark:group-hover:bg-gray-550 flex flex-row gap-2.5 items-center rounded-sm">
|
||||
{<ItemIcon size={18} />}
|
||||
|
||||
<ContextMenuPrimitive.Label className="leading-snug flex-grow text-[14px] font-normal">
|
||||
{item.label}
|
||||
</ContextMenuPrimitive.Label>
|
||||
|
||||
{(item.children?.length ?? 0) > 0 && (
|
||||
<CaretRight weight="fill" size={12} alt="" />
|
||||
)}
|
||||
</div>
|
||||
</ItemComponent>
|
||||
);
|
||||
})}
|
||||
</ContextMenuPrimitive.Group>
|
||||
</>
|
||||
))}
|
||||
</ContextMenuPrimitive.Content>
|
||||
);
|
||||
};
|
||||
|
||||
export { Trigger, Root };
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './Button';
|
||||
export * from './Dropdown';
|
||||
export * from './ContextMenu';
|
||||
export * from './Input';
|
||||
|
||||
BIN
pnpm-lock.yaml
generated
BIN
pnpm-lock.yaml
generated
Binary file not shown.
Reference in New Issue
Block a user