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 { let {
label, label,
sublabel,
value = $bindable(), value = $bindable(),
children, children,
editable = false, editable = false,
type = 'text', type = 'text',
options, options,
placeholder, placeholder,
element element,
onchange,
width
}: { }: {
label: string label: string
/** Secondary label displayed below the main label */
sublabel?: string
value?: string | number | boolean | null | undefined value?: string | number | boolean | null | undefined
children?: Snippet children?: Snippet
editable?: boolean editable?: boolean
@ -30,26 +35,47 @@
options?: SelectOption[] options?: SelectOption[]
placeholder?: string placeholder?: string
element?: 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light' 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() } = $props()
// For checkbox type, convert value to boolean // For checkbox type, derive the checked state from value
let checkboxValue = $state(type === 'checkbox' ? Boolean(value) : false) // This ensures external changes to value are reflected in the checkbox
const checkboxValue = $derived(type === 'checkbox' ? Boolean(value) : false)
$effect(() => { // Handle checkbox change and call onchange if provided
if (type === 'checkbox') { function handleCheckboxChange(checked: boolean) {
value = checkboxValue as any value = checked as any
} onchange?.(checked)
}) }
</script> </script>
<div class="detail-item" class:editable> <div class="detail-item" class:editable class:hasChildren={!!children}>
<span class="label">{label}</span> <div class="label-container">
<span class="label">{label}</span>
{#if sublabel}
<span class="sublabel">{sublabel}</span>
{/if}
</div>
{#if editable} {#if editable}
<div class="edit-value"> <div class="edit-value" style:--custom-width={width}>
{#if type === 'select' && options} {#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'} {:else if type === 'checkbox'}
<Checkbox bind:checked={checkboxValue} contained {element} /> <Checkbox
checked={checkboxValue}
onCheckedChange={handleCheckboxChange}
contained
{element}
/>
{:else if type === 'number'} {:else if type === 'number'}
<Input <Input
bind:value bind:value
@ -60,11 +86,11 @@
alignRight={true} alignRight={true}
/> />
{:else} {:else}
<Input bind:value type="text" contained={true} {placeholder} alignRight={true} /> <Input bind:value type="text" contained={true} {placeholder} alignRight={false} />
{/if} {/if}
</div> </div>
{:else if children} {:else if children}
<div class="value"> <div class="value" class:edit-value={editable}>
{@render children()} {@render children()}
</div> </div>
{:else} {:else}
@ -89,24 +115,37 @@
font-size: typography.$font-regular; font-size: typography.$font-regular;
min-height: calc(spacing.$unit * 5); min-height: calc(spacing.$unit * 5);
&:hover:not(.editable) { &:hover:not(.editable):not(.hasChildren) {
background: colors.$grey-80; background: colors.$grey-80;
} }
&.editable:hover, &.editable:focus-within,
&.editable:focus-within { &.hasChildren:focus-within {
background: var(--input-bg-hover); background: var(--input-bg-hover);
} }
&.editable { &.editable,
&.hasChildren {
background: var(--input-bg); background: var(--input-bg);
} }
.label-container {
display: flex;
flex-direction: column;
flex-shrink: 0;
margin-right: spacing.$unit-2x;
gap: spacing.$unit-fourth;
}
.label { .label {
font-weight: typography.$medium; font-weight: typography.$medium;
color: colors.$grey-50; 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 { .value {
@ -123,13 +162,12 @@
:global(.input), :global(.input),
:global(.select) { :global(.select) {
min-width: 180px; width: var(--custom-width, 240px);
} }
:global(.input.number) { :global(.input.number) {
min-width: 120px; width: var(--custom-width, 120px);
} }
} }
} }
</style> </style>

View file

@ -18,6 +18,10 @@
alignRight?: boolean alignRight?: boolean
accessory?: boolean accessory?: boolean
no1password?: boolean no1password?: boolean
validationState?: 'idle' | 'validating' | 'valid' | 'invalid'
handleBlur?: () => void
handleFocus?: () => void
handleInput?: () => void
} }
let { let {
@ -34,7 +38,7 @@
fullHeight = false, fullHeight = false,
alignRight = false, alignRight = false,
accessory = false, accessory = false,
value = $bindable(''), value = $bindable(),
type = 'text', type = 'text',
placeholder, placeholder,
disabled = false, disabled = false,
@ -42,12 +46,27 @@
required = false, required = false,
class: className = '', class: className = '',
no1password = false, no1password = false,
validationState = 'idle',
handleBlur,
handleFocus,
handleInput,
...restProps ...restProps
}: Props = $props() }: 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 showCounter = $derived(counter !== undefined || maxLength !== undefined)
const currentCount = $derived(String(value).length) const currentCount = $derived(String(value ?? '').length)
const hasWrapper = $derived(accessory || leftIcon || rightIcon || showCounter) const hasWrapper = $derived(accessory || leftIcon || rightIcon || showCounter || validationIcon)
const fieldsetClasses = $derived( const fieldsetClasses = $derived(
['fieldset', hidden && 'hidden', fullWidth && 'full', className].filter(Boolean).join(' ') ['fieldset', hidden && 'hidden', fullWidth && 'full', className].filter(Boolean).join(' ')
@ -68,6 +87,12 @@
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
) )
// Debug: log what's in restProps
$effect(() => {
console.log('[Input] restProps keys:', Object.keys(restProps))
console.log('[Input] hasWrapper:', hasWrapper, 'validationIcon:', validationIcon)
})
</script> </script>
{#if label || error} {#if label || error}
@ -98,6 +123,9 @@
{required} {required}
maxlength={maxLength} maxlength={maxLength}
data-1p-ignore={no1password} data-1p-ignore={no1password}
onblur={handleBlur}
onfocus={handleFocus}
oninput={handleInput}
{...restProps} {...restProps}
/> />
@ -107,6 +135,12 @@
</span> </span>
{/if} {/if}
{#if validationIcon}
<span class="validationIcon {validationState}">
<Icon name={validationIcon} size={16} />
</span>
{/if}
{#if showCounter} {#if showCounter}
<span class="counter"> <span class="counter">
{currentCount}{maxLength ? `/${maxLength}` : ''} {currentCount}{maxLength ? `/${maxLength}` : ''}
@ -124,6 +158,9 @@
{required} {required}
maxlength={maxLength} maxlength={maxLength}
data-1p-ignore={no1password} data-1p-ignore={no1password}
onblur={handleBlur}
onfocus={handleFocus}
oninput={handleInput}
{...restProps} {...restProps}
/> />
{/if} {/if}
@ -149,6 +186,9 @@
{required} {required}
maxlength={maxLength} maxlength={maxLength}
data-1p-ignore={no1password} data-1p-ignore={no1password}
onblur={handleBlur}
onfocus={handleFocus}
oninput={handleInput}
{...restProps} {...restProps}
/> />
@ -158,6 +198,12 @@
</span> </span>
{/if} {/if}
{#if validationIcon}
<span class="validationIcon {validationState}">
<Icon name={validationIcon} size={16} />
</span>
{/if}
{#if showCounter} {#if showCounter}
<span class="counter"> <span class="counter">
{currentCount}{maxLength ? `/${maxLength}` : ''} {currentCount}{maxLength ? `/${maxLength}` : ''}
@ -175,6 +221,9 @@
{required} {required}
maxlength={maxLength} maxlength={maxLength}
data-1p-ignore={no1password} data-1p-ignore={no1password}
onblur={handleBlur}
onfocus={handleFocus}
oninput={handleInput}
{...restProps} {...restProps}
/> />
{/if} {/if}
@ -312,6 +361,34 @@
right: $unit-2x; 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 { &:has(.iconLeft) input {
padding-left: $unit-5x; padding-left: $unit-5x;
} }
@ -320,6 +397,10 @@
padding-right: $unit-5x; padding-right: $unit-5x;
} }
&:has(.validationIcon) input {
padding-right: $unit-5x;
}
&:has(.counter) input { &:has(.counter) input {
padding-right: $unit-8x; padding-right: $unit-8x;
} }
@ -479,4 +560,13 @@
color: var(--text-tertiary); color: var(--text-tertiary);
opacity: 1; opacity: 1;
} }
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style> </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 element
}: Props = $props() }: Props = $props()
$effect(() => {
if (onCheckedChange && checked !== undefined) {
onCheckedChange(checked)
}
})
const sizeClass = $derived(size) const sizeClass = $derived(size)
// contained prop is an alias for variant='bound' // contained prop is an alias for variant='bound'
const variantClass = $derived(variant === 'bound' || contained ? 'bound' : '') const variantClass = $derived(variant === 'bound' || contained ? 'bound' : '')
@ -57,6 +51,7 @@
{indeterminate} {indeterminate}
{disabled} {disabled}
{required} {required}
{onCheckedChange}
name={name ?? ''} name={name ?? ''}
value={value ?? ''} value={value ?? ''}
class="checkbox {sizeClass} {variantClass} {fullWidthClass} {element || ''} {className || ''}" class="checkbox {sizeClass} {variantClass} {fullWidthClass} {element || ''} {className || ''}"