Spaces:
Sleeping
Sleeping
| <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> | |