diff --git a/packages/client/src/components/abuse-report-window.vue b/packages/client/src/components/abuse-report-window.vue
index 6b07639f6d..cd04f62bca 100644
--- a/packages/client/src/components/abuse-report-window.vue
+++ b/packages/client/src/components/abuse-report-window.vue
@@ -1,8 +1,8 @@
 <template>
-<XWindow ref="window" :initial-width="400" :initial-height="500" :can-resize="true" @closed="$emit('closed')">
+<XWindow ref="window" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')">
 	<template #header>
 		<i class="fas fa-exclamation-circle" style="margin-right: 0.5em;"></i>
-		<I18n :src="$ts.reportAbuseOf" tag="span">
+		<I18n :src="i18n.locale.reportAbuseOf" tag="span">
 			<template #name>
 				<b><MkAcct :user="user"/></b>
 			</template>
@@ -11,65 +11,51 @@
 	<div class="dpvffvvy _monolithic_">
 		<div class="_section">
 			<MkTextarea v-model="comment">
-				<template #label>{{ $ts.details }}</template>
-				<template #caption>{{ $ts.fillAbuseReportDescription }}</template>
+				<template #label>{{ i18n.locale.details }}</template>
+				<template #caption>{{ i18n.locale.fillAbuseReportDescription }}</template>
 			</MkTextarea>
 		</div>
 		<div class="_section">
-			<MkButton primary full :disabled="comment.length === 0" @click="send">{{ $ts.send }}</MkButton>
+			<MkButton primary full :disabled="comment.length === 0" @click="send">{{ i18n.locale.send }}</MkButton>
 		</div>
 	</div>
 </XWindow>
 </template>
 
-<script lang="ts">
-import { defineComponent, markRaw } from 'vue';
+<script setup lang="ts">
+import { ref } from 'vue';
+import * as Misskey from 'misskey-js';
 import XWindow from '@/components/ui/window.vue';
 import MkTextarea from '@/components/form/textarea.vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
+import { i18n } from '@/i18n';
 
-export default defineComponent({
-	components: {
-		XWindow,
-		MkTextarea,
-		MkButton,
-	},
+const props = defineProps<{
+	user: Misskey.entities.User;
+	initialComment?: string;
+}>();
 
-	props: {
-		user: {
-			type: Object,
-			required: true,
-		},
-		initialComment: {
-			type: String,
-			required: false,
-		},
-	},
+const emit = defineEmits<{
+	(e: 'closed'): void;
+}>();
 
-	emits: ['closed'],
+const window = ref<InstanceType<typeof XWindow>>();
+const comment = ref(props.initialComment || '');
 
-	data() {
-		return {
-			comment: this.initialComment || '',
-		};
-	},
-
-	methods: {
-		send() {
-			os.apiWithDialog('users/report-abuse', {
-				userId: this.user.id,
-				comment: this.comment,
-			}, undefined, res => {
-				os.alert({
-					type: 'success',
-					text: this.$ts.abuseReported
-				});
-				this.$refs.window.close();
-			});
-		}
-	},
-});
+function send() {
+	os.apiWithDialog('users/report-abuse', {
+		userId: props.user.id,
+		comment: comment.value,
+	}, undefined).then(res => {
+		os.alert({
+			type: 'success',
+			text: i18n.locale.abuseReported
+		});
+		window.value?.close();
+		emit('closed');
+	});
+}
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/components/analog-clock.vue b/packages/client/src/components/analog-clock.vue
index 450488b198..9ca511b6e9 100644
--- a/packages/client/src/components/analog-clock.vue
+++ b/packages/client/src/components/analog-clock.vue
@@ -40,106 +40,64 @@
 </svg>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
 import * as tinycolor from 'tinycolor2';
 
