jedmund-svelte/src/lib/components/admin/Input.svelte
Justin Edmund cf2842d22d refactor: migrate admin UI to Svelte 5 runes
Convert admin components from Svelte 4 to Svelte 5 syntax using $props, $state, $derived, and $bindable runes. Simplifies AdminNavBar logic and improves type safety.
2025-11-03 23:03:28 -08:00

455 lines
8.2 KiB
Svelte

<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements'
type Props = HTMLInputAttributes & {
type?:
| 'text'
| 'email'
| 'password'
| 'url'
| 'search'
| 'number'
| 'tel'
| 'date'
| 'time'
| 'color'
label?: string
error?: string
helpText?: string
size?: 'small' | 'medium' | 'large' | 'jumbo'
pill?: boolean
fullWidth?: boolean
required?: boolean
class?: string
wrapperClass?: string
inputClass?: string
prefixIcon?: boolean
suffixIcon?: boolean
showCharCount?: boolean
maxLength?: number
colorSwatch?: boolean // Show color swatch based on input value
}
let {
label,
error,
helpText,
size = 'medium',
pill = false,
fullWidth = true,
required = false,
disabled = false,
readonly = false,
type = 'text',
value = $bindable(''),
class: className = '',
wrapperClass = '',
inputClass = '',
prefixIcon = false,
suffixIcon = false,
showCharCount = false,
maxLength,
colorSwatch = false,
id = `input-${Math.random().toString(36).substr(2, 9)}`,
...restProps
}: Props = $props()
let charCount = $derived(String(value).length)
let charsRemaining = $derived(maxLength ? maxLength - charCount : 0)
// Color swatch validation and display
function isValidHexColor() {
if (!colorSwatch || !value) return false
const hexRegex = /^#[0-9A-Fa-f]{6}$/
return hexRegex.test(String(value))
}
// Color picker functionality
let colorPickerInput: HTMLInputElement
function handleColorSwatchClick() {
if (colorPickerInput) {
colorPickerInput.click()
}
}
function handleColorPickerChange(event: Event) {
const target = event.target as HTMLInputElement
if (target.value) {
value = target.value.toUpperCase()
}
}
// Compute classes
function wrapperClasses() {
const classes = ['input-wrapper']
if (size) classes.push(`input-wrapper-${size}`)
if (fullWidth) classes.push('full-width')
if (error) classes.push('has-error')
if (disabled) classes.push('is-disabled')
if (prefixIcon) classes.push('has-prefix-icon')
if (suffixIcon) classes.push('has-suffix-icon')
if (colorSwatch) classes.push('has-color-swatch')
if (wrapperClass) classes.push(wrapperClass)
if (className) classes.push(className)
return classes.join(' ')
}
function inputClasses() {
const classes = ['input']
classes.push(`input-${size}`)
if (pill) classes.push('input-pill')
if (inputClass) classes.push(inputClass)
return classes.join(' ')
}
</script>
<div class={wrapperClasses()}>
{#if label}
<label for={id} class="input-label">
{label}
{#if required}
<span class="required-indicator">*</span>
{/if}
</label>
{/if}
<div class="input-container">
{#if prefixIcon}
<span class="input-icon prefix-icon">
<slot name="prefix" />
</span>
{/if}
{#if colorSwatch && isValidHexColor()}
<span
class="color-swatch"
style="background-color: {value}"
onclick={handleColorSwatchClick}
role="button"
tabindex="0"
aria-label="Open color picker"
></span>
{/if}
<input
bind:value
{id}
{type}
{disabled}
{readonly}
{required}
{maxLength}
class={inputClasses()}
{...restProps}
/>
{#if suffixIcon}
<span class="input-icon suffix-icon">
<slot name="suffix" />
</span>
{/if}
{#if colorSwatch}
<input
bind:this={colorPickerInput}
type="color"
value={isValidHexColor() ? String(value) : '#000000'}
oninput={handleColorPickerChange}
onchange={handleColorPickerChange}
style="position: absolute; visibility: hidden; pointer-events: none;"
tabindex="-1"
/>
{/if}
</div>
{#if (error || helpText || showCharCount) && !disabled}
<div class="input-footer">
{#if error}
<span class="input-error">{error}</span>
{:else if helpText}
<span class="input-help">{helpText}</span>
{/if}
{#if showCharCount && maxLength}
<span
class="char-count"
class:warning={charsRemaining < maxLength * 0.1}
class:error={charsRemaining < 0}
>
{charsRemaining}
</span>
{/if}
</div>
{/if}
</div>
<style lang="scss">
@import '$styles/variables.scss';
// Wrapper styles
.input-wrapper {
display: inline-block;
position: relative;
&.full-width {
display: block;
width: 100%;
}
&.has-error {
.input {
border-color: $red-50;
&:focus {
border-color: $red-50;
}
}
}
&.is-disabled {
opacity: 0.6;
}
&.has-color-swatch {
.input {
padding-left: 36px; // Make room for color swatch (20px + 8px margin + 8px padding)
}
}
}
// Label styles
.input-label {
display: block;
margin-bottom: $unit;
font-size: 14px;
font-weight: 500;
color: $gray-20;
}
.required-indicator {
color: $red-50;
margin-left: 2px;
}
// Container for input and icons
.input-container {
position: relative;
display: flex;
align-items: stretch;
width: 100%;
}
// Color swatch styles
.color-swatch {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.1);
z-index: 1;
cursor: pointer;
transition: border-color $transition-fast ease;
&:hover {
border-color: rgba(0, 0, 0, 0.2);
}
}
// Input styles
.input {
width: 100%;
border: 1px solid transparent;
color: $input-text-color;
background-color: $input-background-color;
transition: all $transition-fast ease;
&:hover {
background-color: $input-background-color-hover;
color: $input-text-color-hover;
}
&::placeholder {
color: $gray-50;
}
&:focus {
outline: none;
background-color: $input-background-color-hover;
color: $input-text-color-hover;
}
&:disabled {
background-color: $gray-95;
cursor: not-allowed;
color: $gray-40;
}
&:read-only {
background-color: $gray-97;
cursor: default;
}
}
// Size variations
.input-small {
padding: $unit calc($unit * 1.5);
font-size: 0.75rem;
}
.input-medium {
padding: calc($unit * 1.5) $unit-2x;
font-size: 1rem;
}
.input-large {
padding: $unit-2x $unit-3x;
font-size: 1.25rem;
box-sizing: border-box;
}
.input-jumbo {
padding: $unit-2x $unit-2x;
font-size: 1.33rem;
box-sizing: border-box;
}
// Shape variants - pill vs rounded
.input-pill {
&.input-small {
border-radius: 20px;
}
&.input-medium {
border-radius: 24px;
}
&.input-large {
border-radius: 28px;
}
&.input-jumbo {
border-radius: 32px;
}
}
.input:not(.input-pill) {
&.input-small {
border-radius: $corner-radius-lg;
}
&.input-medium {
border-radius: $corner-radius-2xl;
}
&.input-large {
border-radius: $corner-radius-2xl;
}
&.input-jumbo {
border-radius: $corner-radius-2xl;
}
}
// Icon adjustments
.has-prefix-icon .input {
padding-left: calc($unit-2x + 24px);
}
.has-suffix-icon .input {
padding-right: calc($unit-2x + 24px);
}
.input-icon {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
color: $gray-40;
pointer-events: none;
&.prefix-icon {
left: $unit-2x;
}
&.suffix-icon {
right: $unit-2x;
}
:global(svg) {
width: 16px;
height: 16px;
}
}
// Footer styles
.input-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: $unit-half;
min-height: 20px;
}
.input-error,
.input-help {
font-size: 13px;
line-height: 1.4;
}
.input-error {
color: $red-50;
}
.input-help {
color: $gray-40;
}
.char-count {
font-size: 12px;
color: $gray-50;
font-variant-numeric: tabular-nums;
margin-left: auto;
&.warning {
color: $universe-color;
}
&.error {
color: $red-50;
font-weight: 500;
}
}
// Special input types
input[type='color'].input {
padding: $unit;
cursor: pointer;
&::-webkit-color-swatch-wrapper {
padding: 0;
}
&::-webkit-color-swatch {
border: none;
border-radius: 4px;
}
}
input[type='number'].input {
-moz-appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
// Search input
input[type='search'].input {
&::-webkit-search-decoration,
&::-webkit-search-cancel-button {
-webkit-appearance: none;
}
}
</style>