mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-04 13:19:57 +00:00
Initial commit
This commit is contained in:
5
lib/components/ui/toggle-pill/index.ts
Normal file
5
lib/components/ui/toggle-pill/index.ts
Normal 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 };
|
||||
93
lib/components/ui/toggle-pill/toggle-group.svelte
Normal file
93
lib/components/ui/toggle-pill/toggle-group.svelte
Normal 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>
|
||||
35
lib/components/ui/toggle-pill/toggle-pill.svelte
Normal file
35
lib/components/ui/toggle-pill/toggle-pill.svelte
Normal 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>
|
||||
67
lib/components/ui/toggle-pill/toggle-switch.svelte
Normal file
67
lib/components/ui/toggle-pill/toggle-switch.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user