feat: enhance TagEditor to support primary category selection and reorder categories

This commit is contained in:
2025-11-11 08:25:36 +11:00
parent 6515a30a0e
commit 65ab2776eb

View File

@@ -71,16 +71,18 @@ export function TagEditor({ categories, availableCategories, onSave, showHeading
}, [availableCategories, categoryFilter]); }, [availableCategories, categoryFilter]);
const selectedCategoryRecords = useMemo( const selectedCategoryRecords = useMemo(
() => availableCategories () => {
.filter((entry) => selectedCategories.has(entry.id)) // Preserve the order from the categories prop (first category is primary)
.sort((left, right) => { const orderedRecords: CategoryRecord[] = [];
const topLevelComparison = left.category.localeCompare(right.category); for (const catId of categories) {
if (topLevelComparison !== 0) { const record = availableCategories.find((entry) => entry.id === catId);
return topLevelComparison; if (record && selectedCategories.has(catId)) {
orderedRecords.push(record);
} }
return left.subCategory.localeCompare(right.subCategory); }
}), return orderedRecords;
[availableCategories, selectedCategories] },
[availableCategories, selectedCategories, categories]
); );
const categoryColorMap = useMemo(() => { const categoryColorMap = useMemo(() => {
@@ -137,6 +139,16 @@ export function TagEditor({ categories, availableCategories, onSave, showHeading
onSave(Array.from(next)); onSave(Array.from(next));
}; };
const handleSetPrimaryCategory = (categoryId: string) => {
const currentList = Array.from(selectedCategories);
// Remove the category from its current position
const filteredList = currentList.filter((id) => id !== categoryId);
// Add it to the front
const reorderedList = [categoryId, ...filteredList];
setSelectedCategories(new Set(reorderedList));
void onSave(reorderedList);
};
const handleRemoveCategory = (categoryId: string) => { const handleRemoveCategory = (categoryId: string) => {
if (!selectedCategories.has(categoryId)) { if (!selectedCategories.has(categoryId)) {
return; return;
@@ -201,24 +213,89 @@ export function TagEditor({ categories, availableCategories, onSave, showHeading
<div className="selected-categories"> <div className="selected-categories">
<div className="selected-category-chip-list"> <div className="selected-category-chip-list">
{selectedCategoryRecords.length === 0 && <span className="selected-category-empty">No categories selected</span>} {selectedCategoryRecords.length === 0 && <span className="selected-category-empty">No categories selected</span>}
{selectedCategoryRecords.map((entry) => { {selectedCategoryRecords.map((entry, index) => {
const categoryLabel = formatCategoryLabel(entry.category); const categoryLabel = formatCategoryLabel(entry.category);
const subCategoryLabel = formatCategoryLabel(entry.subCategory); const subCategoryLabel = formatCategoryLabel(entry.subCategory);
const isPrimary = index === 0;
return ( return (
<button <div
key={entry.id} key={entry.id}
type="button"
className="selected-category-chip" className="selected-category-chip"
onClick={() => handleRemoveCategory(entry.id)} style={{
style={createCategoryStyleVars(categoryColorMap.get(entry.id))} ...createCategoryStyleVars(categoryColorMap.get(entry.id)),
title={`${categoryLabel} ${subCategoryLabel}`} display: 'inline-flex',
alignItems: 'center',
gap: '4px',
position: 'relative'
}}
onMouseEnter={(e) => {
const star = e.currentTarget.querySelector('.category-star-button') as HTMLElement;
if (star && !isPrimary) {
star.style.opacity = '1';
star.style.width = 'auto';
star.style.padding = '0 4px';
}
}}
onMouseLeave={(e) => {
const star = e.currentTarget.querySelector('.category-star-button') as HTMLElement;
if (star && !isPrimary) {
star.style.opacity = '0';
star.style.width = '0';
star.style.padding = '0';
}
}}
title={isPrimary ? `${categoryLabel} ${subCategoryLabel} (Primary - used for organization)` : `${categoryLabel} ${subCategoryLabel}`}
> >
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleSetPrimaryCategory(entry.id);
}}
className="category-star-button"
style={{
background: 'none',
border: 'none',
padding: isPrimary ? '0 4px' : '0',
margin: 0,
cursor: 'pointer',
fontSize: '14px',
opacity: isPrimary ? 1 : 0,
width: isPrimary ? 'auto' : '0',
overflow: 'hidden',
transition: 'opacity 0.2s ease, width 0.2s ease, padding 0.2s ease',
color: isPrimary ? 'gold' : 'currentColor',
flexShrink: 0
}}
title={isPrimary ? 'Primary category' : 'Set as primary category'}
aria-label={isPrimary ? 'Primary category' : 'Set as primary category'}
>
{isPrimary ? '★' : '☆'}
</button>
<span className="selected-category-chip-label"> <span className="selected-category-chip-label">
<span className="category-label category-label--muted">{categoryLabel}</span> <span className="category-label category-label--muted">{categoryLabel}</span>
<span className="category-label category-label--primary">{subCategoryLabel}</span> <span className="category-label category-label--primary">{subCategoryLabel}</span>
</span> </span>
<span className="selected-category-chip-remove" aria-hidden="true">×</span> <button
type="button"
className="selected-category-chip-remove-button"
onClick={(e) => {
e.stopPropagation();
handleRemoveCategory(entry.id);
}}
style={{
background: 'none',
border: 'none',
padding: '0 4px',
cursor: 'pointer',
fontSize: '18px',
lineHeight: 1
}}
aria-label="Remove category"
>
<span aria-hidden="true">×</span>
</button> </button>
</div>
); );
})} })}
</div> </div>