275 lines
		
	
	
		
			5.6 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			275 lines
		
	
	
		
			5.6 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
<!--
 | 
						|
SPDX-FileCopyrightText: syuilo and other misskey contributors
 | 
						|
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"
 | 
						|
			:spellcheck="spellcheck"
 | 
						|
			: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, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue';
 | 
						|
import { debounce } from 'throttle-debounce';
 | 
						|
import MkButton from '@/components/MkButton.vue';
 | 
						|
import { useInterval } from '@/scripts/use-interval.js';
 | 
						|
import { i18n } from '@/i18n.js';
 | 
						|
 | 
						|
const props = defineProps<{
 | 
						|
	modelValue: string | number | null;
 | 
						|
	type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local';
 | 
						|
	required?: boolean;
 | 
						|
	readonly?: boolean;
 | 
						|
	disabled?: boolean;
 | 
						|
	pattern?: string;
 | 
						|
	placeholder?: string;
 | 
						|
	autofocus?: boolean;
 | 
						|
	autocomplete?: string;
 | 
						|
	spellcheck?: boolean;
 | 
						|
	step?: any;
 | 
						|
	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'): 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<HTMLElement>();
 | 
						|
const prefixEl = shallowRef<HTMLElement>();
 | 
						|
const suffixEl = shallowRef<HTMLElement>();
 | 
						|
const height =
 | 
						|
	props.small ? 33 :
 | 
						|
	props.large ? 39 :
 | 
						|
	36;
 | 
						|
 | 
						|
const focus = () => inputEl.value.focus();
 | 
						|
const onInput = (ev: 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');
 | 
						|
	}
 | 
						|
};
 | 
						|
 | 
						|
const updated = () => {
 | 
						|
	changed.value = false;
 | 
						|
	if (type.value === 'number') {
 | 
						|
		emit('update:modelValue', parseFloat(v.value));
 | 
						|
	} else {
 | 
						|
		emit('update:modelValue', v.value);
 | 
						|
	}
 | 
						|
};
 | 
						|
 | 
						|
const debouncedUpdated = debounce(1000, updated);
 | 
						|
 | 
						|
watch(modelValue, newValue => {
 | 
						|
	v.value = newValue;
 | 
						|
});
 | 
						|
 | 
						|
watch(v, newValue => {
 | 
						|
	if (!props.manualSave) {
 | 
						|
		if (props.debounce) {
 | 
						|
			debouncedUpdated();
 | 
						|
		} else {
 | 
						|
			updated();
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	invalid.value = inputEl.value.validity.badInput;
 | 
						|
});
 | 
						|
 | 
						|
// このコンポーネントが作成された時、非表示状態である場合がある
 | 
						|
// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
 | 
						|
useInterval(() => {
 | 
						|
	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();
 | 
						|
		}
 | 
						|
	});
 | 
						|
});
 | 
						|
 | 
						|
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(--fgTransparentWeak);
 | 
						|
 | 
						|
	&:empty {
 | 
						|
		display: none;
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
.input {
 | 
						|
	position: relative;
 | 
						|
 | 
						|
	&.inline {
 | 
						|
		display: inline-block;
 | 
						|
		margin: 0;
 | 
						|
	}
 | 
						|
 | 
						|
	&.focused {
 | 
						|
		> .inputCore {
 | 
						|
			border-color: var(--accent) !important;
 | 
						|
			//box-shadow: 0 0 0 4px var(--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(--fg);
 | 
						|
	background: var(--panel);
 | 
						|
	border: solid 1px var(--panel);
 | 
						|
	border-radius: 6px;
 | 
						|
	outline: none;
 | 
						|
	box-shadow: none;
 | 
						|
	box-sizing: border-box;
 | 
						|
	transition: border-color 0.1s ease-out;
 | 
						|
 | 
						|
	&:hover {
 | 
						|
		border-color: var(--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>
 |