229 lines
		
	
	
		
			4.6 KiB
		
	
	
	
		
			Plaintext
		
	
	
	
	
	
		
		
			
		
	
	
			229 lines
		
	
	
		
			4.6 KiB
		
	
	
	
		
			Plaintext
		
	
	
	
	
	
|  | <template> | ||
|  |   <div class="base-textarea"> | ||
|  |     <label v-if="label" :for="textareaId" class="base-textarea__label"> | ||
|  |       {{ label }} | ||
|  |       <span v-if="required" class="base-textarea__required">*</span> | ||
|  |     </label> | ||
|  |      | ||
|  |     <div class="base-textarea__wrapper"> | ||
|  |       <textarea | ||
|  |         :id="textareaId" | ||
|  |         ref="textareaRef" | ||
|  |         :value="modelValue" | ||
|  |         :placeholder="placeholder" | ||
|  |         :disabled="disabled" | ||
|  |         :readonly="readonly" | ||
|  |         :required="required" | ||
|  |         :rows="rows" | ||
|  |         :maxlength="maxlength" | ||
|  |         :aria-invalid="error ? 'true' : 'false'" | ||
|  |         :aria-describedby="error ? `${textareaId}-error` : undefined" | ||
|  |         :class="[ | ||
|  |           'base-textarea__field', | ||
|  |           { 'base-textarea__field--error': error } | ||
|  |         ]" | ||
|  |         @input="handleInput" | ||
|  |         @blur="$emit('blur', $event)" | ||
|  |         @focus="$emit('focus', $event)" | ||
|  |         @keydown="handleKeydown" | ||
|  |         @keyup="$emit('keyup', $event)" | ||
|  |       /> | ||
|  |     </div> | ||
|  |      | ||
|  |     <div v-if="showCharCount && maxlength" class="base-textarea__char-count"> | ||
|  |       {{ modelValue.length }}/{{ maxlength }} | ||
|  |     </div> | ||
|  |      | ||
|  |     <div v-if="error" :id="`${textareaId}-error`" class="base-textarea__error"> | ||
|  |       {{ error }} | ||
|  |     </div> | ||
|  |      | ||
|  |     <div v-else-if="helpText" class="base-textarea__help"> | ||
|  |       {{ helpText }} | ||
|  |     </div> | ||
|  |   </div> | ||
|  | </template> | ||
|  | 
 | ||
|  | <script setup lang="ts"> | ||
|  | import { ref, computed } from 'vue' | ||
|  | 
 | ||
|  | interface Props { | ||
|  |   modelValue: string | ||
|  |   label?: string | ||
|  |   placeholder?: string | ||
|  |   disabled?: boolean | ||
|  |   readonly?: boolean | ||
|  |   required?: boolean | ||
|  |   rows?: number | ||
|  |   maxlength?: number | ||
|  |   showCharCount?: boolean | ||
|  |   error?: string | ||
|  |   helpText?: string | ||
|  |   id?: string | ||
|  |   autoResize?: boolean | ||
|  | } | ||
|  | 
 | ||
|  | const props = withDefaults(defineProps<Props>(), { | ||
|  |   disabled: false, | ||
|  |   readonly: false, | ||
|  |   required: false, | ||
|  |   rows: 3, | ||
|  |   showCharCount: false, | ||
|  |   autoResize: false | ||
|  | }) | ||
|  | 
 | ||
|  | const emit = defineEmits<{ | ||
|  |   'update:modelValue': [value: string] | ||
|  |   blur: [event: FocusEvent] | ||
|  |   focus: [event: FocusEvent] | ||
|  |   keydown: [event: KeyboardEvent] | ||
|  |   keyup: [event: KeyboardEvent] | ||
|  |   submit: [] | ||
|  | }>() | ||
|  | 
 | ||
|  | const textareaRef = ref<HTMLTextAreaElement>() | ||
|  | const textareaId = computed(() => props.id || `textarea-${Math.random().toString(36).substr(2, 9)}`) | ||
|  | 
 | ||
|  | const handleInput = (event: Event) => { | ||
|  |   const target = event.target as HTMLTextAreaElement | ||
|  |   emit('update:modelValue', target.value) | ||
|  |    | ||
|  |   if (props.autoResize) { | ||
|  |     autoResize(target) | ||
|  |   } | ||
|  | } | ||
|  | 
 | ||
|  | const handleKeydown = (event: KeyboardEvent) => { | ||
|  |   emit('keydown', event) | ||
|  |    | ||
|  |   // Submit on Ctrl+Enter or Cmd+Enter | ||
|  |   if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { | ||
|  |     event.preventDefault() | ||
|  |     emit('submit') | ||
|  |   } | ||
|  | } | ||
|  | 
 | ||
