diff --git a/src/lib/components/ui/DetailItem.svelte b/src/lib/components/ui/DetailItem.svelte index 7566e5fb..2876f8f8 100644 --- a/src/lib/components/ui/DetailItem.svelte +++ b/src/lib/components/ui/DetailItem.svelte @@ -14,15 +14,20 @@ let { label, + sublabel, value = $bindable(), children, editable = false, type = 'text', options, placeholder, - element + element, + onchange, + width }: { label: string + /** Secondary label displayed below the main label */ + sublabel?: string value?: string | number | boolean | null | undefined children?: Snippet editable?: boolean @@ -30,26 +35,47 @@ options?: SelectOption[] placeholder?: string element?: 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light' + /** Callback for checkbox type when value changes */ + onchange?: (checked: boolean) => void + /** Custom width for the input field (e.g., '320px') */ + width?: string } = $props() - // For checkbox type, convert value to boolean - let checkboxValue = $state(type === 'checkbox' ? Boolean(value) : false) + // For checkbox type, derive the checked state from value + // This ensures external changes to value are reflected in the checkbox + const checkboxValue = $derived(type === 'checkbox' ? Boolean(value) : false) - $effect(() => { - if (type === 'checkbox') { - value = checkboxValue as any - } - }) + // Handle checkbox change and call onchange if provided + function handleCheckboxChange(checked: boolean) { + value = checked as any + onchange?.(checked) + } -
- {label} +
+
+ {label} + {#if sublabel} + {sublabel} + {/if} +
{#if editable} -
+
{#if type === 'select' && options} - {:else if type === 'checkbox'} - + {:else if type === 'number'} {:else} - + {/if}
{:else if children} -
+
{@render children()}
{:else} @@ -89,24 +115,37 @@ font-size: typography.$font-regular; min-height: calc(spacing.$unit * 5); - &:hover:not(.editable) { + &:hover:not(.editable):not(.hasChildren) { background: colors.$grey-80; } - &.editable:hover, - &.editable:focus-within { + &.editable:focus-within, + &.hasChildren:focus-within { background: var(--input-bg-hover); } - &.editable { + &.editable, + &.hasChildren { background: var(--input-bg); } + .label-container { + display: flex; + flex-direction: column; + flex-shrink: 0; + margin-right: spacing.$unit-2x; + gap: spacing.$unit-fourth; + } + .label { font-weight: typography.$medium; color: colors.$grey-50; - flex-shrink: 0; - margin-right: spacing.$unit-2x; + } + + .sublabel { + font-size: typography.$font-small; + color: colors.$grey-60; + font-weight: typography.$normal; } .value { @@ -123,13 +162,12 @@ :global(.input), :global(.select) { - min-width: 180px; + width: var(--custom-width, 240px); } :global(.input.number) { - min-width: 120px; + width: var(--custom-width, 120px); } } - } diff --git a/src/lib/components/ui/Input.svelte b/src/lib/components/ui/Input.svelte index 37d55703..18617c3e 100644 --- a/src/lib/components/ui/Input.svelte +++ b/src/lib/components/ui/Input.svelte @@ -18,6 +18,10 @@ alignRight?: boolean accessory?: boolean no1password?: boolean + validationState?: 'idle' | 'validating' | 'valid' | 'invalid' + handleBlur?: () => void + handleFocus?: () => void + handleInput?: () => void } let { @@ -34,7 +38,7 @@ fullHeight = false, alignRight = false, accessory = false, - value = $bindable(''), + value = $bindable(), type = 'text', placeholder, disabled = false, @@ -42,12 +46,27 @@ required = false, class: className = '', no1password = false, + validationState = 'idle', + handleBlur, + handleFocus, + handleInput, ...restProps }: Props = $props() + // Determine the validation icon to show + const validationIcon = $derived( + validationState === 'validating' + ? 'loader-2' + : validationState === 'valid' + ? 'check' + : validationState === 'invalid' + ? 'close' + : null + ) + const showCounter = $derived(counter !== undefined || maxLength !== undefined) - const currentCount = $derived(String(value).length) - const hasWrapper = $derived(accessory || leftIcon || rightIcon || showCounter) + const currentCount = $derived(String(value ?? '').length) + const hasWrapper = $derived(accessory || leftIcon || rightIcon || showCounter || validationIcon) const fieldsetClasses = $derived( ['fieldset', hidden && 'hidden', fullWidth && 'full', className].filter(Boolean).join(' ') @@ -68,6 +87,12 @@ .filter(Boolean) .join(' ') ) + + // Debug: log what's in restProps + $effect(() => { + console.log('[Input] restProps keys:', Object.keys(restProps)) + console.log('[Input] hasWrapper:', hasWrapper, 'validationIcon:', validationIcon) + }) {#if label || error} @@ -98,6 +123,9 @@ {required} maxlength={maxLength} data-1p-ignore={no1password} + onblur={handleBlur} + onfocus={handleFocus} + oninput={handleInput} {...restProps} /> @@ -107,6 +135,12 @@ {/if} + {#if validationIcon} + + + + {/if} + {#if showCounter} {currentCount}{maxLength ? `/${maxLength}` : ''} @@ -124,6 +158,9 @@ {required} maxlength={maxLength} data-1p-ignore={no1password} + onblur={handleBlur} + onfocus={handleFocus} + oninput={handleInput} {...restProps} /> {/if} @@ -149,6 +186,9 @@ {required} maxlength={maxLength} data-1p-ignore={no1password} + onblur={handleBlur} + onfocus={handleFocus} + oninput={handleInput} {...restProps} /> @@ -158,6 +198,12 @@ {/if} + {#if validationIcon} + + + + {/if} + {#if showCounter} {currentCount}{maxLength ? `/${maxLength}` : ''} @@ -175,6 +221,9 @@ {required} maxlength={maxLength} data-1p-ignore={no1password} + onblur={handleBlur} + onfocus={handleFocus} + oninput={handleInput} {...restProps} /> {/if} @@ -312,6 +361,34 @@ right: $unit-2x; } + .validationIcon { + position: absolute; + right: $unit-2x; + display: flex; + align-items: center; + pointer-events: none; + + :global(svg) { + fill: currentColor; + } + + &.valid { + color: $wind-text-20; + } + + &.invalid { + color: $error; + } + + &.validating { + color: var(--text-tertiary); + + :global(svg) { + animation: spin 1s linear infinite; + } + } + } + &:has(.iconLeft) input { padding-left: $unit-5x; } @@ -320,6 +397,10 @@ padding-right: $unit-5x; } + &:has(.validationIcon) input { + padding-right: $unit-5x; + } + &:has(.counter) input { padding-right: $unit-8x; } @@ -479,4 +560,13 @@ color: var(--text-tertiary); opacity: 1; } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } diff --git a/src/lib/components/ui/ValidatedInput.svelte b/src/lib/components/ui/ValidatedInput.svelte new file mode 100644 index 00000000..33d974f0 --- /dev/null +++ b/src/lib/components/ui/ValidatedInput.svelte @@ -0,0 +1,146 @@ + + + + +
+ + {#if tooltipOpen && validationMessage} +
+ {validationMessage} +
+ {/if} +
+ + diff --git a/src/lib/components/ui/checkbox/Checkbox.svelte b/src/lib/components/ui/checkbox/Checkbox.svelte index 4d0e984a..197e1080 100644 --- a/src/lib/components/ui/checkbox/Checkbox.svelte +++ b/src/lib/components/ui/checkbox/Checkbox.svelte @@ -40,12 +40,6 @@ element }: Props = $props() - $effect(() => { - if (onCheckedChange && checked !== undefined) { - onCheckedChange(checked) - } - }) - const sizeClass = $derived(size) // contained prop is an alias for variant='bound' const variantClass = $derived(variant === 'bound' || contained ? 'bound' : '') @@ -57,6 +51,7 @@ {indeterminate} {disabled} {required} + {onCheckedChange} name={name ?? ''} value={value ?? ''} class="checkbox {sizeClass} {variantClass} {fullWidthClass} {element || ''} {className || ''}"