mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-26 06:44:58 +00:00
refactor: split command dialog components
This commit is contained in:
@@ -1,352 +1,3 @@
|
||||
// TODO: Split this file (351 LOC) into smaller organisms (<150 LOC each)
|
||||
'use client'
|
||||
|
||||
import { forwardRef, ReactNode, useEffect } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
InputBase,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
Typography,
|
||||
Paper,
|
||||
} from '@mui/material'
|
||||
import SearchIcon from '@mui/icons-material/Search'
|
||||
|
||||
// Custom Kbd component since MUI doesn't export one
|
||||
const Kbd = ({ children, ...props }: { children: ReactNode; [key: string]: unknown }) => (
|
||||
<Box
|
||||
component="kbd"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
borderRadius: 0.5,
|
||||
bgcolor: 'action.hover',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
|
||||
// Types
|
||||
interface CommandItem {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
icon?: ReactNode
|
||||
shortcut?: string[]
|
||||
onSelect?: () => void
|
||||
disabled?: boolean
|
||||
keywords?: string[]
|
||||
}
|
||||
|
||||
interface CommandGroup {
|
||||
heading?: string
|
||||
items: CommandItem[]
|
||||
}
|
||||
|
||||
// Command Dialog
|
||||
interface CommandProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const Command = forwardRef<HTMLDivElement, CommandProps>(
|
||||
({ open, onClose, children, ...props }, ref) => {
|
||||
return (
|
||||
<Dialog
|
||||
ref={ref}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
position: 'fixed',
|
||||
top: '20%',
|
||||
m: 0,
|
||||
borderRadius: 2,
|
||||
maxHeight: '60vh',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
)
|
||||
Command.displayName = 'Command'
|
||||
|
||||
// CommandInput
|
||||
interface CommandInputProps {
|
||||
placeholder?: string
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
const CommandInput = forwardRef<HTMLInputElement, CommandInputProps>(
|
||||
({ placeholder = 'Type a command or search...', value, onChange, autoFocus = true, ...props }, ref) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<SearchIcon sx={{ color: 'text.secondary', mr: 1.5 }} />
|
||||
<InputBase
|
||||
ref={ref}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
autoFocus={autoFocus}
|
||||
fullWidth
|
||||
sx={{
|
||||
fontSize: '0.875rem',
|
||||
'& input': {
|
||||
p: 0,
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
)
|
||||
CommandInput.displayName = 'CommandInput'
|
||||
|
||||
// CommandList
|
||||
interface CommandListProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const CommandList = forwardRef<HTMLDivElement, CommandListProps>(
|
||||
({ children, ...props }, ref) => {
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
sx={{
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
py: 1,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
)
|
||||
CommandList.displayName = 'CommandList'
|
||||
|
||||
// CommandEmpty
|
||||
interface CommandEmptyProps {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const CommandEmpty = forwardRef<HTMLDivElement, CommandEmptyProps>(
|
||||
({ children = 'No results found.', ...props }, ref) => {
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
sx={{
|
||||
py: 6,
|
||||
textAlign: 'center',
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
)
|
||||
CommandEmpty.displayName = 'CommandEmpty'
|
||||
|
||||
// CommandGroup
|
||||
interface CommandGroupProps {
|
||||
heading?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const CommandGroup = forwardRef<HTMLDivElement, CommandGroupProps>(
|
||||
({ heading, children, ...props }, ref) => {
|
||||
return (
|
||||
<Box ref={ref} sx={{ py: 0.5 }} {...props}>
|
||||
{heading && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
display: 'block',
|
||||
color: 'text.secondary',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{heading}
|
||||
</Typography>
|
||||
)}
|
||||
<List disablePadding dense>
|
||||
{children}
|
||||
</List>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
)
|
||||
CommandGroup.displayName = 'CommandGroup'
|
||||
|
||||
// CommandItem
|
||||
interface CommandItemProps {
|
||||
children?: ReactNode
|
||||
icon?: ReactNode
|
||||
shortcut?: string[]
|
||||
onSelect?: () => void
|
||||
disabled?: boolean
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
const CommandItem = forwardRef<HTMLLIElement, CommandItemProps>(
|
||||
({ children, icon, shortcut, onSelect, disabled = false, selected = false, ...props }, ref) => {
|
||||
return (
|
||||
<ListItem ref={ref} disablePadding {...props}>
|
||||
<ListItemButton
|
||||
onClick={onSelect}
|
||||
disabled={disabled}
|
||||
selected={selected}
|
||||
sx={{
|
||||
mx: 1,
|
||||
borderRadius: 1,
|
||||
py: 1.5,
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'action.selected',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{icon && (
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
{icon}
|
||||
</ListItemIcon>
|
||||
)}
|
||||
<ListItemText
|
||||
primary={children}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body2',
|
||||
}}
|
||||
/>
|
||||
{shortcut && shortcut.length > 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, ml: 2 }}>
|
||||
{shortcut.map((key, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
component="kbd"
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: 'monospace',
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 0.5,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{key}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
)
|
||||
CommandItem.displayName = 'CommandItem'
|
||||
|
||||
// CommandSeparator
|
||||
const CommandSeparator = forwardRef<HTMLHRElement, Record<string, never>>(
|
||||
(props, ref) => {
|
||||
return <Divider ref={ref} sx={{ my: 1 }} {...props} />
|
||||
}
|
||||
)
|
||||
CommandSeparator.displayName = 'CommandSeparator'
|
||||
|
||||
// CommandShortcut
|
||||
interface CommandShortcutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const CommandShortcut = forwardRef<HTMLSpanElement, CommandShortcutProps>(
|
||||
({ children, ...props }, ref) => {
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
component="span"
|
||||
sx={{
|
||||
ml: 'auto',
|
||||
fontSize: '0.75rem',
|
||||
letterSpacing: '0.05em',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
)
|
||||
CommandShortcut.displayName = 'CommandShortcut'
|
||||
|
||||
// Hook for command dialog keyboard shortcuts
|
||||
function useCommandShortcut(key: string, callback: () => void) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === key) {
|
||||
event.preventDefault()
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [key, callback])
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
useCommandShortcut,
|
||||
}
|
||||
export type { CommandItem as CommandItemType, CommandGroup as CommandGroupType }
|
||||
export * from './command'
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef } from 'react'
|
||||
import { Box, Dialog, InputBase } from '@mui/material'
|
||||
import SearchIcon from '@mui/icons-material/Search'
|
||||
|
||||
import type { CommandDialogProps, CommandInputProps } from './command.types'
|
||||
|
||||
const CommandDialogRoot = forwardRef<HTMLDivElement, CommandDialogProps>(
|
||||
({ open, onClose, children, ...props }, ref) => {
|
||||
return (
|
||||
<Dialog
|
||||
ref={ref}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
position: 'fixed',
|
||||
top: '20%',
|
||||
m: 0,
|
||||
borderRadius: 2,
|
||||
maxHeight: '60vh',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
)
|
||||
CommandDialogRoot.displayName = 'CommandDialogRoot'
|
||||
|
||||
const CommandInput = forwardRef<HTMLInputElement, CommandInputProps>(
|
||||
({ placeholder = 'Type a command or search...', value, onChange, autoFocus = true, ...props }, ref) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<SearchIcon sx={{ color: 'text.secondary', mr: 1.5 }} />
|
||||
<InputBase
|
||||
ref={ref}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
autoFocus={autoFocus}
|
||||
fullWidth
|
||||
sx={{
|
||||
fontSize: '0.875rem',
|
||||
'& input': {
|
||||
p: 0,
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
)
|
||||
CommandInput.displayName = 'CommandInput'
|
||||
|
||||
export { CommandDialogRoot, CommandInput }
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
|
||||
import type {
|
||||
CommandEmptyProps,
|
||||
CommandGroupProps,
|
||||
CommandItemProps,
|
||||
CommandListProps,
|
||||
CommandShortcutProps,
|
||||
} from './command.types'
|
||||
|
||||
const CommandList = forwardRef<HTMLDivElement, CommandListProps>(({ children, ...props }, ref) => {
|
||||
return (
|
||||
<Box ref={ref} sx={{ maxHeight: 300, overflow: 'auto', py: 1 }} {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
CommandList.displayName = 'CommandList'
|
||||
|
||||
const CommandEmpty = forwardRef<HTMLDivElement, CommandEmptyProps>(
|
||||
({ children = 'No results found.', ...props }, ref) => {
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
sx={{ py: 6, textAlign: 'center', color: 'text.secondary', fontSize: '0.875rem' }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
)
|
||||
CommandEmpty.displayName = 'CommandEmpty'
|
||||
|
||||
const CommandGroup = forwardRef<HTMLDivElement, CommandGroupProps>(({ heading, children, ...props }, ref) => {
|
||||
return (
|
||||
<Box ref={ref} sx={{ py: 0.5 }} {...props}>
|
||||
{heading && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ px: 2, py: 1, display: 'block', color: 'text.secondary', fontWeight: 600, fontSize: '0.75rem' }}
|
||||
>
|
||||
{heading}
|
||||
</Typography>
|
||||
)}
|
||||
<List disablePadding dense>
|
||||
{children}
|
||||
</List>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
CommandGroup.displayName = 'CommandGroup'
|
||||
|
||||
const CommandItem = forwardRef<HTMLLIElement, CommandItemProps>(
|
||||
({ children, icon, shortcut, onSelect, disabled = false, selected = false, ...props }, ref) => {
|
||||
return (
|
||||
<ListItem ref={ref} disablePadding {...props}>
|
||||
<ListItemButton
|
||||
onClick={onSelect}
|
||||
disabled={disabled}
|
||||
selected={selected}
|
||||
sx={{ mx: 1, borderRadius: 1, py: 1.5, '&.Mui-selected': { bgcolor: 'action.selected' } }}
|
||||
>
|
||||
{icon && (
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
{icon}
|
||||
</ListItemIcon>
|
||||
)}
|
||||
<ListItemText primary={children} primaryTypographyProps={{ variant: 'body2' }} />
|
||||
{shortcut && shortcut.length > 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, ml: 2 }}>
|
||||
{shortcut.map((key, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
component="kbd"
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: 'monospace',
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 0.5,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{key}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
)
|
||||
CommandItem.displayName = 'CommandItem'
|
||||
|
||||
const CommandSeparator = forwardRef<HTMLHRElement, Record<string, never>>((props, ref) => {
|
||||
return <Divider ref={ref} sx={{ my: 1 }} {...props} />
|
||||
})
|
||||
CommandSeparator.displayName = 'CommandSeparator'
|
||||
|
||||
const CommandShortcut = forwardRef<HTMLSpanElement, CommandShortcutProps>(({ children, ...props }, ref) => {
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
component="span"
|
||||
sx={{ ml: 'auto', fontSize: '0.75rem', letterSpacing: '0.05em', color: 'text.secondary' }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
CommandShortcut.displayName = 'CommandShortcut'
|
||||
|
||||
export {
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface CommandItem {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
icon?: ReactNode
|
||||
shortcut?: string[]
|
||||
onSelect?: () => void
|
||||
disabled?: boolean
|
||||
keywords?: string[]
|
||||
}
|
||||
|
||||
interface CommandGroup {
|
||||
heading?: string
|
||||
items: CommandItem[]
|
||||
}
|
||||
|
||||
interface CommandDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface CommandInputProps {
|
||||
placeholder?: string
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
interface CommandListProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface CommandEmptyProps {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
interface CommandGroupProps {
|
||||
heading?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface CommandItemProps {
|
||||
children?: ReactNode
|
||||
icon?: ReactNode
|
||||
shortcut?: string[]
|
||||
onSelect?: () => void
|
||||
disabled?: boolean
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
interface CommandShortcutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export type {
|
||||
CommandDialogProps,
|
||||
CommandEmptyProps,
|
||||
CommandGroup,
|
||||
CommandGroupProps,
|
||||
CommandInputProps,
|
||||
CommandItem,
|
||||
CommandItemProps,
|
||||
CommandListProps,
|
||||
CommandShortcutProps,
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import { CommandDialogRoot, CommandInput } from './CommandDialogShell'
|
||||
import {
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from './CommandList'
|
||||
import { useCommandShortcut } from './useCommandShortcuts'
|
||||
|
||||
import type {
|
||||
CommandDialogProps,
|
||||
CommandEmptyProps,
|
||||
CommandGroup as CommandGroupType,
|
||||
CommandGroupProps,
|
||||
CommandInputProps,
|
||||
CommandItem as CommandItemType,
|
||||
CommandItemProps,
|
||||
CommandListProps,
|
||||
CommandShortcutProps,
|
||||
} from './command.types'
|
||||
|
||||
const CommandDialog = Object.assign(CommandDialogRoot, {
|
||||
Input: CommandInput,
|
||||
List: CommandList,
|
||||
Empty: CommandEmpty,
|
||||
Group: CommandGroup,
|
||||
Item: CommandItem,
|
||||
Separator: CommandSeparator,
|
||||
Shortcut: CommandShortcut,
|
||||
})
|
||||
|
||||
export {
|
||||
CommandDialog,
|
||||
CommandDialogRoot,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
useCommandShortcut,
|
||||
}
|
||||
|
||||
export type {
|
||||
CommandDialogProps,
|
||||
CommandEmptyProps,
|
||||
CommandGroupProps,
|
||||
CommandGroupType,
|
||||
CommandInputProps,
|
||||
CommandItemProps,
|
||||
CommandItemType,
|
||||
CommandListProps,
|
||||
CommandShortcutProps,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const useCommandShortcut = (key: string, callback: () => void) => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === key) {
|
||||
event.preventDefault()
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [key, callback])
|
||||
}
|
||||
|
||||
export { useCommandShortcut }
|
||||
Reference in New Issue
Block a user