DetailItem: add sublabel, width, onchange props; Input: add validation state

This commit is contained in:
Justin Edmund 2025-12-01 02:25:41 -08:00
parent e9ba90d656
commit 28ad2fb37e
4 changed files with 302 additions and 33 deletions

View file

@ -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)
}
</script>
<div class="detail-item" class:editable>
<span class="label">{label}</span>
<div class="detail-item" class:editable class:hasChildren={!!children}>
<div class="label-container">
<span class="label">{label}</span>
{#if sublabel}
<span class="sublabel">{sublabel}</span>
{/if}
</div>
{#if editable}
<div class="edit-value">
<div class="edit-value" style:--custom-width={width}>
{#if type === 'select' && options}
<Select bind:value={value as string | number | undefined} {options} {placeholder} size="medium" contained />
<Select
bind:value={value as string | number | undefined}
{options}
{placeholder}
size="medium"
contained
/>
{:else if type === 'checkbox'}
<Checkbox bind:checked={checkboxValue} contained {element} />
<Checkbox
checked={checkboxValue}
onCheckedChange={handleCheckboxChange}
contained
{element}
/>
{:else if type === 'number'}
<Input
bind:value
@ -60,11 +86,11 @@
alignRight={true}
/>
{:else}
<Input bind:value type="text" contained={true} {placeholder} alignRight={true} />
<Input bind:value type="text" contained={true} {placeholder} alignRight={false} />
{/if}
</div>
{:else if children}
<div class="value">
<div class="value" class:edit-value={editable}>
{@render children()}
</div>
{: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);
}
}
}
</style>

View file

@ -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)
})
</script>
{#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 @@
</span>
{/if}
{#if validationIcon}
<span class="validationIcon {validationState}">
<Icon name={validationIcon} size={16} />
</span>
{/if}
{#if showCounter}
<span class="counter">
{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 @@
</span>
{/if}
{#if validationIcon}
<span class="validationIcon {validationState}">
<Icon name={validationIcon} size={16} />
</span>
{/if}
{#if showCounter}
<span class="counter">
{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);
}
}
</style>

View file

@ -0,0 +1,146 @@
<svelte:options runes={true} />
<script lang="ts">
import { Tooltip } from 'bits-ui'
import Input from './Input.svelte'
interface Props {
value: string
placeholder?: string
onValidate: (value: string) => Promise<{ valid: boolean; message: string }>
minLength?: number
contained?: boolean
alignRight?: boolean
}
let {
value = $bindable(''),
placeholder,
onValidate,
minLength = 0,
contained = false,
alignRight = false
}: Props = $props()
let validationState = $state<'idle' | 'validating' | 'valid' | 'invalid'>('idle')
let validationMessage = $state('')
let tooltipOpen = $state(false)
let tooltipTimeout: ReturnType<typeof setTimeout> | null = null
async function handleBlur() {
console.log('[ValidatedInput] handleBlur called, value:', value, 'minLength:', minLength)
// Only validate if value meets minimum length
if (value.length < minLength) {
console.log('[ValidatedInput] Value too short, skipping validation')
validationState = 'idle'
validationMessage = ''
return
}
console.log('[ValidatedInput] Starting validation...')
validationState = 'validating'
hideTooltip()
try {
console.log('[ValidatedInput] Calling onValidate...')
const result = await onValidate(value)
console.log('[ValidatedInput] Validation result:', result)
validationState = result.valid ? 'valid' : 'invalid'
validationMessage = result.message
console.log('[ValidatedInput] State updated:', { validationState, validationMessage })
showTooltip()
} catch (error) {
console.error('[ValidatedInput] Validation error:', error)
validationState = 'invalid'
validationMessage = 'Validation failed'
showTooltip()
}
}
function handleFocus() {
// Hide tooltip when focusing back into input
hideTooltip()
}
function handleInput() {
// Reset validation state when user types
if (validationState !== 'idle') {
validationState = 'idle'
validationMessage = ''
hideTooltip()
}
}
function showTooltip() {
tooltipOpen = true
// Auto-dismiss after 3 seconds
if (tooltipTimeout) clearTimeout(tooltipTimeout)
tooltipTimeout = setTimeout(() => {
tooltipOpen = false
}, 3000)
}
function hideTooltip() {
tooltipOpen = false
if (tooltipTimeout) {
clearTimeout(tooltipTimeout)
tooltipTimeout = null
}
}
// Cleanup on unmount
$effect(() => {
return () => {
if (tooltipTimeout) clearTimeout(tooltipTimeout)
}
})
</script>
<div class="validated-input">
<Input
bind:value
type="text"
{placeholder}
{contained}
{alignRight}
{validationState}
{handleBlur}
{handleFocus}
{handleInput}
/>
{#if tooltipOpen && validationMessage}
<div class="validation-message {validationState}">
{validationMessage}
</div>
{/if}
</div>
<style lang="scss">
@use '$src/themes/colors' as *;
@use '$src/themes/spacing' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/layout' as *;
.validated-input {
width: 240px;
}
.validation-message {
margin-top: $unit-half;
padding: $unit-half $unit;
border-radius: $item-corner-small;
font-size: $font-small;
font-weight: $medium;
&.valid {
background: $wind-bg-20;
color: $wind-text-20;
}
&.invalid {
background: $error--bg--light;
color: $error;
}
}
</style>

View file

@ -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 || ''}"