feat: add 4 built-in gradients, searchable gradient picker, cleaner modal titles
Lint & Test / test (push) Successful in 1m29s
Lint & Test / test (push) Successful in 1m29s
- Add warm, cool, neon, pastel built-in gradients (promoted from frontend presets) - Change gradient seeding to add missing built-ins on every startup (not just first run) - Add searchable option to IconSelect component for filtering by name - Enable search on gradient, effect palette, and audio palette pickers - Simplify modal titles: "Add Gradient" / "Edit Gradient" instead of "Add Color Strip Source: Gradient" - Update INSTALLATION.md and .env.example
This commit is contained in:
@@ -689,6 +689,25 @@ textarea:focus-visible {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.icon-select-search {
|
||||
width: calc(100% - 12px);
|
||||
padding: 8px 12px;
|
||||
margin: 6px 6px 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.icon-select-search::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.icon-select-search:focus {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.icon-select-grid {
|
||||
display: grid;
|
||||
grid-auto-rows: 1fr;
|
||||
@@ -820,6 +839,7 @@ textarea:focus-visible {
|
||||
/* Override inline columns — use responsive auto-fill */
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)) !important;
|
||||
}
|
||||
.icon-select-popup .icon-select-cell.disabled,
|
||||
.type-picker-dialog .icon-select-cell.disabled {
|
||||
opacity: 0.25;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -58,6 +58,8 @@ export interface IconSelectOpts {
|
||||
onChange?: (value: string) => void;
|
||||
columns?: number;
|
||||
placeholder?: string;
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
}
|
||||
|
||||
export class IconSelect {
|
||||
@@ -66,12 +68,15 @@ export class IconSelect {
|
||||
_onChange: ((value: string) => void) | undefined;
|
||||
_columns: number;
|
||||
_placeholder: string;
|
||||
_searchable: boolean;
|
||||
_searchPlaceholder: string;
|
||||
_trigger: HTMLButtonElement;
|
||||
_popup: HTMLDivElement;
|
||||
_searchInput: HTMLInputElement | null = null;
|
||||
_scrollHandler: (() => void) | null = null;
|
||||
_scrollTargets: (HTMLElement | Window)[] = [];
|
||||
|
||||
constructor({ target, items, onChange, columns = 2, placeholder = '' }: IconSelectOpts) {
|
||||
constructor({ target, items, onChange, columns = 2, placeholder = '', searchable = false, searchPlaceholder = 'Filter…' }: IconSelectOpts) {
|
||||
_ensureGlobalListener();
|
||||
|
||||
this._select = target;
|
||||
@@ -79,6 +84,8 @@ export class IconSelect {
|
||||
this._onChange = onChange;
|
||||
this._columns = columns;
|
||||
this._placeholder = placeholder;
|
||||
this._searchable = searchable;
|
||||
this._searchPlaceholder = searchPlaceholder;
|
||||
|
||||
// Hide the native select
|
||||
this._select.style.display = 'none';
|
||||
@@ -100,6 +107,13 @@ export class IconSelect {
|
||||
this._popup.innerHTML = this._buildGrid();
|
||||
document.body.appendChild(this._popup);
|
||||
|
||||
this._bindPopupEvents();
|
||||
|
||||
// Sync to current select value
|
||||
this._syncTrigger();
|
||||
}
|
||||
|
||||
_bindPopupEvents() {
|
||||
// Bind item clicks
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
cell.addEventListener('click', () => {
|
||||
@@ -109,20 +123,33 @@ export class IconSelect {
|
||||
});
|
||||
});
|
||||
|
||||
// Sync to current select value
|
||||
this._syncTrigger();
|
||||
// Bind search input
|
||||
this._searchInput = this._popup.querySelector('.icon-select-search') as HTMLInputElement | null;
|
||||
if (this._searchInput) {
|
||||
this._searchInput.addEventListener('input', () => {
|
||||
const q = this._searchInput!.value.toLowerCase().trim();
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
const el = cell as HTMLElement;
|
||||
el.classList.toggle('disabled', !!q && !el.dataset.search!.includes(q));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_buildGrid() {
|
||||
const cells = this._items.map(item => {
|
||||
return `<div class="icon-select-cell" data-value="${item.value}">
|
||||
const search = (item.label + ' ' + (item.desc || '')).toLowerCase();
|
||||
return `<div class="icon-select-cell" data-value="${item.value}" data-search="${search}">
|
||||
<span class="icon-select-cell-icon">${item.icon}</span>
|
||||
<span class="icon-select-cell-label">${item.label}</span>
|
||||
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="icon-select-grid" style="grid-template-columns:repeat(${this._columns},1fr)">${cells}</div>`;
|
||||
const searchHTML = this._searchable
|
||||
? `<input class="icon-select-search" type="text" placeholder="${this._searchPlaceholder}" autocomplete="off">`
|
||||
: '';
|
||||
return searchHTML + `<div class="icon-select-grid" style="grid-template-columns:repeat(${this._columns},1fr)">${cells}</div>`;
|
||||
}
|
||||
|
||||
_syncTrigger() {
|
||||
@@ -184,6 +211,13 @@ export class IconSelect {
|
||||
this._positionPopup();
|
||||
this._popup.classList.add('open');
|
||||
this._addScrollListener();
|
||||
if (this._searchInput) {
|
||||
this._searchInput.value = '';
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
(cell as HTMLElement).classList.remove('disabled');
|
||||
});
|
||||
requestAnimationFrame(() => desktopFocus(this._searchInput!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,12 +267,7 @@ export class IconSelect {
|
||||
updateItems(items: IconSelectItem[]) {
|
||||
this._items = items;
|
||||
this._popup.innerHTML = this._buildGrid();
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
cell.addEventListener('click', () => {
|
||||
this.setValue((cell as HTMLElement).dataset.value!, true);
|
||||
this._popup.classList.remove('open');
|
||||
});
|
||||
});
|
||||
this._bindPopupEvents();
|
||||
this._syncTrigger();
|
||||
}
|
||||
|
||||
|
||||
@@ -433,7 +433,7 @@ function _ensureEffectPaletteIconSelect() {
|
||||
const items = _buildGradientEntityItems();
|
||||
_syncSelectOptions(sel, items);
|
||||
if (_effectPaletteIconSelect) { _effectPaletteIconSelect.updateItems(items); return; }
|
||||
_effectPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2 });
|
||||
_effectPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2, searchable: true, searchPlaceholder: t('palette.search') });
|
||||
}
|
||||
|
||||
function _ensureGradientEasingIconSelect() {
|
||||
@@ -468,7 +468,7 @@ function _ensureAudioPaletteIconSelect() {
|
||||
const items = _buildGradientEntityItems();
|
||||
_syncSelectOptions(sel, items);
|
||||
if (_audioPaletteIconSelect) { _audioPaletteIconSelect.updateItems(items); return; }
|
||||
_audioPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2 });
|
||||
_audioPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2, searchable: true, searchPlaceholder: t('palette.search') });
|
||||
}
|
||||
|
||||
function _ensureAudioVizIconSelect() {
|
||||
@@ -507,7 +507,7 @@ function _ensureGradientPresetIconSelect() {
|
||||
const items = _buildGradientEntityItems();
|
||||
_syncSelectOptions(sel, items);
|
||||
if (_gradientPresetIconSelect) { _gradientPresetIconSelect.updateItems(items); return; }
|
||||
_gradientPresetIconSelect = new IconSelect({ target: sel, items, columns: 3 });
|
||||
_gradientPresetIconSelect = new IconSelect({ target: sel, items, columns: 3, searchable: true, searchPlaceholder: t('palette.search') });
|
||||
}
|
||||
|
||||
/** Rebuild the gradient picker after entity changes. */
|
||||
@@ -1728,12 +1728,14 @@ export async function showCSSEditor(cssId: any = null, cloneData: any = null, pr
|
||||
}
|
||||
|
||||
await _populateFromCSS(css);
|
||||
(document.getElementById('css-editor-title') as HTMLElement).innerHTML = `${ICON_FILM} ${t('color_strip.edit')}`;
|
||||
const editIcon = getColorStripIcon(css.source_type);
|
||||
(document.getElementById('css-editor-title') as HTMLElement).innerHTML = `${editIcon} ${t('color_strip.edit')} ${t(`color_strip.type.${css.source_type}`)}`;
|
||||
} else if (cloneData) {
|
||||
(document.getElementById('css-editor-id') as HTMLInputElement).value = '';
|
||||
(document.getElementById('css-editor-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
|
||||
await _populateFromCSS(cloneData);
|
||||
(document.getElementById('css-editor-title') as HTMLElement).innerHTML = `${ICON_FILM} ${t('color_strip.add')}`;
|
||||
const cloneIcon = getColorStripIcon(cloneData.source_type);
|
||||
(document.getElementById('css-editor-title') as HTMLElement).innerHTML = `${cloneIcon} ${t('color_strip.add')} ${t(`color_strip.type.${cloneData.source_type}`)}`;
|
||||
} else {
|
||||
(document.getElementById('css-editor-id') as HTMLInputElement).value = '';
|
||||
(document.getElementById('css-editor-name') as HTMLInputElement).value = '';
|
||||
@@ -1748,7 +1750,7 @@ export async function showCSSEditor(cssId: any = null, cloneData: any = null, pr
|
||||
}
|
||||
|
||||
const typeIcon = getColorStripIcon(effectiveType);
|
||||
(document.getElementById('css-editor-title') as HTMLElement).innerHTML = `${typeIcon} ${t('color_strip.add')}: ${t(`color_strip.type.${effectiveType}`)}`;
|
||||
(document.getElementById('css-editor-title') as HTMLElement).innerHTML = `${typeIcon} ${t('color_strip.add')} ${t(`color_strip.type.${effectiveType}`)}`;
|
||||
_autoGenerateCSSName();
|
||||
}
|
||||
|
||||
|
||||
@@ -911,8 +911,8 @@
|
||||
"aria.next": "Next",
|
||||
"aria.hint": "Show hint",
|
||||
"color_strip.select_type": "Select Color Strip Type",
|
||||
"color_strip.add": "Add Color Strip Source",
|
||||
"color_strip.edit": "Edit Color Strip Source",
|
||||
"color_strip.add": "Add",
|
||||
"color_strip.edit": "Edit",
|
||||
"color_strip.name": "Name:",
|
||||
"color_strip.name.placeholder": "Wall Strip",
|
||||
"color_strip.picture_source": "Picture Source:",
|
||||
|
||||
@@ -911,8 +911,8 @@
|
||||
"aria.next": "Вперёд",
|
||||
"aria.hint": "Показать подсказку",
|
||||
"color_strip.select_type": "Выберите тип цветовой полосы",
|
||||
"color_strip.add": "Добавить источник цветовой полосы",
|
||||
"color_strip.edit": "Редактировать источник цветовой полосы",
|
||||
"color_strip.add": "Добавить",
|
||||
"color_strip.edit": "Редактировать",
|
||||
"color_strip.name": "Название:",
|
||||
"color_strip.name.placeholder": "Настенная полоса",
|
||||
"color_strip.picture_source": "Источник изображения:",
|
||||
|
||||
@@ -911,8 +911,8 @@
|
||||
"aria.next": "下一个",
|
||||
"aria.hint": "显示提示",
|
||||
"color_strip.select_type": "选择色带类型",
|
||||
"color_strip.add": "添加色带源",
|
||||
"color_strip.edit": "编辑色带源",
|
||||
"color_strip.add": "添加",
|
||||
"color_strip.edit": "编辑",
|
||||
"color_strip.name": "名称:",
|
||||
"color_strip.name.placeholder": "墙壁灯带",
|
||||
"color_strip.picture_source": "图片源:",
|
||||
|
||||
Reference in New Issue
Block a user