redesign model and variable selection
This commit is contained in:
@@ -294,6 +294,16 @@
|
|||||||
"compare_selection_pending": "Auswahl geändert",
|
"compare_selection_pending": "Auswahl geändert",
|
||||||
"compare_apply_selection": "Anwenden & Diagramme neu laden",
|
"compare_apply_selection": "Anwenden & Diagramme neu laden",
|
||||||
"compare_customize_models": "Modelle anpassen",
|
"compare_customize_models": "Modelle anpassen",
|
||||||
|
"compare_edit_models": "Modelle bearbeiten",
|
||||||
|
"compare_edit_variables": "Variablen bearbeiten",
|
||||||
|
"compare_edit_models_description": "Modelle suchen oder ganze Anbietergruppen auswählen.",
|
||||||
|
"compare_edit_variables_description": "Variablen suchen oder ganze Themengruppen auswählen.",
|
||||||
|
"compare_search_models": "Modelle suchen",
|
||||||
|
"compare_search_variables": "Variablen suchen",
|
||||||
|
"compare_only_selected": "Nur ausgewählte",
|
||||||
|
"compare_select_group": "Alle auswählen",
|
||||||
|
"compare_clear_group": "Alle abwählen",
|
||||||
|
"compare_no_matching_options": "Keine passenden Optionen.",
|
||||||
"compare_no_models_selected": "Keine Modelle ausgewählt.",
|
"compare_no_models_selected": "Keine Modelle ausgewählt.",
|
||||||
"compare_remove_model": "{model} entfernen",
|
"compare_remove_model": "{model} entfernen",
|
||||||
"compare_timeline_title": "Zeitleiste des Modellvergleichs",
|
"compare_timeline_title": "Zeitleiste des Modellvergleichs",
|
||||||
|
|||||||
@@ -294,6 +294,16 @@
|
|||||||
"compare_selection_pending": "Selection changed",
|
"compare_selection_pending": "Selection changed",
|
||||||
"compare_apply_selection": "Apply & reload charts",
|
"compare_apply_selection": "Apply & reload charts",
|
||||||
"compare_customize_models": "Customize models",
|
"compare_customize_models": "Customize models",
|
||||||
|
"compare_edit_models": "Edit models",
|
||||||
|
"compare_edit_variables": "Edit variables",
|
||||||
|
"compare_edit_models_description": "Search models or select complete provider families.",
|
||||||
|
"compare_edit_variables_description": "Search variables or select complete thematic groups.",
|
||||||
|
"compare_search_models": "Search models",
|
||||||
|
"compare_search_variables": "Search variables",
|
||||||
|
"compare_only_selected": "Only selected",
|
||||||
|
"compare_select_group": "Select all",
|
||||||
|
"compare_clear_group": "Clear all",
|
||||||
|
"compare_no_matching_options": "No matching options.",
|
||||||
"compare_no_models_selected": "No models selected.",
|
"compare_no_models_selected": "No models selected.",
|
||||||
"compare_remove_model": "Remove {model}",
|
"compare_remove_model": "Remove {model}",
|
||||||
"compare_timeline_title": "Model Comparison Timeline",
|
"compare_timeline_title": "Model Comparison Timeline",
|
||||||
|
|||||||
@@ -294,6 +294,16 @@
|
|||||||
"compare_selection_pending": "Selección modificada",
|
"compare_selection_pending": "Selección modificada",
|
||||||
"compare_apply_selection": "Aplicar y recargar gráficos",
|
"compare_apply_selection": "Aplicar y recargar gráficos",
|
||||||
"compare_customize_models": "Personalizar modelos",
|
"compare_customize_models": "Personalizar modelos",
|
||||||
|
"compare_edit_models": "Editar modelos",
|
||||||
|
"compare_edit_variables": "Editar variables",
|
||||||
|
"compare_edit_models_description": "Busca modelos o selecciona familias completas de proveedores.",
|
||||||
|
"compare_edit_variables_description": "Busca variables o selecciona grupos temáticos completos.",
|
||||||
|
"compare_search_models": "Buscar modelos",
|
||||||
|
"compare_search_variables": "Buscar variables",
|
||||||
|
"compare_only_selected": "Solo seleccionados",
|
||||||
|
"compare_select_group": "Seleccionar todo",
|
||||||
|
"compare_clear_group": "Deseleccionar todo",
|
||||||
|
"compare_no_matching_options": "No hay opciones coincidentes.",
|
||||||
"compare_no_models_selected": "No hay modelos seleccionados.",
|
"compare_no_models_selected": "No hay modelos seleccionados.",
|
||||||
"compare_remove_model": "Quitar {model}",
|
"compare_remove_model": "Quitar {model}",
|
||||||
"compare_timeline_title": "Cronología de comparación de modelos",
|
"compare_timeline_title": "Cronología de comparación de modelos",
|
||||||
|
|||||||
@@ -294,6 +294,16 @@
|
|||||||
"compare_selection_pending": "Sélection modifiée",
|
"compare_selection_pending": "Sélection modifiée",
|
||||||
"compare_apply_selection": "Appliquer et recharger les graphiques",
|
"compare_apply_selection": "Appliquer et recharger les graphiques",
|
||||||
"compare_customize_models": "Personnaliser les modèles",
|
"compare_customize_models": "Personnaliser les modèles",
|
||||||
|
"compare_edit_models": "Modifier les modèles",
|
||||||
|
"compare_edit_variables": "Modifier les variables",
|
||||||
|
"compare_edit_models_description": "Recherchez des modèles ou sélectionnez des fournisseurs entiers.",
|
||||||
|
"compare_edit_variables_description": "Recherchez des variables ou sélectionnez des groupes thématiques entiers.",
|
||||||
|
"compare_search_models": "Rechercher des modèles",
|
||||||
|
"compare_search_variables": "Rechercher des variables",
|
||||||
|
"compare_only_selected": "Sélection uniquement",
|
||||||
|
"compare_select_group": "Tout sélectionner",
|
||||||
|
"compare_clear_group": "Tout désélectionner",
|
||||||
|
"compare_no_matching_options": "Aucune option correspondante.",
|
||||||
"compare_no_models_selected": "Aucun modèle sélectionné.",
|
"compare_no_models_selected": "Aucun modèle sélectionné.",
|
||||||
"compare_remove_model": "Supprimer {model}",
|
"compare_remove_model": "Supprimer {model}",
|
||||||
"compare_timeline_title": "Chronologie de comparaison des modèles",
|
"compare_timeline_title": "Chronologie de comparaison des modèles",
|
||||||
|
|||||||
@@ -294,6 +294,16 @@
|
|||||||
"compare_selection_pending": "Selezione modificata",
|
"compare_selection_pending": "Selezione modificata",
|
||||||
"compare_apply_selection": "Applica e ricarica i grafici",
|
"compare_apply_selection": "Applica e ricarica i grafici",
|
||||||
"compare_customize_models": "Personalizza modelli",
|
"compare_customize_models": "Personalizza modelli",
|
||||||
|
"compare_edit_models": "Modifica modelli",
|
||||||
|
"compare_edit_variables": "Modifica variabili",
|
||||||
|
"compare_edit_models_description": "Cerca modelli o seleziona intere famiglie di fornitori.",
|
||||||
|
"compare_edit_variables_description": "Cerca variabili o seleziona interi gruppi tematici.",
|
||||||
|
"compare_search_models": "Cerca modelli",
|
||||||
|
"compare_search_variables": "Cerca variabili",
|
||||||
|
"compare_only_selected": "Solo selezionati",
|
||||||
|
"compare_select_group": "Seleziona tutto",
|
||||||
|
"compare_clear_group": "Deseleziona tutto",
|
||||||
|
"compare_no_matching_options": "Nessuna opzione corrispondente.",
|
||||||
"compare_no_models_selected": "Nessun modello selezionato.",
|
"compare_no_models_selected": "Nessun modello selezionato.",
|
||||||
"compare_remove_model": "Rimuovi {model}",
|
"compare_remove_model": "Rimuovi {model}",
|
||||||
"compare_timeline_title": "Cronologia del confronto tra modelli",
|
"compare_timeline_title": "Cronologia del confronto tra modelli",
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
type ExportableChart,
|
type ExportableChart,
|
||||||
type ExportLegendItem
|
type ExportLegendItem
|
||||||
} from '$lib/components/charts';
|
} from '$lib/components/charts';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Switch } from '$lib/components/ui/switch';
|
import { Switch } from '$lib/components/ui/switch';
|
||||||
|
|
||||||
@@ -41,6 +40,7 @@
|
|||||||
|
|
||||||
import { useHeroActions } from '../../hero.svelte';
|
import { useHeroActions } from '../../hero.svelte';
|
||||||
import { defaultParameters, hourly, modelGroups, models as modelOptions } from '../../options';
|
import { defaultParameters, hourly, modelGroups, models as modelOptions } from '../../options';
|
||||||
|
import ComparisonSelectionDrawer from './ComparisonSelectionDrawer.svelte';
|
||||||
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
||||||
import {
|
import {
|
||||||
cardinalDirection,
|
cardinalDirection,
|
||||||
@@ -67,6 +67,9 @@
|
|||||||
group.filter((variable) => variable.value !== 'cloud_cover')
|
group.filter((variable) => variable.value !== 'cloud_cover')
|
||||||
);
|
);
|
||||||
const VARIABLE_IDS = new Set(COMPARISON_HOURLY.flat().map((variable) => variable.value));
|
const VARIABLE_IDS = new Set(COMPARISON_HOURLY.flat().map((variable) => variable.value));
|
||||||
|
const VARIABLE_ORDER = new Map(
|
||||||
|
COMPARISON_HOURLY.flat().map((variable, index) => [variable.value, index])
|
||||||
|
);
|
||||||
const SECONDS_PER_HOUR = 3600;
|
const SECONDS_PER_HOUR = 3600;
|
||||||
const FETCH_DEBOUNCE_MS = 300;
|
const FETCH_DEBOUNCE_MS = 300;
|
||||||
const STANDARD_COMPARE_VARIABLES = [
|
const STANDARD_COMPARE_VARIABLES = [
|
||||||
@@ -95,8 +98,7 @@
|
|||||||
let isMobile = $state(false);
|
let isMobile = $state(false);
|
||||||
let comparisonPlotInsetLeft = $derived(isMobile ? 92 : 116);
|
let comparisonPlotInsetLeft = $derived(isMobile ? 92 : 116);
|
||||||
let comparisonPlotInsetRight = $derived(isMobile ? 8 : 20);
|
let comparisonPlotInsetRight = $derived(isMobile ? 8 : 20);
|
||||||
let modelsOpen = $state(false);
|
let selectionDrawerMode = $state<'models' | 'variables' | null>(null);
|
||||||
let customVariablesOpen = $state(false);
|
|
||||||
|
|
||||||
let params = $state({
|
let params = $state({
|
||||||
...defaultParameters,
|
...defaultParameters,
|
||||||
@@ -138,6 +140,14 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function orderVariables(values: string[]): string[] {
|
||||||
|
return [...values].sort(
|
||||||
|
(left, right) =>
|
||||||
|
(VARIABLE_ORDER.get(left) ?? Number.MAX_SAFE_INTEGER) -
|
||||||
|
(VARIABLE_ORDER.get(right) ?? Number.MAX_SAFE_INTEGER)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let variablesDirty = $derived(!hasSameVariables(params.hourly, appliedHourly));
|
let variablesDirty = $derived(!hasSameVariables(params.hourly, appliedHourly));
|
||||||
let modelsDirty = $derived(!hasSameOrder(params.models, appliedModels));
|
let modelsDirty = $derived(!hasSameOrder(params.models, appliedModels));
|
||||||
let displayedVariablesStale = $derived(
|
let displayedVariablesStale = $derived(
|
||||||
@@ -501,7 +511,19 @@
|
|||||||
const nextValues = values.includes(value)
|
const nextValues = values.includes(value)
|
||||||
? values.filter((item) => item !== value)
|
? values.filter((item) => item !== value)
|
||||||
: [...values, value];
|
: [...values, value];
|
||||||
params[list] = list === 'models' ? orderModels(nextValues) : nextValues;
|
params[list] = list === 'models' ? orderModels(nextValues) : orderVariables(nextValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroupSelection(
|
||||||
|
list: 'models' | 'hourly',
|
||||||
|
values: string[],
|
||||||
|
select: boolean
|
||||||
|
): void {
|
||||||
|
const groupValues = new Set(values);
|
||||||
|
const nextValues = select
|
||||||
|
? [...new Set([...params[list], ...values])]
|
||||||
|
: params[list].filter((value) => !groupValues.has(value));
|
||||||
|
params[list] = list === 'models' ? orderModels(nextValues) : orderVariables(nextValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreStandardVariables(): void {
|
function restoreStandardVariables(): void {
|
||||||
@@ -535,10 +557,15 @@
|
|||||||
return model ? [model] : [];
|
return model ? [model] : [];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
let modelSelectionGroups = $derived(
|
let modelDrawerGroups = $derived(
|
||||||
modelGroups.map((group) => ({
|
modelGroups.map((group) => ({
|
||||||
...group,
|
value: group.value,
|
||||||
selectedCount: group.models.filter((model) => params.models.includes(model.value)).length
|
label: group.label,
|
||||||
|
options: group.models.map((model) => ({
|
||||||
|
value: model.value,
|
||||||
|
label: model.label,
|
||||||
|
metadata: [model.resolution, model.update].filter(Boolean).join(' · ')
|
||||||
|
}))
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
let selectedVariables = $derived(
|
let selectedVariables = $derived(
|
||||||
@@ -551,11 +578,14 @@
|
|||||||
params.hourly.length === STANDARD_COMPARE_VARIABLES.length &&
|
params.hourly.length === STANDARD_COMPARE_VARIABLES.length &&
|
||||||
STANDARD_COMPARE_VARIABLES.every((variable) => params.hourly.includes(variable))
|
STANDARD_COMPARE_VARIABLES.every((variable) => params.hourly.includes(variable))
|
||||||
);
|
);
|
||||||
let variableSelectionGroups = $derived(
|
let variableDrawerGroups = $derived(
|
||||||
COMPARISON_HOURLY.map((variables, index) => ({
|
COMPARISON_HOURLY.map((variables, index) => ({
|
||||||
label: variableGroupLabels[index],
|
value: `variables_${index}`,
|
||||||
variables,
|
label: variableGroupLabels[index](),
|
||||||
selectedCount: variables.filter((variable) => params.hourly.includes(variable.value)).length
|
options: variables.map((variable) => ({
|
||||||
|
value: variable.value,
|
||||||
|
label: variableLabel(variable.value)
|
||||||
|
}))
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
@@ -812,7 +842,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Compact ordered selection summaries with expandable model/variable pickers. -->
|
<!-- 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="models">
|
||||||
<div class="flex items-baseline justify-between gap-3">
|
<div class="flex items-baseline justify-between gap-3">
|
||||||
<h2 id="models" class="text-2xl md:text-3xl">{m.compare_models_heading()}</h2>
|
<h2 id="models" class="text-2xl md:text-3xl">{m.compare_models_heading()}</h2>
|
||||||
@@ -870,11 +900,10 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
|
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
|
||||||
onclick={() => (modelsOpen = !modelsOpen)}
|
onclick={() => (selectionDrawerMode = 'models')}
|
||||||
aria-expanded={modelsOpen}
|
aria-haspopup="dialog"
|
||||||
aria-controls="model-options"
|
|
||||||
>
|
>
|
||||||
{modelsOpen ? m.action_close() : m.compare_customize_models()}
|
{m.compare_customize_models()}
|
||||||
</button>
|
</button>
|
||||||
{#if modelsDirty}
|
{#if modelsDirty}
|
||||||
<button
|
<button
|
||||||
@@ -887,47 +916,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if modelsOpen}
|
|
||||||
<div id="model-options" class="mt-3 border-t border-border/60 pt-3">
|
|
||||||
<div class="grid items-start gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{#each modelSelectionGroups as group (group.value)}
|
|
||||||
<details class="rounded-lg border border-border/60 bg-background/40">
|
|
||||||
<summary
|
|
||||||
class="flex min-h-10 cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-sm font-bold marker:content-none"
|
|
||||||
>
|
|
||||||
<span>{group.label}</span>
|
|
||||||
<span class="flex items-center gap-2 text-xs font-normal text-muted-foreground">
|
|
||||||
<span>{group.selectedCount}/{group.models.length}</span>
|
|
||||||
<span aria-hidden="true">⌄</span>
|
|
||||||
</span>
|
|
||||||
</summary>
|
|
||||||
<div class="border-t border-border/50 p-2">
|
|
||||||
{#each group.models as model (model.value)}
|
|
||||||
<div class="flex min-h-9 items-center rounded-md px-1 hover:bg-muted/60">
|
|
||||||
<Checkbox
|
|
||||||
id="{model.value}_model"
|
|
||||||
value={model.value}
|
|
||||||
checked={params.models.includes(model.value)}
|
|
||||||
onCheckedChange={() => toggleSelection('models', model.value)}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
for="{model.value}_model"
|
|
||||||
class="min-w-0 flex-1 cursor-pointer py-1.5 pl-2 text-xs"
|
|
||||||
>
|
|
||||||
<span class="font-medium">{model.label}</span>
|
|
||||||
<span class="text-muted-foreground">
|
|
||||||
· {model.resolution} · {model.update}
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -999,11 +987,10 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
|
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
|
||||||
onclick={() => (customVariablesOpen = !customVariablesOpen)}
|
onclick={() => (selectionDrawerMode = 'variables')}
|
||||||
aria-expanded={customVariablesOpen}
|
aria-haspopup="dialog"
|
||||||
aria-controls="variable-options"
|
|
||||||
>
|
>
|
||||||
{customVariablesOpen ? m.action_close() : m.compare_customize_variables()}
|
{m.compare_customize_variables()}
|
||||||
</button>
|
</button>
|
||||||
{#if variablesDirty}
|
{#if variablesDirty}
|
||||||
<button
|
<button
|
||||||
@@ -1016,42 +1003,32 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if customVariablesOpen}
|
|
||||||
<div id="variable-options" class="border-t border-border/60 p-3 sm:p-4">
|
|
||||||
<div class="grid items-start gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{#each variableSelectionGroups as variableGroup (variableGroup.label())}
|
|
||||||
<details class="rounded-lg border border-border/60 bg-background/40">
|
|
||||||
<summary
|
|
||||||
class="flex min-h-10 cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-sm font-bold marker:content-none"
|
|
||||||
>
|
|
||||||
<span>{variableGroup.label()}</span>
|
|
||||||
<span class="flex items-center gap-2 text-xs font-normal text-muted-foreground">
|
|
||||||
<span>{variableGroup.selectedCount}/{variableGroup.variables.length}</span>
|
|
||||||
<span aria-hidden="true">⌄</span>
|
|
||||||
</span>
|
|
||||||
</summary>
|
|
||||||
<div class="border-t border-border/50 p-2">
|
|
||||||
{#each variableGroup.variables as variable (variable.value)}
|
|
||||||
<div class="flex min-h-10 items-center rounded-md px-1 hover:bg-muted/60">
|
|
||||||
<Checkbox
|
|
||||||
id="{variable.value}_hourly"
|
|
||||||
checked={params.hourly.includes(variable.value)}
|
|
||||||
onCheckedChange={() => toggleSelection('hourly', variable.value)}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
for="{variable.value}_hourly"
|
|
||||||
class="min-w-0 flex-1 cursor-pointer py-2 pl-2 text-sm"
|
|
||||||
>
|
|
||||||
{variableLabel(variable.value)}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{#if selectionDrawerMode === 'models'}
|
||||||
|
<ComparisonSelectionDrawer
|
||||||
|
open
|
||||||
|
mode="models"
|
||||||
|
groups={modelDrawerGroups}
|
||||||
|
selected={params.models}
|
||||||
|
dirty={comparisonSelectionDirty}
|
||||||
|
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}
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<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>
|
||||||
Reference in New Issue
Block a user