selection in page
This commit is contained in:
@@ -39,7 +39,7 @@
|
||||
|
||||
import { useHeroActions } from '../../hero.svelte';
|
||||
import { defaultParameters, hourly, modelGroups, models as modelOptions } from '../../options';
|
||||
import ComparisonSelectionDrawer from './ComparisonSelectionDrawer.svelte';
|
||||
import ComparisonSelectionPanel from './ComparisonSelectionPanel.svelte';
|
||||
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
||||
import {
|
||||
cardinalDirection,
|
||||
@@ -60,6 +60,10 @@
|
||||
const AUTOMATIC_SEAMLESS_MODEL_IDS = new Set(
|
||||
modelGroups.find((group) => group.value === 'automatic_seamless')?.models.map((model) => model.value)
|
||||
);
|
||||
const COMPARE_MODEL_GROUPS = [
|
||||
...modelGroups.filter((group) => group.value !== 'automatic_seamless'),
|
||||
...modelGroups.filter((group) => group.value === 'automatic_seamless')
|
||||
];
|
||||
// Weather code and total cloud cover form one comparison feature. Cloud cover
|
||||
// remains an auxiliary API field for pictogram shading, not a standalone chart.
|
||||
const COMPARISON_HOURLY = hourly.map((group) =>
|
||||
@@ -96,8 +100,6 @@
|
||||
let isMobile = $state(false);
|
||||
let comparisonPlotInsetLeft = $derived(isMobile ? 92 : 116);
|
||||
let comparisonPlotInsetRight = $derived(isMobile ? 8 : 20);
|
||||
let selectionDrawerMode = $state<'models' | 'variables' | null>(null);
|
||||
|
||||
let params = $state({
|
||||
...defaultParameters,
|
||||
hourly: [...STANDARD_COMPARE_VARIABLES],
|
||||
@@ -542,14 +544,8 @@
|
||||
m.compare_group_upper_air
|
||||
];
|
||||
const allHourlyVariables = COMPARISON_HOURLY.flat();
|
||||
let selectedModels = $derived(
|
||||
params.models.flatMap((value) => {
|
||||
const model = modelOptions.find((candidate) => candidate.value === value);
|
||||
return model ? [model] : [];
|
||||
})
|
||||
);
|
||||
let modelDrawerGroups = $derived(
|
||||
modelGroups.map((group) => ({
|
||||
let modelSelectionGroups = $derived(
|
||||
COMPARE_MODEL_GROUPS.map((group) => ({
|
||||
value: group.value,
|
||||
label: group.label,
|
||||
options: group.models.map((model) => ({
|
||||
@@ -559,17 +555,7 @@
|
||||
}))
|
||||
}))
|
||||
);
|
||||
let selectedVariables = $derived(
|
||||
params.hourly.flatMap((value) => {
|
||||
const variable = allHourlyVariables.find((candidate) => candidate.value === value);
|
||||
return variable ? [variable] : [];
|
||||
})
|
||||
);
|
||||
let standardVariablesActive = $derived(
|
||||
params.hourly.length === STANDARD_COMPARE_VARIABLES.length &&
|
||||
STANDARD_COMPARE_VARIABLES.every((variable) => params.hourly.includes(variable))
|
||||
);
|
||||
let variableDrawerGroups = $derived(
|
||||
let variableSelectionGroups = $derived(
|
||||
COMPARISON_HOURLY.map((variables, index) => ({
|
||||
value: `variables_${index}`,
|
||||
label: variableGroupLabels[index](),
|
||||
@@ -824,8 +810,31 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Compact summaries keep the page scannable; full editing happens in the shared drawer. -->
|
||||
<section class="mt-6 border-t border-border/60 pt-5 md:mt-10" aria-labelledby="models">
|
||||
<section
|
||||
class="mt-6 border-t border-border/60 pt-5 md:mt-10"
|
||||
aria-labelledby="hourly_weather_variables"
|
||||
>
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<h2 id="hourly_weather_variables" class="text-2xl md:text-3xl">
|
||||
{m.compare_variables_heading()}
|
||||
</h2>
|
||||
<span class="shrink-0 text-sm text-muted-foreground">
|
||||
{params.hourly.length}/{allHourlyVariables.length}
|
||||
</span>
|
||||
</div>
|
||||
<ComparisonSelectionPanel
|
||||
mode="variables"
|
||||
groups={variableSelectionGroups}
|
||||
selected={params.hourly}
|
||||
dirty={variablesDirty}
|
||||
onToggle={(value) => toggleSelection('hourly', value)}
|
||||
onToggleGroup={(values, select) => toggleGroupSelection('hourly', values, select)}
|
||||
onApply={applyComparisonSelection}
|
||||
onRestoreDefaults={restoreStandardVariables}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="mt-5 border-t border-border/60 pt-5 md:mt-10" aria-labelledby="models">
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<h2 id="models" class="text-2xl md:text-3xl">{m.compare_models_heading()}</h2>
|
||||
<span class="shrink-0 text-sm text-muted-foreground">
|
||||
@@ -840,177 +849,13 @@
|
||||
{m.compare_many_models_warning({ count: params.models.length })}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-3 rounded-xl border border-border/70 bg-card p-3 shadow-sm sm:p-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-sm font-bold">{m.compare_selected_models()}</h3>
|
||||
{#if modelsDirty}
|
||||
<span
|
||||
class="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold tracking-wide text-amber-700 uppercase dark:text-amber-300"
|
||||
>
|
||||
{m.compare_selection_pending()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedModels.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
{#each selectedModels as model, index (model.value)}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-8 max-w-full items-center gap-1.5 rounded-full border border-border/70 bg-background px-2.5 py-1 text-xs hover:border-destructive/40 hover:bg-destructive/5"
|
||||
onclick={() => toggleSelection('models', model.value)}
|
||||
aria-label={m.compare_remove_model({ model: model.label })}
|
||||
>
|
||||
<span class="text-[10px] font-bold text-muted-foreground tabular-nums"
|
||||
>{index + 1}</span
|
||||
>
|
||||
<span
|
||||
class="size-2.5 shrink-0 rounded-full"
|
||||
style:background-color={modelColor(model.value, params.models)}
|
||||
></span>
|
||||
<span class="truncate">{model.label}</span>
|
||||
<span aria-hidden="true" class="text-muted-foreground">×</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">{m.compare_no_models_selected()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap gap-2 sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
|
||||
onclick={() => (selectionDrawerMode = 'models')}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
{m.compare_customize_models()}
|
||||
</button>
|
||||
{#if modelsDirty}
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-9 rounded-lg bg-primary px-3 py-1.5 text-xs font-bold text-primary-foreground hover:bg-primary/90"
|
||||
onclick={applyComparisonSelection}
|
||||
>
|
||||
{m.compare_apply_selection()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="mt-5 border-t border-border/60 pt-5 md:mt-10"
|
||||
aria-labelledby="hourly_weather_variables"
|
||||
>
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<h2 id="hourly_weather_variables" class="text-2xl md:text-3xl">
|
||||
{m.compare_variables_heading()}
|
||||
</h2>
|
||||
<span class="shrink-0 text-sm text-muted-foreground">
|
||||
{params.hourly.length}/{allHourlyVariables.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-3 rounded-xl border bg-card shadow-sm {standardVariablesActive
|
||||
? 'border-primary/35'
|
||||
: 'border-border/70'}"
|
||||
>
|
||||
<div class="flex flex-col gap-3 p-3 sm:flex-row sm:items-start sm:justify-between sm:p-4">
|
||||
<div class="flex min-w-0 gap-3">
|
||||
<span
|
||||
class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border {standardVariablesActive
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-muted-foreground/40 bg-muted'}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if standardVariablesActive}<span class="size-2 rounded-full bg-primary-foreground"></span>{/if}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="font-bold">{m.compare_standard_preset()}</h3>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-bold tracking-wide uppercase {variablesDirty
|
||||
? 'bg-amber-500/15 text-amber-700 dark:text-amber-300'
|
||||
: standardVariablesActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-muted text-muted-foreground'}"
|
||||
>
|
||||
{variablesDirty
|
||||
? m.compare_variables_pending()
|
||||
: standardVariablesActive
|
||||
? m.compare_preset_active()
|
||||
: m.compare_custom_selection({ count: params.hourly.length })}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-sm text-muted-foreground">
|
||||
{m.compare_standard_preset_description()}
|
||||
</p>
|
||||
{#if selectedVariables.length > 0}
|
||||
<p class="mt-2 text-xs leading-relaxed text-foreground/80">
|
||||
{selectedVariables.map((variable) => variableLabel(variable.value)).join(' · ')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap gap-2 sm:justify-end">
|
||||
{#if !standardVariablesActive}
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
|
||||
onclick={restoreStandardVariables}
|
||||
>
|
||||
{m.compare_restore_defaults()}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
|
||||
onclick={() => (selectionDrawerMode = 'variables')}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
{m.compare_customize_variables()}
|
||||
</button>
|
||||
{#if variablesDirty}
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-9 rounded-lg bg-primary px-3 py-1.5 text-xs font-bold text-primary-foreground hover:bg-primary/90"
|
||||
onclick={applyComparisonSelection}
|
||||
>
|
||||
{m.compare_apply_selection()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if selectionDrawerMode === 'models'}
|
||||
<ComparisonSelectionDrawer
|
||||
open
|
||||
<ComparisonSelectionPanel
|
||||
mode="models"
|
||||
groups={modelDrawerGroups}
|
||||
groups={modelSelectionGroups}
|
||||
selected={params.models}
|
||||
dirty={comparisonSelectionDirty}
|
||||
dirty={modelsDirty}
|
||||
onToggle={(value) => toggleSelection('models', value)}
|
||||
onToggleGroup={(values, select) => toggleGroupSelection('models', values, select)}
|
||||
onApply={applyComparisonSelection}
|
||||
onClose={() => (selectionDrawerMode = null)}
|
||||
/>
|
||||
{:else if selectionDrawerMode === 'variables'}
|
||||
<ComparisonSelectionDrawer
|
||||
open
|
||||
mode="variables"
|
||||
groups={variableDrawerGroups}
|
||||
selected={params.hourly}
|
||||
dirty={comparisonSelectionDirty}
|
||||
onToggle={(value) => toggleSelection('hourly', value)}
|
||||
onToggleGroup={(values, select) => toggleGroupSelection('hourly', values, select)}
|
||||
onApply={applyComparisonSelection}
|
||||
onClose={() => (selectionDrawerMode = null)}
|
||||
onRestoreDefaults={restoreStandardVariables}
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
metadata?: string;
|
||||
}
|
||||
|
||||
interface OptionGroup {
|
||||
value: string;
|
||||
label: string;
|
||||
options: Option[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
mode: 'models' | 'variables';
|
||||
groups: OptionGroup[];
|
||||
selected: string[];
|
||||
dirty: boolean;
|
||||
onToggle: (value: string) => void;
|
||||
onToggleGroup: (values: string[], select: boolean) => void;
|
||||
onApply: () => void;
|
||||
onClose: () => void;
|
||||
onRestoreDefaults?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open,
|
||||
mode,
|
||||
groups,
|
||||
selected,
|
||||
dirty,
|
||||
onToggle,
|
||||
onToggleGroup,
|
||||
onApply,
|
||||
onClose,
|
||||
onRestoreDefaults
|
||||
}: Props = $props();
|
||||
|
||||
let search = $state('');
|
||||
let onlySelected = $state(false);
|
||||
let normalizedSearch = $derived(search.trim().toLocaleLowerCase());
|
||||
|
||||
let visibleGroups = $derived.by(() =>
|
||||
groups.flatMap((group) => {
|
||||
const options = group.options.filter((option) => {
|
||||
if (onlySelected && !selected.includes(option.value)) return false;
|
||||
if (!normalizedSearch) return true;
|
||||
return `${group.label} ${option.label} ${option.value} ${option.metadata ?? ''}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(normalizedSearch);
|
||||
});
|
||||
return options.length > 0 ? [{ ...group, options }] : [];
|
||||
})
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (open) return;
|
||||
search = '';
|
||||
onlySelected = false;
|
||||
});
|
||||
|
||||
function groupSelection(group: OptionGroup): { count: number; all: boolean; partial: boolean } {
|
||||
const count = group.options.filter((option) => selected.includes(option.value)).length;
|
||||
return { count, all: count === group.options.length, partial: count > 0 && count < group.options.length };
|
||||
}
|
||||
|
||||
function applyAndClose(): void {
|
||||
onApply();
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Dialog {open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
||||
<Dialog.DialogContent
|
||||
showCloseButton={false}
|
||||
class="top-0 right-0 bottom-0 left-auto flex h-dvh w-full max-w-none translate-x-0 translate-y-0 flex-col gap-0 rounded-none border-0 p-0 sm:w-[640px] sm:max-w-[90vw] sm:border-l"
|
||||
>
|
||||
<header class="shrink-0 border-b border-border bg-background px-4 py-4 sm:px-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<Dialog.DialogTitle class="text-lg font-bold">
|
||||
{mode === 'models' ? m.compare_edit_models() : m.compare_edit_variables()}
|
||||
</Dialog.DialogTitle>
|
||||
<Dialog.DialogDescription class="mt-0.5 text-xs">
|
||||
{mode === 'models'
|
||||
? m.compare_edit_models_description()
|
||||
: m.compare_edit_variables_description()}
|
||||
</Dialog.DialogDescription>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg text-xl text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onclick={onClose}
|
||||
aria-label={m.action_close()}
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
placeholder={mode === 'models'
|
||||
? m.compare_search_models()
|
||||
: m.compare_search_variables()}
|
||||
aria-label={mode === 'models'
|
||||
? m.compare_search_models()
|
||||
: m.compare_search_variables()}
|
||||
/>
|
||||
<div
|
||||
class="flex min-h-9 shrink-0 cursor-pointer items-center gap-2 rounded-lg border border-border px-3 text-xs font-semibold hover:bg-muted"
|
||||
>
|
||||
<Checkbox id="drawer_{mode}_only_selected" bind:checked={onlySelected} />
|
||||
<Label for="drawer_{mode}_only_selected" class="cursor-pointer text-xs">
|
||||
{m.compare_only_selected()}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-5">
|
||||
{#if visibleGroups.length > 0}
|
||||
<div class="grid items-start gap-3 sm:grid-cols-2">
|
||||
{#each visibleGroups as visibleGroup (visibleGroup.value)}
|
||||
{@const fullGroup = groups.find((group) => group.value === visibleGroup.value) ?? visibleGroup}
|
||||
{@const selection = groupSelection(fullGroup)}
|
||||
<section class="overflow-hidden rounded-xl border border-border/70 bg-card">
|
||||
<div class="flex min-h-11 items-center gap-2 border-b border-border/60 bg-muted/30 px-3 py-2">
|
||||
<Checkbox
|
||||
id="drawer_{mode}_group_{fullGroup.value}"
|
||||
checked={selection.all}
|
||||
indeterminate={selection.partial}
|
||||
onCheckedChange={() =>
|
||||
onToggleGroup(
|
||||
fullGroup.options.map((option) => option.value),
|
||||
!selection.all
|
||||
)}
|
||||
/>
|
||||
<Label
|
||||
for="drawer_{mode}_group_{fullGroup.value}"
|
||||
class="min-w-0 flex-1 cursor-pointer text-sm font-bold"
|
||||
>
|
||||
{fullGroup.label}
|
||||
</Label>
|
||||
<span class="shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
||||
{selection.count}/{fullGroup.options.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-md px-2 py-1 text-[11px] font-semibold text-primary hover:bg-primary/10"
|
||||
onclick={() =>
|
||||
onToggleGroup(
|
||||
fullGroup.options.map((option) => option.value),
|
||||
!selection.all
|
||||
)}
|
||||
>
|
||||
{selection.all ? m.compare_clear_group() : m.compare_select_group()}
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
{#each visibleGroup.options as option (option.value)}
|
||||
<div class="flex min-h-10 items-center rounded-lg px-1.5 hover:bg-muted/60">
|
||||
<Checkbox
|
||||
id="drawer_{mode}_{option.value}"
|
||||
checked={selected.includes(option.value)}
|
||||
onCheckedChange={() => onToggle(option.value)}
|
||||
/>
|
||||
<Label
|
||||
for="drawer_{mode}_{option.value}"
|
||||
class="min-w-0 flex-1 cursor-pointer py-2 pl-2"
|
||||
>
|
||||
<span class="block text-sm font-medium">{option.label}</span>
|
||||
{#if option.metadata}
|
||||
<span class="block text-[10px] text-muted-foreground">
|
||||
{option.metadata}
|
||||
</span>
|
||||
{/if}
|
||||
</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
{m.compare_no_matching_options()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer class="shrink-0 border-t border-border bg-background px-4 py-3 sm:px-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
{#if mode === 'variables' && onRestoreDefaults}
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
|
||||
onclick={onRestoreDefaults}
|
||||
>
|
||||
{m.compare_restore_defaults()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-10 rounded-lg border border-border px-4 py-2 text-sm font-semibold hover:bg-muted"
|
||||
onclick={onClose}
|
||||
>
|
||||
{m.action_close()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-10 rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={!dirty}
|
||||
onclick={applyAndClose}
|
||||
>
|
||||
{m.compare_apply_selection()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</Dialog.DialogContent>
|
||||
</Dialog.Dialog>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
metadata?: string;
|
||||
}
|
||||
|
||||
interface OptionGroup {
|
||||
value: string;
|
||||
label: string;
|
||||
options: Option[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
mode: 'models' | 'variables';
|
||||
groups: OptionGroup[];
|
||||
selected: string[];
|
||||
dirty: boolean;
|
||||
onToggle: (value: string) => void;
|
||||
onToggleGroup: (values: string[], select: boolean) => void;
|
||||
onApply: () => void;
|
||||
onRestoreDefaults?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
mode,
|
||||
groups,
|
||||
selected,
|
||||
dirty,
|
||||
onToggle,
|
||||
onToggleGroup,
|
||||
onApply,
|
||||
onRestoreDefaults
|
||||
}: Props = $props();
|
||||
|
||||
let search = $state('');
|
||||
let onlySelected = $state(false);
|
||||
let normalizedSearch = $derived(search.trim().toLocaleLowerCase());
|
||||
|
||||
let visibleGroups = $derived.by(() =>
|
||||
groups.flatMap((group) => {
|
||||
const options = group.options.filter((option) => {
|
||||
if (onlySelected && !selected.includes(option.value)) return false;
|
||||
if (!normalizedSearch) return true;
|
||||
return `${group.label} ${option.label} ${option.value} ${option.metadata ?? ''}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(normalizedSearch);
|
||||
});
|
||||
return options.length > 0 ? [{ ...group, options }] : [];
|
||||
})
|
||||
);
|
||||
|
||||
function groupSelection(group: OptionGroup): { count: number; all: boolean; partial: boolean } {
|
||||
const count = group.options.filter((option) => selected.includes(option.value)).length;
|
||||
return {
|
||||
count,
|
||||
all: count === group.options.length,
|
||||
partial: count > 0 && count < group.options.length
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-3 overflow-hidden rounded-xl border border-border/70 bg-card shadow-sm">
|
||||
<div class="border-b border-border/70 bg-muted/20 p-3 sm:p-4">
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
placeholder={mode === 'models' ? m.compare_search_models() : m.compare_search_variables()}
|
||||
aria-label={mode === 'models' ? m.compare_search_models() : m.compare_search_variables()}
|
||||
/>
|
||||
<div
|
||||
class="flex min-h-9 shrink-0 cursor-pointer items-center gap-2 rounded-lg border border-border bg-background px-3 text-xs font-semibold hover:bg-muted"
|
||||
>
|
||||
<Checkbox id="panel_{mode}_only_selected" bind:checked={onlySelected} />
|
||||
<Label for="panel_{mode}_only_selected" class="cursor-pointer text-xs">
|
||||
{m.compare_only_selected()}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 sm:p-4">
|
||||
{#if visibleGroups.length > 0}
|
||||
<div class="columns-1 gap-3 md:columns-2">
|
||||
{#each visibleGroups as visibleGroup (visibleGroup.value)}
|
||||
{@const fullGroup =
|
||||
groups.find((group) => group.value === visibleGroup.value) ?? visibleGroup}
|
||||
{@const selection = groupSelection(fullGroup)}
|
||||
<section
|
||||
class="mb-3 inline-block w-full break-inside-avoid overflow-hidden rounded-xl border border-border/70 bg-background align-top"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-11 items-center gap-2 border-b border-border/60 bg-muted/30 px-3 py-2"
|
||||
>
|
||||
<Checkbox
|
||||
id="panel_{mode}_group_{fullGroup.value}"
|
||||
checked={selection.all}
|
||||
indeterminate={selection.partial}
|
||||
onCheckedChange={() =>
|
||||
onToggleGroup(
|
||||
fullGroup.options.map((option) => option.value),
|
||||
!selection.all
|
||||
)}
|
||||
/>
|
||||
<Label
|
||||
for="panel_{mode}_group_{fullGroup.value}"
|
||||
class="min-w-0 flex-1 cursor-pointer text-sm font-bold"
|
||||
>
|
||||
{fullGroup.label}
|
||||
</Label>
|
||||
<span class="shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
||||
{selection.count}/{fullGroup.options.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-md px-2 py-1 text-[11px] font-semibold text-primary hover:bg-primary/10"
|
||||
onclick={() =>
|
||||
onToggleGroup(
|
||||
fullGroup.options.map((option) => option.value),
|
||||
!selection.all
|
||||
)}
|
||||
>
|
||||
{selection.all ? m.compare_clear_group() : m.compare_select_group()}
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
{#each visibleGroup.options as option (option.value)}
|
||||
<div class="flex min-h-10 items-center rounded-lg px-1.5 hover:bg-muted/60">
|
||||
<Checkbox
|
||||
id="panel_{mode}_{option.value}"
|
||||
checked={selected.includes(option.value)}
|
||||
onCheckedChange={() => onToggle(option.value)}
|
||||
/>
|
||||
<Label
|
||||
for="panel_{mode}_{option.value}"
|
||||
class="min-w-0 flex-1 cursor-pointer py-2 pl-2"
|
||||
>
|
||||
<span class="block text-sm font-medium">{option.label}</span>
|
||||
{#if option.metadata}
|
||||
<span class="block text-[10px] text-muted-foreground">
|
||||
{option.metadata}
|
||||
</span>
|
||||
{/if}
|
||||
</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{m.compare_no_matching_options()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-2 border-t border-border bg-muted/20 px-3 py-3 sm:px-4"
|
||||
>
|
||||
<div>
|
||||
{#if mode === 'variables' && onRestoreDefaults}
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-9 rounded-lg border border-border bg-background px-3 py-1.5 text-xs font-semibold hover:bg-muted"
|
||||
onclick={onRestoreDefaults}
|
||||
>
|
||||
{m.compare_restore_defaults()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-10 rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={!dirty}
|
||||
onclick={onApply}
|
||||
>
|
||||
{m.compare_apply_selection()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user