<!-- SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div> <div :class="$style.label" @click="focus"><slot name="label"></slot></div> <div :class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused }]"> <div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div> <input ref="inputEl" v-model="v" v-adaptive-border :class="$style.inputCore" :type="type" :disabled="disabled" :required="required" :readonly="readonly" :placeholder="placeholder" :pattern="pattern" :autocomplete="autocomplete" :autocapitalize="autocapitalize" :spellcheck="spellcheck" :inputmode="inputmode" :step="step" :list="id" :min="min" :max="max" @focus="focused = true" @blur="focused = false" @keydown="onKeydown($event)" @input="onInput" > <datalist v-if="datalist" :id="id"> <option v-for="data in datalist" :key="data" :value="data"/> </datalist> <div ref="suffixEl" :class="$style.suffix"><slot name="suffix"></slot></div> </div> <div :class="$style.caption"><slot name="caption"></slot></div> <MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> </div> </template> <script lang="ts" setup> import { onMounted, onUnmounted, nextTick, ref, shallowRef, watch, computed, toRefs, InputHTMLAttributes } from 'vue'; import { debounce } from 'throttle-debounce'; import MkButton from '@/components/MkButton.vue'; import { useInterval } from '@@/js/use-interval.js'; import { i18n } from '@/i18n.js'; import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js'; const props = defineProps<{ modelValue: string | number | null; type?: InputHTMLAttributes['type']; required?: boolean; readonly?: boolean; disabled?: boolean; pattern?: string; placeholder?: string; autofocus?: boolean; autocomplete?: string; mfmAutocomplete?: boolean | SuggestionType[], autocapitalize?: string; spellcheck?: boolean; inputmode?: InputHTMLAttributes['inputmode']; step?: InputHTMLAttributes['step']; datalist?: string[]; min?: number; max?: number; inline?: boolean; debounce?: boolean; manualSave?: boolean; small?: boolean; large?: boolean; }>(); const emit = defineEmits<{ (ev: 'change', _ev: KeyboardEvent): void; (ev: 'keydown', _ev: KeyboardEvent): void; (ev: 'enter', _ev: KeyboardEvent): void; (ev: 'update:modelValue', value: string | number): void; }>(); const { modelValue, type, autofocus } = toRefs(props); const v = ref(modelValue.value); const id = Math.random().toString(); // TODO: uuid? const focused = ref(false); const changed = ref(false); const invalid = ref(false); const filled = computed(() => v.value !== '' && v.value != null); const inputEl = shallowRef<HTMLInputElement>(); const prefixEl = shallowRef<HTMLElement>(); const suffixEl = shallowRef<HTMLElement>(); const height = props.small ? 33 : props.large ? 39 : 36; let autocompleteWorker: Autocomplete | null = null; const focus = () => inputEl.value?.focus(); const onInput = (event: Event) => { const ev = event as KeyboardEvent; changed.value = true; emit('change', ev); }; const onKeydown = (ev: KeyboardEvent) => { if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return; emit('keydown', ev); if (ev.code === 'Enter') { emit('enter', ev); } }; const updated = () => { changed.value = false; if (type.value === 'number') { emit('update:modelValue', typeof v.value === 'number' ? v.value : parseFloat(v.value ?? '0')); } else { emit('update:modelValue', v.value ?? ''); } }; const debouncedUpdated = debounce(1000, updated); watch(modelValue, newValue => { v.value = newValue; }); watch(v, () => { if (!props.manualSave) { if (props.debounce) { debouncedUpdated(); } else { updated(); } } invalid.value = inputEl.value?.validity.badInput ?? true; }); // このコンポーネントが作成された時、非表示状態である場合がある // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する useInterval(() => { if (inputEl.value == null) return; if (prefixEl.value) { if (prefixEl.value.offsetWidth) { inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; } } if (suffixEl.value) { if (suffixEl.value.offsetWidth) { inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; } } }, 100, { immediate: true, afterMounted: true, }); onMounted(() => { nextTick(() => { if (autofocus.value) { focus(); } }); if (props.mfmAutocomplete && inputEl.value) { autocompleteWorker = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? undefined : props.mfmAutocomplete); } }); onUnmounted(() => { if (autocompleteWorker) { autocompleteWorker.detach(); } }); defineExpose({ focus, }); </script> <style lang="scss" module> .label { font-size: 0.85em; padding: 0 0 8px 0; user-select: none; &:empty { display: none; } } .caption { font-size: 0.85em; padding: 8px 0 0 0; color: var(--MI_THEME-fgTransparentWeak); &:empty { display: none; } } .input { position: relative; &.inline { display: inline-block; margin: 0; } &.focused { > .inputCore { border-color: var(--MI_THEME-accent) !important; //box-shadow: 0 0 0 4px var(--MI_THEME-focus); } } &.disabled { opacity: 0.7; &, > .inputCore { cursor: not-allowed !important; } } } .inputCore { appearance: none; -webkit-appearance: none; display: block; height: v-bind("height + 'px'"); width: 100%; margin: 0; padding: 0 12px; font: inherit; font-weight: normal; font-size: 1em; color: var(--MI_THEME-fg); background: var(--MI_THEME-panel); border: solid 1px var(--MI_THEME-panel); border-radius: 6px; outline: none; box-shadow: none; box-sizing: border-box; transition: border-color 0.1s ease-out; &:hover { border-color: var(--MI_THEME-inputBorderHover) !important; } } .prefix, .suffix { display: flex; align-items: center; position: absolute; z-index: 1; top: 0; padding: 0 12px; font-size: 1em; height: v-bind("height + 'px'"); min-width: 16px; max-width: 150px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; box-sizing: border-box; pointer-events: none; &:empty { display: none; } } .prefix { left: 0; padding-right: 6px; } .suffix { right: 0; padding-left: 6px; } .save { margin: 8px 0 0 0; } </style>