Files
VRCX/src/components/ui/input-group/InputGroupTextareaField.vue
2026-01-14 16:32:28 +13:00

178 lines
6.2 KiB
Vue

<script setup>
import { computed, nextTick, onMounted, ref, useAttrs, watch } from 'vue';
import { X } from 'lucide-vue-next';
import { cn } from '@/lib/utils';
import { useVModel } from '@vueuse/core';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupTextarea } from '.';
defineOptions({ inheritAttrs: false });
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
class: { type: null, required: false },
inputClass: { type: null, required: false },
clearable: { type: Boolean, default: false },
showCount: { type: Boolean, default: false },
maxlength: { type: Number, required: false },
autosize: { type: [Boolean, Object], default: false }
});
const emit = defineEmits(['update:modelValue', 'input', 'change']);
const attrs = useAttrs();
const modelValue = useVModel(props, 'modelValue', emit, {
passive: true,
defaultValue: props.modelValue
});
const textareaRef = ref(null);
const valueLength = computed(() => String(modelValue.value ?? '').length);
const maxLength = computed(() => props.maxlength ?? attrs.maxlength);
const wrapperClass = computed(() => cn(props.class, attrs.class, 'flex-nowrap'));
const inputClass = computed(() => cn(props.inputClass));
const wrapperStyle = computed(() => {
const raw = attrs.style;
if (!raw) return undefined;
if (typeof raw === 'string') {
const filtered = raw
.split(';')
.map((s) => s.trim())
.filter((s) => s && !s.toLowerCase().startsWith('display:'))
.join('; ');
return filtered || undefined;
}
if (Array.isArray(raw)) {
return raw.map((s) => {
if (typeof s === 'string') {
return s
.split(';')
.map((p) => p.trim())
.filter((p) => p && !p.toLowerCase().startsWith('display:'))
.join('; ');
}
if (s && typeof s === 'object') {
const next = { ...s };
delete next.display;
return next;
}
return s;
});
}
if (raw && typeof raw === 'object') {
const next = { ...raw };
delete next.display;
return next;
}
return raw;
});
const showCount = computed(() => Boolean(maxLength.value) && props.showCount);
const autosizeConfig = computed(() => {
if (!props.autosize) return null;
return typeof props.autosize === 'object' ? props.autosize : {};
});
const inputAttrs = computed(() => {
const rest = { ...(attrs ?? {}) };
delete rest.class;
delete rest.style;
delete rest.maxlength;
delete rest.onInput;
delete rest.onChange;
return {
...rest,
maxlength: maxLength.value
};
});
const isDisabled = computed(() => Boolean(/** @type {any} */ (inputAttrs.value)?.disabled));
function resolveTextareaEl() {
const instance = textareaRef.value;
if (!instance) return null;
return instance.$el ?? instance;
}
function resizeTextarea() {
if (!autosizeConfig.value) return;
const el = resolveTextareaEl();
if (!el) return;
const computedStyle = window.getComputedStyle(el);
const lineHeight = parseFloat(computedStyle.lineHeight) || 16;
const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
const paddingBottom = parseFloat(computedStyle.paddingBottom) || 0;
const minRows = autosizeConfig.value.minRows ?? (Number(attrs.rows) || 1);
const maxRows = autosizeConfig.value.maxRows ?? Number.POSITIVE_INFINITY;
const minHeight = lineHeight * minRows + paddingTop + paddingBottom;
const maxHeight = lineHeight * maxRows + paddingTop + paddingBottom;
el.style.height = 'auto';
const nextHeight = Math.min(maxHeight, Math.max(el.scrollHeight, minHeight));
el.style.height = `${nextHeight}px`;
}
function clearValue() {
if (isDisabled.value) return;
modelValue.value = '';
emit('input', '');
emit('change', '');
nextTick(resizeTextarea);
}
function handleInput(event) {
const value = event?.target?.value ?? '';
emit('input', value);
resizeTextarea();
}
function handleChange(event) {
const value = event?.target?.value ?? '';
emit('change', value);
}
onMounted(() => {
if (autosizeConfig.value) {
nextTick(resizeTextarea);
}
});
watch(
() => modelValue.value,
() => {
if (autosizeConfig.value) {
nextTick(resizeTextarea);
}
}
);
</script>
<template>
<InputGroup :class="wrapperClass" :style="wrapperStyle" :data-disabled="isDisabled ? 'true' : undefined">
<InputGroupAddon v-if="$slots.leading" align="block-start">
<slot name="leading" />
</InputGroupAddon>
<InputGroupTextarea
ref="textareaRef"
v-model="modelValue"
:class="inputClass"
v-bind="inputAttrs"
@input="handleInput"
@change="handleChange" />
<InputGroupAddon v-if="$slots.trailing" align="block-end">
<slot name="trailing" />
</InputGroupAddon>
<InputGroupAddon v-if="props.clearable && valueLength > 0" align="inline-end">
<InputGroupButton size="icon-xs" :disabled="isDisabled" @click="clearValue">
<X class="size-3.5" />
<span class="sr-only">Clear</span>
</InputGroupButton>
</InputGroupAddon>
<InputGroupAddon v-if="showCount && valueLength > 0" align="block-end">
<InputGroupText class="gap-1 tabular-nums text-xs">
<span>{{ valueLength }}</span>
<span class="text-muted-foreground/70">/ {{ maxLength }}</span>
</InputGroupText>
</InputGroupAddon>
</InputGroup>
</template>