DetailItem: add sublabel, width, onchange props; Input: add validation state
This commit is contained in:
parent
e9ba90d656
commit
28ad2fb37e
4 changed files with 302 additions and 33 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
146
src/lib/components/ui/ValidatedInput.svelte
Normal file
146
src/lib/components/ui/ValidatedInput.svelte
Normal 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>
|
||||||
|
|
@ -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 || ''}"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue