Files
KGStudio/src/components/ChordPickerPopup.tsx
T
2026-05-28 22:52:58 -07:00

323 lines
9.2 KiB
TypeScript

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<ChordQuality, string> = {
maj: 'Maj',
min: 'Min',
sus2: 'Sus2',
sus4: 'Sus4',
power: '5',
aug: 'Aug',
dim: 'Dim',
};
const EXTENSION_LABELS: Record<ChordExtension, string> = {
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<ChordPickerPopupProps> = ({ value, onChange, onTabNavigate }) => {
const [descriptor, setDescriptor] = React.useState<ChordDescriptor>(() => parseChordSymbol(value) ?? createFallbackDescriptor(value));
const [inputText, setInputText] = React.useState(value);
const [error, setError] = React.useState<string | null>(null);
const [showRootDropdown, setShowRootDropdown] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement | null>(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 (
<div
className="chord-picker-popup"
onKeyDownCapture={(event) => {
if (event.key === 'Tab' && onTabNavigate) {
event.preventDefault();
event.stopPropagation();
const isValid = handleSubmit();
if (!isValid) {
return;
}
onTabNavigate(event.shiftKey ? 'backward' : 'forward');
}
}}
>
<div className="chord-picker-header">
<label className="chord-picker-input-shell">
<span className="chord-picker-input-label">Chord</span>
<input
ref={inputRef}
className={`chord-picker-input${error ? ' invalid' : ''}`}
value={inputText}
onChange={(event) => {
setInputText(event.target.value);
if (error) {
setError(null);
}
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleSubmit();
}
}}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
placeholder="Type a chord"
/>
</label>
</div>
<div className="chord-picker-section">
<div className="chord-picker-section-header">
<span>Root Note</span>
<KGDropdown
options={[...ROOT_OPTIONS]}
value={descriptor.root}
onChange={(nextRoot) => applyDescriptor({ ...descriptor, root: nextRoot })}
label="Root Note"
showValueAsLabel={true}
isOpen={showRootDropdown}
onToggle={setShowRootDropdown}
buttonClassName="chord-picker-root-button"
className="chord-picker-root-dropdown"
/>
</div>
</div>
<div className="chord-picker-section chord-picker-grid">
{QUALITY_ROWS.map((row, rowIndex) => (
<div className="chord-picker-button-row" key={`quality-row-${rowIndex}`}>
{row.map((quality) => (
<button
key={quality}
type="button"
aria-label={QUALITY_LABELS[quality]}
className={`chord-picker-button quality${descriptor.quality === quality ? ' selected' : ''}`}
onClick={() => handleQualityChange(quality)}
>
{QUALITY_LABELS[quality]}
</button>
))}
</div>
))}
</div>
<div className="chord-picker-section chord-picker-grid">
{EXTENSION_ROWS.map((row, rowIndex) => (
<div className="chord-picker-button-row" key={`extension-row-${rowIndex}`}>
{row.map((extension) => (
<button
key={extension}
type="button"
aria-label={EXTENSION_LABELS[extension]}
className={`chord-picker-button extension${descriptor.extensions.includes(extension) ? ' selected' : ''}`}
onClick={() => handleExtensionToggle(extension)}
>
{EXTENSION_LABELS[extension]}
</button>
))}
</div>
))}
</div>
<div className="chord-picker-footer">
<span className="chord-picker-preview">{formatChordSymbolForDisplay(descriptor.symbol || value)}</span>
{error && <span className="chord-picker-error">{error}</span>}
</div>
</div>
);
};
export default ChordPickerPopup;