replace el-input with InputGroup

This commit is contained in:
pa
2026-01-12 20:09:58 +09:00
committed by Natsumi
parent 4749e8cb56
commit 065870a7f8
67 changed files with 707 additions and 366 deletions

View File

@@ -0,0 +1,47 @@
<script setup>
import { useAttrs } from 'vue';
import { useVModel } from '@vueuse/core';
import InputGroupField from './InputGroupField.vue';
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 },
showPassword: { type: Boolean, default: false },
showCount: { type: Boolean, default: false },
maxlength: { type: Number, required: false },
size: { type: String, default: 'default' }
});
const emit = defineEmits(['update:modelValue']);
const attrs = useAttrs();
const modelValue = useVModel(props, 'modelValue', emit, {
passive: true,
defaultValue: props.modelValue
});
</script>
<template>
<InputGroupField
v-model="modelValue"
:class="props.class"
:input-class="props.inputClass"
:clearable="props.clearable"
:show-password="props.showPassword"
:show-count="props.showCount"
:maxlength="props.maxlength"
:size="props.size"
v-bind="attrs">
<template v-if="$slots.leading" #leading>
<slot name="leading" />
</template>
<template v-if="$slots.actions" #trailing>
<slot name="actions" />
</template>
</InputGroupField>
</template>

View File

@@ -0,0 +1,50 @@
<script setup>
import { computed, useAttrs, useSlots } from 'vue';
import { useVModel } from '@vueuse/core';
import InputGroupField from './InputGroupField.vue';
import { InputGroupText } from '.';
defineOptions({ inheritAttrs: false });
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
class: { type: null, required: false },
inputClass: { type: null, required: false },
prefixText: { type: String, default: '' },
suffixText: { type: String, default: '' },
clearable: { type: Boolean, default: false },
size: { type: String, default: 'default' }
});
const emit = defineEmits(['update:modelValue']);
const attrs = useAttrs();
const slots = useSlots();
const modelValue = useVModel(props, 'modelValue', emit, {
passive: true,
defaultValue: props.modelValue
});
const hasLeading = computed(() => Boolean(props.prefixText) || Boolean(slots.leading));
const hasTrailing = computed(() => Boolean(props.suffixText) || Boolean(slots.trailing));
</script>
<template>
<InputGroupField
v-model="modelValue"
:class="props.class"
:input-class="props.inputClass"
:clearable="props.clearable"
:size="props.size"
v-bind="attrs">
<template v-if="hasLeading" #leading>
<InputGroupText v-if="props.prefixText">{{ props.prefixText }}</InputGroupText>
<slot name="leading" />
</template>
<template v-if="hasTrailing" #trailing>
<slot name="trailing" />
<InputGroupText v-if="props.suffixText">{{ props.suffixText }}</InputGroupText>
</template>
</InputGroupField>
</template>

View File

@@ -1,81 +0,0 @@
<script setup>
import { Hash, X } from 'lucide-vue-next';
import { computed, useAttrs } from 'vue';
import { cn } from '@/lib/utils';
import { useVModel } from '@vueuse/core';
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea
} from '.';
defineOptions({ inheritAttrs: false });
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
maxlength: { type: Number, required: true },
multiline: { type: Boolean, default: false },
class: { type: null, required: false },
inputClass: { type: null, required: false },
countClass: { type: null, required: false },
iconClass: { type: null, required: false },
clearable: { type: Boolean, default: false }
});
const emit = defineEmits(['update:modelValue']);
const attrs = useAttrs();
const modelValue = useVModel(props, 'modelValue', emit, {
passive: true,
defaultValue: props.modelValue
});
const valueLength = computed(() => String(modelValue.value ?? '').length);
const remaining = computed(() => Math.max(props.maxlength - valueLength.value, 0));
const wrapperClass = computed(() => cn(props.class, attrs.class));
const inputClass = computed(() => cn(props.inputClass));
const inputAttrs = computed(() => {
const { class: _class, style: _style, ...rest } = attrs;
return rest;
});
const isDisabled = computed(() => Boolean(inputAttrs.value.disabled));
function clearValue() {
if (isDisabled.value) return;
modelValue.value = '';
}
</script>
<template>
<InputGroup :class="wrapperClass" :style="attrs.style">
<InputGroupAddon v-if="$slots.leading" align="inline-start">
<slot name="leading" />
</InputGroupAddon>
<component
:is="props.multiline ? InputGroupTextarea : InputGroupInput"
v-model="modelValue"
:maxlength="props.maxlength"
:class="inputClass"
v-bind="inputAttrs" />
<InputGroupAddon v-if="$slots.trailing" align="inline-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 :align="props.multiline ? 'block-end' : 'inline-end'" v-if="valueLength > 0">
<InputGroupText :class="cn('gap-1 tabular-nums text-xs', props.multiline && 'ml-auto', props.countClass)">
<span>{{ valueLength }}</span>
<span class="text-muted-foreground/70">/ {{ props.maxlength }}</span>
</InputGroupText>
</InputGroupAddon>
</InputGroup>
</template>

View File