-export default defineComponent({
-	props: {
-		thickness: {
-			type: Number,
-			default: 0.1
-		}
-	},
+withDefaults(defineProps<{
+	thickness: number;
+}>(), {
+	thickness: 0.1,
+});
 
-	data() {
-		return {
-			now: new Date(),
-			enabled: true,
+const now = ref(new Date());
+const enabled = ref(true);
+const graduationsPadding = ref(0.5);
+const handsPadding = ref(1);
+const handsTailLength = ref(0.7);
+const hHandLengthRatio = ref(0.75);
+const mHandLengthRatio = ref(1);
+const sHandLengthRatio = ref(1);
+const computedStyle = getComputedStyle(document.documentElement);
 
-			graduationsPadding: 0.5,
-			handsPadding: 1,
-			handsTailLength: 0.7,
-			hHandLengthRatio: 0.75,
-			mHandLengthRatio: 1,
-			sHandLengthRatio: 1,
-
-			computedStyle: getComputedStyle(document.documentElement)
-		};
-	},
-
-	computed: {
-		dark(): boolean {
-			return tinycolor(this.computedStyle.getPropertyValue('--bg')).isDark();
-		},
-
-		majorGraduationColor(): string {
-			return this.dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)';
-		},
-		minorGraduationColor(): string {
-			return this.dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
-		},
-
-		sHandColor(): string {
-			return this.dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)';
-		},
-		mHandColor(): string {
-			return tinycolor(this.computedStyle.getPropertyValue('--fg')).toHexString();
-		},
-		hHandColor(): string {
-			return tinycolor(this.computedStyle.getPropertyValue('--accent')).toHexString();
-		},
-
-		s(): number {
-			return this.now.getSeconds();
-		},
-		m(): number {
-			return this.now.getMinutes();
-		},
-		h(): number {
-			return this.now.getHours();
-		},
-
-		hAngle(): number {
-			return Math.PI * (this.h % 12 + (this.m + this.s / 60) / 60) / 6;
-		},
-		mAngle(): number {
-			return Math.PI * (this.m + this.s / 60) / 30;
-		},
-		sAngle(): number {
-			return Math.PI * this.s / 30;
-		},
-
-		graduations(): any {
-			const angles = [];
-			for (let i = 0; i < 60; i++) {
-				const angle = Math.PI * i / 30;
-				angles.push(angle);
-			}
-
-			return angles;
-		}
-	},
-
-	mounted() {
-		const update = () => {
-			if (this.enabled) {
-				this.tick();
-				setTimeout(update, 1000);
-			}
-		};
-		update();
-	},
-
-	beforeUnmount() {
-		this.enabled = false;
-	},
-
-	methods: {
-		tick() {
-			this.now = new Date();
-		}
+const dark = computed(() => tinycolor(computedStyle.getPropertyValue('--bg')).isDark());
+const majorGraduationColor = computed(() => dark.value ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)');
+const minorGraduationColor = computed(() => dark.value ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)');
+const sHandColor = computed(() => dark.value ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)');
+const mHandColor = computed(() => tinycolor(computedStyle.getPropertyValue('--fg')).toHexString());
+const hHandColor = computed(() => tinycolor(computedStyle.getPropertyValue('--accent')).toHexString());
+const s = computed(() => now.value.getSeconds());
+const m = computed(() => now.value.getMinutes());
+const h = computed(() => now.value.getHours());
+const hAngle = computed(() => Math.PI * (h.value % 12 + (m.value + s.value / 60) / 60) / 6);
+const mAngle = computed(() => Math.PI * (m.value + s.value / 60) / 30);
+const sAngle = computed(() => Math.PI * s.value / 30);
+const graduations = computed(() => {
+	const angles: number[] = [];
+	for (let i = 0; i < 60; i++) {
+		const angle = Math.PI * i / 30;
+		angles.push(angle);
 	}
+
+	return angles;
+});
+
+function tick() {
+	now.value = new Date();
+}
+
+onMounted(() => {
+	const update = () => {
+		if (enabled.value) {
+			tick();
+			setTimeout(update, 1000);
+		}
+	};
+	update();
+});
+
+onBeforeUnmount(() => {
+	enabled.value = false;
 });
 </script>
 
diff --git a/packages/client/src/components/autocomplete.vue b/packages/client/src/components/autocomplete.vue
index 30be2ac741..7ba83b7cb1 100644
--- a/packages/client/src/components/autocomplete.vue
+++ b/packages/client/src/components/autocomplete.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="swhvrteh _popup _shadow" :style="{ zIndex }" @contextmenu.prevent="() => {}">
+<div ref="rootEl" class="swhvrteh _popup _shadow" :style="{ zIndex }" @contextmenu.prevent="() => {}">
 	<ol v-if="type === 'user'" ref="suggests" class="users">
 		<li v-for="user in users" tabindex="-1" class="user" @click="complete(type, user)" @keydown="onKeydown">
 			<img class="avatar" :src="user.avatarUrl"/>
@@ -8,7 +8,7 @@
 			</span>
 			<span class="username">@{{ acct(user) }}</span>
 		</li>
-		<li tabindex="-1" class="choose" @click="chooseUser()" @keydown="onKeydown">{{ $ts.selectUser }}</li>
+		<li tabindex="-1" class="choose" @click="chooseUser()" @keydown="onKeydown">{{ i18n.locale.selectUser }}</li>
 	</ol>
 	<ol v-else-if="hashtags.length > 0" ref="suggests" class="hashtags">
 		<li v-for="hashtag in hashtags" tabindex="-1" @click="complete(type, hashtag)" @keydown="onKeydown">
@@ -17,8 +17,8 @@
 	</ol>
 	<ol v-else-if="emojis.length > 0" ref="suggests" class="emojis">
 		<li v-for="emoji in emojis" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown">
-			<span v-if="emoji.isCustomEmoji" class="emoji"><img :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span>
-			<span v-else-if="!$store.state.useOsNativeEmojis" class="emoji"><img :src="emoji.url" :alt="emoji.emoji"/></span>
+			<span v-if="emoji.isCustomEmoji" class="emoji"><img :src="defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span>
+			<span v-else-if="!defaultStore.state.useOsNativeEmojis" class="emoji"><img :src="emoji.url" :alt="emoji.emoji"/></span>
 			<span v-else class="emoji">{{ emoji.emoji }}</span>
 			<span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span>
 			<span v-if="emoji.aliasOf" class="alias">({{ emoji.aliasOf }})</span>
@@ -33,15 +33,17 @@
 </template>
 
 <script lang="ts">
-import { defineComponent, markRaw } from 'vue';
-import { emojilist } from '@/scripts/emojilist';
+import { markRaw, ref, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
 import contains from '@/scripts/contains';
-import { twemojiSvgBase } from '@/scripts/twemoji-base';
 import { getStaticImageUrl } from '@/scripts/get-static-image-url';
 import { acct } from '@/filters/user';
 import * as os from '@/os';
-import { instance } from '@/instance';
 import { MFM_TAGS } from '@/scripts/mfm-tags';
+import { defaultStore } from '@/store';
+import { emojilist } from '@/scripts/emojilist';
+import { instance } from '@/instance';
+import { twemojiSvgBase } from '@/scripts/twemoji-base';
+import { i18n } from '@/i18n';
 
 type EmojiDef = {
 	emoji: string;
@@ -54,16 +56,14 @@ type EmojiDef = {
 const lib = emojilist.filter(x => x.category !== 'flags');
 
 const char2file = (char: string) => {
-	let codes = Array.from(char).map(x => x.codePointAt(0).toString(16));
+	let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16));
 	if (!codes.includes('200d')) codes = codes.filter(x => x != 'fe0f');
-	codes = codes.filter(x => x && x.length);
-	return codes.join('-');
+	return codes.filter(x => x && x.length).join('-');
 };
 
 const emjdb: EmojiDef[] = lib.map(x => ({
 	emoji: x.char,
 	name: x.name,
-	aliasOf: null,
 	url: `${twemojiSvgBase}/${char2file(x.char)}.svg`
 }));
 
@@ -112,291 +112,270 @@ emojiDefinitions.sort((a, b) => a.name.length - b.name.length);
 const emojiDb = markRaw(emojiDefinitions.concat(emjdb));
 //#endregion
 
-export default defineComponent({
-	props: {
-		type: {
-			type: String,
-			required: true,
-		},
+export default {
+	emojiDb,
+	emojiDefinitions,
+	emojilist,
+	customEmojis,
+};
+</script>
 
-		q: {
-			type: String,
-			required: false,
-		},
+<script lang="ts" setup>
+const props = defineProps<{
+	type: string;
+	q: string | null;
+	textarea: HTMLTextAreaElement;
+	close: () => void;
+	x: number;
+	y: number;
+}>();
 
-		textarea: {
-			type: HTMLTextAreaElement,
-			required: true,
-		},
+const emit = defineEmits<{
+	(e: 'done', v: { type: string; value: any }): void;
+	(e: 'closed'): void;
+}>();
 
-		close: {
-			type: Function,
-			required: true,
-		},
+const suggests = ref<Element>();
+const rootEl = ref<HTMLDivElement>();
 
-		x: {
-			type: Number,
-			required: true,
-		},
+const fetching = ref(true);
+const users = ref<any[]>([]);
+const hashtags = ref<any[]>([]);
+const emojis = ref<(EmojiDef)[]>([]);
+const items = ref<Element[] | HTMLCollection>([]);
+const mfmTags = ref<string[]>([]);
+const select = ref(-1);
+const zIndex = os.claimZIndex('high');
 
-		y: {
-			type: Number,
-			required: true,
-		},
-	},
+function complete(type: string, value: any) {
+	emit('done', { type, value });
+	emit('closed');
+	if (type === 'emoji') {
+		let recents = defaultStore.state.recentlyUsedEmojis;
+		recents = recents.filter((e: any) => e !== value);
+		recents.unshift(value);
+		defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32));
+	}
+}
 
-	emits: ['done', 'closed'],
+function setPosition() {
+	if (!rootEl.value) return;
+	if (props.x + rootEl.value.offsetWidth > window.innerWidth) {
+		rootEl.value.style.left = (window.innerWidth - rootEl.value.offsetWidth) + 'px';
+	} else {
+		rootEl.value.style.left = `${props.x}px`;
+	}
+	if (props.y + rootEl.value.offsetHeight > window.innerHeight) {
+		rootEl.value.style.top = (props.y - rootEl.value.offsetHeight) + 'px';
+		rootEl.value.style.marginTop = '0';
+	} else {
+		rootEl.value.style.top = props.y + 'px';
+		rootEl.value.style.marginTop = 'calc(1em + 8px)';
+	}
+}
 
-	data() {
-		return {
-			getStaticImageUrl,
-			fetching: true,
-			users: [],
-			hashtags: [],
-			emojis: [],
-			items: [],
-			mfmTags: [],
-			select: -1,
-			zIndex: os.claimZIndex('high'),
+function exec() {
+	select.value = -1;
+	if (suggests.value) {
+		for (const el of Array.from(items.value)) {
+			el.removeAttribute('data-selected');
 		}
-	},
-
-	updated() {
-		this.setPosition();
-		this.items = (this.$refs.suggests as Element | undefined)?.children || [];
-	},
-
-	mounted() {
-		this.setPosition();
-
-		this.textarea.addEventListener('keydown', this.onKeydown);
-
-		for (const el of Array.from(document.querySelectorAll('body *'))) {
-			el.addEventListener('mousedown', this.onMousedown);
+	}
+	if (props.type === 'user') {
+		if (!props.q) {
+			users.value = [];
+			fetching.value = false;
+			return;
 		}
 
-		this.$nextTick(() => {
-			this.exec();
+		const cacheKey = `autocomplete:user:${props.q}`;
+		const cache = sessionStorage.getItem(cacheKey);
 
-			this.$watch('q', () => {
-				this.$nextTick(() => {
-					this.exec();
+		if (cache) {
+			const users = JSON.parse(cache);
+			users.value = users;
+			fetching.value = false;
+		} else {
+			os.api('users/search-by-username-and-host', {
+				username: props.q,
+				limit: 10,
+				detail: false
+			}).then(searchedUsers => {
+				users.value = searchedUsers as any[];
+				fetching.value = false;
+				// キャッシュ
+				sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
+			});
+		}
+	} else if (props.type === 'hashtag') {
+		if (!props.q || props.q == '') {
+			hashtags.value = JSON.parse(localStorage.getItem('hashtags') || '[]');
+			fetching.value = false;
+		} else {
+			const cacheKey = `autocomplete:hashtag:${props.q}`;
+			const cache = sessionStorage.getItem(cacheKey);
+			if (cache) {
+				const hashtags = JSON.parse(cache);
+				hashtags.value = hashtags;
+				fetching.value = false;
+			} else {
+				os.api('hashtags/search', {
+					query: props.q,
+					limit: 30
+				}).then(searchedHashtags => {
+					hashtags.value = searchedHashtags as any[];
+					fetching.value = false;
+					// キャッシュ
+					sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags));
 				});
+			}
+		}
+	} else if (props.type === 'emoji') {
+		if (!props.q || props.q == '') {
+			// 最近使った絵文字をサジェスト
+			emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => emojiDb.find(e => e.emoji == emoji)).filter(x => x) as EmojiDef[];
+			return;
+		}
+
+		const matched: EmojiDef[] = [];
+		const max = 30;
+
+		emojiDb.some(x => {
+			if (x.name.startsWith(props.q || '') && !x.aliasOf && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
+			return matched.length == max;
+		});
+
+		if (matched.length < max) {
+			emojiDb.some(x => {
+				if (x.name.startsWith(props.q || '') && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
+				return matched.length == max;
+			});
+		}
+
+		if (matched.length < max) {
+			emojiDb.some(x => {
+				if (x.name.includes(props.q || '') && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
+				return matched.length == max;
+			});
+		}
+
+		emojis.value = matched;
+	} else if (props.type === 'mfmTag') {
+		if (!props.q || props.q == '') {
+			mfmTags.value = MFM_TAGS;
+			return;
+		}
+
+		mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q || ''));
+	}
+}
+
+function onMousedown(e: Event) {
+	if (!contains(rootEl.value, e.target) && (rootEl.value != e.target)) props.close();
+}
+
+function onKeydown(e: KeyboardEvent) {
+	const cancel = () => {
+		e.preventDefault();
+		e.stopPropagation();
+	};
+
+	switch (e.key) {
+		case 'Enter':
+			if (select.value !== -1) {
+				cancel();
+				(items.value[select.value] as any).click();
+			} else {
+				props.close();
+			}
+			break;
+
+		case 'Escape':
+			cancel();
+			props.close();
+			break;
+
+		case 'ArrowUp':
+			if (select.value !== -1) {
+				cancel();
+				selectPrev();
+			} else {
+				props.close();
+			}
+			break;
+
+		case 'Tab':
+		case 'ArrowDown':
+			cancel();
+			selectNext();
+			break;
+
+		default:
+			e.stopPropagation();
+			props.textarea.focus();
+	}
+}
+
+function selectNext() {
+	if (++select.value >= items.value.length) select.value = 0;
+	if (items.value.length === 0) select.value = -1;
+	applySelect();
+}
+
+function selectPrev() {
+	if (--select.value < 0) select.value = items.value.length - 1;
+	applySelect();
+}
+
+function applySelect() {
+	for (const el of Array.from(items.value)) {
+		el.removeAttribute('data-selected');
+	}
+
+	if (select.value !== -1) {
+		items.value[select.value].setAttribute('data-selected', 'true');
+		(items.value[select.value] as any).focus();
+	}
+}
+
+function chooseUser() {
+	props.close();
+	os.selectUser().then(user => {
+		complete('user', user);
+		props.textarea.focus();
+	});
+}
+
+onUpdated(() => {
+	setPosition();
+	items.value = suggests.value?.children || [];
+});
+
+onMounted(() => {
+	setPosition();
+
+	props.textarea.addEventListener('keydown', onKeydown);
+
+	for (const el of Array.from(document.querySelectorAll('body *'))) {
+		el.addEventListener('mousedown', onMousedown);
+	}
+
+	nextTick(() => {
+		exec();
+
+		watch(() => props.q, () => {
+			nextTick(() => {
+				exec();
 			});
 		});
-	},
+	});
+});
 
-	beforeUnmount() {
-		this.textarea.removeEventListener('keydown', this.onKeydown);
+onBeforeUnmount(() => {
+	props.textarea.removeEventListener('keydown', onKeydown);
 
-		for (const el of Array.from(document.querySelectorAll('body *'))) {
-			el.removeEventListener('mousedown', this.onMousedown);
-		}
-	},
-
-	methods: {
-		complete(type, value) {
-			this.$emit('done', { type, value });
-			this.$emit('closed');
-
-			if (type === 'emoji') {
-				let recents = this.$store.state.recentlyUsedEmojis;
-				recents = recents.filter((e: any) => e !== value);
-				recents.unshift(value);
-				this.$store.set('recentlyUsedEmojis', recents.splice(0, 32));
-			}
-		},
-
-		setPosition() {
-			if (this.x + this.$el.offsetWidth > window.innerWidth) {
-				this.$el.style.left = (window.innerWidth - this.$el.offsetWidth) + 'px';
-			} else {
-				this.$el.style.left = this.x + 'px';
-			}
-
-			if (this.y + this.$el.offsetHeight > window.innerHeight) {
-				this.$el.style.top = (this.y - this.$el.offsetHeight) + 'px';
-				this.$el.style.marginTop = '0';
-			} else {
-				this.$el.style.top = this.y + 'px';
-				this.$el.style.marginTop = 'calc(1em + 8px)';
-			}
-		},
-
-		exec() {
-			this.select = -1;
-			if (this.$refs.suggests) {
-				for (const el of Array.from(this.items)) {
-					el.removeAttribute('data-selected');
-				}
-			}
-
-			if (this.type === 'user') {
-				if (this.q == null) {
-					this.users = [];
-					this.fetching = false;
-					return;
-				}
-
-				const cacheKey = `autocomplete:user:${this.q}`;
-				const cache = sessionStorage.getItem(cacheKey);
-				if (cache) {
-					const users = JSON.parse(cache);
-					this.users = users;
-					this.fetching = false;
-				} else {
-					os.api('users/search-by-username-and-host', {
-						username: this.q,
-						limit: 10,
-						detail: false
-					}).then(users => {
-						this.users = users;
-						this.fetching = false;
-
-						// キャッシュ
-						sessionStorage.setItem(cacheKey, JSON.stringify(users));
-					});
-				}
-			} else if (this.type === 'hashtag') {
-				if (this.q == null || this.q == '') {
-					this.hashtags = JSON.parse(localStorage.getItem('hashtags') || '[]');
-					this.fetching = false;
-				} else {
-					const cacheKey = `autocomplete:hashtag:${this.q}`;
-					const cache = sessionStorage.getItem(cacheKey);
-					if (cache) {
-						const hashtags = JSON.parse(cache);
-						this.hashtags = hashtags;
-						this.fetching = false;
-					} else {
-						os.api('hashtags/search', {
-							query: this.q,
-							limit: 30
-						}).then(hashtags => {
-							this.hashtags = hashtags;
-							this.fetching = false;
-
-							// キャッシュ
-							sessionStorage.setItem(cacheKey, JSON.stringify(hashtags));
-						});
-					}
-				}
-			} else if (this.type === 'emoji') {
-				if (this.q == null || this.q == '') {
-					// 最近使った絵文字をサジェスト
-					this.emojis = this.$store.state.recentlyUsedEmojis.map(emoji => emojiDb.find(e => e.emoji == emoji)).filter(x => x != null);
-					return;
-				}
-
-				const matched = [];
-				const max = 30;
-
-				emojiDb.some(x => {
-					if (x.name.startsWith(this.q) && !x.aliasOf && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
-					return matched.length == max;
-				});
-				if (matched.length < max) {
-					emojiDb.some(x => {
-						if (x.name.startsWith(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
-						return matched.length == max;
-					});
-				}
-				if (matched.length < max) {
-					emojiDb.some(x => {
-						if (x.name.includes(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
-						return matched.length == max;
-					});
-				}
-
-				this.emojis = matched;
-			} else if (this.type === 'mfmTag') {
-				if (this.q == null || this.q == '') {
-					this.mfmTags = MFM_TAGS;
-					return;
-				}
-
-				this.mfmTags = MFM_TAGS.filter(tag => tag.startsWith(this.q));
-			}
-		},
-
-		onMousedown(e) {
-			if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close();
-		},
-
-		onKeydown(e) {
-			const cancel = () => {
-				e.preventDefault();
-				e.stopPropagation();
-			};
-
-			switch (e.which) {
-				case 10: // [ENTER]
-				case 13: // [ENTER]
-					if (this.select !== -1) {
-						cancel();
-						(this.items[this.select] as any).click();
-					} else {
-						this.close();
-					}
-					break;
-
-				case 27: // [ESC]
-					cancel();
-					this.close();
-					break;
-
-				case 38: // [↑]
-					if (this.select !== -1) {
-						cancel();
-						this.selectPrev();
-					} else {
-						this.close();
-					}
-					break;
-
-				case 9: // [TAB]
-				case 40: // [↓]
-					cancel();
-					this.selectNext();
-					break;
-
-				default:
-					e.stopPropagation();
-					this.textarea.focus();
-			}
-		},
-
-		selectNext() {
-			if (++this.select >= this.items.length) this.select = 0;
-			if (this.items.length === 0) this.select = -1;
-			this.applySelect();
-		},
-
-		selectPrev() {
-			if (--this.select < 0) this.select = this.items.length - 1;
-			this.applySelect();
-		},
-
-		applySelect() {
-			for (const el of Array.from(this.items)) {
-				el.removeAttribute('data-selected');
-			}
-
-			if (this.select !== -1) {
-				this.items[this.select].setAttribute('data-selected', 'true');
-				(this.items[this.select] as any).focus();
-			}
-		},
-
-		chooseUser() {
-			this.close();
-			os.selectUser().then(user => {
-				this.complete('user', user);
-				this.textarea.focus();
-			});
-		},
-
-		acct
+	for (const el of Array.from(document.querySelectorAll('body *'))) {
+		el.removeEventListener('mousedown', onMousedown);
 	}
 });
 </script>
diff --git a/packages/client/src/components/captcha.vue b/packages/client/src/components/captcha.vue
index baa922506e..2a4181255f 100644
--- a/packages/client/src/components/captcha.vue
+++ b/packages/client/src/components/captcha.vue
@@ -1,12 +1,14 @@
 <template>
 <div>
-	<span v-if="!available">{{ $ts.waiting }}<MkEllipsis/></span>
-	<div ref="captcha"></div>
+	<span v-if="!available">{{ i18n.locale.waiting }}<MkEllipsis/></span>
+	<div ref="captchaEl"></div>
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, PropType } from 'vue';
+<script lang="ts" setup>
+import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
 
 type Captcha = {
 	render(container: string | Node, options: {
@@ -14,7 +16,7 @@ type Captcha = {
 	}): string;
 	remove(id: string): void;
 	execute(id: string): void;
-	reset(id: string): void;
+	reset(id?: string): void;
 	getResponse(id: string): string;
 };
 
@@ -29,95 +31,87 @@ declare global {
 	}
 }
 
-export default defineComponent({
-	props: {
-		provider: {
-			type: String as PropType<CaptchaProvider>,
-			required: true,
-		},
-		sitekey: {
-			type: String,
-			required: true,
-		},
-		modelValue: {
-			type: String,
-		},
-	},
+const props = defineProps<{
+	provider: CaptchaProvider;
+	sitekey: string;
+	modelValue?: string | null;
+}>();
 
-	data() {
-		return {
-			available: false,
-		};
-	},
+const emit = defineEmits<{
+	(e: 'update:modelValue', v: string | null): void;
+}>();
 
-	computed: {
-		variable(): string {
-			switch (this.provider) {
-				case 'hcaptcha': return 'hcaptcha';
-				case 'recaptcha': return 'grecaptcha';
-			}
-		},
-		loaded(): boolean {
-			return !!window[this.variable];
-		},
-		src(): string {
-			const endpoint = ({
-				hcaptcha: 'https://hcaptcha.com/1',
-				recaptcha: 'https://www.recaptcha.net/recaptcha',
-			} as Record<CaptchaProvider, string>)[this.provider];
+const available = ref(false);
 
-			return `${typeof endpoint === 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`;
-		},
-		captcha(): Captcha {
-			return window[this.variable] || {} as unknown as Captcha;
-		},
-	},
+const captchaEl = ref<HTMLDivElement | undefined>();
 
-	created() {
-		if (this.loaded) {
-			this.available = true;
-		} else {
-			(document.getElementById(this.provider) || document.head.appendChild(Object.assign(document.createElement('script'), {
-				async: true,
-				id: this.provider,
-				src: this.src,
-			})))
-				.addEventListener('load', () => this.available = true);
-		}
-	},
-
-	mounted() {
-		if (this.available) {
-			this.requestRender();
-		} else {
-			this.$watch('available', this.requestRender);
-		}
-	},
-
-	beforeUnmount() {
-		this.reset();
-	},
-
-	methods: {
-		reset() {
-			if (this.captcha?.reset) this.captcha.reset();
-		},
-		requestRender() {
-			if (this.captcha.render && this.$refs.captcha instanceof Element) {
-				this.captcha.render(this.$refs.captcha, {
-					sitekey: this.sitekey,
-					theme: this.$store.state.darkMode ? 'dark' : 'light',
-					callback: this.callback,
-					'expired-callback': this.callback,
-					'error-callback': this.callback,
-				});
-			} else {
-				setTimeout(this.requestRender.bind(this), 1);
-			}
-		},
-		callback(response?: string) {
-			this.$emit('update:modelValue', typeof response == 'string' ? response : null);
-		},
-	},
+const variable = computed(() => {
+	switch (props.provider) {
+		case 'hcaptcha': return 'hcaptcha';
+		case 'recaptcha': return 'grecaptcha';
+	}
 });
+
+const loaded = computed(() => !!window[variable.value]);
+
+const src = computed(() => {
+	const endpoint = ({
+		hcaptcha: 'https://hcaptcha.com/1',
+		recaptcha: 'https://www.recaptcha.net/recaptcha',
+	} as Record<CaptchaProvider, string>)[props.provider];
+
+	return `${typeof endpoint === 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`;
+});
+
+const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);
+
+if (loaded.value) {
+	available.value = true;
+} else {
+	(document.getElementById(props.provider) || document.head.appendChild(Object.assign(document.createElement('script'), {
+		async: true,
+		id: props.provider,
+		src: src.value,
+	})))
+		.addEventListener('load', () => available.value = true);
+}
+
+function reset() {
+	if (captcha.value?.reset) captcha.value.reset();
+}
+
+function requestRender() {
+	if (captcha.value.render && captchaEl.value instanceof Element) {
+		captcha.value.render(captchaEl.value, {
+			sitekey: props.sitekey,
+			theme: defaultStore.state.darkMode ? 'dark' : 'light',
+			callback: callback,
+			'expired-callback': callback,
+			'error-callback': callback,
+		});
+	} else {
+		setTimeout(requestRender, 1);
+	}
+}
+
+function callback(response?: string) {
+	emit('update:modelValue', typeof response == 'string' ? response : null);
+}
+
+onMounted(() => {
+	if (available.value) {
+		requestRender();
+	} else {
+		watch(available, requestRender);
+	}
+});
+
+onBeforeUnmount(() => {
+	reset();
+});
+
+defineExpose({
+	reset,
+});
+
 </script>
diff --git a/packages/client/src/components/channel-follow-button.vue b/packages/client/src/components/channel-follow-button.vue
index abde7c8148..0ad5384cd5 100644
--- a/packages/client/src/components/channel-follow-button.vue
+++ b/packages/client/src/components/channel-follow-button.vue
@@ -6,66 +6,54 @@
 >
 	<template v-if="!wait">
 		<template v-if="isFollowing">
-			<span v-if="full">{{ $ts.unfollow }}</span><i class="fas fa-minus"></i>
+			<span v-if="full">{{ i18n.locale.unfollow }}</span><i class="fas fa-minus"></i>
 		</template>
 		<template v-else>
-			<span v-if="full">{{ $ts.follow }}</span><i class="fas fa-plus"></i>
+			<span v-if="full">{{ i18n.locale.follow }}</span><i class="fas fa-plus"></i>
 		</template>
 	</template>
 	<template v-else>
-		<span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i>
+		<span v-if="full">{{ i18n.locale.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i>
 	</template>
 </button>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { ref } from 'vue';
 import * as os from '@/os';
+import { i18n } from '@/i18n';
 
-export default defineComponent({
-	props: {
-		channel: {
-			type: Object,
-			required: true
-		},
-		full: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-	},
-
-	data() {
-		return {
-			isFollowing: this.channel.isFollowing,
-			wait: false,
-		};
-	},
-
-	methods: {
-		async onClick() {
-			this.wait = true;
-
-			try {
-				if (this.isFollowing) {
-					await os.api('channels/unfollow', {
-						channelId: this.channel.id
-					});
-					this.isFollowing = false;
-				} else {
-					await os.api('channels/follow', {
-						channelId: this.channel.id
-					});
-					this.isFollowing = true;
-				}
-			} catch (e) {
-				console.error(e);
-			} finally {
-				this.wait = false;
-			}
-		}
-	}
+const props = withDefaults(defineProps<{
+	channel: Record<string, any>;
+	full?: boolean;
+}>(), {
+	full: false,
 });
+
+const isFollowing = ref<boolean>(props.channel.isFollowing);
+const wait = ref(false);
+
+async function onClick() {
+	wait.value = true;
+
+	try {
+		if (isFollowing.value) {
+			await os.api('channels/unfollow', {
+				channelId: props.channel.id
+			});
+			isFollowing.value = false;
+		} else {
+			await os.api('channels/follow', {
+				channelId: props.channel.id
+			});
+			isFollowing.value = true;
+		}
+	} catch (e) {
+		console.error(e);
+	} finally {
+		wait.value = false;
+	}
+}
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/components/channel-preview.vue b/packages/client/src/components/channel-preview.vue
index f2b6de97fd..8d135a192f 100644
--- a/packages/client/src/components/channel-preview.vue
+++ b/packages/client/src/components/channel-preview.vue
@@ -6,7 +6,7 @@
 		<div class="status">
 			<div>
 				<i class="fas fa-users fa-fw"></i>
-				<I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;">
+				<I18n :src="i18n.locale._channel.usersCount" tag="span" style="margin-left: 4px;">
 					<template #n>
 						<b>{{ channel.usersCount }}</b>
 					</template>
@@ -14,7 +14,7 @@
 			</div>
 			<div>
 				<i class="fas fa-pencil-alt fa-fw"></i>
-				<I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;">
+				<I18n :src="i18n.locale._channel.notesCount" tag="span" style="margin-left: 4px;">
 					<template #n>
 						<b>{{ channel.notesCount }}</b>
 					</template>
@@ -27,37 +27,26 @@
 	</article>
 	<footer>
 		<span v-if="channel.lastNotedAt">
-			{{ $ts.updatedAt }}: <MkTime :time="channel.lastNotedAt"/>
+			{{ i18n.locale.updatedAt }}: <MkTime :time="channel.lastNotedAt"/>
 		</span>
 	</footer>
 </MkA>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed } from 'vue';
+import { i18n } from '@/i18n';
 
-export default defineComponent({
-	props: {
-		channel: {
-			type: Object,
-			required: true
-		},
-	},
+const props = defineProps<{
+	channel: Record<string, any>;
+}>();
 
-	data() {
-		return {
-		};
-	},
-
-	computed: {
-		bannerStyle() {
-			if (this.channel.bannerUrl) {
-				return { backgroundImage: `url(${this.channel.bannerUrl})` };
-			} else {
-				return { backgroundColor: '#4c5e6d' };
-			}
-		}
-	},
+const bannerStyle = computed(() => {
+	if (props.channel.bannerUrl) {
+		return { backgroundImage: `url(${props.channel.bannerUrl})` };
+	} else {
+		return { backgroundColor: '#4c5e6d' };
+	}
 });
 </script>
 
diff --git a/packages/client/src/components/code-core.vue b/packages/client/src/components/code-core.vue
index b58484c2ac..45a38afe04 100644
--- a/packages/client/src/components/code-core.vue
+++ b/packages/client/src/components/code-core.vue
@@ -3,33 +3,17 @@
 <pre v-else :class="`language-${prismLang}`"><code :class="`language-${prismLang}`" v-html="html"></code></pre>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed } from 'vue';
 import 'prismjs';
 import 'prismjs/themes/prism-okaidia.css';
 
-export default defineComponent({
-	props: {
-		code: {
-			type: String,
-			required: true
-		},
-		lang: {
-			type: String,
-			required: false
-		},
-		inline: {
-			type: Boolean,
-			required: false
-		}
-	},
-	computed: {
-		prismLang() {
-			return Prism.languages[this.lang] ? this.lang : 'js';
-		},
-		html() {
-			return Prism.highlight(this.code, Prism.languages[this.prismLang], this.prismLang);
-		}
-	}
-});
+const props = defineProps<{
+	code: string;
+	lang?: string;
+	inline?: boolean;
+}>();
+
+const prismLang = computed(() => Prism.languages[props.lang] ? props.lang : 'js');
+const html = computed(() => Prism.highlight(props.code, Prism.languages[prismLang.value], prismLang.value));
 </script>
diff --git a/packages/client/src/components/code.vue b/packages/client/src/components/code.vue
index f5d6c5673a..d6478fd2f8 100644
--- a/packages/client/src/components/code.vue
+++ b/packages/client/src/components/code.vue
@@ -2,26 +2,14 @@
 <XCode :code="code" :lang="lang" :inline="inline"/>
 </template>
 
-<script lang="ts">
-import { defineComponent, defineAsyncComponent } from 'vue';
+<script lang="ts" setup>
+import { defineAsyncComponent } from 'vue';
 
-export default defineComponent({
-	components: {
-		XCode: defineAsyncComponent(() => import('./code-core.vue'))
-	},
-	props: {
-		code: {
-			type: String,
-			required: true
-		},
-		lang: {
-			type: String,
-			required: false
-		},
-		inline: {
-			type: Boolean,
-			required: false
-		}
-	}
-});
+defineProps<{
+	code: string;
+	lang?: string;
+	inline?: boolean;
+}>();
+
+const XCode = defineAsyncComponent(() => import('./code-core.vue'));
 </script>
diff --git a/packages/client/src/components/cw-button.vue b/packages/client/src/components/cw-button.vue
index b0a9860de2..ccfd11462a 100644
--- a/packages/client/src/components/cw-button.vue
+++ b/packages/client/src/components/cw-button.vue
@@ -1,6 +1,6 @@
 <template>
 <button class="nrvgflfu _button" @click="toggle">
-	<b>{{ modelValue ? $ts._cw.hide : $ts._cw.show }}</b>
+	<b>{{ modelValue ? i18n.locale._cw.hide : i18n.locale._cw.show }}</b>
 	<span v-if="!modelValue">{{ label }}</span>
 </button>
 </template>
diff --git a/packages/client/src/components/date-separated-list.vue b/packages/client/src/components/date-separated-list.vue
index aa84c6f60d..c85a0a6ffc 100644
--- a/packages/client/src/components/date-separated-list.vue
+++ b/packages/client/src/components/date-separated-list.vue
@@ -1,6 +1,8 @@
 <script lang="ts">
 import { defineComponent, h, PropType, TransitionGroup } from 'vue';
 import MkAd from '@/components/global/ad.vue';
+import { i18n } from '@/i18n';
+import { defaultStore } from '@/store';
 
 export default defineComponent({
 	props: {
@@ -30,29 +32,29 @@ export default defineComponent({
 		},
 	},
 
-	methods: {
-		getDateText(time: string) {
+	setup(props, { slots, expose }) {
+		function getDateText(time: string) {
 			const date = new Date(time).getDate();
 			const month = new Date(time).getMonth() + 1;
-			return this.$t('monthAndDay', {
+			return i18n.t('monthAndDay', {
 				month: month.toString(),
 				day: date.toString()
 			});
 		}
-	},
 
-	render() {
-		if (this.items.length === 0) return;
+		if (props.items.length === 0) return;
 
-		const renderChildren = () => this.items.map((item, i) => {
-			const el = this.$slots.default({
+		const renderChildren = () => props.items.map((item, i) => {
+			if (!slots || !slots.default) return;
+
+			const el = slots.default({
 				item: item
 			})[0];
 			if (el.key == null && item.id) el.key = item.id;
 
 			if (
-				i != this.items.length - 1 &&
-				new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate()
+				i != props.items.length - 1 &&
+				new Date(item.createdAt).getDate() != new Date(props.items[i + 1].createdAt).getDate()
 			) {
 				const separator = h('div', {
 					class: 'separator',
@@ -64,10 +66,10 @@ export default defineComponent({
 						h('i', {
 							class: 'fas fa-angle-up icon',
 						}),
-						this.getDateText(item.createdAt)
+						getDateText(item.createdAt)
 					]),
 					h('span', [
-						this.getDateText(this.items[i + 1].createdAt),
+						getDateText(props.items[i + 1].createdAt),
 						h('i', {
 							class: 'fas fa-angle-down icon',
 						})
@@ -76,7 +78,7 @@ export default defineComponent({
 
 				return [el, separator];
 			} else {
-				if (this.ad && item._shouldInsertAd_) {
+				if (props.ad && item._shouldInsertAd_) {
 					return [h(MkAd, {
 						class: 'a', // advertiseの意(ブロッカー対策)
 						key: item.id + ':ad',
@@ -88,18 +90,19 @@ export default defineComponent({
 			}
 		});
 
-		return h(this.$store.state.animation ? TransitionGroup : 'div', this.$store.state.animation ? {
-			class: 'sqadhkmv' + (this.noGap ? ' noGap' : ''),
-			name: 'list',
-			tag: 'div',
-			'data-direction': this.direction,
-			'data-reversed': this.reversed ? 'true' : 'false',
-		} : {
-			class: 'sqadhkmv' + (this.noGap ? ' noGap' : ''),
-		}, {
-			default: renderChildren
-		});
-	},
+		return () => h(
+			defaultStore.state.animation ? TransitionGroup : 'div',
+			defaultStore.state.animation ? {
+					class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
+					name: 'list',
+					tag: 'div',
+					'data-direction': props.direction,
+					'data-reversed': props.reversed ? 'true' : 'false',
+				} : {
+					class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
+				},
+			{ default: renderChildren });
+	}
 });
 </script>
 
diff --git a/packages/client/src/components/dialog.vue b/packages/client/src/components/dialog.vue
index c2fa1b02b8..9cd5234684 100644
--- a/packages/client/src/components/dialog.vue
+++ b/packages/client/src/components/dialog.vue
@@ -14,7 +14,7 @@
 		</div>
 		<header v-if="title"><Mfm :text="title"/></header>
 		<div v-if="text" class="body"><Mfm :text="text"/></div>
-		<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown">
+		<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" @keydown="onInputKeydown">
 			<template v-if="input.type === 'password'" #prefix><i class="fas fa-lock"></i></template>
 		</MkInput>
 		<MkSelect v-if="select" v-model="selectedValue" autofocus>
@@ -38,118 +38,107 @@
 </MkModal>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { onBeforeUnmount, onMounted, ref } from 'vue';
 import MkModal from '@/components/ui/modal.vue';
 import MkButton from '@/components/ui/button.vue';
 import MkInput from '@/components/form/input.vue';
 import MkSelect from '@/components/form/select.vue';
 
-export default defineComponent({
-	components: {
-		MkModal,
-		MkButton,
-		MkInput,
-		MkSelect,
-	},
+type Input = {
+	type: HTMLInputElement['type'];
+	placeholder?: string | null;
+	default: any | null;
+};
 
-	props: {
-		type: {
-			type: String,
-			required: false,
-			default: 'info'
-		},
-		title: {
-			type: String,
-			required: false
-		},
-		text: {
-			type: String,
-			required: false
-		},
-		input: {
-			required: false
-		},
-		select: {
-			required: false
-		},
-		icon: {
-			required: false
-		},
-		actions: {
-			required: false
-		},
-		showOkButton: {
-			type: Boolean,
-			default: true
-		},
-		showCancelButton: {
-			type: Boolean,
-			default: false
-		},
-		cancelableByBgClick: {
-			type: Boolean,
-			default: true
-		},
-	},
+type Select = {
+	items: {
+		value: string;
+		text: string;
+	}[];
+	groupedItems: {
+		label: string;
+		items: {
+			value: string;
+			text: string;
+		}[];
+	}[];
+	default: string | null;
+};
 
-	emits: ['done', 'closed'],
+const props = withDefaults(defineProps<{
+	type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting';
+	title: string;
+	text?: string;
+	input?: Input;
+	select?: Select;
+	icon?: string;
+	actions?: {
+		text: string;
+		primary?: boolean,
+		callback: (...args: any[]) => void;
+	}[];
+	showOkButton?: boolean;
+	showCancelButton?: boolean;
+	cancelableByBgClick?: boolean;
+}>(), {
+	type: 'info',
+	showOkButton: true,
+	showCancelButton: false,
+	cancelableByBgClick: true,
+});
 
-	data() {
-		return {
-			inputValue: this.input && this.input.default ? this.input.default : null,
-			selectedValue: this.select ? this.select.default ? this.select.default : this.select.items ? this.select.items[0].value : this.select.groupedItems[0].items[0].value : null,
-		};
-	},
+const emit = defineEmits<{
+	(e: 'done', v: { canceled: boolean; result: any }): void;
+	(e: 'closed'): void;
+}>();
 
-	mounted() {
-		document.addEventListener('keydown', this.onKeydown);
-	},
+const modal = ref<InstanceType<typeof MkModal>>();
 
-	beforeUnmount() {
-		document.removeEventListener('keydown', this.onKeydown);
-	},
+const inputValue = ref(props.input?.default || null);
+const selectedValue = ref(props.select?.default || null);
 
-	methods: {
-		done(canceled, result?) {
-			this.$emit('done', { canceled, result });
-			this.$refs.modal.close();
-		},
+function done(canceled: boolean, result?) {
+	emit('done', { canceled, result });
+	modal.value?.close();
+}
 
-		async ok() {
-			if (!this.showOkButton) return;
+async function ok() {
+	if (!props.showOkButton) return;
 
-			const result =
-				this.input ? this.inputValue :
-				this.select ? this.selectedValue :
-				true;
-			this.done(false, result);
-		},
+	const result =
+		props.input ? inputValue.value :
+		props.select ? selectedValue.value :
+		true;
+	done(false, result);
+}
 
-		cancel() {
-			this.done(true);
-		},
+function cancel() {
+	done(true);
+}
+/*
+function onBgClick() {
+	if (props.cancelableByBgClick) cancel();
+}
+*/
+function onKeydown(e: KeyboardEvent) {
+	if (e.key === 'Escape') cancel();
+}
 
-		onBgClick() {
-			if (this.cancelableByBgClick) {
-				this.cancel();
-			}
-		},
-
-		onKeydown(e) {
-			if (e.which === 27) { // ESC
-				this.cancel();
-			}
-		},
-
-		onInputKeydown(e) {
-			if (e.which === 13) { // Enter
-				e.preventDefault();
-				e.stopPropagation();
-				this.ok();
-			}
-		}
+function onInputKeydown(e: KeyboardEvent) {
+	if (e.key === 'Enter') {
+		e.preventDefault();
+		e.stopPropagation();
+		ok();
 	}
+}
+
+onMounted(() => {
+	document.addEventListener('keydown', onKeydown);
+});
+
+onBeforeUnmount(() => {
+	document.removeEventListener('keydown', onKeydown);
 });
 </script>