* chore(docs): .claude配下の再構成 * fix AGENTS.md * fix AGENTS.md * fix review * 行番号参照の除去 * docs: fix storybook note in vue reviewer agent * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix local review * fix --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
14 KiB
Vue SFC 規約・テンプレート集 + a11y チェックリスト
Misskey の Vue 3 SFC 規約と、新規 Mk* コンポーネント / 既存コンポーネント編集時のテンプレート / アクセシビリティ要件をまとめたページ。
目次
SFC スタイルの基本
Composition API + <script setup lang="ts"> を基本とする (Options API は新規導入しない)。型宣言や module スコープのユーティリティを置きたい時は、setup ブロックと 併用 する形で追加の <script lang="ts"> ブロックを置いて構わない (例: MkInput.vue は SupportedTypes 型を別ブロックで宣言してから setup を書いている)。SCSS は CSS Modules で書き、<style lang="scss" module> を使う。
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<!-- ... -->
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
// ...
</script>
<style lang="scss" module>
.root {
/* ... */
}
</style>
<script> / <style> 規約
| 項目 | 規約 | 新規不可 |
|---|---|---|
<script> 開始タグ |
<script lang="ts" setup> または <script setup lang="ts"> (順序不問) |
<script> (lang 無し) / Options API (export default { data() {...} }) |
| Props 定義 | defineProps<{ ... }>() (type-only) |
runtime object 形式 defineProps({ name: { type: String } }) |
| Emits 定義 | defineEmits<{ (ev: 'click'): void }>() (type-only) |
runtime array 形式 defineEmits(['click']) |
| 型ジェネリック | <script setup lang="ts" generic="T extends ..."> 属性で渡す。複雑な型宣言が必要なら 2 ブロック構成 (generic パターン) |
— |
<style> 開始タグ |
<style lang="scss" module>、参照は :class="$style.foo" |
<style scoped> (module なし) は新規不可 (legacy 混在) |
| CSS 値 | var(--MI_THEME-...) (テーマ) / var(--MI-...) (UI 共通定数) を使う |
#fff / rgb(...) / rgba(...) のハードコード (scss-modules.md) |
| グローバル class | _button / _panel / _selectable / _buttonPrimary 等の global utility class を活用 |
— |
| アイコン | Tabler icons クラス <i class="ti ti-info-circle"> |
インライン SVG / 別アイコンセット |
テンプレート集
simple (<slot> + 単純 props)
下記は <slot> + props + withDefaults の典型パターンを示す合成例 (特定ファイルの写しではない)。実在する単純コンポーネントの例は MkInfo.vue 等を参照。
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[$style.root, { [$style.warn]: variant === 'warn' }]" class="_selectable">
<i v-if="variant === 'warn'" class="ti ti-alert-triangle" :class="$style.icon"></i>
<i v-else class="ti ti-info-circle" :class="$style.icon"></i>
<div><slot></slot></div>
</div>
</template>
<script lang="ts" setup>
const props = withDefaults(defineProps<{
variant?: 'info' | 'warn';
}>(), {
variant: 'info',
});
</script>
<style lang="scss" module>
.root {
display: flex;
align-items: center;
gap: 4px;
padding: 12px 14px;
font-size: 90%;
background: var(--MI_THEME-infoBg);
color: var(--MI_THEME-infoFg);
border-radius: var(--MI-radius);
&.warn {
background: var(--MI_THEME-infoWarnBg);
color: var(--MI_THEME-infoWarnFg);
}
}
.icon {
margin-right: 4px;
}
</style>
ポイント:
- デフォルト値が必要なら
withDefaults(defineProps<{...}>(), { ... })を使う (type-only のまま既定値を渡せる) _selectableは本文選択を許可する global utility class (scss-modules.md 参照)<i class="ti ti-...">は Tabler icons。v-if切り替えで variant 別アイコンを出すのは多用パターン
generic + 2 ブロック script
参考: MkInput.vue
型ジェネリックを取りつつ、その型計算や type エイリアス宣言を setup ブロックの中に書きたくない場合は、型宣言用 <script lang="ts"> と setup 用 <script lang="ts" setup> を 2 つ並べる 構成にできる。
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<button
v-for="item in items"
:key="String(item.value)"
class="_button"
:class="[$style.item, { [$style.active]: item.value === modelValue }]"
@click="select(item.value)"
>
{{ item.label }}
</button>
</div>
</template>
<script lang="ts">
// module scope: 型 / 定数 / 純関数のみ。setup の中から見える。
export type ChoiceItem<T> = {
value: T;
label: string;
};
</script>
<script lang="ts" setup generic="T extends string | number">
const props = defineProps<{
modelValue: T;
items: ChoiceItem<T>[];
}>();
const emit = defineEmits<{
(ev: 'update:modelValue', value: T): void;
}>();
function select(value: T) {
emit('update:modelValue', value);
}
</script>
ポイント:
generic="T extends string | number"の制約を付けることで、v-modelで渡された型がstring/number系に限定される- 2 ブロック構成にする理由は setup ブロック内では
export typeが書けない から MkSelect.vueのような複雑な型エクスポートをするコンポーネントで多用される
defineModel で v-model 連動
参考: MkSelect.vue, MkRadios.vue
defineModel を使うと props.modelValue + emit('update:modelValue', v) の 2 行が 1 行に圧縮できる。
<template>
<label :class="[$style.root, { [$style.disabled]: disabled }]">
<input
v-model="checked"
type="checkbox"
:class="$style.input"
:disabled="disabled"
>
<span :class="$style.label"><slot></slot></span>
</label>
</template>
<script lang="ts" setup>
const checked = defineModel<boolean>({ required: true });
const props = defineProps<{
disabled?: boolean;
}>();
</script>
ポイント:
defineModel<boolean>()は 自動でprops.modelValueとemit('update:modelValue', v)を生成 する。返り値はRefなのでchecked.value = ...で書き換えると emit されるdefineModel('foo')のように引数を渡すとv-model:foo(props.foo+emit('update:foo', v)) の連動が作れる- 新規ファイルの v-model 連動は原則として
defineModelを使う (props.modelValue+emitの手書きは既存コードに残るのみ)
emit + 名前付き slot で外部から動作を差し込む
下記は emit + 名前付き slot の典型パターンを示す合成例 (特定ファイルの写しではない)。クリック時の処理を呼び出し元に委ねるパターン (確認 UI など)。なお MkButton.vue 自体は (ev: 'click', payload: PointerEvent) のみを emit する単機能ボタンで、この合成例とは構造が異なる。
<template>
<div :class="$style.root" class="_panel">
<div :class="$style.header">
<slot name="header">{{ i18n.ts.confirm }}</slot>
</div>
<div :class="$style.body">
<slot></slot>
</div>
<div :class="$style.footer">
<button class="_button" :class="$style.cancel" @click="emit('cancel')">
{{ i18n.ts.cancel }}
</button>
<button class="_button _buttonPrimary" :class="$style.ok" @click="emit('ok')">
{{ i18n.ts.ok }}
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { i18n } from '@/i18n.js';
const emit = defineEmits<{
(ev: 'ok'): void;
(ev: 'cancel'): void;
}>();
</script>
ポイント:
- 名前付き slot (
<slot name="header">) と無名 slot (<slot></slot>) は両方使ってよい _panel/_button/_buttonPrimaryは global utility class なので、自前で同じスタイルを書かないemit('ok')等の単純 emit は中継するだけにし、os.confirmなどの実際の確認 UI 起動は呼び出し元の責務にする (テスト・差し替えしやすくするため)
a11y チェックリスト
Misskey の PR レビューで頻繁に出る a11y 指摘をまとめた。新規 / 既存コンポーネントを編集する時は以下を満たす。
クリック可能要素
第一選択: <button class="_button">
<button class="_button" :class="$style.action" :disabled="disabled" @click="onClick">
{{ i18n.ts.save }}
</button>
_buttonglobal class はボタンの装飾を除去するリセット (背景/枠線なし +cursor: pointer+ disabled cursor)。focus ring や ripple は付かない — ripple 付きのボタンが要るならMkButton.vueコンポーネントを使う<button>はデフォルトでtabindex/ Enter / Space /aria-disabledの挙動とブラウザ標準のフォーカスリングを持つので、追加の ARIA を書かなくてよい- form の中で意図せず submit させたくない場合は
type="button"を明示する (省略時はtype="submit"扱い)
やむを得ず <div @click> を使う場合
装飾やレイアウト都合で <button> が使えないときは、4 点セット を必ず揃える。
<div
role="button"
tabindex="0"
:aria-disabled="disabled"
:class="$style.fakeButton"
@click="onClick"
@keydown.enter="onClick"
@keydown.space.prevent="onClick"
>
<slot></slot>
</div>
| 属性 / ハンドラ | なぜ必要か |
|---|---|
role="button" |
スクリーンリーダーにボタンとして読ませる |
tabindex="0" |
キーボードでフォーカス可能にする |
@keydown.enter |
Enter で発火 (本物の <button> の挙動を再現) |
@keydown.space.prevent |
Space で発火 + ページスクロール防止 |
:aria-disabled |
disabled スタイルだけでなく状態も伝える |
@keydown.enter を忘れて click だけ付けるのが最頻出ミス。
<a> をボタン代わりに使うのは原則禁止
URL に飛ばない <a href="#" @click.prevent> は a11y / SEO 両面で良くない。リンクなら <MkA> (MkA.vue)、アクションなら <button> を使う。
フォーム要素
<label> 接続
<!-- ✅ for / id で結ぶ -->
<label :for="id">{{ i18n.ts.username }}</label>
<input :id="id" v-model="username" type="text">
<!-- ✅ ラップする (id 不要) -->
<label>
{{ i18n.ts.username }}
<input v-model="username" type="text">
</label>
label を slot で受け取る共通コンポーネント (MkInput.vue, MkSwitch.vue) を使うとこの規約は自然に守れる。
aria-label で代替
slot や label を見せたくない (アイコンのみのボタンなど) 場合は aria-label:
<button class="_button" :aria-label="i18n.ts.close" @click="emit('close')">
<i class="ti ti-x"></i>
</button>
aria-label の値も i18n 経由にする (英語直書きは禁止)。
実情: 現状コードベースでは aria-label の使用例自体が乏しい (アイコンの hover ヒントには :title="i18n.ts..." が使われるが、title は tooltip でありスクリーンリーダー向けラベルの代替にはならない)。このため aria-label は確立した慣習というより a11y 上の推奨ベストプラクティスとして書いている。新規でアイコンのみのボタンを足すなら付けるのが望ましい。
:disabled と aria-disabled の整合
- 本物の
<button :disabled>ならブラウザが click を抑止するが、<div role="button">は止めてくれない。aria-disabledを付けるだけでなく、ハンドラ側でも早期 return する:
function onClick() {
if (props.disabled) return; // ← これが無いと disabled でも発火する
// ...
}
キーボード操作
- Tab で全ての操作可能要素にたどり着けること (
tabindex="-1"を不用意に付けない) - モーダル / popup を開いたら focus trap を考える (MkModal.vue のような既存コンポーネントは内部で対応している)
- リスト中の項目は矢印キー操作も考慮する。Space / Enter で開く・確定する UI は
MkSelect.vueの@keydown.space.enter(メニューを開く) パターンを参考にする
既存実装の参考
| パターン | 既存コンポーネント |
|---|---|
| 標準的なボタン | MkButton.vue |
| カスタム UI でも a11y を満たす | MkSwitch.vue |
| input + label slot | MkInput.vue |
| キーボード操作対応の選択 UI | MkSelect.vue |
ありがちな PR レビュー指摘
<div @click>に role / tabindex / keydown が無い- アイコンだけのボタンに
aria-labelが無い (Tabler icon 自体には意味情報が無い) disabledスタイルだけ付けてaria-disabled/ ハンドラ抑止が無い- フォーカスリング (
:focus-visible/outline) をoutline: noneで消したまま放置