summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components/MkRadios.vue
diff options
context:
space:
mode:
authormisskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com>2026-03-05 10:56:50 +0000
committerGitHub <noreply@github.com>2026-03-05 10:56:50 +0000
commitfe3dd8edb5f30104cd0a7ed755eb254feda2922d (patch)
treeaf6cf5fa4ca75302ac2de5db742cead00bc13d21 /packages/frontend/src/components/MkRadios.vue
parentMerge pull request #16998 from misskey-dev/develop (diff)
parentRelease: 2026.3.0 (diff)
downloadmisskey-fe3dd8edb5f30104cd0a7ed755eb254feda2922d.tar.gz
misskey-fe3dd8edb5f30104cd0a7ed755eb254feda2922d.tar.bz2
misskey-fe3dd8edb5f30104cd0a7ed755eb254feda2922d.zip
Merge pull request #17217 from misskey-dev/develop
Release: 2026.3.0
Diffstat (limited to 'packages/frontend/src/components/MkRadios.vue')
-rw-r--r--packages/frontend/src/components/MkRadios.vue280
1 files changed, 203 insertions, 77 deletions
diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue
index 426a1d2c2b..e2210e858e 100644
--- a/packages/frontend/src/components/MkRadios.vue
+++ b/packages/frontend/src/components/MkRadios.vue
@@ -3,99 +3,225 @@ SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
+<template>
+<div :class="{ [$style.vertical]: vertical }">
+ <div :class="$style.label">
+ <slot name="label"></slot>
+ </div>
+
+ <div :class="$style.body">
+ <div
+ v-for="option in options"
+ :key="getKey(option.value)"
+ v-adaptive-border
+ :class="[$style.optionRoot, { [$style.disabled]: option.disabled, [$style.checked]: model === option.value }]"
+ :aria-checked="model === option.value"
+ :aria-disabled="option.disabled"
+ role="checkbox"
+ @click="toggle(option)"
+ >
+ <input
+ type="radio"
+ :disabled="option.disabled"
+ :class="$style.optionInput"
+ >
+ <span :class="$style.optionButton">
+ <span></span>
+ </span>
+ <div :class="$style.optionContent">
+ <i v-if="option.icon" :class="[$style.optionIcon, option.icon]" :style="option.iconStyle"></i>
+ <div>
+ <slot v-if="option.slotId != null" :name="`option-${option.slotId as SlotNames}`"></slot>
+ <template v-else>
+ <div :style="option.labelStyle">{{ option.label ?? option.value }}</div>
+ <div v-if="option.caption" :class="$style.optionCaption">{{ option.caption }}</div>
+ </template>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div :class="$style.caption">
+ <slot name="caption"></slot>
+ </div>
+</div>
+</template>
+
<script lang="ts">
-import { Comment, defineComponent, h, ref, watch } from 'vue';
-import MkRadio from './MkRadio.vue';
-import type { VNode } from 'vue';
+import type { StyleValue } from 'vue';
+import type { OptionValue } from '@/types/option-value.js';
+
+export type MkRadiosOption<T = OptionValue, S = string> = {
+ value: T;
+ slotId?: S;
+ label?: string;
+ labelStyle?: StyleValue;
+ icon?: string;
+ iconStyle?: StyleValue;
+ caption?: string;
+ disabled?: boolean;
+};
+</script>
-export default defineComponent({
- props: {
- modelValue: {
- required: false,
- },
- vertical: {
- type: Boolean,
- default: false,
- },
- },
- setup(props, context) {
- const value = ref(props.modelValue);
- watch(value, () => {
- context.emit('update:modelValue', value.value);
- });
- watch(() => props.modelValue, v => {
- value.value = v;
- });
- if (!context.slots.default) return null;
- let options = context.slots.default();
- const label = context.slots.label && context.slots.label();
- const caption = context.slots.caption && context.slots.caption();
+<script setup lang="ts" generic="const T extends MkRadiosOption">
+defineProps<{
+ options: T[];
+ vertical?: boolean;
+}>();
- // なぜかFragmentになることがあるため
- if (options.length === 1 && options[0].props == null) options = options[0].children as VNode[];
+type SlotNames = NonNullable<T extends MkRadiosOption<any, infer U> ? U : never>;
- // vnodeのうちv-if=falseなものを除外する(trueになるものはoptionなど他typeになる)
- options = options.filter(vnode => vnode.type !== Comment);
+defineSlots<{
+ label?: () => void;
+ caption?: () => void;
+} & {
+ [K in `option-${SlotNames}`]: () => void;
+}>();
- return () => h('div', {
- class: [
- 'novjtcto',
- ...(props.vertical ? ['vertical'] : []),
- ],
- }, [
- ...(label ? [h('div', {
- class: 'label',
- }, label)] : []),
- h('div', {
- class: 'body',
- }, options.map(option => h(MkRadio, {
- key: option.key as string,
- value: option.props?.value,
- disabled: option.props?.disabled,
- modelValue: value.value,
- 'onUpdate:modelValue': _v => value.value = _v,
- }, () => option.children)),
- ),
- ...(caption ? [h('div', {
- class: 'caption',
- }, caption)] : []),
- ]);
- },
-});
+const model = defineModel<T['value']>({ required: true });
+
+function getKey(value: OptionValue): PropertyKey {
+ if (value === null) return '___null___';
+ return value;
+}
+
+function toggle(o: MkRadiosOption): void {
+ if (o.disabled) return;
+ model.value = o.value;
+}
</script>
-<style lang="scss">
-.novjtcto {
- > .label {
- font-size: 0.85em;
- padding: 0 0 8px 0;
- user-select: none;
+<style lang="scss" module>
+.label {
+ font-size: 0.85em;
+ padding: 0 0 8px 0;
+ user-select: none;
- &:empty {
- display: none;
- }
+ &:empty {
+ display: none;
}
+}
+
+.body {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
- > .body {
- display: flex;
- gap: 10px;
- flex-wrap: wrap;
+.caption {
+ font-size: 0.85em;
+ padding: 8px 0 0 0;
+ color: color(from var(--MI_THEME-fg) srgb r g b / 0.75);
+
+ &:empty {
+ display: none;
}
+}
- > .caption {
- font-size: 0.85em;
- padding: 8px 0 0 0;
- color: color(from var(--MI_THEME-fg) srgb r g b / 0.75);
+.vertical > .body {
+ flex-direction: column;
+}
- &:empty {
- display: none;
- }
+.optionRoot {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ text-align: left;
+ cursor: pointer;
+ padding: 8px 10px;
+ min-width: 60px;
+ background-color: var(--MI_THEME-panel);
+ background-clip: padding-box !important;
+ border: solid 1px var(--MI_THEME-panel);
+ border-radius: 6px;
+ font-size: 90%;
+ transition: all 0.2s;
+ user-select: none;
+
+ &.disabled {
+ opacity: 0.6;
+ cursor: not-allowed !important;
+ }
+
+ &:hover {
+ border-color: var(--MI_THEME-inputBorderHover) !important;
+ }
+
+ &:focus-within {
+ outline: none;
+ box-shadow: 0 0 0 2px var(--MI_THEME-focus);
}
- &.vertical {
- > .body {
- flex-direction: column;
+ &.checked {
+ background-color: var(--MI_THEME-accentedBg) !important;
+ border-color: var(--MI_THEME-accentedBg) !important;
+ color: var(--MI_THEME-accent);
+ cursor: default !important;
+
+ .optionButton {
+ border-color: var(--MI_THEME-accent);
+
+ &::after {
+ background-color: var(--MI_THEME-accent);
+ transform: scale(1);
+ opacity: 1;
+ }
+ }
+
+ .optionCaption {
+ color: color(from var(--MI_THEME-accent) srgb r g b / 0.75);
}
}
}
+
+.optionInput {
+ position: absolute;
+ width: 0;
+ height: 0;
+ opacity: 0;
+ margin: 0;
+}
+
+.optionButton {
+ position: relative;
+ display: inline-block;
+ width: 14px;
+ height: 14px;
+ background: none;
+ border: solid 2px var(--MI_THEME-inputBorder);
+ border-radius: 100%;
+ transition: inherit;
+
+ &::after {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 3px;
+ right: 3px;
+ bottom: 3px;
+ left: 3px;
+ border-radius: 100%;
+ opacity: 0;
+ transform: scale(0);
+ transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
+ }
+}
+
+.optionContent {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-left: 8px;
+}
+
+.optionCaption {
+ font-size: 0.85em;
+ padding: 2px 0 0 0;
+ color: color(from var(--MI_THEME-fg) srgb r g b / 0.75);
+ transition: all 0.2s;
+}
+
+.optionIcon {
+ flex-shrink: 0;
+}
</style>