feat(#5): replace icon key inputs with searchable modal icon picker

This commit is contained in:
Josh Creek
2026-02-25 20:58:33 +00:00
parent b11026cfef
commit 5c3eed296a
+262 -84
View File
@@ -4,7 +4,7 @@
import cytoscape from 'cytoscape';
import dagre from 'cytoscape-dagre';
import { listIconDefinitions } from '$lib/config/iconRegistry';
import { listIconDefinitions, resolveIconPath } from '$lib/config/iconRegistry';
import {
cloneNetworkData,
createEmptyDevice,
@@ -95,6 +95,7 @@
let mapControlsCollapsed = false;
const iconDefinitions = listIconDefinitions();
const iconResultLimit = 100;
let tooltip = {
visible: false,
text: '',
@@ -112,13 +113,22 @@
};
const tooltipViewportPadding = 8;
function normalizeIconSearchValue(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
$: normalizedIconSearch = normalizeIconSearchValue(iconSearch);
$: iconSearchTokens = normalizedIconSearch.split(' ').filter(Boolean);
$: filteredIconDefinitions = iconDefinitions.filter((icon) => {
const needle = iconSearch.trim().toLowerCase();
if (!needle) {
if (iconSearchTokens.length === 0) {
return true;
}
return icon.label.toLowerCase().includes(needle) || icon.key.toLowerCase().includes(needle);
const searchable = normalizeIconSearchValue(`${icon.label} ${icon.key}`);
return (
iconSearchTokens.every((token) => searchable.includes(token))
);
});
$: visibleIconDefinitions = filteredIconDefinitions.slice(0, iconResultLimit);
$: selectedMachine =
selectedTarget?.type === 'machine' ? networkData.machines[selectedTarget.index] : null;
@@ -145,6 +155,22 @@
Object.keys(machineVmIndex)
.filter((hostId) => (machineVmIndex[hostId]?.vmCount ?? 0) > 0)
.every((hostId) => isHostCollapsed(hostId));
$: selectedTargetIconKey =
selectedTarget?.type === 'machine'
? selectedMachine?.iconKey ?? ''
: selectedTarget?.type === 'device'
? selectedDevice?.iconKey ?? ''
: selectedTarget?.type === 'vm'
? selectedVm?.iconKey ?? ''
: '';
$: selectedTargetIconPath = resolveIconPath(selectedTargetIconKey || undefined);
$: addModalIconKey =
addModalKind === 'machine'
? newMachineDraft.iconKey ?? ''
: addModalKind === 'device'
? newDeviceDraft.iconKey ?? ''
: '';
$: addModalIconPath = resolveIconPath(addModalIconKey || undefined);
function equalsIgnoreCase(a: string, b: string): boolean {
return a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0;
@@ -351,20 +377,20 @@
if (selectedTarget.type === 'machine') {
if (!networkData.machines[selectedTarget.index]) {
selectedTarget = null;
closeSelectedTargetModal();
}
return;
}
if (selectedTarget.type === 'device') {
if (!networkData.devices[selectedTarget.index]) {
selectedTarget = null;
closeSelectedTargetModal();
}
return;
}
if (!networkData.machines[selectedTarget.machineIndex]?.software.vms[selectedTarget.vmIndex]) {
selectedTarget = null;
closeSelectedTargetModal();
}
}
@@ -433,8 +459,8 @@
'background-image': 'data(iconUrl)',
'background-fit': 'contain',
'background-clip': 'none',
'background-width': '56%',
'background-height': '56%',
'background-width': 'auto',
'background-height': 'auto',
'background-position-y': '30%'
}
},
@@ -1014,6 +1040,56 @@
applyDraft(next, { autosave: options?.autosave ?? true });
}
function setSelectedTargetIconKey(nextKey: string) {
const normalized = nextKey.trim();
const target = selectedTarget;
if (!target) {
return;
}
if (target.type === 'machine') {
const index = target.index;
mutateDraft(
(draft) => {
draft.machines[index].iconKey = normalized || undefined;
},
{ autosave: true }
);
return;
}
if (target.type === 'device') {
const index = target.index;
mutateDraft(
(draft) => {
draft.devices[index].iconKey = normalized || undefined;
},
{ autosave: true }
);
return;
}
const machineIndex = target.machineIndex;
const vmIndex = target.vmIndex;
mutateDraft(
(draft) => {
draft.machines[machineIndex].software.vms[vmIndex].iconKey = normalized || undefined;
},
{ autosave: true }
);
}
function setAddModalIconKey(nextKey: string) {
const normalized = nextKey.trim();
if (addModalKind === 'machine') {
newMachineDraft.iconKey = normalized || undefined;
return;
}
if (addModalKind === 'device') {
newDeviceDraft.iconKey = normalized || undefined;
}
}
function nextUniqueName(base: string, existing: string[]): string {
const normalized = new Set(existing.map((name) => name.toLowerCase()));
if (!normalized.has(base.toLowerCase())) {
@@ -1195,7 +1271,7 @@
return;
}
applyDraft(deleteOwner(networkData, 'machine', machine.machineName));
selectedTarget = null;
closeSelectedTargetModal();
deleteTarget = null;
}
@@ -1205,7 +1281,7 @@
return;
}
applyDraft(deleteOwner(networkData, 'device', device.name));
selectedTarget = null;
closeSelectedTargetModal();
deleteTarget = null;
}
@@ -1213,7 +1289,7 @@
mutateDraft((draft) => {
draft.machines[machineIndex]?.software.vms.splice(vmIndex, 1);
});
selectedTarget = null;
closeSelectedTargetModal();
deleteTarget = null;
}
@@ -1248,7 +1324,7 @@
});
selectedTarget = { type: 'device', index: networkData.devices.length - 1 };
}
addModalKind = null;
closeAddEntityModal();
}
function onThemeToggleChange(event: Event) {
@@ -1267,6 +1343,20 @@
mapControlsCollapsed = !mapControlsCollapsed;
}
function clearIconSearch() {
iconSearch = '';
}
function closeSelectedTargetModal() {
selectedTarget = null;
clearIconSearch();
}
function closeAddEntityModal() {
addModalKind = null;
clearIconSearch();
}
function saveStateLabel(): string {
if (saveState === 'saved') {
return writable ? 'Saved' : 'Read-only';
@@ -1424,7 +1514,7 @@
cy.on('tap', (event) => {
if (event.target === cy) {
selectedTarget = null;
closeSelectedTargetModal();
}
});
@@ -1628,13 +1718,45 @@
: selectedVm?.name ?? 'Virtual Machine'
}
maxWidth="880px"
on:close={() => (selectedTarget = null)}
on:close={closeSelectedTargetModal}
>
<div class="icon-search">
<label>
Icon Search
<input type="search" bind:value={iconSearch} placeholder="Filter icon keys..." />
<input
type="text"
bind:value={iconSearch}
placeholder="Search icons..."
autocomplete="off"
/>
</label>
{#if selectedTargetIconPath}
<div class="icon-preview-card">
<img src={selectedTargetIconPath} alt="Selected icon preview" loading="lazy" />
<code>{selectedTargetIconKey}</code>
</div>
{/if}
<p class="icon-results-meta">
Showing {Math.min(visibleIconDefinitions.length, iconResultLimit)} of {filteredIconDefinitions.length}
matching icons
</p>
<div class="icon-results-grid">
{#each visibleIconDefinitions as icon (icon.key)}
<button
type="button"
class="icon-result-option"
on:click={() => setSelectedTargetIconKey(icon.key)}
title={icon.key}
>
<img src={icon.path} alt={icon.label} loading="lazy" />
<span>{icon.label}</span>
<code>{icon.key}</code>
</button>
{/each}
</div>
{#if filteredIconDefinitions.length === 0}
<p class="icon-results-empty">No icons match your search.</p>
{/if}
</div>
{#if selectedTarget?.type === 'machine' && selectedMachine}
<div class="inline-actions">
@@ -1702,23 +1824,6 @@
)}
/>
</label>
<label>
Icon
<input
type="text"
list="icon-options"
value={selectedMachine.iconKey ?? ''}
on:change={(event) =>
mutateDraft(
(draft) => {
const nextValue = (event.currentTarget as HTMLInputElement).value.trim();
draft.machines[selectedTarget.index].iconKey = nextValue || undefined;
},
{ autosave: true }
)}
placeholder="server"
/>
</label>
</section>
<section class="edit-section">
@@ -1829,7 +1934,6 @@
Icon
<input
type="text"
list="icon-options"
bind:value={vm.iconKey}
on:change={() => mutateDraft(() => undefined, { autosave: true })}
/>
@@ -2007,22 +2111,6 @@
)}
></textarea>
</label>
<label>
Icon
<input
type="text"
list="icon-options"
value={selectedDevice.iconKey ?? ''}
on:change={(event) =>
mutateDraft(
(draft) => {
const nextValue = (event.currentTarget as HTMLInputElement).value.trim();
draft.devices[selectedTarget.index].iconKey = nextValue || undefined;
},
{ autosave: true }
)}
/>
</label>
</section>
<section class="edit-section">
@@ -2179,24 +2267,6 @@
)}
/>
</label>
<label>
Icon
<input
type="text"
list="icon-options"
value={selectedVm.iconKey ?? ''}
on:change={(event) =>
mutateDraft(
(draft) => {
const nextValue = (event.currentTarget as HTMLInputElement).value.trim();
draft.machines[selectedTarget.machineIndex].software.vms[
selectedTarget.vmIndex
].iconKey = nextValue || undefined;
},
{ autosave: true }
)}
/>
</label>
</section>
<div class="modal-footer">
<button
@@ -2219,13 +2289,45 @@
isOpen={addModalKind !== null}
title={addModalKind === 'machine' ? 'Add Machine' : 'Add Device'}
maxWidth="760px"
on:close={() => (addModalKind = null)}
on:close={closeAddEntityModal}
>
<div class="icon-search">
<label>
Icon Search
<input type="search" bind:value={iconSearch} placeholder="Filter icon keys..." />
<input
type="text"
bind:value={iconSearch}
placeholder="Search icons..."
autocomplete="off"
/>
</label>
{#if addModalIconPath}
<div class="icon-preview-card">
<img src={addModalIconPath} alt="Selected icon preview" loading="lazy" />
<code>{addModalIconKey}</code>
</div>
{/if}
<p class="icon-results-meta">
Showing {Math.min(visibleIconDefinitions.length, iconResultLimit)} of {filteredIconDefinitions.length}
matching icons
</p>
<div class="icon-results-grid">
{#each visibleIconDefinitions as icon (icon.key)}
<button
type="button"
class="icon-result-option"
on:click={() => setAddModalIconKey(icon.key)}
title={icon.key}
>
<img src={icon.path} alt={icon.label} loading="lazy" />
<span>{icon.label}</span>
<code>{icon.key}</code>
</button>
{/each}
</div>
{#if filteredIconDefinitions.length === 0}
<p class="icon-results-empty">No icons match your search.</p>
{/if}
</div>
{#if addModalKind === 'machine'}
<section class="edit-section">
@@ -2245,10 +2347,6 @@
Operating System
<input type="text" bind:value={newMachineDraft.operatingSystem} />
</label>
<label>
Icon
<input type="text" list="icon-options" bind:value={newMachineDraft.iconKey} />
</label>
</section>
{:else if addModalKind === 'device'}
<section class="edit-section">
@@ -2268,10 +2366,6 @@
Notes
<textarea bind:value={newDeviceDraft.notes}></textarea>
</label>
<label>
Icon
<input type="text" list="icon-options" bind:value={newDeviceDraft.iconKey} />
</label>
</section>
{/if}
@@ -2320,12 +2414,6 @@
</div>
</Modal>
<datalist id="icon-options">
{#each filteredIconDefinitions as icon (icon.key)}
<option value={icon.key}>{icon.label}</option>
{/each}
</datalist>
<style>
.diagram-shell {
display: grid;
@@ -2699,6 +2787,8 @@
.icon-search {
margin-bottom: 0.7rem;
display: grid;
gap: 0.6rem;
}
.icon-search label {
@@ -2718,6 +2808,94 @@
font-size: 0.86rem;
}
.icon-preview-card {
display: inline-flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: color-mix(in oklab, var(--panel-bg) 92%, black 8%);
}
.icon-preview-card img {
width: 28px;
height: 28px;
object-fit: contain;
}
.icon-preview-card code {
font-size: 0.74rem;
color: var(--muted-text);
word-break: break-all;
}
.icon-results-meta {
margin: 0;
font-size: 0.74rem;
font-weight: 600;
color: var(--muted-text);
}
.icon-results-empty {
margin: 0;
font-size: 0.74rem;
font-weight: 600;
color: var(--muted-text);
}
.icon-results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 0.45rem;
max-height: 280px;
overflow: auto;
padding-right: 0.25rem;
}
.icon-result-option {
display: grid;
grid-template-columns: 24px 1fr;
grid-template-areas:
'icon label'
'icon key';
align-items: center;
gap: 0.2rem 0.5rem;
border: 1px solid var(--panel-border);
background: var(--panel-bg);
color: var(--panel-contrast);
padding: 0.4rem 0.45rem;
border-radius: 8px;
cursor: pointer;
text-align: left;
}
.icon-result-option:hover {
background: color-mix(in oklab, var(--panel-bg) 84%, #3b82f6 16%);
}
.icon-result-option img {
grid-area: icon;
width: 20px;
height: 20px;
object-fit: contain;
}
.icon-result-option span {
grid-area: label;
font-size: 0.75rem;
font-weight: 700;
line-height: 1.2;
}
.icon-result-option code {
grid-area: key;
font-size: 0.66rem;
color: var(--muted-text);
line-height: 1.2;
word-break: break-all;
}
@media (max-width: 900px) {
.diagram-shell {
height: auto;