feat: shadcn quant selects with dynamic trigger width in download command

Replaces the native quant selects with the ui/select component using a
new xs trigger size: h-6, mono text, w-fit so the trigger width follows
the selected quant instead of reserving space for the widest option.
The primary-tinted look and the dashed default-preview state carry over
via trigger classes; SELECT_CLASS is no longer needed.

Assisted-by: Claude Sonnet
This commit is contained in:
Aleksander Grygier
2026-09-04 20:07:37 +02:00
parent 419d2c9df0
commit 5809350d41
5 changed files with 72 additions and 37 deletions
@@ -4,6 +4,7 @@
import { HuggingFaceService } from '$lib/services';
interface Props {
class?: string;
/** Org whose avatar is shown (may differ from the repo's org for base models). */
org: string;
/** Repo's own org, shown as a small corner badge when provided. */
@@ -22,6 +23,7 @@
let {
baseImageClass = '',
class: className = '',
org,
quantImageClass = 'h-full w-full',
quantOrg,
@@ -57,7 +59,7 @@
});
</script>
<span class="relative mt-0.5 inline-flex shrink-0">
<span class="relative mt-0.5 inline-flex shrink-0 {className}">
{#if avatarError}
<span
aria-hidden="true"
@@ -135,7 +135,7 @@
onclick={() => onSelect?.(model.id)}
type="button"
>
<ModelsDiscoverAvatar org={avatarOrg} quantOrg={showBaseModelAvatar ? org : undefined} />
<ModelsDiscoverAvatar class="mt-1" org={avatarOrg} quantOrg={showBaseModelAvatar ? org : undefined} />
<span class="min-w-0 flex-1">
<ModelId
@@ -1,6 +1,7 @@
<script lang="ts">
import { type QuantOption, SELECT_CLASS } from './download-options.utils';
import { type QuantOption } from './download-options.utils';
import { Check, Copy } from '@lucide/svelte';
import * as Select from '$lib/components/ui/select';
import { type ModelSidecar } from '$lib/constants';
import { copyToClipboard } from '$lib/utils';
@@ -43,6 +44,14 @@
let copied = $state(false);
// Trigger labels: the select trigger has no automatic value rendering.
let baseSelectedLabel = $derived(
baseOptions.find((option) => option.path === basePick)?.label ?? basePick
);
let draftSelectedLabel = $derived(
draftOptions.find((option) => option.path === draftPick)?.label ?? draftPick
);
async function copy() {
await copyToClipboard(command);
copied = true;
@@ -67,19 +76,33 @@
<!-- Base quant: always part of the command, the 8-bit file by default. -->
{#if baseOptions.length}
<select
aria-label="Base model quantization"
class="{SELECT_CLASS} {mainSelected ? '' : 'border-dashed'} -ml-2"
onchange={(e) => onBasePick(e.currentTarget.value)}
title={mainSelected ? undefined : 'Default quant - pick a file above or another quant here'}
value={basePick}
>
{#each baseOptions as option (option.path)}
<option disabled={option.disabled} value={option.path}>
{option.label}
</option>
{/each}
</select>
<Select.Root onValueChange={(v) => v && onBasePick(v)} type="single" value={basePick}>
<Select.Trigger
aria-label="Base model quantization"
class="-ml-2 border-primary/15 bg-primary/[0.07] font-mono text-foreground hover:bg-primary/15 focus-visible:border-primary/40 focus-visible:ring-0 {mainSelected
? ''
: 'border-dashed'}"
size="xs"
title={mainSelected
? undefined
: 'Default quant - pick a file above or another quant here'}
>
{baseSelectedLabel}
</Select.Trigger>
<Select.Content class="font-mono text-xs">
{#each baseOptions as option (option.path)}
<Select.Item
class="text-xs"
disabled={option.disabled}
label={option.label}
value={option.path}
>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/if}
<!-- Draft segment: appears once a draft is picked, quant inline too. -->
@@ -88,18 +111,28 @@
<span class="shrink-0">{modelId}{draftQuant ? ':' : ''}</span>
<select
aria-label="Draft model quantization"
class="{SELECT_CLASS} -ml-2"
onchange={(e) => onDraftPick(e.currentTarget.value)}
value={draftPick}
>
{#each draftOptions as option (option.path)}
<option disabled={option.disabled} value={option.path}>
{option.label}
</option>
{/each}
</select>
<Select.Root onValueChange={(v) => v && onDraftPick(v)} type="single" value={draftPick}>
<Select.Trigger
aria-label="Draft model quantization"
class="-ml-2 border-primary/15 bg-primary/[0.07] font-mono text-foreground hover:bg-primary/15 focus-visible:border-primary/40 focus-visible:ring-0"
size="xs"
>
{draftSelectedLabel}
</Select.Trigger>
<Select.Content class="font-mono text-xs">
{#each draftOptions as option (option.path)}
<Select.Item
class="text-xs"
disabled={option.disabled}
label={option.label}
value={option.path}
>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<span>--spec-type</span>
@@ -43,9 +43,3 @@ export function labelFor(path: string): string {
return basename.replace(/\.gguf$/i, '');
}
// min-w keeps the value clear of the native chevron: Safari sizes a select
// to its widest option, so an exactly-as-wide value would otherwise let the
// chevron overlap the text (draft selects are all same-width quants).
export const SELECT_CLASS =
'h-6 min-w-22 max-w-36 shrink-0 cursor-pointer rounded border border-primary/15 bg-primary/[0.07] py-0 pr-3 pl-2 font-mono text-xs text-foreground outline-none transition-colors hover:bg-primary/15 focus-visible:border-primary/40 focus-visible:ring-0';
@@ -11,18 +11,24 @@
variant = 'default',
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: 'sm' | 'default';
size?: 'xs' | 'sm' | 'default';
variant?: 'default' | 'plain';
} = $props();
// Super small trigger: fits its selected value, for dense inline use.
const xsClasses =
"flex h-6 w-fit items-center justify-between gap-1 rounded-md border border-input bg-transparent px-2 py-0 text-xs whitespace-nowrap outline-none select-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='text-'])]:text-muted-foreground";
const baseClasses = $derived(
variant === 'plain'
? "group inline-flex w-full items-center justify-end gap-2 whitespace-nowrap px-0 py-0 text-sm font-medium text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3 [&_svg:not([class*='text-'])]:text-muted-foreground"
: "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground"
: size === 'xs'
? xsClasses
: "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground"
);
const chevronClasses = $derived(
variant === 'plain'
variant === 'plain' || size === 'xs'
? 'size-3 opacity-60 transition-transform group-data-[state=open]:-rotate-180'
: 'size-4 opacity-50'
);