From 0a547af9fe535320cd89e3648b07b9b615278d27 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 16:18:31 -0400 Subject: [PATCH] Replace ISAC attribute dropdowns with search --- docs/DESIGN_SYSTEM.md | 9 ++++++++ src/app.css | 1 + .../components/SearchableAssignment.svelte | 21 ++++++++++++++----- src/routes/admin/brands/+page.svelte | 3 ++- src/routes/admin/protocols/+page.svelte | 4 ++-- src/routes/admin/skillchips/+page.svelte | 2 +- src/routes/admin/talents/+page.svelte | 3 ++- src/routes/admin/weapons/+page.svelte | 6 +++--- 8 files changed, 36 insertions(+), 13 deletions(-) diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index d0105ff..593226b 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -783,3 +783,12 @@ relationship. Prefer these modes over native selects once a source registry is large enough that scanning a dropdown is inefficient. The selected record, pending-contribution state, keyboard focus, and remove interaction must remain explicit in both modes. + +Every ISAC relationship to an Attribute uses `SearchableAssignment`; native +attribute dropdowns are not permitted because the registry is expected to +contain hundreds of records. Use full mode for primary editor sections and +multi-value pools. Use compact single mode inside repeated effect, bonus, or +augmentation rows: its selected record and search field remain inline while +the filtered result registry opens as an anchored overlay. The compact overlay +must layer above adjacent rows, retain pending-record labels, and collapse to +one column on narrow screens. diff --git a/src/app.css b/src/app.css index 45d555d..316966a 100644 --- a/src/app.css +++ b/src/app.css @@ -110,6 +110,7 @@ .dashboard-row>.role-summary{display:flex;flex-wrap:wrap;align-items:center;gap:3px}.role-summary b{flex:0 0 auto;width:max-content;padding:3px 5px;border:1px solid #6e5226;color:var(--shd-orange);font-size:6px;letter-spacing:.6px} .game-icon{display:block;width:24px;height:24px;object-fit:contain}.weapon-editor{border-top-color:#d07a2c}.weapon-identity-grid{display:grid;grid-template-columns:1.6fr repeat(5,minmax(0,1fr));gap:8px}.weapon-fixed-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}.damage-type-picker{min-width:0;margin:0;padding:0;border:0}.damage-type-picker legend{color:#858b8d;font-size:8px;font-weight:bold;letter-spacing:1px}.damage-type-picker>div{height:32px;display:grid;grid-template-columns:repeat(3,1fr);margin-top:5px;border:1px solid #363b3c;background:#0d0f10}.damage-type-picker label{min-width:0;border-left:1px solid #303536;cursor:pointer}.damage-type-picker label:first-child{border-left:0}.damage-type-picker input{position:absolute;opacity:0;pointer-events:none}.damage-type-picker span{height:100%;display:flex;align-items:center;justify-content:center;gap:3px;color:#737a7c}.damage-type-picker .game-icon{width:17px;height:17px;opacity:.62}.damage-type-picker b{overflow:hidden;font-size:5px;letter-spacing:.25px;text-overflow:ellipsis}.damage-type-picker label:hover span{background:#171b1c;color:#ecebe5}.damage-type-picker label:active span{transform:translateY(1px);filter:brightness(.88)}.damage-type-picker input:checked+span{background:#211a0e;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.damage-type-picker input:checked+span .game-icon{opacity:1}.damage-type-picker input:focus-visible+span{outline:2px solid #f6a821;outline-offset:2px}.exotic-toggle>span{min-height:32px;margin-top:5px;padding:0 9px;display:flex;align-items:center;gap:7px;border:1px solid #363b3c;background:#0d0f10;color:#d3d5d1;font-size:8px}.exotic-toggle input{width:auto;margin:0;accent-color:#f6a821}.weapon-tier-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:9px}.weapon-tier-card{min-width:0;margin:0;padding:7px;border:1px solid #3b4142;background:#101314}.weapon-tier-card legend{padding:0 7px;color:#f6a821;font-size:8px;font-weight:900;letter-spacing:1.2px}.weapon-range-row{display:grid;grid-template-columns:65px 1fr 1fr;gap:5px;align-items:end;padding:5px;border-top:1px solid #282e2f}.weapon-range-row:first-of-type{border-top:0}.weapon-range-row>strong{align-self:center;display:flex;align-items:center;gap:4px;color:#d7d9d5;font-size:9px}.weapon-range-row>strong .game-icon{width:20px;height:20px}.weapon-range-row>strong small{display:block;color:#596164;font-size:5px;letter-spacing:.5px}.weapon-range-row label{color:#737a7c;font-size:6px;letter-spacing:.7px}.weapon-range-row input{margin-top:3px;padding:7px;font-size:8px}.weapon-talent-pools{display:grid;grid-template-columns:1fr 1fr;gap:9px}.compact-selection{grid-template-columns:repeat(2,minmax(0,1fr));max-height:210px;overflow:auto}.weapon-base-bonuses,.weapon-augment-effects{display:grid;gap:5px;margin-top:7px}.base-bonus-row,.augment-effect{display:grid;grid-template-columns:35px minmax(180px,1fr) 130px 30px;gap:6px;align-items:center;padding:5px;border:1px solid #303536;background:#101314}.base-bonus-row>strong,.augment-effect>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:#f6a821;font-size:8px}.base-bonus-row .field,.augment-effect .field{margin:0}.base-bonus-row button,.augment-effect button{height:30px;border:1px solid #563734;background:#1d1212;color:#da746c;cursor:pointer}.base-bonus-row button:disabled{opacity:.3;cursor:not-allowed}.augment-effect:not(:has(>button)){grid-template-columns:35px minmax(180px,1fr) 130px}.augment-sequence{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:5px;margin-top:7px}.augment-sequence>div{min-height:42px;display:grid;place-items:center;padding:5px;border:1px solid #303536;background:#0d1011}.augment-sequence b{color:#f6a821;font-size:8px}.augment-sequence span{color:#727a7c;font-size:7px}.augment-sequence .exotic-step{border-color:#875d25;background:#211a0e}.weapon-row{min-width:1180px;grid-template-columns:1.3fr .7fr 1.2fr .6fr .6fr .5fr .5fr .3fr}.weapon-row>span small{color:#596164}.weapon-damage-label{display:flex!important;align-items:center;gap:4px}.weapon-damage-label .game-icon{width:16px;height:16px}.weapon-dashboard-table{margin-top:10px} .assignment-picker{border:1px solid #303536;background:#0d1011}.assignment-selected{display:grid;grid-template-columns:70px 1fr;min-height:45px;border-bottom:1px solid #303536}.assignment-summary{display:grid;place-items:center;background:#15191a}.assignment-summary span{color:#687073;font-size:6px;letter-spacing:1px}.assignment-summary b{color:#f6a821;font-size:14px}.assignment-chips{display:flex;align-items:center;gap:5px;overflow:auto;padding:6px}.assignment-chips>button{flex:0 0 auto;display:flex;align-items:center;gap:7px;padding:6px 8px;border:1px solid #6b5128;background:#211a0e;color:#e2cda7;font-size:7px;cursor:pointer}.assignment-chips>button:hover{border-color:#f6a821;color:#f6a821}.assignment-chips>button:active{transform:translateY(1px)}.assignment-chips small{color:#596164;font-size:7px}.assignment-search{display:block;padding:7px 8px;color:#70787a;font-size:6px;letter-spacing:1px}.assignment-search input{height:32px;margin-top:5px;padding:0 9px;font-size:9px}.assignment-results-head{height:27px;display:flex;align-items:center;justify-content:space-between;padding:0 9px;border-top:1px solid #262b2c;border-bottom:1px solid #262b2c;color:#687073;font-size:6px;letter-spacing:1px}.assignment-results-head b{color:#8a672b}.assignment-results{max-height:210px;overflow:auto}.assignment-results>button{width:100%;min-height:34px;display:flex;align-items:center;justify-content:space-between;padding:6px 9px;border:0;border-top:1px solid #24292a;background:#111415;color:#a4aaac;text-align:left;font-size:8px;cursor:pointer}.assignment-results>button:first-child{border-top:0}.assignment-results>button:hover,.assignment-results>button:focus-visible{background:#171b1c;color:#fff;box-shadow:inset 2px 0 #f6a821}.assignment-results>button.selected{background:#211a0e;color:#f6a821;box-shadow:inset 2px 0 #f6a821}.assignment-results>button b{font-size:6px;letter-spacing:.7px}.assignment-results>button:active{transform:translateY(1px);filter:brightness(.88)}.assignment-results>p{margin:0;padding:25px;text-align:center;color:#596164;font-size:7px;letter-spacing:1px} +.assignment-picker.compact{position:relative;z-index:1;display:grid;grid-template-columns:minmax(120px,.85fr) minmax(135px,1fr);min-width:0}.assignment-picker.compact:focus-within,.assignment-picker.compact.open{z-index:20;border-color:#8a672b}.assignment-picker.compact .assignment-selected{grid-template-columns:42px minmax(0,1fr);min-width:0;min-height:40px;border-right:1px solid var(--shd-border);border-bottom:0}.assignment-picker.compact .assignment-summary span{font-size:5px}.assignment-picker.compact .assignment-summary b{font-size:10px}.assignment-picker.compact .assignment-chips{min-width:0;padding:4px}.assignment-picker.compact .assignment-chips>button{max-width:100%;padding:5px 6px}.assignment-picker.compact .assignment-chips>button span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.assignment-picker.compact .assignment-search{display:grid;align-content:center;padding:4px}.assignment-picker.compact .assignment-search>span{display:none}.assignment-picker.compact .assignment-search input{height:32px;margin:0}.assignment-picker.compact .assignment-results-head,.assignment-picker.compact .assignment-results{display:none;position:absolute;left:-1px;right:-1px;background:#0d1011}.assignment-picker.compact.open .assignment-results-head{top:100%;display:flex;border:1px solid #8a672b;border-bottom-color:var(--shd-border)}.assignment-picker.compact.open .assignment-results{top:calc(100% + 27px);display:block;border:1px solid #8a672b;border-top:0;box-shadow:0 12px 24px #000b}.assignment-picker.compact .assignment-results>button{min-height:38px}@media(max-width:600px){.assignment-picker.compact{grid-template-columns:1fr}.assignment-picker.compact .assignment-selected{border-right:0;border-bottom:1px solid var(--shd-border)}} .registry-pagination{width:100%;height:auto;min-height:48px;margin:8px 0 0;padding:7px 9px;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:12px;border:1px solid #303536;border-bottom:0;background:#0f1213}.pagination-range{display:flex;align-items:center;gap:6px;color:#697174;font-size:7px;letter-spacing:.8px}.pagination-range b{color:#d7d9d5;font-size:9px}.pagination-pages{display:flex;align-items:center;justify-content:center;gap:4px}.pagination-pages a,.pagination-sizes a{min-width:30px;height:30px;display:grid;place-items:center;border:1px solid #343a3b;background:#111415;color:#8c9395;font-size:7px;font-weight:900;letter-spacing:.5px;cursor:pointer}.pagination-pages a:first-child,.pagination-pages a:last-child{min-width:58px;padding:0 8px}.pagination-pages a:hover,.pagination-pages a:focus-visible,.pagination-sizes a:hover,.pagination-sizes a:focus-visible{border-color:#8a672b;color:#f6a821}.pagination-pages a.active,.pagination-sizes a.active{border-color:#f6a821;background:#211a0e;color:#f6a821}.pagination-pages a:active,.pagination-sizes a:active{transform:translateY(1px);filter:brightness(.88)}.pagination-pages a.disabled{opacity:.35;pointer-events:none}.pagination-gap{color:#555d5f;font-size:8px}.pagination-sizes{display:flex;align-items:center;gap:4px}.pagination-sizes>span{margin-right:3px;color:#697174;font-size:7px;letter-spacing:1px}.pagination-sizes a{min-width:34px}.registry-pagination a:focus-visible{outline:2px solid #f6a821;outline-offset:2px} .registry-pagination+.dashboard-table{margin-top:0} @media(max-width:900px){.registry-pagination{grid-template-columns:1fr}.pagination-range,.pagination-pages,.pagination-sizes{justify-content:center}}@media(max-width:560px){.pagination-pages a:not(:first-child):not(:last-child):not(.active),.pagination-gap{display:none}.pagination-sizes>span{display:none}} diff --git a/src/lib/components/SearchableAssignment.svelte b/src/lib/components/SearchableAssignment.svelte index 5ba65d2..e6aa213 100644 --- a/src/lib/components/SearchableAssignment.svelte +++ b/src/lib/components/SearchableAssignment.svelte @@ -5,8 +5,9 @@ selected = $bindable([]), value = $bindable(''), mode = 'multiple', + compact = false, excludedIds = [], - fieldName, + fieldName = '', emptyLabel = 'NO MATCHING RECORDS', subjectLabel = 'TALENTS', searchLabel = `FILTER AVAILABLE ${subjectLabel}`, @@ -17,8 +18,9 @@ selected?: string[]; value?: string; mode?: 'multiple' | 'single'; + compact?: boolean; excludedIds?: string[]; - fieldName: string; + fieldName?: string; emptyLabel?: string; subjectLabel?: string; searchLabel?: string; @@ -26,6 +28,7 @@ emptySelectionLabel?: string; } = $props(); let query = $state(''); + let open = $state(false); const selectedIds = $derived(mode === 'single' ? (value ? [value] : []) : selected); const normalizedQuery = $derived(query.normalize('NFKC').trim().toLocaleLowerCase()); const available = $derived(items.filter((item) => @@ -35,13 +38,21 @@ const visible = $derived(available.slice(0, 30)); const selectedItems = $derived(selectedIds.map((id) => items.find((item) => item.id === id)).filter((item): item is Item => Boolean(item))); const toggle = (id: string) => { - if (mode === 'single') value = value === id ? '' : id; + if (mode === 'single') { + value = value === id ? '' : id; + if (compact) { + open = false; + query = ''; + } + } else selected = selected.includes(id) ? selected.filter((value) => value !== id) : [...selected, id]; }; -
- {#each selectedIds as id}{/each} +
{ if (compact) open = true; }} onfocusout={(event) => { + if (compact && !event.currentTarget.contains(event.relatedTarget as Node | null)) open = false; +}}> + {#if fieldName}{#each selectedIds as id}{/each}{/if}
{mode === 'single' ? 'SELECTED' : 'ASSIGNED'}{selectedIds.length}
diff --git a/src/routes/admin/brands/+page.svelte b/src/routes/admin/brands/+page.svelte index 91990fb..cea769b 100644 --- a/src/routes/admin/brands/+page.svelte +++ b/src/routes/admin/brands/+page.svelte @@ -3,6 +3,7 @@ import RegistryPagination from '$lib/components/RegistryPagination.svelte'; import KpiFilters from '$lib/components/KpiFilters.svelte'; import IconUploadField from '$lib/components/IconUploadField.svelte'; + import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; let { data, form } = $props(); type Effect = { pieces_required: number; stat_attribute_id: string; value: number }; type Editor = { id: string; name: string; iconUrl: string; active: number; effects: Effect[] }; @@ -41,7 +42,7 @@
{#each [2, 3, 4] as pieces} {@const effect = effectFor(editing, pieces)} -
{pieces}{pieces === 4 ? '+ PIECES' : ' PIECES'}
+
{pieces}{pieces === 4 ? '+ PIECES' : ' PIECES'}
ATTRIBUTE
{/each}
diff --git a/src/routes/admin/protocols/+page.svelte b/src/routes/admin/protocols/+page.svelte index b052609..41a78ec 100644 --- a/src/routes/admin/protocols/+page.svelte +++ b/src/routes/admin/protocols/+page.svelte @@ -143,7 +143,7 @@
UNIQUE PRIMARY ATTRIBUTELEVEL 1 BASE VALUE · LINEAR TO 2× AT LEVEL 50
LEVEL {protocolLevel}
0150
-
LEVEL {protocolLevel} PREVIEW{format(levelValue(editing.primaryEffect.base_value, protocolLevel), editing.primaryEffect.stat_attribute_id)}LEVEL 50 · {format(levelValue(editing.primaryEffect.base_value, 50), editing.primaryEffect.stat_attribute_id)}
+
PRIMARY STAT
LEVEL {protocolLevel} PREVIEW{format(levelValue(editing.primaryEffect.base_value, protocolLevel), editing.primaryEffect.stat_attribute_id)}LEVEL 50 · {format(levelValue(editing.primaryEffect.base_value, 50), editing.primaryEffect.stat_attribute_id)}
UNIQUE PROTOCOL TALENTOWNED EXCLUSIVELY BY THIS PROTOCOL · FIVE LINEAR UPGRADES
{editing.talent.id ? 'LINKED RECORD' : 'NEW RECORD'}
@@ -151,7 +151,7 @@
{#each [1, 2, 3, 4, 5] as upgrade}{/each}
UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× EFFECT VALUE' : `${Math.round((1 + talentUpgrade / 5) * 100)}% EFFECT VALUE`}

{talentPreview(talentUpgrade) || 'Talent description preview'}

ACTIVATION TRIGGERSREUSABLE TRIGGER DEFINITIONS
{editing.talent.triggerIds.length}
-
TALENT EFFECTSBASE VALUE AT ZERO UPGRADES
{#each editing.talent.effects as effect, index}
{index + 1}{format(upgradeValue(effect.base_value, 5), effect.stat_attribute_id)} AT +5
{/each}
+
TALENT EFFECTSBASE VALUE AT ZERO UPGRADES
{#each editing.talent.effects as effect, index}
{index + 1}{format(upgradeValue(effect.base_value, 5), effect.stat_attribute_id)} AT +5
{/each}
{#if form?.protocolError}

{form.protocolError}

{/if} diff --git a/src/routes/admin/skillchips/+page.svelte b/src/routes/admin/skillchips/+page.svelte index 845486c..23c07e9 100644 --- a/src/routes/admin/skillchips/+page.svelte +++ b/src/routes/admin/skillchips/+page.svelte @@ -91,7 +91,7 @@
{3 - Number(highEndCount)} SUPERIOR · {highEndCount} HIGH-END

{talentPreview() || 'Talent description preview'}

ACTIVATION TRIGGERSREUSABLE TRIGGER DEFINITIONS
{editing.talent.triggerIds.length}
-
TALENT EFFECTSMINIMUM TO MAXIMUM ACROSS QUALITY MIX
{#each editing.talent.effects as effect, index}
{index + 1}
{/each}
+
TALENT EFFECTSMINIMUM TO MAXIMUM ACROSS QUALITY MIX
{#each editing.talent.effects as effect, index}
{index + 1}
{/each}
{#if form?.skillChipError}

{form.skillChipError}

{/if} diff --git a/src/routes/admin/talents/+page.svelte b/src/routes/admin/talents/+page.svelte index 75bae35..fe32c59 100644 --- a/src/routes/admin/talents/+page.svelte +++ b/src/routes/admin/talents/+page.svelte @@ -3,6 +3,7 @@ import RegistryPagination from '$lib/components/RegistryPagination.svelte'; import KpiFilters from '$lib/components/KpiFilters.svelte'; import IconUploadField from '$lib/components/IconUploadField.svelte'; + import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; let { data, form } = $props(); type Effect = { statId: string; valueI: string; valueII: string }; let talent = $state(null), trigger = $state(null), uptime = $state(false); @@ -75,7 +76,7 @@

SHARED ACTIVATION TRIGGERS

{#each data.triggers.filter((item) => Number(item.active) === 1) as item}
{#if item.pending}PENDING · {item.proposerName}{:else}{/if}
{/each}

SHARED ATTRIBUTE EFFECTS · TIER VALUES

-
ATTRIBUTETIER I VALUETIER II VALUE
{#each talent.effects as effect, index}
{/each}
+
ATTRIBUTETIER I VALUETIER II VALUE
{#each talent.effects as effect, index}
{/each}
{#if form?.talentError}

{form.talentError}

{/if}
diff --git a/src/routes/admin/weapons/+page.svelte b/src/routes/admin/weapons/+page.svelte index 66929c8..3fe0119 100644 --- a/src/routes/admin/weapons/+page.svelte +++ b/src/routes/admin/weapons/+page.svelte @@ -97,7 +97,7 @@
TIER I PREVIEW

{exoticPreview('I') || 'Description preview'}

TIER II PREVIEW

{exoticPreview('II') || 'Description preview'}

ACTIVATION TRIGGERSSHARED BETWEEN BOTH TIERS
{editing.exoticTalent.triggerIds.length}
-
ATTRIBUTE EFFECTSONE ROW PER TEMPLATE PLACEHOLDER
{#each editing.exoticTalent.effects as effect: any, index}
{index + 1}
{/each}
+
ATTRIBUTE EFFECTSONE ROW PER TEMPLATE PLACEHOLDER
{#each editing.exoticTalent.effects as effect: any, index}
{index + 1}
{/each}
{/if} @@ -113,7 +113,7 @@
{#each editing.baseBonuses as bonus: any, index} -
{String(index + 1).padStart(2, '0')}
+
{String(index + 1).padStart(2, '0')}
ATTRIBUTE
{/each}
TIER PROGRESSIONDAMAGE AND CORE ATTRIBUTE RANGES
3 TIERS REQUIRED
@@ -133,7 +133,7 @@
TACTICAL AUGMENTATION 01ONE OR TWO UNIQUE ATTRIBUTE UPGRADES
{#each editing.augmentEffects as effect: any, index} -
0{index + 1}{#if index > 0}{/if}
+
0{index + 1}
ATTRIBUTE
{#if index > 0}{/if}
{/each}
02+20% DAMAGE
03+40% DAMAGE
04+60% DAMAGE
05+80% DAMAGE
{#if Number(editing.is_exotic)}
06EXOTIC TALENT → TIER II
{/if}