@@ -0,0 +1,124 @@
<script setup>
import { computed, ref, useAttrs } from 'vue';
import { useVModel } from '@vueuse/core';
import { Eye, EyeOff, X } from 'lucide-vue-next';
import { cn } from '@/lib/utils';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText } 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 },
showPassword: { type: Boolean, default: false },
showCount: { type: Boolean, default: false },
maxlength: { type: Number, required: false },
type: { type: String, required: false },
size: { type: String, default: 'default' }
});
const emit = defineEmits(['update:modelValue', 'input', 'change']);
const attrs = useAttrs();
const modelValue = useVModel(props, 'modelValue', emit, {
passive: true,
defaultValue: props.modelValue
});
const reveal = ref(false);
const valueLength = computed(() => String(modelValue.value ?? '').length);
const maxLength = computed(() => props.maxlength ?? attrs.maxlength);
const wrapperClass = computed(() =>
cn(props.class, attrs.class, props.size === 'sm' && 'h-8')
);
const inputClass = computed(() => cn(props.inputClass));
const inputType = computed(() => {
const rawType = props.type ?? attrs.type;
if (props.showPassword) {
return reveal.value ? 'text' : rawType || 'password';
}
return rawType;
});
const inputAttrs = computed(() => {
const {
class: _class,
style: _style,
type: _type,
maxlength: _maxlength,
onInput: _onInput,
onChange: _onChange,
...rest
} = attrs;
return {
...rest,
type: inputType.value,
maxlength: maxLength.value
};
});
const isDisabled = computed(() => Boolean(inputAttrs.value.disabled));
const showCount = computed(() => Boolean(maxLength.value) && props.showCount);
function clearValue() {
if (isDisabled.value) return;
modelValue.value = '';
emit('input', '');
emit('change', '');
}
function toggleReveal() {
if (isDisabled.value) return;
reveal.value = !reveal.value;
}
function handleInput(event) {
const value = event?.target?.value ?? '';
emit('input', value);
}
function handleChange(event) {
const value = event?.target?.value ?? '';
emit('change', value);
}
</script>
<template>
<InputGroup :class="wrapperClass" :style="attrs.style" :data-disabled="isDisabled ? 'true' : undefined">
<InputGroupAddon v-if="$slots.leading" align="inline-start">
<slot name="leading" />
</InputGroupAddon>
<InputGroupInput
v-model="modelValue"
:class="inputClass"
v-bind="inputAttrs"
@input="handleInput"
@change="handleChange" />
<InputGroupAddon v-if="$slots.trailing" align="inline-end">
<slot name="trailing" />
</InputGroupAddon>
<InputGroupAddon v-if="props.showPassword" align="inline-end">
<InputGroupButton size="icon-xs" :disabled="isDisabled" @click="toggleReveal">
<Eye v-if="!reveal" class="size-3.5" />
<EyeOff v-else class="size-3.5" />
<span class="sr-only">Toggle password</span>
</InputGroupButton>
</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="inline-end">
<InputGroupText class="gap-1 tabular-nums text-xs">
<span>{{ valueLength }}</span>
<span class="text-muted-foreground/70">/ {{ maxLength }}</span>
</InputGroupText>
</InputGroupAddon>
</InputGroup>
</template>

View File

@@ -0,0 +1,42 @@
<script setup>
import { useAttrs } from 'vue';
import { useVModel } from '@vueuse/core';
import { Search } from 'lucide-vue-next';
import InputGroupField from './InputGroupField.vue';
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: true },
size: { type: String, default: 'default' }
});
const emit = defineEmits(['update:modelValue']);
const attrs = useAttrs();
const modelValue = useVModel(props, 'modelValue', emit, {
passive: true,
defaultValue: props.modelValue
});
</script>
<template>
<InputGroupField
v-model="modelValue"
:class="props.class"
:input-class="props.inputClass"
:clearable="props.clearable"
:size="props.size"
v-bind="attrs">
<template #leading>
<Search class="size-4" />
</template>
<template v-if="$slots.trailing" #trailing>
<slot name="trailing" />
</template>
</InputGroupField>
</template>

View File

@@ -0,0 +1,144 @@
<script setup>
import { computed, nextTick, onMounted, ref, useAttrs, watch } from 'vue';
import { useVModel } from '@vueuse/core';
import { X } from 'lucide-vue-next';
import { cn } from '@/lib/utils';
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));
const inputClass = computed(() => cn(props.inputClass));
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 {
class: _class,
style: _style,
maxlength: _maxlength,
onInput: _onInput,
onChange: _onChange,
...rest
} = attrs;
return {
...rest,
maxlength: maxLength.value
};
});
const isDisabled = computed(() => Boolean(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="attrs.style" :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>

View File

@@ -6,7 +6,11 @@ export { default as InputGroupButton } from './InputGroupButton.vue';
export { default as InputGroupInput } from './InputGroupInput.vue';
export { default as InputGroupText } from './InputGroupText.vue';
export { default as InputGroupTextarea } from './InputGroupTextarea.vue';
export { default as InputGroupCharCount } from './InputGroupCharCount.vue';
export { default as InputGroupField } from './InputGroupField.vue';
export { default as InputGroupTextareaField } from './InputGroupTextareaField.vue';
export { default as InputGroupSearch } from './InputGroupSearch.vue';
export { default as InputGroupAffix } from './InputGroupAffix.vue';
export { default as InputGroupAction } from './InputGroupAction.vue';
export const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",