refactor: split command dialog components

This commit is contained in:
2025-12-27 18:12:29 +00:00
parent b20011a21e
commit 30f35ae07f
6 changed files with 357 additions and 350 deletions

View File

@@ -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'

View File

@@ -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 }

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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 }