diff options
| author | かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> | 2025-09-13 21:00:33 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-09-13 21:00:33 +0900 |
| commit | d4654dd7bd5bf1c7faa74ed89f592448c0076be8 (patch) | |
| tree | b4f51e86f174717fef469fbedca48faa2a55e841 /packages/frontend/src/components | |
| parent | fix(deps): update dependency vite [security] (#16535) (diff) | |
| download | misskey-d4654dd7bd5bf1c7faa74ed89f592448c0076be8.tar.gz misskey-d4654dd7bd5bf1c7faa74ed89f592448c0076be8.tar.bz2 misskey-d4654dd7bd5bf1c7faa74ed89f592448c0076be8.zip | |
refactor(frontend): os.select, MkSelectのitem指定をオブジェクトによる定義に統一し、型を狭める (#16475)
* 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>
Diffstat (limited to 'packages/frontend/src/components')
| -rw-r--r-- | packages/frontend/src/components/MkAntennaEditor.vue | 43 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkAsUi.vue | 18 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkDialog.vue | 34 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkEmbedCodeGenDialog.vue | 19 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkFormDialog.vue | 24 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkInstanceStats.vue | 126 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkPaginationControl.vue | 14 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkPollEditor.vue | 38 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkPostForm.vue | 10 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkRoleSelectDialog.vue | 8 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkSelect.vue | 174 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkWatermarkEditorDialog.vue | 16 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkWidgets.vue | 14 |
13 files changed, 300 insertions, 238 deletions
diff --git a/packages/frontend/src/components/MkAntennaEditor.vue b/packages/frontend/src/components/MkAntennaEditor.vue index e2febf7225..a41fdbc45d 100644 --- a/packages/frontend/src/components/MkAntennaEditor.vue +++ b/packages/frontend/src/components/MkAntennaEditor.vue @@ -10,17 +10,11 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="name"> <template #label>{{ i18n.ts.name }}</template> </MkInput> - <MkSelect v-model="src"> + <MkSelect v-model="src" :items="antennaSourcesSelectDef"> <template #label>{{ i18n.ts.antennaSource }}</template> - <option value="all">{{ i18n.ts._antennaSources.all }}</option> - <!--<option value="home">{{ i18n.ts._antennaSources.homeTimeline }}</option>--> - <option value="users">{{ i18n.ts._antennaSources.users }}</option> - <!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>--> - <option value="users_blacklist">{{ i18n.ts._antennaSources.userBlacklist }}</option> </MkSelect> - <MkSelect v-if="src === 'list'" v-model="userListId"> + <MkSelect v-if="src === 'list'" v-model="userListId" :items="userListsSelectDef"> <template #label>{{ i18n.ts.userList }}</template> - <option v-for="list in userLists" :key="list.id" :value="list.id">{{ list.name }}</option> </MkSelect> <MkTextarea v-else-if="src === 'users' || src === 'users_blacklist'" v-model="users"> <template #label>{{ i18n.ts.users }}</template> @@ -52,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { watch, ref } from 'vue'; +import { watch, ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import type { DeepPartial } from '@/utility/merge.js'; import MkButton from '@/components/MkButton.vue'; @@ -64,6 +58,7 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { deepMerge } from '@/utility/merge.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & { id?: string; @@ -99,9 +94,35 @@ const emit = defineEmits<{ (ev: 'deleted'): void, }>(); +const { + model: src, + def: antennaSourcesSelectDef, +} = useMkSelect({ + items: [ + { value: 'all', label: i18n.ts._antennaSources.all }, + //{ value: 'home', label: i18n.ts._antennaSources.homeTimeline }, + { value: 'users', label: i18n.ts._antennaSources.users }, + //{ value: 'list', label: i18n.ts._antennaSources.userList }, + { value: 'users_blacklist', label: i18n.ts._antennaSources.userBlacklist }, + ], + initialValue: initialAntenna.src, +}); + +const { + model: userListId, + def: userListsSelectDef, +} = useMkSelect({ + items: computed(() => { + if (userLists.value == null) return []; + return userLists.value.map(list => ({ + value: list.id, + label: list.name, + })); + }), + initialValue: initialAntenna.userListId, +}); + const name = ref<string>(initialAntenna.name); -const src = ref<Misskey.entities.AntennasCreateRequest['src']>(initialAntenna.src); -const userListId = ref<string | null>(initialAntenna.userListId); const users = ref<string>(initialAntenna.users.join('\n')); const keywords = ref<string>(initialAntenna.keywords.map(x => x.join(' ')).join('\n')); const excludeKeywords = ref<string>(initialAntenna.excludeKeywords.map(x => x.join(' ')).join('\n')); diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index 20a953c72c..a3b6112629 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -32,10 +32,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkInput> - <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="valueForSelect" @update:modelValue="onSelectUpdate"> + <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="valueForSelect" :items="selectDef" @update:modelValue="onSelectUpdate"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> - <option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option> </MkSelect> <MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" inline @click="openPostForm">{{ c.text }}</MkButton> <div v-else-if="c.type === 'postForm'" :class="$style.postForm"> @@ -74,6 +73,7 @@ import MkSelect from '@/components/MkSelect.vue'; import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js'; import MkFolder from '@/components/MkFolder.vue'; import MkPostForm from '@/components/MkPostForm.vue'; +import { useMkSelect } from '@/composables/use-mkselect.js'; const props = withDefaults(defineProps<{ component: AsUiComponent; @@ -130,7 +130,19 @@ function onSwitchUpdate(v: boolean) { } } -const valueForSelect = ref('default' in c && typeof c.default !== 'boolean' ? c.default ?? null : null); +const { + model: valueForSelect, + def: selectDef, +} = useMkSelect({ + items: computed(() => { + if (c.type !== 'select') return []; + return (c.items ?? []).map(item => ({ + value: item.value, + label: item.text, + })); + }), + initialValue: (c.type === 'select' && 'default' in c && typeof c.default !== 'boolean') ? c.default ?? null : null, +}); function onSelectUpdate(v) { valueForSelect.value = v; diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 3f7519a43f..705301a6a6 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -29,16 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string)?.length ?? 0, min: input.minLength ?? 'NaN' })"/> </template> </MkInput> - <MkSelect v-if="select" v-model="selectedValue" autofocus> - <template v-if="select.items"> - <template v-for="item in select.items"> - <optgroup v-if="'sectionTitle' in item" :label="item.sectionTitle"> - <option v-for="subItem in item.items" :value="subItem.value">{{ subItem.text }}</option> - </optgroup> - <option v-else :value="item.value">{{ item.text }}</option> - </template> - </template> - </MkSelect> + <MkSelect v-if="select" v-model="selectedValue" :items="selectDef" autofocus></MkSelect> <div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons"> <MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason != null" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> <MkButton v-if="showCancelButton || input || select" data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton> @@ -56,6 +47,8 @@ import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; +import type { MkSelectItem, OptionValue } from '@/components/MkSelect.vue'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { i18n } from '@/i18n.js'; type Input = { @@ -67,17 +60,9 @@ type Input = { maxLength?: number; }; -type SelectItem = { - value: any; - text: string; -}; - type Select = { - items: (SelectItem | { - sectionTitle: string; - items: SelectItem[]; - })[]; - default: string | null; + items: MkSelectItem[]; + default: OptionValue | null; }; type Result = string | number | true | null; @@ -115,7 +100,6 @@ const emit = defineEmits<{ const modal = useTemplateRef('modal'); const inputValue = ref<string | number | null>(props.input?.default ?? null); -const selectedValue = ref(props.select?.default ?? null); const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'charactersBelow'>(() => { if (props.input) { @@ -134,6 +118,14 @@ const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'character return null; }); +const { + def: selectDef, + model: selectedValue, +} = useMkSelect({ + items: computed(() => props.select?.items ?? []), + initialValue: props.select?.default ?? null, +}); + // overload function を使いたいので lint エラーを無視する function done(canceled: true): void; function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue index 17823deb85..0cb8499699 100644 --- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue +++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue @@ -52,11 +52,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #suffix>px</template> <template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template> </MkInput> - <MkSelect v-model="colorMode"> + <MkSelect v-model="colorMode" :items="colorModeDef"> <template #label>{{ i18n.ts.theme }}</template> - <option value="auto">{{ i18n.ts.syncDeviceDarkMode }}</option> - <option value="light">{{ i18n.ts.light }}</option> - <option value="dark">{{ i18n.ts.dark }}</option> </MkSelect> <MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch> <MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch> @@ -105,6 +102,7 @@ import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js'; @@ -162,7 +160,18 @@ const isEmbedWithScrollbar = computed(() => embedRouteWithScrollbar.includes(pro const header = ref(props.params?.header ?? true); const maxHeight = ref(props.params?.maxHeight !== 0 ? props.params?.maxHeight ?? null : 500); -const colorMode = ref<'light' | 'dark' | 'auto'>(props.params?.colorMode ?? 'auto'); +const { + model: colorMode, + def: colorModeDef, +} = useMkSelect({ + items: [ + { value: 'auto', label: i18n.ts.syncDeviceDarkMode }, + { value: 'light', label: i18n.ts.light }, + { value: 'dark', label: i18n.ts.dark }, + ], + initialValue: props.params?.colorMode ?? 'auto', +}); + const rounded = ref(props.params?.rounded ?? true); const border = ref(props.params?.border ?? true); diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index 8d697499a5..142ccb12a3 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -39,9 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-text="v.label || k"></span> <template v-if="v.description" #caption>{{ v.description }}</template> </MkSwitch> - <MkSelect v-else-if="v.type === 'enum'" v-model="values[k]"> + <MkSelect v-else-if="v.type === 'enum'" v-model="values[k]" :items="getMkSelectDef(v)"> <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> - <option v-for="option in v.enum" :key="getEnumKey(option)" :value="getEnumValue(option)">{{ getEnumLabel(option) }}</option> </MkSelect> <MkRadios v-else-if="v.type === 'radio'" v-model="values[k]"> <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> @@ -77,7 +76,8 @@ import MkRange from './MkRange.vue'; import MkButton from './MkButton.vue'; import MkRadios from './MkRadios.vue'; import XFile from './MkFormDialog.file.vue'; -import type { EnumItem, Form, RadioFormItem } from '@/utility/form.js'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; +import type { Form, EnumFormItem, RadioFormItem } from '@/utility/form.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; @@ -120,16 +120,14 @@ function cancel() { dialog.value?.close(); } -function getEnumLabel(e: EnumItem) { - return typeof e === 'string' ? e : e.label; -} - -function getEnumValue(e: EnumItem) { - return typeof e === 'string' ? e : e.value; -} - -function getEnumKey(e: EnumItem) { - return typeof e === 'string' ? e : typeof e.value === 'string' ? e.value : JSON.stringify(e.value); +function getMkSelectDef(def: EnumFormItem): MkSelectItem[] { + return def.enum.map((v) => { + if (typeof v === 'string') { + return { value: v, label: v }; + } else { + return { value: v.value, label: v.label }; + } + }); } function getRadioKey(e: RadioFormItem['options'][number]) { diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index 15578ca1c9..13048a2e1b 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -9,31 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header>Chart</template> <div :class="$style.chart"> <div class="selects"> - <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> - <optgroup v-if="shouldShowFederation" :label="i18n.ts.federation"> - <option value="federation">{{ i18n.ts._charts.federation }}</option> - <option value="ap-request">{{ i18n.ts._charts.apRequest }}</option> - </optgroup> - <optgroup :label="i18n.ts.users"> - <option value="users">{{ i18n.ts._charts.usersIncDec }}</option> - <option value="users-total">{{ i18n.ts._charts.usersTotal }}</option> - <option value="active-users">{{ i18n.ts._charts.activeUsers }}</option> - </optgroup> - <optgroup :label="i18n.ts.notes"> - <option value="notes">{{ i18n.ts._charts.notesIncDec }}</option> - <option value="local-notes">{{ i18n.ts._charts.localNotesIncDec }}</option> - <option v-if="shouldShowFederation" value="remote-notes">{{ i18n.ts._charts.remoteNotesIncDec }}</option> - <option value="notes-total">{{ i18n.ts._charts.notesTotal }}</option> - </optgroup> - <optgroup :label="i18n.ts.drive"> - <option value="drive-files">{{ i18n.ts._charts.filesIncDec }}</option> - <option value="drive">{{ i18n.ts._charts.storageUsageIncDec }}</option> - </optgroup> - </MkSelect> - <MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;"> - <option value="hour">{{ i18n.ts.perHour }}</option> - <option value="day">{{ i18n.ts.perDay }}</option> - </MkSelect> + <MkSelect v-model="chartSrc" :items="chartSrcDef" style="margin: 0; flex: 1;"></MkSelect> + <MkSelect v-model="chartSpan" :items="chartSpanDef" style="margin: 0 0 0 10px;"></MkSelect> </div> <div class="chart _panel"> <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="true"></MkChart> @@ -43,13 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFoldableSection class="item"> <template #header>Active users heatmap</template> - <MkSelect v-model="heatmapSrc" style="margin: 0 0 12px 0;"> - <option value="active-users">Active users</option> - <option value="notes">Notes</option> - <option v-if="shouldShowFederation" value="ap-requests-inbox-received">AP Requests: inboxReceived</option> - <option v-if="shouldShowFederation" value="ap-requests-deliver-succeeded">AP Requests: deliverSucceeded</option> - <option v-if="shouldShowFederation" value="ap-requests-deliver-failed">AP Requests: deliverFailed</option> - </MkSelect> + <MkSelect v-model="heatmapSrc" :items="heatmapSrcDef" style="margin: 0 0 12px 0;"></MkSelect> <div class="_panel" :class="$style.heatmap"> <MkHeatmap :src="heatmapSrc" :label="'Read & Write'"/> </div> @@ -84,10 +55,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, computed, useTemplateRef } from 'vue'; +import { onMounted, computed, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; -import type { HeatmapSource } from '@/components/MkHeatmap.vue'; import MkSelect from '@/components/MkSelect.vue'; +import type { MkSelectItem, ItemOption } from '@/components/MkSelect.vue'; import MkChart from '@/components/MkChart.vue'; import type { ChartSrc } from '@/components/MkChart.vue'; import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; @@ -101,15 +72,96 @@ import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue'; import MkRetentionLineChart from '@/components/MkRetentionLineChart.vue'; import { initChart } from '@/utility/init-chart.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; initChart(); const shouldShowFederation = computed(() => instance.federation !== 'none' || $i?.isModerator); const chartLimit = 500; -const chartSpan = ref<'hour' | 'day'>('hour'); -const chartSrc = ref<ChartSrc>('active-users'); -const heatmapSrc = ref<HeatmapSource>('active-users'); +const { + model: chartSpan, + def: chartSpanDef, +} = useMkSelect({ + items: [ + { value: 'hour', label: i18n.ts.perHour }, + { value: 'day', label: i18n.ts.perDay }, + ], + initialValue: 'hour', +}); +const { + model: chartSrc, + def: chartSrcDef, +} = useMkSelect({ + items: computed<MkSelectItem<ChartSrc>[]>(() => { + const items: MkSelectItem<ChartSrc>[] = []; + + if (shouldShowFederation.value) { + items.push({ + type: 'group', + label: i18n.ts.federation, + items: [ + { value: 'federation', label: i18n.ts._charts.federation }, + { value: 'ap-request', label: i18n.ts._charts.apRequest }, + ], + }); + } + + items.push({ + type: 'group', + label: i18n.ts.users, + items: [ + { value: 'users', label: i18n.ts._charts.usersIncDec }, + { value: 'users-total', label: i18n.ts._charts.usersTotal }, + { value: 'active-users', label: i18n.ts._charts.activeUsers }, + ], + }); + + const notesItems: ItemOption<ChartSrc>[] = [ + { value: 'notes', label: i18n.ts._charts.notesIncDec }, + { value: 'local-notes', label: i18n.ts._charts.localNotesIncDec }, + ]; + + if (shouldShowFederation.value) notesItems.push({ value: 'remote-notes', label: i18n.ts._charts.remoteNotesIncDec }); + + notesItems.push( + { value: 'notes-total', label: i18n.ts._charts.notesTotal }, + ); + + items.push({ + type: 'group', + label: i18n.ts.notes, + items: notesItems, + }); + + items.push({ + type: 'group', + label: i18n.ts.drive, + items: [ + { value: 'drive-files', label: i18n.ts._charts.filesIncDec }, + { value: 'drive', label: i18n.ts._charts.storageUsageIncDec }, + ], + }); + + return items; + }), + initialValue: 'active-users', +}); +const { + model: heatmapSrc, + def: heatmapSrcDef, +} = useMkSelect({ + items: computed(() => [ + { value: 'active-users' as const, label: 'Active Users' }, + { value: 'notes' as const, label: 'Notes' }, + ...(shouldShowFederation.value ? [ + { value: 'ap-requests-inbox-received' as const, label: 'AP Requests: inboxReceived' }, + { value: 'ap-requests-deliver-succeeded' as const, label: 'AP Requests: deliverSucceeded' }, + { value: 'ap-requests-deliver-failed' as const, label: 'AP Requests: deliverFailed' }, + ] : []), + ]), + initialValue: 'active-users', +}); const subDoughnutEl = useTemplateRef('subDoughnutEl'); const pubDoughnutEl = useTemplateRef('pubDoughnutEl'); diff --git a/packages/frontend/src/components/MkPaginationControl.vue b/packages/frontend/src/components/MkPaginationControl.vue index 10bed575a4..55aa3f2dc2 100644 --- a/packages/frontend/src/components/MkPaginationControl.vue +++ b/packages/frontend/src/components/MkPaginationControl.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> <div :class="$style.control"> - <MkSelect v-model="order" :class="$style.order" :items="[{ label: i18n.ts._order.newest, value: 'newest' }, { label: i18n.ts._order.oldest, value: 'oldest' }]"> + <MkSelect v-model="order" :class="$style.order" :items="orderDef"> <template #prefix><i class="ti ti-arrows-sort"></i></template> </MkSelect> <MkButton v-if="paginator.canSearch" v-tooltip="i18n.ts.search" iconOnly transparent rounded :active="searchOpened" @click="searchOpened = !searchOpened"><i class="ti ti-search"></i></MkButton> @@ -45,6 +45,7 @@ import { i18n } from '@/i18n.js'; import MkSelect from '@/components/MkSelect.vue'; import MkInput from '@/components/MkInput.vue'; import { formatDateTimeString } from '@/utility/format-time-string.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; const props = withDefaults(defineProps<{ paginator: T; @@ -58,7 +59,16 @@ const props = withDefaults(defineProps<{ const searchOpened = ref(false); const filterOpened = ref(props.filterOpened); -const order = ref<'newest' | 'oldest'>('newest'); +const { + model: order, + def: orderDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._order.newest, value: 'newest' }, + { label: i18n.ts._order.oldest, value: 'oldest' }, + ], + initialValue: 'newest', +}); const date = ref<number | null>(null); const q = ref<string | null>(null); diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue index 174c923bcf..b7c3d1f42d 100644 --- a/packages/frontend/src/components/MkPollEditor.vue +++ b/packages/frontend/src/components/MkPollEditor.vue @@ -22,11 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="multiple">{{ i18n.ts._poll.canMultipleVote }}</MkSwitch> <section> <div> - <MkSelect v-model="expiration" small> + <MkSelect v-model="expiration" :items="expirationDef" small> <template #label>{{ i18n.ts._poll.expiration }}</template> - <option value="infinite">{{ i18n.ts._poll.infinite }}</option> - <option value="at">{{ i18n.ts._poll.at }}</option> - <option value="after">{{ i18n.ts._poll.after }}</option> </MkSelect> <section v-if="expiration === 'at'"> <MkInput v-model="atDate" small type="date" class="input"> @@ -40,12 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="after" small type="number" :min="1" class="input"> <template #label>{{ i18n.ts._poll.duration }}</template> </MkInput> - <MkSelect v-model="unit" small> - <option value="second">{{ i18n.ts._time.second }}</option> - <option value="minute">{{ i18n.ts._time.minute }}</option> - <option value="hour">{{ i18n.ts._time.hour }}</option> - <option value="day">{{ i18n.ts._time.day }}</option> - </MkSelect> + <MkSelect v-model="unit" :items="unitDef" small></MkSelect> </section> </div> </section> @@ -61,6 +53,7 @@ import MkButton from './MkButton.vue'; import { formatDateTimeString } from '@/utility/format-time-string.js'; import { addTime } from '@/utility/time.js'; import { i18n } from '@/i18n.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; export type PollEditorModelValue = { expiresAt: number | null; @@ -78,11 +71,32 @@ const emit = defineEmits<{ const choices = ref(props.modelValue.choices); const multiple = ref(props.modelValue.multiple); -const expiration = ref('infinite'); +const { + model: expiration, + def: expirationDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._poll.infinite, value: 'infinite' }, + { label: i18n.ts._poll.at, value: 'at' }, + { label: i18n.ts._poll.after, value: 'after' }, + ], + initialValue: 'infinite', +}); const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd')); const atTime = ref('00:00'); const after = ref(0); -const unit = ref('second'); +const { + model: unit, + def: unitDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._time.second, value: 'second' }, + { label: i18n.ts._time.minute, value: 'minute' }, + { label: i18n.ts._time.hour, value: 'hour' }, + { label: i18n.ts._time.day, value: 'day' }, + ], + initialValue: 'second', +}); if (props.modelValue.expiresAt) { expiration.value = 'at'; diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index a3ff89fc4d..9fec7ea4da 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -567,11 +567,11 @@ async function toggleReactionAcceptance() { const select = await os.select({ title: i18n.ts.reactionAcceptance, items: [ - { value: null, text: i18n.ts.all }, - { value: 'likeOnlyForRemote' as const, text: i18n.ts.likeOnlyForRemote }, - { value: 'nonSensitiveOnly' as const, text: i18n.ts.nonSensitiveOnly }, - { value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' as const, text: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote }, - { value: 'likeOnly' as const, text: i18n.ts.likeOnly }, + { value: null, label: i18n.ts.all }, + { value: 'likeOnlyForRemote' as const, label: i18n.ts.likeOnlyForRemote }, + { value: 'nonSensitiveOnly' as const, label: i18n.ts.nonSensitiveOnly }, + { value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' as const, label: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote }, + { value: 'likeOnly' as const, label: i18n.ts.likeOnly }, ], default: reactionAcceptance.value, }); diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue index f1cc98def4..937804703d 100644 --- a/packages/frontend/src/components/MkRoleSelectDialog.vue +++ b/packages/frontend/src/components/MkRoleSelectDialog.vue @@ -102,12 +102,12 @@ async function addRole() { const items = roles.value .filter(r => r.isPublic) .filter(r => !selectedRoleIds.value.includes(r.id)) - .map(r => ({ text: r.name, value: r })); + .map(r => ({ label: r.name, value: r.id })); - const { canceled, result: role } = await os.select({ items }); - if (canceled || role == null) return; + const { canceled, result: roleId } = await os.select({ items }); + if (canceled || roleId == null) return; - selectedRoleIds.value.push(role.id); + selectedRoleIds.value.push(roleId); } async function removeRole(roleId: string) { 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; +export type MkSelectItem<T extends OptionValue = OptionValue> = ItemOption<T> | ItemGroup<T>; -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 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, { diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.vue index 206298b194..75a45548fd 100644 --- a/packages/frontend/src/components/MkWatermarkEditorDialog.vue +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="$style.controls"> <div class="_spacer _gaps"> - <MkSelect v-model="type" :items="[{ label: i18n.ts._watermarkEditor.text, value: 'text' }, { label: i18n.ts._watermarkEditor.image, value: 'image' }, { label: i18n.ts._watermarkEditor.advanced, value: 'advanced' }]"> + <MkSelect v-model="type" :items="typeDef"> <template #label>{{ i18n.ts._watermarkEditor.type }}</template> </MkSelect> @@ -86,6 +86,7 @@ import * as os from '@/os.js'; import { deepClone } from '@/utility/clone.js'; import { ensureSignin } from '@/i.js'; import { genId } from '@/utility/id.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; const $i = ensureSignin(); @@ -186,7 +187,18 @@ async function cancel() { dialog.value?.close(); } -const type = ref(preset.layers.length > 1 ? 'advanced' : preset.layers[0].type); +const { + model: type, + def: typeDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._watermarkEditor.text, value: 'text' }, + { label: i18n.ts._watermarkEditor.image, value: 'image' }, + { label: i18n.ts._watermarkEditor.advanced, value: 'advanced' }, + ], + initialValue: preset.layers.length > 1 ? 'advanced' : preset.layers[0].type, +}); + watch(type, () => { if (type.value === 'text') { preset.layers = [createTextLayer()]; diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index 08a018ea9b..cf7c2cda80 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -7,9 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.root"> <template v-if="edit"> <header :class="$style.editHeader"> - <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--MI-margin)" data-cy-widget-select> + <MkSelect v-model="widgetAdderSelected" :items="widgetAdderSelectedDef" style="margin-bottom: var(--MI-margin)" data-cy-widget-select> <template #label>{{ i18n.ts.selectWidget }}</template> - <option v-for="widget in _widgetDefs" :key="widget" :value="widget">{{ i18n.ts._widgets[widget] }}</option> </MkSelect> <MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> <MkButton inline @click="emit('exit')">{{ i18n.ts.close }}</MkButton> @@ -59,6 +58,7 @@ import { widgets as widgetDefs, federationWidgets } from '@/widgets/index.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -89,7 +89,15 @@ const widgetRefs = {}; const configWidget = (id: string) => { widgetRefs[id].configure(); }; -const widgetAdderSelected = ref<string | null>(null); + +const { + model: widgetAdderSelected, + def: widgetAdderSelectedDef, +} = useMkSelect({ + items: computed(() => [{ label: i18n.ts.none, value: null }, ..._widgetDefs.value.map(x => ({ label: i18n.ts._widgets[x], value: x }))]), + initialValue: null, +}); + const addWidget = () => { if (widgetAdderSelected.value == null) return; |