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 {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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
|
||||
}: 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 || ''}"
|
||||
|
|
|
|||
Loading…
Reference in a new issue