import React from 'react'; import './ChordPickerPopup.css'; import KGDropdown from './common/KGDropdown'; import { buildChordSymbol, CHORD_EXTENSION_OPTIONS, formatChordSymbolForDisplay, type ChordDescriptor, type ChordExtension, type ChordQuality, parseChordSymbol, } from '../util/chordUtil'; interface ChordPickerPopupProps { value: string; onChange: (symbol: string) => void; onTabNavigate?: (direction: 'forward' | 'backward') => void; } const ROOT_OPTIONS = ['C', 'C#', 'Db', 'D', 'D#', 'Eb', 'E', 'F', 'F#', 'Gb', 'G', 'G#', 'Ab', 'A', 'A#', 'Bb', 'B'] as const; const QUALITY_LABELS: Record = { maj: 'Maj', min: 'Min', sus2: 'Sus2', sus4: 'Sus4', power: '5', aug: 'Aug', dim: 'Dim', }; const EXTENSION_LABELS: Record = { b5: 'b5', '#5': '#5', '6': '6', '7': '7', dim7: 'dim7', maj7: 'maj7', b9: 'b9', '9': '9', '#9': '#9', '11': '11', '#11': '#11', b13: 'b13', '13': '13', }; const QUALITY_ROWS: ChordQuality[][] = [ ['maj', 'min', 'sus2', 'sus4'], ['power', 'aug', 'dim'], ]; const EXTENSION_ROWS: ChordExtension[][] = [ ['b5', '#5', '6', '7'], ['dim7', 'maj7', 'b9', '9'], ['#9', '11', '#11', 'b13'], ['13'], ]; function createFallbackDescriptor(value: string): ChordDescriptor { return { root: 'C', quality: 'maj', extensions: [], symbol: value || 'C', }; } function normalizeDescriptor(descriptor: ChordDescriptor): ChordDescriptor { const quality = descriptor.quality; let extensions = [...descriptor.extensions]; const dedupe = (nextExtensions: ChordExtension[]) => Array.from(new Set(nextExtensions)); const removeExtensions = (targets: ChordExtension[]) => { extensions = extensions.filter(extension => !targets.includes(extension)); }; if (quality === 'aug' && !extensions.includes('#5')) { extensions.push('#5'); } if (quality === 'dim' && !extensions.includes('b5')) { extensions.push('b5'); } if (quality === 'aug') { removeExtensions(['b5']); } if (quality === 'dim') { removeExtensions(['#5']); } if (extensions.includes('7')) { removeExtensions(['dim7', 'maj7', '6']); } if (extensions.includes('dim7')) { removeExtensions(['7', 'maj7', '6']); } if (extensions.includes('maj7')) { removeExtensions(['7', 'dim7', '6']); } if (extensions.includes('6')) { removeExtensions(['7', 'dim7', 'maj7', '13', 'b13']); } if (extensions.includes('13') || extensions.includes('b13')) { removeExtensions(['6']); } if (extensions.includes('b9')) { removeExtensions(['9', '#9']); } else if (extensions.includes('9')) { removeExtensions(['b9', '#9']); } else if (extensions.includes('#9')) { removeExtensions(['b9', '9']); } if (extensions.includes('11')) { removeExtensions(['#11']); } else if (extensions.includes('#11')) { removeExtensions(['11']); } if (extensions.includes('b13')) { removeExtensions(['13']); } else if (extensions.includes('13')) { removeExtensions(['b13']); } return { ...descriptor, quality, extensions: CHORD_EXTENSION_OPTIONS.filter(extension => dedupe(extensions).includes(extension)), }; } const ChordPickerPopup: React.FC = ({ value, onChange, onTabNavigate }) => { const [descriptor, setDescriptor] = React.useState(() => parseChordSymbol(value) ?? createFallbackDescriptor(value)); const [inputText, setInputText] = React.useState(value); const [error, setError] = React.useState(null); const [showRootDropdown, setShowRootDropdown] = React.useState(false); const inputRef = React.useRef(null); React.useEffect(() => { const parsed = parseChordSymbol(value); setDescriptor(parsed ?? createFallbackDescriptor(value)); setInputText(value); setError(null); setShowRootDropdown(false); }, [value]); React.useEffect(() => { if (!inputRef.current) { return; } inputRef.current.focus(); inputRef.current.select(); }, [value]); const applyDescriptor = React.useCallback((nextDescriptor: ChordDescriptor) => { const normalized = normalizeDescriptor(nextDescriptor); const nextSymbol = buildChordSymbol(normalized); if (!nextSymbol) { setError('Unsupported chord combination'); return; } const parsed = parseChordSymbol(nextSymbol); if (!parsed) { setError('Unsupported chord combination'); return; } setDescriptor(parsed); setInputText(parsed.symbol); setError(null); onChange(parsed.symbol); }, [onChange]); const handleQualityChange = React.useCallback((quality: ChordQuality) => { applyDescriptor({ ...descriptor, quality, }); }, [applyDescriptor, descriptor]); const handleExtensionToggle = React.useCallback((extension: ChordExtension) => { const isSelected = descriptor.extensions.includes(extension); const nextExtensions = isSelected ? descriptor.extensions.filter(candidate => candidate !== extension) : [...descriptor.extensions, extension]; let nextQuality = descriptor.quality; if (!isSelected && extension === 'b5' && nextQuality === 'aug') { nextQuality = 'maj'; } if (!isSelected && extension === '#5' && nextQuality === 'dim') { nextQuality = 'maj'; } applyDescriptor({ ...descriptor, quality: nextQuality, extensions: nextExtensions, }); }, [applyDescriptor, descriptor]); const handleSubmit = React.useCallback(() => { const parsed = parseChordSymbol(inputText); if (!parsed) { setError('Unable to parse chord'); return false; } setDescriptor(parsed); setInputText(parsed.symbol); setError(null); onChange(parsed.symbol); return true; }, [inputText, onChange]); return (
{ if (event.key === 'Tab' && onTabNavigate) { event.preventDefault(); event.stopPropagation(); const isValid = handleSubmit(); if (!isValid) { return; } onTabNavigate(event.shiftKey ? 'backward' : 'forward'); } }} >
Root Note applyDescriptor({ ...descriptor, root: nextRoot })} label="Root Note" showValueAsLabel={true} isOpen={showRootDropdown} onToggle={setShowRootDropdown} buttonClassName="chord-picker-root-button" className="chord-picker-root-dropdown" />
{QUALITY_ROWS.map((row, rowIndex) => (
{row.map((quality) => ( ))}
))}
{EXTENSION_ROWS.map((row, rowIndex) => (
{row.map((extension) => ( ))}
))}
{formatChordSymbolForDisplay(descriptor.symbol || value)} {error && {error}}
); }; export default ChordPickerPopup;