From d4654dd7bd5bf1c7faa74ed89f592448c0076be8 Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sat, 13 Sep 2025 21:00:33 +0900
Subject: refactor(frontend): os.select,
 MkSelectのitem指定をオブジェクトによる定義に統一し、型を狭める (#16475)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* refactor(frontend): MkSelectのitem指定をオブジェクトによる定義に統一

* fix

* spdx

* fix

* fix os.select

* fix lint

* add comment

* fix

* fix: os.select対応漏れを修正

* fix

* fix

* fix: MkSelectのmodelに対する型チェックを厳格化

* fix

* fix

* fix

* Update packages/frontend/src/components/MkEmbedCodeGenDialog.vue

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>

* fix

* fix types

* fix

* fix

* Update packages/frontend/src/pages/admin/roles.editor.vue

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>

* fix: MkSelectに直接配列を指定している場合に正常に型が解決されるように

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
---
 packages/frontend/src/components/MkSelect.vue | 176 ++++++++------------------
 1 file changed, 55 insertions(+), 121 deletions(-)

(limited to 'packages/frontend/src/components/MkSelect.vue')

diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue
index 9cbaf676c7..e79236fe54 100644
--- a/packages/frontend/src/components/MkSelect.vue
+++ b/packages/frontend/src/components/MkSelect.vue
@@ -40,46 +40,41 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts">
-type ItemOption = {
+export type OptionValue = string | number | null;
+
+export type ItemOption<T extends OptionValue = OptionValue> = {
 	type?: 'option';
-	value: string | number | null;
+	value: T;
 	label: string;
 };
 
-type ItemGroup = {
+export type ItemGroup<T extends OptionValue = OptionValue> = {
 	type: 'group';
-	label: string;
-	items: ItemOption[];
+	label?: string;
+	items: ItemOption<T>[];
 };
 
-export type MkSelectItem = ItemOption | ItemGroup;
-
-type ValuesOfItems<T> = T extends (infer U)[]
-	? U extends { type: 'group'; items: infer V }
-		? V extends (infer W)[]
-			? W extends { value: infer X }
-				? X
-				: never
-			: never
-		: U extends { value: infer Y }
-			? Y
-			: never
+export type MkSelectItem<T extends OptionValue = OptionValue> = ItemOption<T> | ItemGroup<T>;
+
+export type GetMkSelectValueType<T extends MkSelectItem> = T extends ItemGroup
+	? T['items'][number]['value']
+	: T extends ItemOption
+		? T['value']
+		: never;
+
+export type GetMkSelectValueTypesFromDef<T extends MkSelectItem[]> = T[number] extends MkSelectItem
+	? GetMkSelectValueType<T[number]>
 	: never;
 </script>
 
-<script lang="ts" setup generic="T extends MkSelectItem[]">
-import { onMounted, nextTick, ref, watch, computed, toRefs, useSlots } from 'vue';
+<script lang="ts" setup generic="const ITEMS extends MkSelectItem[], MODELT extends OptionValue">
+import { onMounted, nextTick, ref, watch, computed, toRefs } from 'vue';
 import { useInterval } from '@@/js/use-interval.js';
-import type { VNode, VNodeChild } from 'vue';
 import type { MenuItem } from '@/types/menu.js';
 import * as os from '@/os.js';
 
-// TODO: itemsをslot内のoptionで指定する用法は廃止する(props.itemsを必須化する)
-// see: https://github.com/misskey-dev/misskey/issues/15558
-// あと型推論と相性が良くない
-
 const props = defineProps<{
-	modelValue: ValuesOfItems<T>;
+	items: ITEMS;
 	required?: boolean;
 	readonly?: boolean;
 	disabled?: boolean;
@@ -88,16 +83,17 @@ const props = defineProps<{
 	inline?: boolean;
 	small?: boolean;
 	large?: boolean;
-	items?: T;
 }>();
 
-const emit = defineEmits<{
-	(ev: 'update:modelValue', value: ValuesOfItems<T>): void;
-}>();
+type ModelTChecked = MODELT & (
+	MODELT extends GetMkSelectValueTypesFromDef<ITEMS>
+	? unknown
+	: 'Error: The type of model does not match the type of items.'
+);
 
-const slots = useSlots();
+const model = defineModel<ModelTChecked>({ required: true });
 
-const { modelValue, autofocus } = toRefs(props);
+const { autofocus } = toRefs(props);
 const focused = ref(false);
 const opening = ref(false);
 const currentValueText = ref<string | null>(null);
@@ -140,52 +136,26 @@ onMounted(() => {
 	});
 });
 
-watch([modelValue, () => props.items], () => {
-	if (props.items) {
-		let found: ItemOption | null = null;
-		for (const item of props.items) {
-			if (item.type === 'group') {
-				for (const option of item.items) {
-					if (option.value === modelValue.value) {
-						found = option;
-						break;
-					}
-				}
-			} else {
-				if (item.value === modelValue.value) {
-					found = item;
+watch([model, () => props.items], () => {
+	let found: ItemOption | null = null;
+	for (const item of props.items) {
+		if (item.type === 'group') {
+			for (const option of item.items) {
+				if (option.value === model.value) {
+					found = option;
 					break;
 				}
 			}
-		}
-		if (found) {
-			currentValueText.value = found.label;
-		}
-		return;
-	}
-
-	const scanOptions = (options: VNodeChild[]) => {
-		for (const vnode of options) {
-			if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue;
-			if (vnode.type === 'optgroup') {
-				const optgroup = vnode;
-				if (Array.isArray(optgroup.children)) scanOptions(optgroup.children);
-			} else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある
-				const fragment = vnode;
-				if (Array.isArray(fragment.children)) scanOptions(fragment.children);
-			} else if (vnode.props == null) { // v-if で条件が false のときにこうなる
-				// nop?
-			} else {
-				const option = vnode;
-				if (option.props?.value === modelValue.value) {
-					currentValueText.value = option.children as string;
-					break;
-				}
+		} else {
+			if (item.value === model.value) {
+				found = item;
+				break;
 			}
 		}
-	};
-
-	scanOptions(slots.default!());
+	}
+	if (found) {
+		currentValueText.value = found.label;
+	}
 }, { immediate: true, deep: true });
 
 function show() {
@@ -196,68 +166,32 @@ function show() {
 
 	const menu: MenuItem[] = [];
 
-	if (props.items) {
-		for (const item of props.items) {
-			if (item.type === 'group') {
+	for (const item of props.items) {
+		if (item.type === 'group') {
+			if (item.label != null) {
 				menu.push({
 					type: 'label',
 					text: item.label,
 				});
-				for (const option of item.items) {
-					menu.push({
-						text: option.label,
-						active: computed(() => modelValue.value === option.value),
-						action: () => {
-							emit('update:modelValue', option.value);
-						},
-					});
-				}
-			} else {
+			}
+			for (const option of item.items) {
 				menu.push({
-					text: item.label,
-					active: computed(() => modelValue.value === item.value),
+					text: option.label,
+					active: computed(() => model.value === option.value),
 					action: () => {
-						emit('update:modelValue', item.value);
+						model.value = option.value as ModelTChecked;
 					},
 				});
 			}
-		}
-	} else {
-		let options = slots.default!();
-
-		const pushOption = (option: VNode) => {
+		} else {
 			menu.push({
-				text: option.children as string,
-				active: computed(() => modelValue.value === option.props?.value),
+				text: item.label,
+				active: computed(() => model.value === item.value),
 				action: () => {
-					emit('update:modelValue', option.props?.value);
+					model.value = item.value as ModelTChecked;
 				},
 			});
-		};
-
-		const scanOptions = (options: VNodeChild[]) => {
-			for (const vnode of options) {
-				if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue;
-				if (vnode.type === 'optgroup') {
-					const optgroup = vnode;
-					menu.push({
-						type: 'label',
-						text: optgroup.props?.label,
-					});
-					if (Array.isArray(optgroup.children)) scanOptions(optgroup.children);
-				} else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある
-					const fragment = vnode;
-					if (Array.isArray(fragment.children)) scanOptions(fragment.children);
-				} else if (vnode.props == null) { // v-if で条件が false のときにこうなる
-					// nop?
-				} else {
-					const option = vnode;
-					pushOption(option);
-				}
-			}
-		};
-
-		scanOptions(options);
+		}
 	}
 
 	os.popupMenu(menu, container.value, {
-- 
cgit v1.2.3-freya

