browser / src /components /ModelPicker.vue
lrigamai's picture
Group LoRAs by character, add table view and docked detail panel
45fb44a
Raw History Blame Contribute Delete
5.38 kB
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import type { BaseModel } from "../types";
import { modelLabel, pad2 } from "../lib/format";
import Icon from "./Icon.vue";
/**
* Base-model filter: a button summarising the selection, opening a checklist. Any combination can
* be ticked and stays as ticked; "All models" (nothing ticked) clears it. `total` = number of characters.
*/
const props = defineProps<{ models: BaseModel[]; total: number }>();
const picked = defineModel<string[]>({ required: true });
const open = ref(false);
const root = ref<HTMLElement>();
const summary = computed(() => {
if (!picked.value.length) return "All models";
if (picked.value.length > 2) return `${picked.value.length} models`;
return picked.value.map(modelLabel).join(" + ");
});
function toggle(id: string) {
const next = picked.value.includes(id) ? picked.value.filter((m) => m !== id) : [...picked.value, id];
// Keep base-model order.
picked.value = props.models.map((m) => m.id).filter((m) => next.includes(m));
}
// Closes on a click outside or Esc.
function onDoc(e: Event) {
if (open.value && !root.value?.contains(e.target as Node)) open.value = false;
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape" && open.value) {
open.value = false;
root.value?.querySelector<HTMLElement>(".trigger")?.focus();
}
}
onMounted(() => {
document.addEventListener("pointerdown", onDoc);
document.addEventListener("keydown", onKey);
});
onBeforeUnmount(() => {
document.removeEventListener("pointerdown", onDoc);
document.removeEventListener("keydown", onKey);
});
</script>
<template>
<div ref="root" class="picker">
<button class="trigger mono" type="button" :aria-expanded="open" aria-haspopup="true" @click="open = !open">
<span class="k">Models</span>
<span class="v" :class="{ on: picked.length }">{{ summary }}</span>
<Icon name="chevron" class="caret" />
</button>
<Transition name="pop">
<div v-if="open" class="menu" role="group" aria-label="Filter by base model">
<button class="row all" type="button" role="menuitemcheckbox" :aria-checked="!picked.length" @click="picked = []">
<span class="box" aria-hidden="true"><Icon v-if="!picked.length" name="check" /></span>
<span class="name">All models</span>
<span class="n mono">{{ pad2(total) }}</span>
</button>
<button
v-for="m in models"
:key="m.id"
class="row"
type="button"
role="menuitemcheckbox"
:aria-checked="picked.includes(m.id)"
@click="toggle(m.id)"
>
<span class="box" aria-hidden="true"><Icon v-if="picked.includes(m.id)" name="check" /></span>
<span class="name">{{ m.id }}</span>
<span class="n mono">{{ pad2(m.count) }}</span>
</button>
</div>
</Transition>
</div>
</template>
<style scoped>
.picker {
position: relative;
}
/* fixed width: the summary changes as models are ticked, and the button mustn't resize with it */
.trigger {
display: flex;
align-items: center;
gap: 10px;
width: 220px;
height: 32px;
padding: 0 10px 0 12px;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
background: none;
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
cursor: pointer;
transition: border-color 0.15s;
}
.trigger:hover,
.trigger[aria-expanded="true"] {
border-color: var(--text-3);
}
.k {
font-size: 10px;
color: var(--text-3);
}
.v {
flex: 1;
overflow: hidden;
color: var(--text);
text-align: left;
text-overflow: ellipsis;
}
.v.on {
color: var(--accent);
}
.caret {
color: var(--text-3);
transform: rotate(90deg);
transition: transform 0.2s var(--ease);
}
.trigger[aria-expanded="true"] .caret {
transform: rotate(-90deg);
}
.menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 10;
width: 100%;
min-width: 240px;
padding: 4px;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
background: var(--surface);
box-shadow: 0 12px 40px rgb(0 0 0 / 0.55);
}
.row {
display: grid;
grid-template-columns: 16px 1fr auto;
align-items: center;
gap: 10px;
width: 100%;
height: 36px;
padding: 0 10px;
border: 0;
border-radius: 2px;
background: none;
text-align: left;
cursor: pointer;
}
.row:hover {
background: var(--surface-2);
}
.all {
margin-bottom: 4px;
border-bottom: 1px solid var(--line);
border-radius: 2px 2px 0 0;
}
.box {
display: grid;
place-items: center;
width: 16px;
height: 16px;
border: 1px solid var(--line-strong);
border-radius: 2px;
font-size: 12px;
color: var(--accent-ink);
}
[aria-checked="true"] .box {
border-color: var(--accent);
background: var(--accent);
}
.name {
overflow: hidden;
font-family: var(--font-display);
font-size: 18px;
font-weight: 800;
letter-spacing: 0.01em;
text-transform: uppercase;
color: var(--text-2);
text-overflow: ellipsis;
white-space: nowrap;
}
[aria-checked="true"] .name {
color: var(--text);
}
.n {
font-size: 10px;
color: var(--text-3);
}
[aria-checked="true"] .n {
color: var(--accent);
}
.pop-enter-active,
.pop-leave-active {
transition: opacity 0.15s, transform 0.15s var(--ease);
}
.pop-enter-from,
.pop-leave-to {
opacity: 0;
transform: translateY(-4px);
}
/* phones: the label goes, the summary stays */
@media (max-width: 560px) {
.trigger {
width: 150px;
}
.k {
display: none;
}
}
</style>