Files
windmill/frontend/src/lib/components/Toggle.svelte
T
GuilhemandClaude Opus 5 d02ff9ac24 fix: centre the toggle knob inside its track (#11314)
* fix: centre the toggle knob inside its track

The knob was positioned against the toggle's wrapper rather than the track,
so its 2px inset resolved to a 1px gap inside the track's 1px border. On a 1x
screen a toggle that lands on a fractional x blurs its edges across a whole
pixel, which swallows that 1px gap and makes the knob look like it overflows
the track.

Position the knob inside the track's border with a 2px gap on every side,
size the toggle in whole px so the 18px root font of large screens cannot
make the track fractional, and give each size an explicit checked translate
now that the knob is no longer exactly one translate-x-full wide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: hoist the toggle knob's shared inset out of the size branches

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(frontend): add a Toggles tab to the kitchen sink

Covers the four sizes, the three colors, the label and EE-badge variants, and
a grid of quarter-pixel offsets: the knob's gap against the track border only
misreads once the track lands between device pixels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 15:12:09 +02:00

180 lines
5.3 KiB
Svelte

<script lang="ts">
import { createBubbler, stopPropagation } from 'svelte/legacy'
const bubble = createBubbler()
import { classNames } from '$lib/utils'
import { createEventDispatcher, untrack } from 'svelte'
import { twMerge } from 'tailwind-merge'
import Tooltip from './Tooltip.svelte'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import { inputBorderClass } from './text_input/TextInput.svelte'
import EEOnly from './EEOnly.svelte'
interface Props {
options?: {
left?: string
leftTooltip?: string
right?: string
rightTooltip?: string
rightDocumentationLink?: string
title?: string
}
checked?: boolean
disabled?: boolean
textClass?: string
textStyle?: string
color?: 'blue' | 'red' | 'nord'
id?: any
lightMode?: boolean
eeOnly?: boolean
aiId?: string | undefined
aiDescription?: string | undefined
class?: string | undefined
size?: '2xs' | 'xs' | 'sm' | 'md'
textDisabled?: boolean
right?: import('svelte').Snippet
}
let {
options = {},
checked = $bindable(),
disabled = false,
textClass = '',
textStyle = '',
color = 'blue',
id = (Math.random() + 1).toString(36).substring(10),
lightMode = false,
eeOnly = false,
aiId = undefined,
aiDescription = undefined,
class: className = undefined,
size = 'sm',
textDisabled = false,
right
}: Props = $props()
const dispatch = createEventDispatcher<{ change: boolean }>()
const bothOptions = Boolean(untrack(() => options).left) && Boolean(untrack(() => options).right)
// Same badge the labelled containers (Label, Section, Subsection) show. Gated on
// `disabled` rather than on the license: a caller can leave an EE-only toggle
// interactive on CE precisely because it is already on, and "EE only" beside a control
// that plainly works reads as a lie.
const showEeBadge = $derived(eeOnly && disabled)
</script>
{#snippet control()}
<label
for={id}
class="{className || ''} z-auto flex flex-row items-center duration-50 {disabled
? 'grayscale opacity-50 cursor-not-allowed'
: 'cursor-pointer'}"
title={options?.title}
>
{#if Boolean(options?.left)}
<span
class={twMerge(
'mr-2 font-normal duration-50 select-none',
bothOptions || textDisabled
? checked
? 'text-disabled'
: 'text-primary'
: 'text-primary',
size === '2xs' ? 'text-2xs' : 'text-xs',
textClass
)}
style={textStyle}
>
{options?.left}
{#if options?.leftTooltip}
<Tooltip light={lightMode}>{options?.leftTooltip}</Tooltip>
{/if}
</span>
{/if}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- Keeps the sr-only checkbox's absolute box on the toggle; it does not size the knob,
which resolves against the track. -->
<div
class="relative"
onclick={stopPropagation(bubble('click'))}
use:triggerableByAI={{
id: aiId,
description: aiDescription,
callback: () => {
checked = !checked
}
}}
>
<input
onfocus={bubble('focus')}
onclick={bubble('click')}
{disabled}
type="checkbox"
{id}
class="sr-only peer"
bind:checked
onchange={stopPropagation((e) => {
dispatch('change', !!checked)
})}
/>
<!-- Knob offsets resolve against the track's padding box, so the track stays
relative. Whole px and 2px of inset: at 1x a track on a fractional x blurs its
edge over a whole pixel, which swallows a thinner gap.
knob = h - 6, checked translate = w - h. -->
<div
class={classNames(
"relative transition-all bg-surface-sunken rounded-full peer peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-surface after:border-white after:border after:rounded-full after:transition-all items-center",
color == 'red'
? 'peer-checked:bg-red-600'
: color == 'blue'
? 'peer-checked:bg-blue-400 '
: 'peer-checked:bg-nord-950 dark:peer-checked:bg-nord-900',
size === 'md'
? 'w-[44px] h-[24px] after:h-[18px] after:w-[18px] peer-checked:after:translate-x-[20px]'
: size === 'sm'
? 'w-[36px] h-[20px] after:h-[14px] after:w-[14px] peer-checked:after:translate-x-[16px]'
: size === '2xs'
? 'w-[20px] h-[12px] after:h-[6px] after:w-[6px] peer-checked:after:translate-x-[8px]'
: 'w-[28px] h-[16px] after:h-[10px] after:w-[10px] peer-checked:after:translate-x-[12px]',
inputBorderClass()
)}
></div>
</div>
{#if Boolean(options?.right)}
<span
class={twMerge(
'ml-2 font-normal duration-50 select-none',
bothOptions || textDisabled
? checked
? 'text-primary'
: 'text-disabled'
: 'text-primary',
size === '2xs' ? 'text-2xs' : 'text-xs',
textClass
)}
style={textStyle}
>
{options?.right}
{#if options?.rightTooltip}
<Tooltip documentationLink={options.rightDocumentationLink}>
{options.rightTooltip}
</Tooltip>
{/if}
</span>
{/if}
{@render right?.()}
</label>
{/snippet}
{#if showEeBadge}
<!-- The badge sits outside the label: a disabled one is greyed out and half
transparent, which would swallow it. -->
<div class="flex flex-row items-center gap-2">
{@render control()}
<EEOnly />
</div>
{:else}
{@render control()}
{/if}