Initial commit

This commit is contained in:
Jarek Krochmalski
2025-12-28 21:16:03 +01:00
commit 62e3c6439e
552 changed files with 104858 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
import TogglePill from './toggle-pill.svelte';
import ToggleSwitch from './toggle-switch.svelte';
import ToggleGroup from './toggle-group.svelte';
export { TogglePill, ToggleSwitch, ToggleGroup };

View File

@@ -0,0 +1,93 @@
<script lang="ts">
/**
* ToggleGroup - A pill-style toggle for switching between multiple values (2+).
* Extends ToggleSwitch to support any number of options.
* Perfect for settings like scanner selection: "None / Trivy / Grype / Both"
*
* Keyboard navigation:
* - Tab: Focus the selected option (or first if none selected)
* - Arrow Left/Right: Navigate between options
* - Space/Enter: Select the focused option
*/
import type { Component } from 'svelte';
interface Option {
value: string;
label?: string;
icon?: Component;
}
interface Props {
value: string;
options: Option[];
disabled?: boolean;
onchange?: (value: string) => void;
}
let {
value = $bindable(),
options,
disabled = false,
onchange
}: Props = $props();
let buttons = $state<HTMLButtonElement[]>([]);
function select(optionValue: string) {
if (disabled || value === optionValue) return;
value = optionValue;
onchange?.(value);
}
function handleKeydown(e: KeyboardEvent, index: number) {
if (disabled) return;
let nextIndex = index;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
nextIndex = (index + 1) % options.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
nextIndex = (index - 1 + options.length) % options.length;
} else if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
select(options[index].value);
return;
} else {
return;
}
// Move focus and select the new option
buttons[nextIndex]?.focus();
select(options[nextIndex].value);
}
</script>
<div
class="inline-flex items-center rounded-md bg-muted p-0.5 text-xs font-medium {disabled ? 'opacity-50 cursor-not-allowed' : ''}"
role="radiogroup"
>
{#each options as option, i}
{@const isSelected = value === option.value}
{@const displayLabel = option.label ?? option.value}
<button
bind:this={buttons[i]}
type="button"
role="radio"
aria-checked={isSelected}
tabindex={isSelected ? 0 : -1}
class="px-2 py-1 rounded transition-colors flex items-center gap-1.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 {isSelected
? 'bg-slate-300 text-slate-900 shadow-sm dark:bg-amber-900/50 dark:text-amber-100'
: 'text-muted-foreground hover:text-foreground'} {disabled ? 'cursor-not-allowed' : ''}"
onclick={() => select(option.value)}
onkeydown={(e) => handleKeydown(e, i)}
{disabled}
>
{#if option.icon}
<svelte:component this={option.icon} class="w-3 h-3" />
{/if}
{displayLabel}
</button>
{/each}
</div>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { Check, CircleOff } from 'lucide-svelte';
interface Props {
checked: boolean;
disabled?: boolean;
onLabel?: string;
offLabel?: string;
onchange?: (checked: boolean) => void;
}
let { checked = $bindable(), disabled = false, onLabel = 'ON', offLabel = 'OFF', onchange }: Props = $props();
function toggle() {
if (disabled) return;
const newValue = !checked;
checked = newValue;
onchange?.(newValue);
}
</script>
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 {checked ? 'bg-green-500/15 text-green-600 hover:bg-green-500/25' : 'bg-muted text-muted-foreground hover:bg-muted/80'} {disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
onclick={toggle}
{disabled}
>
{#if checked}
<Check class="w-3 h-3" />
{onLabel}
{:else}
<CircleOff class="w-3 h-3" />
{offLabel}
{/if}
</button>

View File

@@ -0,0 +1,67 @@
<script lang="ts">
/**
* ToggleSwitch - A pill-style toggle for switching between two custom values.
* Unlike TogglePill which shows On/Off with check icons, this shows both options
* side by side with one highlighted, perfect for settings like "12h / 24h".
*/
interface Props {
value: string;
leftValue: string;
rightValue: string;
leftLabel?: string;
rightLabel?: string;
disabled?: boolean;
onchange?: (value: string) => void;
}
let {
value = $bindable(),
leftValue,
rightValue,
leftLabel,
rightLabel,
disabled = false,
onchange
}: Props = $props();
// Use labels if provided, otherwise use values
const displayLeft = $derived(leftLabel ?? leftValue);
const displayRight = $derived(rightLabel ?? rightValue);
function selectLeft() {
if (disabled || value === leftValue) return;
value = leftValue;
onchange?.(value);
}
function selectRight() {
if (disabled || value === rightValue) return;
value = rightValue;
onchange?.(value);
}
</script>
<div
class="inline-flex items-center rounded-md bg-muted p-0.5 text-xs font-medium {disabled ? 'opacity-50 cursor-not-allowed' : ''}"
>
<button
type="button"
class="px-2 py-1 rounded transition-colors {value === leftValue
? 'bg-slate-300 text-slate-900 shadow-sm dark:bg-amber-900/50 dark:text-amber-100'
: 'text-muted-foreground hover:text-foreground'} {disabled ? 'cursor-not-allowed' : ''}"
onclick={selectLeft}
{disabled}
>
{displayLeft}
</button>
<button
type="button"
class="px-2 py-1 rounded transition-colors {value === rightValue
? 'bg-slate-300 text-slate-900 shadow-sm dark:bg-amber-900/50 dark:text-amber-100'
: 'text-muted-foreground hover:text-foreground'} {disabled ? 'cursor-not-allowed' : ''}"
onclick={selectRight}
{disabled}
>
{displayRight}
</button>
</div>