Hwandji's picture
feat: initial HuggingFace Space deployment
4343907
raw
history blame
9.51 kB
<template>
<div class="saap-input" :class="wrapperClasses">
<!-- Label -->
<label v-if="label" :for="inputId" class="saap-input__label">
{{ label }}
<span v-if="required" class="saap-input__required" aria-label="required">*</span>
</label>
<!-- Input Container -->
<div class="saap-input__container">
<!-- Left Icon -->
<component
v-if="icon && iconPosition === 'left'"
:is="iconComponent"
class="saap-input__icon saap-input__icon--left"
aria-hidden="true"
/>
<!-- Input Element -->
<input
:id="inputId"
ref="inputRef"
:class="inputClasses"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:required="required"
:aria-invalid="!!error"
:aria-describedby="ariaDescribedBy"
v-bind="$attrs"
@input="handleInput"
@change="handleChange"
@focus="handleFocus"
@blur="handleBlur"
>
<!-- Right Icon -->
<component
v-if="icon && iconPosition === 'right'"
:is="iconComponent"
class="saap-input__icon saap-input__icon--right"
aria-hidden="true"
/>
<!-- Clear Button -->
<button
v-if="clearable && modelValue && !disabled && !readonly"
type="button"
class="saap-input__clear"
aria-label="Clear input"
@click="handleClear"
>
<XIcon class="saap-input__clear-icon" />
</button>
</div>
<!-- Helper Text -->
<div v-if="hint || error || success" class="saap-input__helper">
<!-- Error Message -->
<div
v-if="error"
:id="`${inputId}-error`"
class="saap-input__error"
role="alert"
>
<AlertCircleIcon class="saap-input__error-icon" aria-hidden="true" />
{{ error }}
</div>
<!-- Success Message -->
<div
v-else-if="success"
:id="`${inputId}-success`"
class="saap-input__success"
>
<CheckCircleIcon class="saap-input__success-icon" aria-hidden="true" />
{{ success }}
</div>
<!-- Hint -->
<div
v-else-if="hint"
:id="`${inputId}-hint`"
class="saap-input__hint"
>
{{ hint }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, defineEmits, defineProps, ref, useId, withDefaults } from 'vue'
import { XIcon, AlertCircleIcon, CheckCircleIcon } from 'lucide-vue-next'
import type { InputProps } from '@/types'
interface Props extends InputProps {
clearable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
size: 'md',
disabled: false,
readonly: false,
required: false,
iconPosition: 'left',
clearable: false
})
// Emits
const emit = defineEmits<{
'update:modelValue': [value: string | number]
change: [value: string | number]
focus: [event: FocusEvent]
blur: [event: FocusEvent]
clear: []
}>()
// Refs
const inputRef = ref<HTMLInputElement>()
const inputId = useId()
// Computed
const wrapperClasses = computed(() => [
`saap-input--${props.size}`,
{
'saap-input--disabled': props.disabled,
'saap-input--readonly': props.readonly,
'saap-input--error': !!props.error,
'saap-input--success': !!props.success,
'saap-input--with-icon': !!props.icon,
'saap-input--with-icon-left': props.icon && props.iconPosition === 'left',
'saap-input--with-icon-right': props.icon && props.iconPosition === 'right',
'saap-input--clearable': props.clearable
}
])
const inputClasses = computed(() => [
'saap-input__field',
{
'saap-input__field--with-icon-left': props.icon && props.iconPosition === 'left',
'saap-input__field--with-icon-right': props.icon && props.iconPosition === 'right',
'saap-input__field--clearable': props.clearable && props.modelValue
}
])
const iconComponent = computed(() => {
if (props.icon) {
return resolveIconComponent(props.icon)
}
return null
})
const ariaDescribedBy = computed(() => {
const ids = []
if (props.error) ids.push(`${inputId}-error`)
if (props.success) ids.push(`${inputId}-success`)
if (props.hint) ids.push(`${inputId}-hint`)
return ids.length ? ids.join(' ') : undefined
})
// Methods
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
function handleChange(event: Event) {
const target = event.target as HTMLInputElement
emit('change', target.value)
}
function handleFocus(event: FocusEvent) {
emit('focus', event)
}
function handleBlur(event: FocusEvent) {
emit('blur', event)
}
function handleClear() {
emit('update:modelValue', '')
emit('clear')
inputRef.value?.focus()
}
function resolveIconComponent(iconName: string) {
try {
return () => import(`lucide-vue-next/dist/esm/icons/${iconName}.js`)
} catch {
return null
}
}
// Expose methods for parent components
defineExpose({
focus: () => inputRef.value?.focus(),
blur: () => inputRef.value?.blur(),
select: () => inputRef.value?.select()
})
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.saap-input {
display: flex;
flex-direction: column;
gap: var(--saap-space-2);
// Sizes
&--sm {
font-size: var(--saap-text-sm);
}
&--md {
font-size: var(--saap-text-base);
}
&--lg {
font-size: var(--saap-text-lg);
}
}
.saap-input__label {
display: flex;
align-items: center;
gap: var(--saap-space-1);
font-weight: var(--saap-font-medium);
color: var(--saap-neutral-700);
font-size: var(--saap-text-sm);
line-height: var(--saap-leading-normal);
}
.saap-input__required {
color: var(--saap-error);
font-weight: var(--saap-font-normal);
}
.saap-input__container {
position: relative;
display: flex;
align-items: center;
}
.saap-input__field {
@include input-base;
// Icon spacing
&--with-icon-left {
padding-left: 2.5rem;
.saap-input--sm & {
padding-left: 2.25rem;
}
.saap-input--lg & {
padding-left: 3rem;
}
}
&--with-icon-right {
padding-right: 2.5rem;
.saap-input--sm & {
padding-right: 2.25rem;
}
.saap-input--lg & {
padding-right: 3rem;
}
}
&--clearable {
padding-right: 2.5rem;
.saap-input--sm & {
padding-right: 2.25rem;
}
.saap-input--lg & {
padding-right: 3rem;
}
}
// States
.saap-input--error & {
border-color: var(--saap-error);
&:focus {
border-color: var(--saap-error);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
}
.saap-input--success & {
border-color: var(--saap-success);
&:focus {
border-color: var(--saap-success);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
}
}
}
.saap-input__icon {
position: absolute;
width: 1rem;
height: 1rem;
color: var(--saap-neutral-500);
pointer-events: none;
&--left {
left: var(--saap-space-3);
.saap-input--sm & {
left: var(--saap-space-2);
}
.saap-input--lg & {
left: var(--saap-space-4);
}
}
&--right {
right: var(--saap-space-3);
.saap-input--sm & {
right: var(--saap-space-2);
}
.saap-input--lg & {
right: var(--saap-space-4);
}
}
.saap-input--sm & {
width: 0.875rem;
height: 0.875rem;
}
.saap-input--lg & {
width: 1.125rem;
height: 1.125rem;
}
}
.saap-input__clear {
position: absolute;
right: var(--saap-space-3);
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: var(--saap-border-radius-sm);
color: var(--saap-neutral-500);
cursor: pointer;
transition: var(--saap-transition-fast);
&:hover {
color: var(--saap-neutral-700);
background: var(--saap-neutral-100);
}
&:focus {
outline: 2px solid var(--saap-primary-400);
outline-offset: 1px;
}
.saap-input--sm & {
right: var(--saap-space-2);
width: 1rem;
height: 1rem;
}
.saap-input--lg & {
right: var(--saap-space-4);
width: 1.5rem;
height: 1.5rem;
}
}
.saap-input__clear-icon {
width: 0.75rem;
height: 0.75rem;
.saap-input--sm & {
width: 0.625rem;
height: 0.625rem;
}
.saap-input--lg & {
width: 1rem;
height: 1rem;
}
}
.saap-input__helper {
display: flex;
align-items: flex-start;
gap: var(--saap-space-2);
font-size: var(--saap-text-sm);
line-height: var(--saap-leading-normal);
}
.saap-input__error,
.saap-input__success,
.saap-input__hint {
display: flex;
align-items: flex-start;
gap: var(--saap-space-1);
}
.saap-input__error {
color: var(--saap-error);
}
.saap-input__success {
color: var(--saap-success);
}
.saap-input__hint {
color: var(--saap-neutral-600);
}
.saap-input__error-icon,
.saap-input__success-icon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
margin-top: 0.125rem; // Align with text
}
// Focus within styling
.saap-input:focus-within .saap-input__label {
color: var(--saap-primary-600);
}
.saap-input--error:focus-within .saap-input__label {
color: var(--saap-error);
}
.saap-input--success:focus-within .saap-input__label {
color: var(--saap-success);
}
</style>