Files
DumiFlex-ComfyUI-Wildcard-P…/src/components/context/ModulePickerModal.vue
T
DumiFlex dd793c536e fix(ui): three layout defects — seed rows, picker chips, playground banner
Seed-list modal: a locked row carries three fixed-width toggles plus a hard
178px seed input and a roll button. On the 486px modal that overran the panel
— the input clipped at the right edge and the roll button was pushed out of
view entirely. The value column now shrinks (`min-width: 0`, 178px as a max
rather than a fixed width), and a long derived seed ellipsis-clips instead of
shoving the DERIVED tag off the edge.

Module picker: the selected-module chip rendered its name as a bare text
node, so a long name like "Accent piece (color-neutral)" wrapped onto a
second line inside a chip fixed at 22px tall and spilled out of its own pill.
Wrapped in a span that ellipsis-clips, with the full name on hover.

Display playground banner: the header scrim was hardcoded to a near-black
rgba over the brand gradient. Under the light theme the band stayed dark
while `--wp-text` flipped to a dark ink — dark text on a dark banner. The
scrim now follows the theme, keeping the same 0.85 knock-back either way.
2026-07-28 04:20:00 +03:00

1763 lines
62 KiB
Vue

<template>
<ModalShell :visible="visible" @close="$emit('close')">
<div class="wp-picker" ref="rootEl" tabindex="-1">
<!-- Header -->
<header class="wp-picker__head">
<div class="wp-picker__head-text">
<div class="wp-picker__eyebrow">
Add modules from library
</div>
<div class="wp-picker__title">
Browse Library
<span class="wp-picker__title-detail">
· {{ modules.length }} {{ modules.length === 1 ? "module" : "modules" }}
</span>
</div>
</div>
<button type="button" class="wp-picker__close" aria-label="Close" @click="$emit('close')">
<i class="pi pi-times" aria-hidden="true"></i>
</button>
</header>
<!-- Toolbar: search + kind tabs + compact filter buttons.
Filters are inline on the toolbar (no dedicated row) so
the body has the maximum vertical space. Quick-toggle
filters (favorites, hide-added) are bare icon buttons;
multi-value filters (category, tags) live behind a
single "Filters" popover button so a 50-tag library
doesn't push the rows offscreen. -->
<div class="wp-picker__toolbar">
<!-- Top row — search box stretches; filter icon buttons +
popover trigger sit inline to its right. Wrapped in a
flex-row so the toolbar's outer column layout doesn't
stack each icon onto its own line. -->
<div class="wp-picker__toolbar-top">
<div class="wp-picker__search" :class="{ 'is-focused': searchFocused }">
<i class="pi pi-search wp-picker__search-icon" aria-hidden="true"></i>
<input
ref="searchInput"
v-model="searchTerm"
type="text"
placeholder="Search modules by name or var…"
aria-label="Search modules"
data-testid="picker-search"
@focus="searchFocused = true"
@blur="searchFocused = false"
/>
<span v-if="searchTerm" class="wp-picker__search-clear" @click="searchTerm = ''">
<i class="pi pi-times" aria-hidden="true"></i>
</span>
</div>
<!-- Inline icon-only quick toggles. Each is a thin square
button next to search. Active state = accent fill. -->
<button
v-if="hasFavoritesInData"
type="button"
class="wp-picker__icon-btn"
:class="{ 'wp-picker__icon-btn--active': favoritesOnly }"
:title="favoritesOnly ? 'Showing favorites only · click to clear' : 'Show favorites only'"
data-testid="picker-filter-favorites"
@click="favoritesOnly = !favoritesOnly"
>
<i :class="['pi', favoritesOnly ? 'pi-star-fill' : 'pi-star']" aria-hidden="true"></i>
</button>
<button
v-if="alreadyAddedSet.size > 0"
type="button"
class="wp-picker__icon-btn"
:class="{ 'wp-picker__icon-btn--active': hideAlreadyAdded }"
:title="hideAlreadyAdded ? `Hiding ${alreadyAddedSet.size} already-added modules · click to show` : `Hide ${alreadyAddedSet.size} already-added modules`"
data-testid="picker-filter-hide-added"
data-test="hide-added-toggle"
@click="hideAlreadyAdded = !hideAlreadyAdded"
>
<i :class="['pi', hideAlreadyAdded ? 'pi-eye-slash' : 'pi-eye']" aria-hidden="true"></i>
</button>
<!-- Filters popover trigger. Renders only when there's
actually category or tag data to filter on. Badge shows
the count of dimensions currently active (category
selection + selected tag count) so users see at a
glance there's an active filter without opening it. -->
<div
v-if="usedCategories.length || availableTags.length"
class="wp-picker__filters-pop"
>
<button
type="button"
class="wp-picker__icon-btn wp-picker__filter-trigger"
:class="{ 'wp-picker__icon-btn--active': activePopoverCount > 0 || filtersOpen }"
:title="filtersOpen ? 'Close filters' : 'More filters'"
:aria-expanded="filtersOpen"
data-testid="picker-filter-trigger"
@click="filtersOpen = !filtersOpen"
>
<i class="pi pi-filter" aria-hidden="true"></i>
<span
v-if="activePopoverCount > 0"
class="wp-picker__filter-badge"
aria-label="active filter count"
>{{ activePopoverCount }}</span>
</button>
<!-- Popover panel. Anchored under the trigger button.
Click-outside closes via the `wp-picker__pop-shield`
full-modal cover. Stacked vertically so categories +
tags both have generous room without crowding the
toolbar. -->
<Transition name="wp-pop">
<div v-if="filtersOpen" class="wp-picker__pop" data-testid="picker-filter-pop">
<div v-if="usedCategories.length" class="wp-picker__pop-section">
<div class="wp-picker__pop-label">CATEGORY</div>
<select
class="wp-picker__filter-cat-select"
:value="selectedCategoryId ?? ''"
aria-label="Filter by category"
data-testid="picker-filter-category"
@change="selectedCategoryId = ($event.target as HTMLSelectElement).value || null"
>
<option value="">All categories</option>
<option v-for="c in usedCategories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div v-if="availableTags.length" class="wp-picker__pop-section">
<div class="wp-picker__pop-label">
TAGS
<span class="wp-picker__pop-label-hint">click to AND-narrow</span>
</div>
<div class="wp-picker__filter-tags">
<button
v-for="tag in availableTags"
:key="tag"
type="button"
class="wp-picker__tag-chip"
:class="{ 'wp-picker__tag-chip--active': selectedTags.has(tag) }"
:data-testid="`picker-tag-${tag}`"
@click="toggleTag(tag)"
>{{ tag }}</button>
</div>
</div>
<button
v-if="hasActiveFilter"
type="button"
class="wp-picker__pop-clear"
data-testid="picker-filter-clear"
@click="clearAllFilters"
>
<i class="pi pi-filter-slash" aria-hidden="true"></i>
Clear all filters
</button>
</div>
</Transition>
</div>
</div><!-- /wp-picker__toolbar-top -->
<div class="wp-picker-tabs" role="tablist">
<button
type="button"
data-test="picker-tab-all"
data-testid="picker-tab-all"
:class="['wp-picker-tab', { active: kindFilter === 'all' }]"
role="tab"
:aria-selected="kindFilter === 'all'"
@click="kindFilter = 'all'"
><i class="pi pi-th-large" />All <span class="wp-pill-count">{{ totalCount }}</span></button>
<button
v-for="(label, kind) in KIND_LABELS"
:key="kind"
type="button"
:data-test="`picker-tab-${kind}`"
:data-k="kind"
:class="['wp-picker-tab', { active: kindFilter === kind }]"
role="tab"
:aria-selected="kindFilter === kind"
@click="kindFilter = kind as KindFilter"
><i :class="kindIcon(kind)" />{{ label }}
<span class="wp-pill-count">{{ countByKind(kind) }}</span>
</button>
</div>
</div>
<!-- Click-shield: when popover is open, a transparent layer
over the rest of the modal closes it on click. Sized to
the modal interior so the popover itself stays
interactive. -->
<div
v-if="filtersOpen"
class="wp-picker__pop-shield"
@click="filtersOpen = false"
></div>
<!-- Body: scrollable list grouped by kind -->
<div class="wp-picker__body">
<div
v-if="loading"
class="wp-picker__skel"
aria-busy="true"
aria-live="polite"
aria-label="Loading modules"
>
<template v-for="g in 2" :key="g">
<div class="wp-picker__skel-head">
<span class="wp-skel wp-skel--text wp-picker__skel-label"></span>
<span class="wp-picker__skel-line"></span>
<span class="wp-skel wp-skel--text wp-picker__skel-tally"></span>
</div>
<div v-for="i in 4" :key="`${g}-${i}`" class="wp-picker__skel-row">
<span class="wp-skel wp-picker__skel-check"></span>
<span class="wp-skel wp-picker__skel-icon"></span>
<span class="wp-picker__skel-main">
<span class="wp-skel wp-skel--text wp-picker__skel-name"></span>
<span class="wp-skel wp-skel--text wp-picker__skel-var"></span>
</span>
<span class="wp-skel wp-skel--text wp-picker__skel-uuid"></span>
</div>
</template>
</div>
<div v-else-if="loadError" class="wp-picker__state wp-picker__state--error">
<i class="pi pi-exclamation-triangle" aria-hidden="true"></i>
{{ loadError }}
<button type="button" class="wp-picker__retry" @click="reload">Retry</button>
</div>
<div v-else-if="filteredModules.length === 0" class="wp-picker__state">
<i class="pi pi-inbox" aria-hidden="true"></i>
<template v-if="searchTerm">No matches for "{{ searchTerm }}"</template>
<template v-else-if="isHideAddedSuppressing">
All matching modules are already in this Context.
</template>
<template v-else>No modules in this category.</template>
<button
v-if="isHideAddedSuppressing"
type="button"
class="wp-picker__create"
data-testid="picker-show-already-added"
title="Turn off the hide-already-added filter so you can re-add an existing module as a sibling."
@click="hideAlreadyAdded = false"
><i class="pi pi-eye" aria-hidden="true"></i> Show already added</button>
<button
v-else-if="!searchTerm"
type="button"
class="wp-picker__create"
data-testid="picker-create-module"
:title="`Open the library to create a new ${kindFilter === 'all' ? 'module' : (KIND_SINGULAR[kindFilter] ?? 'module')}`"
@click="createInSpa"
><i class="pi pi-plus" aria-hidden="true"></i> {{ createLabel }}</button>
</div>
<template v-else>
<template v-for="group in groupedFiltered" :key="group.kind">
<div class="wp-picker__section-head">
<span>{{ kindLabel(group.kind) }}</span>
<span class="wp-picker__section-line"></span>
<span class="wp-picker__section-count">{{ group.items.length }}</span>
</div>
<button
v-for="row in group.items"
:key="row.id"
type="button"
:class="['wp-picker__row', { 'is-already-added': isAlreadyAdded(row.id) }]"
:data-kind="row.type"
:data-checked="selected.has(row.id) ? '' : null"
:data-disabled="!isPickable(row) || null"
:data-testid="`picker-row-${row.id}`"
:data-test="`picker-row-${row.id}`"
:disabled="!isPickable(row)"
:title="isPickable(row) ? (isAlreadyAdded(row.id) ? 'Already in this Context. Click to add as sibling — binding will be auto-suffixed.' : '') : 'Module has no payload — cannot embed.'"
@click="isPickable(row) && onRowClick(row)"
>
<span
class="wp-picker__check"
data-test="picker-checkbox"
:class="{ on: selected.has(row.id) }"
aria-hidden="true"
>
<i v-if="selected.has(row.id)" class="pi pi-check"></i>
</span>
<span class="wp-picker__row-icon" :data-kind="row.type">
<i :class="['pi', kindIcon(row.type)]" aria-hidden="true"></i>
</span>
<span class="wp-picker__row-main">
<span class="wp-picker__row-name">{{ row.name }}</span>
<span class="wp-picker__row-var">{{ subtitleFor(row) }}</span>
</span>
<span class="wp-picker__row-uuid">{{ shortUuid(row.id) }}</span>
</button>
</template>
</template>
</div>
<!-- Selection drawer (when ≥1 picked) -->
<div v-if="selected.size > 0" class="wp-picker__selection">
<div class="wp-picker__selection-top">
<span class="wp-picker__selection-count">
<strong>{{ selected.size }}</strong>
picked
</span>
<span class="wp-picker__selection-spacer"></span>
<button
type="button"
class="wp-picker__selection-clear"
@click="selected.clear()"
data-testid="picker-clear"
>
Clear
</button>
</div>
<div class="wp-picker__selection-chips">
<span
v-for="id in [...selected]"
:key="id"
class="wp-picker__sel-chip"
:data-kind="byId.get(id)?.type"
>
<span class="wp-picker__sel-dot"></span>
<!-- Wrapped in its own span so it can ellipsis-clip. As a bare
text node it wrapped onto a second line inside a chip fixed at
22px tall, so a long name like "Accent piece (color-neutral)"
spilled out of its own pill. -->
<span
class="wp-picker__sel-name"
:title="byId.get(id)?.name ?? id"
>{{ byId.get(id)?.name ?? id }}</span>
<button
type="button"
class="wp-picker__sel-close"
:aria-label="`Remove ${byId.get(id)?.name ?? id}`"
@click="selected.delete(id)"
>
<i class="pi pi-times" aria-hidden="true"></i>
</button>
</span>
</div>
</div>
<!-- Footer -->
<footer class="wp-picker__footer">
<span class="wp-picker__hint">
<kbd>↑</kbd><kbd>↓</kbd> nav
· <kbd>tab</kbd> kind
· <kbd>space</kbd> toggle · <kbd>⌘↵</kbd> add
· <kbd>esc</kbd> cancel
</span>
<span class="wp-picker__footer-spacer"></span>
<button type="button" class="wp-picker__btn" @click="$emit('close')">Cancel</button>
<button
type="button"
class="wp-picker__btn wp-picker__btn--primary"
data-test="picker-add-btn"
:disabled="selected.size === 0"
@click="commitAdd"
><i class="pi pi-plus" aria-hidden="true"></i> Add {{ selected.size }} {{ selected.size === 1 ? "module" : "modules" }}</button>
</footer>
</div>
</ModalShell>
</template>
<script setup lang="ts">
/**
* ModulePickerModal — library browser for WP_Context nodes.
*
* Lists every module the SPA returns from `/wp/api/modules`, lets the
* user filter by kind + search + tags + category, and emits an `add`
* event with the array of selected ids when the user clicks "Add N
* modules". The parent (ContextWidget) calls embed-bundle — the picker
* stays state-light and easy to test in isolation.
*
* Add flow: checkboxes are always visible. Click any row to toggle it;
* a selection drawer appears above the footer when ≥1 module is
* selected. Press "Add N modules" (or Cmd/Ctrl+Enter) to confirm.
*
* Every embeddable kind with a payload is pickable. Rows render
* disabled only when their payload is missing (broken library entry).
*/
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import ModalShell from "../shared/ModalShell.vue";
import { kindIcon } from "../shared/kind-icons";
interface PickerModule {
id: string;
type: string;
name: string;
payload?: Record<string, unknown>;
tags?: string[];
is_favorite?: boolean;
category_id?: string | null;
}
const props = defineProps<{
visible: boolean;
/**
* Module ids already present in the host Context. When supplied,
* the "hide already added" toggle filters them out of the list so
* users browsing a big library can focus on net-new picks.
*/
alreadyAdded?: string[];
/**
* Alias accepted alongside `alreadyAdded`. Both are merged into the
* same set so callers can use either spelling. Provided for API
* symmetry with the task-12 spec (`alreadyAddedIds`).
*/
alreadyAddedIds?: string[];
}>();
const emit = defineEmits<{
/** Emits array of selected module ids when user clicks "Add N modules". */
(e: "add", ids: string[]): void;
(e: "close"): void;
}>();
interface CategoryRow {
id: string;
name: string;
}
// ── State ──────────────────────────────────────────────────────────
const modules = ref<PickerModule[]>([]);
const categories = ref<CategoryRow[]>([]);
const loading = ref(false);
const loadError = ref<string | null>(null);
const searchTerm = ref("");
const searchFocused = ref(false);
const searchInput = ref<HTMLInputElement | null>(null);
const rootEl = ref<HTMLDivElement | null>(null);
// Use reactive() so Vue can track .has()/.size/.add()/.delete()/.clear() individually
// (plain ref<Set> only tracks the ref's .value assignment, not Set mutations).
const selected = reactive<Set<string>>(new Set());
type KindFilter = "all" | "wildcard" | "fixed_values" | "combine" | "derivation" | "constraint";
const kindFilter = ref<KindFilter>("all");
// ── Filters ────────────────────────────────────────────────────────
// Three orthogonal filters, all AND-combined with the kind tab +
// search box. None affect the data load — pure client-side narrowing
// over `modules`.
const favoritesOnly = ref(false);
const hideAlreadyAdded = ref(true);
const selectedTags = ref<Set<string>>(new Set());
const selectedCategoryId = ref<string | null>(null); // null = "any"
const filtersOpen = ref(false); // popover state for category + tags
/**
* Count of filter dimensions currently active inside the popover
* (category + tags). Drives the badge on the trigger button so
* users see at-a-glance there's a hidden filter active without
* opening the popover. Excludes the inline toggles
* (favorites + hide-added) — those have their own visible state
* via the icon-button's accent fill.
*/
const activePopoverCount = computed<number>(() => {
let n = 0;
if (selectedCategoryId.value !== null) n++;
n += selectedTags.value.size;
return n;
});
/**
* Tags surfaced by the loaded library, deduped + sorted for the
* filter chip row. We derive from the data instead of a hardcoded
* list so the picker reflects whatever taxonomy the user has built
* in the SPA library editor.
*/
const availableTags = computed<string[]>(() => {
const set = new Set<string>();
for (const m of modules.value) {
if (Array.isArray(m.tags)) {
for (const t of m.tags) {
if (typeof t === "string" && t) set.add(t);
}
}
}
return [...set].sort((a, b) => a.localeCompare(b));
});
/** Hide the favorites filter button when no row in the loaded
* library is favorited. Avoids a dead button on libraries that
* haven't adopted the feature. */
const hasFavoritesInData = computed<boolean>(() =>
modules.value.some((m) => m.is_favorite),
);
function toggleTag(tag: string) {
const next = new Set(selectedTags.value);
if (next.has(tag)) next.delete(tag);
else next.add(tag);
selectedTags.value = next;
}
const hasActiveFilter = computed(() =>
favoritesOnly.value
|| hideAlreadyAdded.value
|| selectedTags.value.size > 0
|| selectedCategoryId.value !== null
|| kindFilter.value !== "all"
|| !!searchTerm.value.trim(),
);
function clearAllFilters() {
favoritesOnly.value = false;
hideAlreadyAdded.value = false;
selectedTags.value = new Set();
selectedCategoryId.value = null;
kindFilter.value = "all";
searchTerm.value = "";
}
/** Categories surfaced by the live library, deduped against
* modules that actually reference them so the dropdown doesn't
* list dead categories. Sorted alphabetically. */
const usedCategories = computed<CategoryRow[]>(() => {
const used = new Set<string>();
for (const m of modules.value) {
if (typeof m.category_id === "string" && m.category_id) used.add(m.category_id);
}
return categories.value
.filter((c) => used.has(c.id))
.sort((a, b) => a.name.localeCompare(b.name));
});
// ── Loading ────────────────────────────────────────────────────────
async function reload() {
loading.value = true;
loadError.value = null;
try {
// Modules + categories in parallel. Categories failure is
// non-fatal — the picker still works without category-name
// resolution; the dropdown just wouldn't show. Modules failure
// surfaces the error UI.
const [modulesRes, categoriesRes] = await Promise.allSettled([
fetch("/wp/api/modules", { credentials: "same-origin" }),
fetch("/wp/api/categories", { credentials: "same-origin" }),
]);
if (modulesRes.status === "rejected" || !modulesRes.value.ok) {
const status = modulesRes.status === "fulfilled" ? modulesRes.value.status : "?";
throw new Error(`Library load failed (HTTP ${status}).`);
}
const modulesJson = (await modulesRes.value.json()) as { items?: PickerModule[] };
modules.value = (modulesJson.items ?? []).filter(
(m): m is PickerModule => !!m && typeof m.id === "string",
);
if (categoriesRes.status === "fulfilled" && categoriesRes.value.ok) {
const catJson = (await categoriesRes.value.json()) as { items?: CategoryRow[] };
categories.value = (catJson.items ?? []).filter(
(c): c is CategoryRow => !!c && typeof c.id === "string" && typeof c.name === "string",
);
} else {
categories.value = [];
}
} catch (e) {
loadError.value = e instanceof Error ? e.message : String(e);
} finally {
loading.value = false;
}
}
watch(
() => props.visible,
(v) => {
if (!v) return;
// Re-fetch each time the modal opens — library may have changed.
void reload();
// Reset transient UI state.
selected.clear();
searchTerm.value = "";
kindFilter.value = "all";
favoritesOnly.value = false;
hideAlreadyAdded.value = true;
selectedTags.value = new Set();
selectedCategoryId.value = null;
filtersOpen.value = false;
void nextTick(() => searchInput.value?.focus());
},
{ immediate: true },
);
onMounted(() => {
if (props.visible) void reload();
});
// ── Derived ────────────────────────────────────────────────────────
const byId = computed(() => {
const m = new Map<string, PickerModule>();
for (const row of modules.value) m.set(row.id, row);
return m;
});
const KIND_LABELS: Record<string, string> = {
wildcard: "Wildcards",
fixed_values: "Fixed",
combine: "Combines",
derivation: "Derivations",
constraint: "Constraints",
};
// Empty-state "create" deep-links into the manager SPA's new-module editor
// for the active kind (the picker runs on the canvas; the SPA is the
// authoring surface). Mirrors topbar.ts's `/wp/<route>` window.open.
const KIND_NEW_ROUTE: Record<string, string> = {
wildcard: "wildcards",
fixed_values: "fixed-values",
combine: "combines",
derivation: "derivations",
constraint: "constraints",
};
const KIND_SINGULAR: Record<string, string> = {
wildcard: "wildcard",
fixed_values: "fixed value",
combine: "combine",
derivation: "derivation",
constraint: "constraint",
};
const createLabel = computed(() =>
kindFilter.value === "all"
? "New module"
: `New ${KIND_SINGULAR[kindFilter.value] ?? "module"}`,
);
function createInSpa(): void {
const slug = kindFilter.value === "all"
? "wildcards"
: (KIND_NEW_ROUTE[kindFilter.value] ?? "wildcards");
window.open(`/wp/${slug}/new`, "_blank", "noopener");
}
const visibleModules = computed<PickerModule[]>(() => modules.value);
function countByKind(kind: string): number {
return visibleModules.value.filter((m) => m.type === kind).length;
}
const totalCount = computed(() => visibleModules.value.length);
const alreadyAddedSet = computed<Set<string>>(() => {
const ids = [...(props.alreadyAdded ?? []), ...(props.alreadyAddedIds ?? [])];
return new Set(ids);
});
function isAlreadyAdded(id: string): boolean {
return alreadyAddedSet.value.has(id);
}
const filteredModules = computed(() => {
const q = searchTerm.value.trim().toLowerCase();
const tagFilter = selectedTags.value;
return visibleModules.value.filter((m) => {
// Kind tab.
if (kindFilter.value !== "all" && m.type !== kindFilter.value) return false;
// Favorites toggle.
if (favoritesOnly.value && !m.is_favorite) return false;
// Hide-already-added toggle.
if (hideAlreadyAdded.value && alreadyAddedSet.value.has(m.id)) return false;
// Category dropdown.
if (selectedCategoryId.value !== null && m.category_id !== selectedCategoryId.value) return false;
// Tag chips — AND across selected tags so picking multiple
// narrows progressively (matches how multi-tag taxonomies are
// typically built — "show me modules that are BOTH `outfit`
// AND `historical`").
if (tagFilter.size > 0) {
const tags = Array.isArray(m.tags) ? m.tags : [];
for (const t of tagFilter) {
if (!tags.includes(t)) return false;
}
}
// Search box. Matches against identifier-shaped fields only — the
// tag taxonomy has its own dedicated chip filter behind the funnel
// icon, so folding tag-substring matches into the free-text search
// surfaced unrelated rows (e.g. typing a wildcard name pulled in
// every module that happened to share that word as a tag) and
// muddied the result set.
if (!q) return true;
if (m.name.toLowerCase().includes(q)) return true;
if (m.id.toLowerCase().includes(q)) return true;
// Match $var bindings on wildcards / combines so users can search by the
// identifier they reference downstream, not just the display name.
const p = (m.payload ?? {}) as { var_binding?: string; output_var?: string };
if (p.var_binding && p.var_binding.toLowerCase().includes(q)) return true;
if (p.output_var && p.output_var.toLowerCase().includes(q)) return true;
return false;
});
});
/** True when `filteredModules` is empty BUT only because the
* hide-already-added toggle hid modules that would otherwise show.
* Lets the empty-state copy distinguish "nothing in this category" from
* "all the matching modules are already in this Context" — the second
* is a normal state once a bundle is loaded, and the misleading
* "+ New module" CTA shouldn't fire there. */
const isHideAddedSuppressing = computed<boolean>(() => {
if (filteredModules.value.length > 0) return false;
if (searchTerm.value.trim()) return false;
if (!hideAlreadyAdded.value) return false;
// Replay the same non-search gates as `filteredModules`, minus the
// hide-already-added filter. If any module survives, the toggle is
// the reason the list reads empty.
const tagFilter = selectedTags.value;
for (const m of visibleModules.value) {
if (kindFilter.value !== "all" && m.type !== kindFilter.value) continue;
if (favoritesOnly.value && !m.is_favorite) continue;
if (selectedCategoryId.value !== null && m.category_id !== selectedCategoryId.value) continue;
if (tagFilter.size > 0) {
const tags = Array.isArray(m.tags) ? m.tags : [];
let allMatch = true;
for (const t of tagFilter) {
if (!tags.includes(t)) { allMatch = false; break; }
}
if (!allMatch) continue;
}
if (alreadyAddedSet.value.has(m.id)) return true;
}
return false;
});
const KIND_ORDER = ["wildcard", "combine", "derivation", "constraint", "fixed_values"];
const groupedFiltered = computed<{ kind: string; items: PickerModule[] }[]>(() => {
const groups: Record<string, PickerModule[]> = {};
for (const m of filteredModules.value) (groups[m.type] ??= []).push(m);
return KIND_ORDER
.filter((k) => groups[k]?.length)
.map((k) => ({
kind: k,
items: [...groups[k]].sort((a, b) => a.name.localeCompare(b.name)),
}));
});
// ── Display helpers ────────────────────────────────────────────────
// `kindIcon` imported from ../shared/kind-icons
function kindLabel(kind: string): string {
switch (kind) {
case "wildcard": return "Wildcards";
case "combine": return "Combines";
case "derivation": return "Derivations";
case "constraint": return "Constraints";
case "fixed_values": return "Fixed values";
default: return kind;
}
}
function shortUuid(id: string): string {
// ids look like `wc_outfit_a1b2c3d4`; surface just the trailing 8 hex
// when present, else show the full id (legacy / non-standard ids).
const m = /_([0-9a-f]{8})$/.exec(id);
return m ? m[1] : id;
}
function subtitleFor(m: PickerModule): string {
const p = (m.payload ?? {}) as Record<string, unknown>;
switch (m.type) {
case "wildcard": {
const binding = (p.var_binding as string)?.trim();
const opts = (p.options as unknown[])?.length ?? 0;
return `${binding ? `$${binding}` : "wildcard"}${opts ? ` · ${opts} options` : ""}`;
}
case "combine": {
const out = (p.output_var as string)?.trim();
return out ? `→ $${out}` : "template";
}
case "derivation": {
const rules = (p.rules as unknown[])?.length ?? 0;
return `${rules} rule${rules === 1 ? "" : "s"}`;
}
case "constraint": return "constraint matrix";
case "fixed_values": return `${(p.values as unknown[])?.length ?? 0} vars`;
default: return m.type;
}
}
// ── Pickability gate ───────────────────────────────────────────────
//
// Defensive guard against bad rows — rows with a falsy `payload` are
// broken library entries and not pickable.
function isPickable(m: PickerModule): boolean {
if (!m.payload) return false;
return true;
}
// ── Click handlers ─────────────────────────────────────────────────
function onRowClick(m: PickerModule) {
if (!isPickable(m)) return;
// Clicking a row toggles its checkbox. Confirm with "Add N modules" footer button.
if (selected.has(m.id)) selected.delete(m.id);
else selected.add(m.id);
}
/** Emit `add` with all selected ids and reset selection. */
function commitAdd() {
emit("add", [...selected]);
selected.clear();
}
// ── Keyboard navigation ───────────────────────────────────────────
//
// Arrow keys walk the focusable, pickable rows; the first ArrowDown
// from the search input drops focus onto the first row, ArrowUp from
// the first row jumps back. Enter on a focused row is handled by the
// native `<button>` activation (= row click), so we only need to
// special-case the multi-pick "embed all" shortcut (Cmd/Ctrl+Enter).
//
// Disabled rows (non-wildcard kinds in 5.5.4) are skipped — the
// `:disabled` attribute already excludes them from the focus walk
// because we only target rows that are NOT disabled.
function getRowButtons(): HTMLButtonElement[] {
if (!rootEl.value) return [];
const all = rootEl.value.querySelectorAll<HTMLButtonElement>(
".wp-picker__row:not([disabled])",
);
return Array.from(all);
}
function getKindTabs(): HTMLButtonElement[] {
if (!rootEl.value) return [];
return Array.from(rootEl.value.querySelectorAll<HTMLButtonElement>(".wp-picker-tab"));
}
// ── Capture-phase key isolation ───────────────────────────────────
//
// ComfyUI registers its canvas hotkeys (Q queue, R refresh, F fit,
// M model picker, Space pan, …) at the document/window level in
// *capture phase*. A bubble-phase listener on the modal root would
// run AFTER ComfyUI's handler — too late: the event has already
// fired the canvas shortcut. So we install our own capture-phase
// listener on `window` while the picker is open and
// stopImmediatePropagation any key whose target is inside our
// modal (or moving toward it).
//
// We DO scope by target now. An earlier rev intentionally swallowed
// every key globally because clicking an inert area inside the
// modal sometimes moved focus to `document.body`, so the next key
// failed `rootEl.contains(target)` and ComfyUI's hotkey fired. The
// problem with the global swallow: while the picker stays open
// (e.g. the user clicked elsewhere on the canvas — picker visible
// but not focused), every keystroke including ones aimed at the
// PromptAssembler textarea got eaten. The fix here keeps the
// per-target check — and on click of an inert modal area we
// proactively re-focus the rootEl below so subsequent keys stay
// rooted in the modal.
function isInsideModal(target: EventTarget | null): boolean {
if (!rootEl.value || !(target instanceof Node)) return false;
return rootEl.value.contains(target);
}
function captureKeyDown(e: KeyboardEvent) {
if (!props.visible) return;
if (!isInsideModal(e.target)) return;
// Stop ComfyUI's capture-phase listeners from seeing this key at
// all. stopImmediatePropagation also short-circuits any sibling
// capture listeners attached on `window` itself.
e.stopPropagation();
if (typeof e.stopImmediatePropagation === "function") {
e.stopImmediatePropagation();
}
// Now drive our own picker keybinds.
if (e.key === "Escape") {
e.preventDefault();
emit("close");
return;
}
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
if (selected.size > 0) {
e.preventDefault();
commitAdd();
}
return;
}
// Tab cycles through the kind tabs ONLY (per user UX request).
// Forward = next tab, Shift+Tab = previous, both wrap. Pressing
// Tab from any focus inside the picker lands on the next kind
// tab — the rest of the modal's focusable elements stop sharing
// the Tab cycle. Activation still requires Enter/Space on the
// focused tab (native button behavior).
if (e.key === "Tab") {
const tabs = getKindTabs();
if (tabs.length === 0) return;
e.preventDefault();
const currentIdx = tabs.findIndex((t) => t === document.activeElement);
let nextIdx: number;
if (e.shiftKey) {
nextIdx = currentIdx <= 0 ? tabs.length - 1 : currentIdx - 1;
} else {
nextIdx = currentIdx === -1 ? 0 : (currentIdx + 1) % tabs.length;
}
tabs[nextIdx].focus();
return;
}
const target = e.target as HTMLElement | null;
const inSearch = target === searchInput.value;
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
const rows = getRowButtons();
if (rows.length === 0) return;
if (inSearch) {
if (e.key === "ArrowDown") {
e.preventDefault();
rows[0].focus();
}
return;
}
const idx = rows.findIndex((r) => r === target);
if (idx !== -1) {
e.preventDefault();
if (e.key === "ArrowDown") {
if (idx === rows.length - 1) return;
rows[idx + 1].focus();
} else {
if (idx === 0) {
searchInput.value?.focus();
return;
}
rows[idx - 1].focus();
}
return;
}
// Focus on Multi toggle / close button / footer button: drop into
// the list.
e.preventDefault();
if (e.key === "ArrowDown") rows[0].focus();
else rows[rows.length - 1].focus();
}
function captureKeyOther(e: KeyboardEvent) {
// keyup / keypress: silence them too while the user is operating
// inside the modal so ComfyUI shortcuts that bind on keyup (e.g.
// Space release for canvas pan) don't fire. Scoped by target same
// as keydown — leaves other widgets' keystrokes alone.
if (!props.visible) return;
if (!isInsideModal(e.target)) return;
e.stopPropagation();
if (typeof e.stopImmediatePropagation === "function") {
e.stopImmediatePropagation();
}
}
// Keep focus rooted inside the modal when the user clicks inert
// areas (padding, body bottom, modal frame). Without this the
// browser would drop focus to document.body, and the next key
// would fail `isInsideModal(e.target)` and let a ComfyUI hotkey
// fire instead.
function captureModalClick(e: MouseEvent) {
if (!props.visible) return;
if (!isInsideModal(e.target)) return;
const t = e.target as HTMLElement | null;
// If the click landed on a focusable control, let the native
// focus management run.
if (t && (t.matches?.("input, textarea, button, [tabindex], [contenteditable=true]"))) {
return;
}
// Otherwise pull focus back to the modal root so subsequent
// keystrokes still resolve to a target inside the modal.
rootEl.value?.focus();
}
function attachCaptureListeners() {
window.addEventListener("keydown", captureKeyDown, { capture: true });
window.addEventListener("keyup", captureKeyOther, { capture: true });
window.addEventListener("keypress", captureKeyOther, { capture: true });
window.addEventListener("mousedown", captureModalClick, { capture: true });
}
function detachCaptureListeners() {
window.removeEventListener("keydown", captureKeyDown, { capture: true });
window.removeEventListener("keyup", captureKeyOther, { capture: true });
window.removeEventListener("keypress", captureKeyOther, { capture: true });
window.removeEventListener("mousedown", captureModalClick, { capture: true });
}
watch(
() => props.visible,
(v) => {
if (v) attachCaptureListeners();
else detachCaptureListeners();
},
);
onBeforeUnmount(detachCaptureListeners);
</script>
<style>
/* `wp-skel` family is shared with BundlePickerModal — import unscoped
* so the keyframe + base rules dedupe across consumers and aren't
* subject to per-component scoping rewrites. */
@import "../shared/_skeleton.css";
</style>
<style scoped>
/* Tokens via global tokens.css. Keep selectors prefixed `wp-picker__`
* so this component never collides with SPA Manager classes. */
/* Skeleton frames — geometry mirrors `.wp-picker__row` + the section
* head pattern so the placeholder layout matches the loaded list and
* doesn't shift on resolve. Rows get a faint translucent wash so each
* row reads as a distinct shape against the modal body (loaded rows
* are transparent until hover; the skeleton needs a touch more
* visual weight or the stack reads as floating bars). */
.wp-picker__skel { padding: 6px 8px 8px; }
.wp-picker__skel-head {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 8px 6px;
}
.wp-picker__skel-label { width: 84px; height: 11px; }
.wp-picker__skel-line {
flex: 1;
height: 1px;
background: var(--wp-border);
}
.wp-picker__skel-tally { width: 22px; height: 11px; }
.wp-picker__skel-row {
display: grid;
grid-template-columns: 18px 24px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
height: 40px;
padding: 0 10px;
border-radius: 5px;
background: color-mix(in srgb, var(--wp-text) 3%, transparent);
margin-bottom: 2px;
}
.wp-picker__skel-check { width: 16px; height: 16px; border-radius: 4px; }
.wp-picker__skel-icon { width: 22px; height: 22px; border-radius: 4px; }
.wp-picker__skel-main {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.wp-picker__skel-name { width: 38%; min-width: 80px; max-width: 220px; height: 12px; }
.wp-picker__skel-var { width: 60px; height: 9px; opacity: 0.7; }
.wp-picker__skel-uuid { width: 64px; height: 10px; }
/* Width variation so the rows don't read as a perfectly aligned grid —
* a real library has names of varying length, so a perfectly identical
* stack tells the eye "this is fake" instantly. */
.wp-picker__skel-row:nth-child(3n) .wp-picker__skel-name { width: 28%; }
.wp-picker__skel-row:nth-child(3n+1) .wp-picker__skel-name { width: 46%; }
.wp-picker__skel-row:nth-child(4n) .wp-picker__skel-var { width: 80px; }
.wp-picker__skel-row:nth-child(4n+2) .wp-picker__skel-var { width: 46px; }
.wp-picker {
background: var(--wp-bg-2);
border: 1px solid var(--wp-border-strong);
border-radius: var(--wp-radius-lg);
box-shadow: var(--wp-shadow-lg);
width: 780px;
max-width: calc(100vw - 32px);
height: 600px;
max-height: calc(100vh - 32px);
display: flex;
flex-direction: column;
color: var(--wp-text);
overflow: hidden;
font-family: var(--wp-font);
}
/* ── Header — brand gradient + dark wash overlay. Mirrors BundlePicker
* so both pickers read as part of the same family. */
.wp-picker__head {
display: flex; align-items: center; gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--wp-border);
background: var(--wp-brand-gradient);
position: relative;
}
.wp-picker__head::after {
content: "";
position: absolute;
inset: 0;
/* Theme-aware wash so the brand gradient stays subtle in both
* palettes — dark bg in dark mode, near-white in light mode. */
background: color-mix(in srgb, var(--wp-bg) 85%, transparent);
pointer-events: none;
}
.wp-picker__head > * { position: relative; z-index: 1; }
.wp-picker__head-text { flex: 1; min-width: 0; }
.wp-picker__eyebrow {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--wp-text-muted);
font-weight: 600;
display: flex; align-items: center; gap: 8px;
}
.wp-picker__title {
font-size: 14px;
font-weight: 600;
color: var(--wp-text);
margin-top: 1px;
}
.wp-picker__title-detail {
font-size: 11.5px;
color: var(--wp-text-dim);
margin-left: 6px;
font-weight: 400;
}
.wp-picker__close {
width: 28px; height: 28px;
display: inline-flex; align-items: center; justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--wp-text-muted);
cursor: pointer;
transition: background var(--wp-motion-quick) ease, color var(--wp-motion-quick) ease;
}
.wp-picker__close:hover { background: var(--wp-bg-3); color: var(--wp-text); }
.wp-picker__close:focus-visible {
outline: none;
border-color: var(--wp-accent-500);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--wp-accent-500) 25%, transparent);
}
.wp-picker__close .pi { font-size: 11px; }
/* ── Toolbar ─────────────────────────────────────────────────────── */
.wp-picker__toolbar {
padding: 10px 16px 0;
display: flex;
flex-direction: column;
gap: 10px;
}
/* Top row of the toolbar — search box + inline filter icon
* buttons + popover trigger all on a single line. Search flexes to
* eat remaining space; icon buttons stay 30 by 30 fixed. */
.wp-picker__toolbar-top {
display: flex;
align-items: center;
gap: 6px;
}
.wp-picker__toolbar-top > .wp-picker__search { flex: 1; min-width: 0; }
.wp-picker__search {
position: relative;
height: 28px;
background: var(--wp-bg-3);
border: 1px solid var(--wp-border);
border-radius: 6px;
display: flex; align-items: center;
padding: 0 8px;
gap: 6px;
transition: border-color var(--wp-motion-quick) ease, background var(--wp-motion-quick) ease;
}
.wp-picker__search.is-focused,
.wp-picker__search:focus-within {
border-color: var(--wp-accent-500);
background: var(--wp-bg-2);
}
.wp-picker__search-icon { font-size: 11px; color: var(--wp-text-dim); flex-shrink: 0; }
.wp-picker__search input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--wp-text);
font-size: 12px;
font-family: var(--wp-font);
}
.wp-picker__search input::placeholder { color: var(--wp-text-dim); }
.wp-picker__search-clear {
cursor: pointer;
color: var(--wp-text-dim);
font-size: 10px;
padding: 2px;
border-radius: 999px;
transition: color var(--wp-motion-quick) ease;
}
.wp-picker__search-clear:hover { color: var(--wp-text); }
.wp-picker-tabs {
display: flex;
gap: 2px;
padding: 0 12px;
border-bottom: 1px solid var(--wp-border);
align-items: center;
}
.wp-picker-tab {
background: transparent;
border: none;
padding: 9px 12px;
font: 500 11px/1 var(--wp-font-sans);
color: var(--wp-text-muted);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
border-bottom: 2px solid transparent;
}
.wp-picker-tab:hover { color: var(--wp-text); }
.wp-picker-tab.active { color: var(--wp-text); border-bottom-color: var(--wp-accent); }
.wp-picker-tab .pi { font-size: 13px; }
.wp-picker-tab[data-k="wildcard"] .pi { color: var(--wp-kind-wildcard); }
.wp-picker-tab[data-k="fixed_values"] .pi { color: var(--wp-kind-fixed); }
.wp-picker-tab[data-k="combine"] .pi { color: var(--wp-kind-combine); }
.wp-picker-tab[data-k="derivation"] .pi { color: var(--wp-kind-derivation); }
.wp-picker-tab[data-k="constraint"] .pi { color: var(--wp-kind-constraint); }
.wp-pill-count {
font-family: var(--wp-font-mono);
font-size: 10px;
color: var(--wp-text-dim);
background: var(--wp-bg-3);
padding: 1px 5px;
border-radius: 999px;
border: 1px solid var(--wp-border);
}
/* ── Body / list ─────────────────────────────────────────────────── */
.wp-picker__body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 8px 8px;
}
.wp-picker__body::-webkit-scrollbar { width: 8px; }
.wp-picker__body::-webkit-scrollbar-thumb { background: var(--wp-scrollbar-thumb, rgba(255, 255, 255, 0.08)); border-radius: 999px; }
.wp-picker__state {
padding: 32px 16px;
text-align: center;
color: var(--wp-text-muted);
font-size: 12.5px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.wp-picker__state .pi { font-size: 18px; color: var(--wp-text-dim); }
.wp-picker__state--error { color: var(--wp-danger, #fca5a5); }
.wp-picker__create {
margin-top: 4px;
height: 30px;
padding: 0 14px;
display: inline-flex; align-items: center; gap: 7px;
background: var(--wp-accent-600); border: 1px solid var(--wp-accent-500);
color: #fff; border-radius: 7px;
font: 500 12px var(--wp-font-sans); cursor: pointer;
transition: background var(--wp-motion-quick) ease;
}
.wp-picker__create:hover { background: var(--wp-accent-500); }
.wp-picker__create .pi { font-size: 11px; color: #fff; }
.wp-picker__retry {
margin-top: 8px;
height: 28px;
padding: 0 12px;
background: var(--wp-bg-3);
border: 1px solid var(--wp-border);
border-radius: 6px;
color: var(--wp-text);
font-size: 12px;
font-family: inherit;
cursor: pointer;
}
.wp-picker__retry:hover { background: var(--wp-bg-4); }
.wp-picker__section-head {
display: flex; align-items: center; gap: 8px;
padding: 12px 8px 4px;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
color: var(--wp-text-dim);
}
.wp-picker__section-line { flex: 1; height: 1px; background: var(--wp-border); }
.wp-picker__section-count {
font-family: var(--wp-font-mono);
color: var(--wp-text-dim);
font-size: 10px;
letter-spacing: 0;
text-transform: none;
}
.wp-picker__row {
/* Always 4-column grid: checkbox | icon | main | uuid.
* Checkbox (18px) is always rendered. */
display: grid;
grid-template-columns: 18px 24px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
width: 100%;
height: 36px;
padding: 0 8px;
background: transparent;
border: none;
border-radius: 5px;
color: var(--wp-text);
cursor: pointer;
font-family: inherit;
text-align: left;
transition: background var(--wp-motion-quick) ease;
}
.wp-picker__row:hover { background: var(--wp-bg-3); }
.wp-picker__row:focus-visible {
/* Keyboard focus. The SPA's outset accent halo
* (`box-shadow: 0 0 0 3px ... 25% transparent`) clips against
* neighbour rows in this flush list — kept inset instead so the
* highlight stays inside the row's own bounds. Bright 1.5px accent
* stroke + slight bg lift — distinguishable from hover (bg only)
* and from data-checked (purple-tinted bg + low-op stroke). */
outline: none;
background: var(--wp-bg-3);
box-shadow: inset 0 0 0 1.5px var(--wp-accent-500);
}
.wp-picker__row[data-checked]:focus-visible {
/* Selected + focused: keep the purple tint of the checked state
* but swap the low-opacity ring for the bright focus stroke. */
background: rgba(139, 92, 246, 0.18);
box-shadow: inset 0 0 0 1.5px var(--wp-accent-500);
}
.wp-picker__row[data-checked] {
background: rgba(139, 92, 246, 0.12);
box-shadow: inset 0 0 0 1px rgba(139, 92, 246, 0.42);
}
.wp-picker__row[data-disabled] {
cursor: not-allowed;
opacity: 0.42;
}
.wp-picker__row[data-disabled]:hover { background: transparent; }
.wp-picker__row.is-already-added {
opacity: 0.4;
border: 1px dashed var(--wp-border-strong);
cursor: default;
}
.wp-picker__row.is-already-added:hover { background: transparent; }
.wp-picker__check {
width: 16px; height: 16px;
border: 1.4px solid var(--wp-border-strong);
border-radius: 4px;
background: var(--wp-bg-3);
display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0;
transition: background var(--wp-motion-quick) ease, border-color var(--wp-motion-quick) ease;
}
.wp-picker__check .pi {
font-size: 9px;
color: #fff;
}
.wp-picker__row[data-checked] .wp-picker__check,
.wp-picker__check.on {
background: var(--wp-accent-600);
border-color: var(--wp-accent-500);
}
.wp-picker__row-icon {
width: 22px; height: 22px;
border-radius: 4px;
display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.wp-picker__row-icon .pi { font-size: 10px; }
.wp-picker__row-icon[data-kind="wildcard"] { background: rgba(167,139,250,0.14); color: var(--wp-kind-wildcard); }
.wp-picker__row-icon[data-kind="combine"] { background: rgba(52,211,153,0.14); color: var(--wp-kind-combine); }
.wp-picker__row-icon[data-kind="derivation"] { background: rgba(251,191,36,0.14); color: var(--wp-kind-derivation); }
.wp-picker__row-icon[data-kind="constraint"] { background: rgba(244,114,182,0.14); color: var(--wp-kind-constraint); }
.wp-picker__row-icon[data-kind="fixed_values"]{ background: rgba(34,211,238,0.14); color: var(--wp-kind-fixed); }
.wp-picker__row-main {
display: flex; align-items: baseline; gap: 8px;
min-width: 0;
}
.wp-picker__row-name {
/* Take remaining space + min-width:0 so flex shrink + ellipsis work
* (children of a flex container don't shrink below their text's
* natural width without an explicit `min-width: 0` override). */
flex: 1 1 auto;
min-width: 0;
font-size: 12.5px;
font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.wp-picker__row-var {
/* Hold natural width; cap so a long subtitle can't push the name
* into a 1-character ellipsis. */
flex: 0 0 auto;
max-width: 50%;
font-family: var(--wp-font-mono);
font-size: 11px;
color: var(--wp-text-muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.wp-picker__row-uuid {
font-family: var(--wp-font-mono);
font-size: 10px;
color: var(--wp-text-dim);
white-space: nowrap;
flex-shrink: 0;
}
/* ── Selection drawer ────────────────────────────────────────────── */
.wp-picker__selection {
background: var(--wp-bg-1);
border-top: 1px solid var(--wp-accent-600);
padding: 10px 16px;
display: flex; flex-direction: column; gap: 8px;
}
.wp-picker__selection-top {
display: flex; align-items: center; gap: 10px;
}
.wp-picker__selection-count {
font-size: 12.5px;
font-weight: 500;
color: var(--wp-accent-text);
}
.wp-picker__selection-count strong {
font-family: var(--wp-font-mono);
color: #fff;
background: var(--wp-accent-600);
padding: 1px 7px;
border-radius: 999px;
font-size: 11px;
margin-right: 4px;
}
.wp-picker__selection-spacer { flex: 1; }
.wp-picker__selection-clear {
background: transparent;
border: none;
color: var(--wp-text-muted);
cursor: pointer;
font-size: 11.5px;
font-family: inherit;
padding: 2px 6px;
border-radius: 4px;
}
.wp-picker__selection-clear:hover { color: var(--wp-text); background: var(--wp-bg-3); }
.wp-picker__selection-chips {
display: flex; align-items: center; gap: 6px;
flex-wrap: wrap;
}
.wp-picker__sel-chip {
display: inline-flex; align-items: center; gap: 6px;
height: 22px;
padding: 0 4px 0 8px;
background: var(--wp-bg-3);
border: 1px solid var(--wp-border-strong);
border-radius: 999px;
font-size: 11.5px;
color: var(--wp-text);
font-weight: 500;
max-width: 200px;
}
.wp-picker__sel-name {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wp-picker__sel-dot {
width: 7px; height: 7px;
border-radius: 999px;
flex-shrink: 0;
}
.wp-picker__sel-chip[data-kind="wildcard"] .wp-picker__sel-dot { background: var(--wp-kind-wildcard); }
.wp-picker__sel-chip[data-kind="combine"] .wp-picker__sel-dot { background: var(--wp-kind-combine); }
.wp-picker__sel-chip[data-kind="derivation"] .wp-picker__sel-dot { background: var(--wp-kind-derivation); }
.wp-picker__sel-chip[data-kind="constraint"] .wp-picker__sel-dot { background: var(--wp-kind-constraint); }
.wp-picker__sel-chip[data-kind="fixed_values"] .wp-picker__sel-dot { background: var(--wp-kind-fixed); }
.wp-picker__sel-close {
width: 16px; height: 16px;
display: inline-flex; align-items: center; justify-content: center;
border: none;
background: transparent;
color: var(--wp-text-dim);
cursor: pointer;
border-radius: 999px;
}
.wp-picker__sel-close:hover { background: var(--wp-bg-4); color: var(--wp-text); }
.wp-picker__sel-close .pi { font-size: 8px; }
/* ── Footer ──────────────────────────────────────────────────────── */
.wp-picker__footer {
height: 44px;
padding: 0 16px;
display: flex; align-items: center; gap: 10px;
border-top: 1px solid var(--wp-border);
background: var(--wp-bg-1);
color: var(--wp-text-dim);
font-size: 11px;
}
/* Hint strip — keycap-style kbd chips matching BundlePicker so both
* pickers feel like the same family. Raised look via 2px bottom
* border, monospaced label, neutral chip color. */
.wp-picker__hint {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
color: var(--wp-text-dim, var(--wp-text-muted));
font: 500 11px/1 var(--wp-font-sans);
}
.wp-picker__hint kbd {
font: 600 10px/1 var(--wp-font-mono, monospace);
background: var(--wp-bg2, var(--wp-bg-3));
border: 1px solid var(--wp-border);
border-bottom-width: 2px;
color: var(--wp-text);
border-radius: 3px;
padding: 2px 5px;
margin: 0 1px;
}
.wp-picker__footer-spacer { flex: 1; }
.wp-picker__btn {
height: 30px;
padding: 0 12px;
border-radius: 6px;
border: 1px solid var(--wp-border);
background: var(--wp-bg-3);
color: var(--wp-text);
font-size: 12px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
display: inline-flex; align-items: center; gap: 6px;
transition: background var(--wp-motion-quick) ease, border-color var(--wp-motion-quick) ease;
}
.wp-picker__btn:hover:not(:disabled) {
background: var(--wp-bg-4);
border-color: var(--wp-border-strong);
}
.wp-picker__btn:focus-visible {
outline: none;
border-color: var(--wp-accent-500);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--wp-accent-500) 25%, transparent);
}
.wp-picker__btn:disabled { opacity: 0.5; cursor: not-allowed; }
.wp-picker__btn--primary {
background: var(--wp-accent-600);
border-color: var(--wp-accent-500);
color: #fff;
}
.wp-picker__btn--primary:hover:not(:disabled) { background: var(--wp-accent-500); }
.wp-picker__btn--primary:focus-visible {
/* Already on-accent; brighten the halo a notch so the ring is
* visible against the violet body. */
border-color: var(--wp-accent-300);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--wp-accent-300) 35%, transparent);
}
/* ── Inline filter buttons (toolbar) ────────────────────────────
* Square icon buttons sitting next to the search box. Off = ghost
* outline + muted icon; on = accent fill. Match the visual weight
* of the kind tabs so the toolbar reads as one cohesive row. */
.wp-picker__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
/* Match search-box height (28px) so the toolbar row aligns. */
width: 28px;
height: 28px;
background: transparent;
border: 1px solid var(--wp-border);
border-radius: 6px;
color: var(--wp-text3);
cursor: pointer;
transition: background var(--wp-motion-quick), color var(--wp-motion-quick), border-color var(--wp-motion-quick);
flex-shrink: 0;
}
.wp-picker__icon-btn:hover {
color: var(--wp-text);
border-color: var(--wp-border2);
}
.wp-picker__icon-btn--active {
background: var(--wp-accent);
border-color: var(--wp-accent);
color: #fff;
}
.wp-picker__icon-btn--active:hover { filter: brightness(1.1); color: #fff; }
.wp-picker__icon-btn .pi { font-size: 12px; }
/* Filter-trigger badge — small pill in the corner showing how many
* dimensions inside the popover are active. */
.wp-picker__filter-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 7px;
background: var(--wp-amber, #ffd400);
color: #000;
font-family: var(--wp-font-mono, monospace);
font-size: 9px;
font-weight: 700;
line-height: 14px;
text-align: center;
pointer-events: none;
}
/* ── Filters popover ────────────────────────────────────────────
* Anchored under the trigger button. Sits inside the modal at
* z-index above body but below the click-shield's covered area.
* Tags + category live here so a 50-tag library doesn't crowd
* the toolbar. */
.wp-picker__filters-pop {
position: relative;
display: inline-flex;
}
.wp-picker__pop {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 10;
min-width: 280px;
max-width: 360px;
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
box-shadow: var(--wp-shadow-md, 0 6px 20px rgba(0, 0, 0, 0.45));
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.wp-picker__pop-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.wp-picker__pop-label {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 9px;
font-family: var(--wp-font-mono, monospace);
color: var(--wp-text3);
letter-spacing: 0.08em;
font-weight: 600;
}
.wp-picker__pop-label-hint {
font-size: 9px;
font-weight: 400;
letter-spacing: 0;
color: var(--wp-text3);
font-style: italic;
text-transform: none;
}
.wp-picker__pop-clear {
background: none;
border: 1px dashed var(--wp-border2);
border-radius: var(--wp-radius-sm);
color: var(--wp-text2);
cursor: pointer;
font-family: var(--wp-font-mono, monospace);
font-size: 10px;
padding: 5px 8px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
}
.wp-picker__pop-clear:hover {
color: var(--wp-accent);
border-color: var(--wp-accent);
}
.wp-picker__pop-clear .pi { font-size: 10px; }
/* Click-shield — covers the rest of the modal so a click outside
* the popover closes it. Transparent. Excluded from the popover's
* own area via z-index ordering (popover is z:10, shield is z:5). */
.wp-picker__pop-shield {
position: absolute;
inset: 0;
z-index: 5;
}
/* Pop animation — quick scale + fade so the panel doesn't pop
* abruptly. Origin at top-right anchors the open animation toward
* the trigger. */
.wp-pop-enter-active,
.wp-pop-leave-active {
transition: opacity var(--wp-motion-quick), transform var(--wp-motion-quick);
transform-origin: top right;
}
.wp-pop-enter-from,
.wp-pop-leave-to {
opacity: 0;
transform: scale(0.96) translateY(-2px);
}
/* Tag chips inside popover (same visual as before, just in a
* tighter container). */
.wp-picker__tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
background: transparent;
border: 1px solid var(--wp-border);
border-radius: 999px;
color: var(--wp-text3);
font-family: var(--wp-font-mono, monospace);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 2px 8px;
cursor: pointer;
transition: background var(--wp-motion-quick), color var(--wp-motion-quick), border-color var(--wp-motion-quick);
}
.wp-picker__tag-chip:hover {
color: var(--wp-text);
border-color: var(--wp-border2);
}
.wp-picker__tag-chip--active {
background: color-mix(in srgb, var(--wp-accent) 18%, transparent);
border-color: var(--wp-accent);
color: var(--wp-accent);
}
.wp-picker__tag-chip--active:hover {
background: color-mix(in srgb, var(--wp-accent) 28%, transparent);
color: var(--wp-accent2, var(--wp-accent));
}
.wp-picker__filter-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
/* Tag panel scrolls vertically inside the popover when the
* library has too many tags — keeps popover height bounded. */
max-height: 200px;
overflow-y: auto;
}
/* Category dropdown — same chrome as before but full-width inside
* the popover panel. Hide native browser arrow + draw our own. */
.wp-picker__filter-cat-select {
width: 100%;
background: var(--wp-bg);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-sm);
color: var(--wp-text);
font-family: var(--wp-font-sans, sans-serif);
font-size: 11px;
padding: 5px 22px 5px 8px;
cursor: pointer;
outline: none;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position: calc(100% - 12px) 50%, calc(100% - 7px) 50%;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.wp-picker__filter-cat-select:focus { border-color: var(--wp-accent); }
.wp-picker__filter-cat-select option {
background: var(--wp-bg2);
color: var(--wp-text);
}
</style>