|  | const autoResize = (textarea: HTMLTextAreaElement) => { | ||
|  |   textarea.style.height = 'auto' | ||
|  |   textarea.style.height = textarea.scrollHeight + 'px' | ||
|  | } | ||
|  | 
 | ||
|  | const focus = () => { | ||
|  |   textareaRef.value?.focus() | ||
|  | } | ||
|  | 
 | ||
|  | const selectAll = () => { | ||
|  |   textareaRef.value?.select() | ||
|  | } | ||
|  | 
 | ||
|  | defineExpose({ | ||
|  |   focus, | ||
|  |   selectAll, | ||
|  |   textareaRef | ||
|  | }) | ||
|  | </script> | ||
|  | 
 | ||
|  | <style scoped> | ||
|  | .base-textarea { | ||
|  |   display: flex; | ||
|  |   flex-direction: column; | ||
|  |   gap: 0.5rem; | ||
|  | } | ||
|  | 
 | ||
|  | .base-textarea__label { | ||
|  |   font-size: 0.875rem; | ||
|  |   font-weight: 500; | ||
|  |   color: #374151; | ||
|  | } | ||
|  | 
 | ||
|  | .base-textarea__required { | ||
|  |   color: #ef4444; | ||
|  | } | ||
|  | 
 | ||
|  | .base-textarea__wrapper { | ||
|  |   position: relative; | ||
|  | } | ||
|  | 
 | ||
|  | .base-textarea__field { | ||
|  |   width: 100%; | ||
|  |   padding: 0.75rem 1rem; | ||
|  |   border: 1px solid #d1d5db; | ||
|  |   border-radius: 8px; | ||
|  |   font-size: 1rem; | ||
|  |   font-family: inherit; | ||
|  |   background-color: #ffffff; | ||
|  |   color: #111827; | ||
|  |   transition: all 0.2s ease; | ||
|  |   outline: none; | ||
|  |   resize: vertical; | ||
|  |   min-height: 3rem; | ||
|  | } | ||
|  | 
 | ||
|  | .base-textarea__field:focus { | ||
|  |   border-color: #646cff; | ||
|  |   box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1); | ||
|  | } | ||
|  | 
 | ||
|  | .base-textarea__field:disabled { | ||
|  |   background-color: #f9fafb; | ||
|  |   color: #9ca3af; | ||
|  |   cursor: not-allowed; | ||
|  |   resize: none; | ||
|  | } | ||
|  | 
 | ||
|  | .base-textarea__field:readonly { | ||
|  |   background-color: #f9fafb; | ||
|  |   cursor: default; | ||
|  |   resize: none; | ||
|  | } | ||
|  | 
 | ||
|  | .base-textarea__field--error { | ||
|  |   border-color: #ef4444; | ||
|  | } | ||
|  | 
 | ||
|  | .base-textarea__field--error:focus { | ||
|  |   border-color: #ef4444; | ||
|  |   box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); | ||
|  | } | ||
|  | 
 | ||
|  | .base-textarea__char-count { | ||
|  |   font-size: 0.75rem; | ||
|  |   color: #6b7280; | ||
|  |   text-align: right; | ||
|  | } | ||
|  | 
 | ||
|  | .base-textarea__error { | ||
|  |   font-size: 0.875rem; | ||
|  |   color: #ef4444; | ||
|  | } | ||
|  | 
 | ||
|  | .base-textarea__help { | ||
|  |   font-size: 0.875rem; | ||
|  |   color: #6b7280; | ||
|  | } | ||
|  | 
 | ||
|  | /* Dark mode */ | ||
|  | @media (prefers-color-scheme: dark) { | ||
|  |   .base-textarea__label { | ||
|  |     color: rgba(255, 255, 255, 0.87); | ||
|  |   } | ||
|  |    | ||
|  |   .base-textarea__field { | ||
|  |     background-color: #374151; | ||
|  |     color: rgba(255, 255, 255, 0.87); | ||
|  |     border-color: #4b5563; | ||
|  |   } | ||
|  |    | ||
|  |   .base-textarea__field:disabled, | ||
|  |   .base-textarea__field:readonly { | ||
|  |     background-color: #1f2937; | ||
|  |     color: #9ca3af; | ||
|  |   } | ||
|  |    | ||
|  |   .base-textarea__help, | ||
|  |   .base-textarea__char-count { | ||
|  |     color: #9ca3af; | ||
|  |   } | ||
|  | } | ||
|  | </style> |