summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
diff options
context:
space:
mode:
authorAcid Chicken (硫酸鶏) <root@acid-chicken.com>2023-04-04 09:38:34 +0900
committerGitHub <noreply@github.com>2023-04-04 09:38:34 +0900
commit38d0b6216715feb3c3d8e78d9256b5f63ccf5500 (patch)
treedea120f5f1293b8eb704729a8e2da3cac9333e0f /packages/frontend/src/components
parentadd notes (diff)
downloadsharkey-38d0b6216715feb3c3d8e78d9256b5f63ccf5500.tar.gz
sharkey-38d0b6216715feb3c3d8e78d9256b5f63ccf5500.tar.bz2
sharkey-38d0b6216715feb3c3d8e78d9256b5f63ccf5500.zip
build(#10336): Storybook & Chromatic & msw (#10365)
* build(#10336): init * fix(#10336): invalid name conversion * build(#10336): load locales and vite config * refactor(#10336): remove unused imports * build(#10336): separate definitions and generated codes * refactor(#10336): remove hatches * refactor(#10336): module semantics * refactor(#10336): remove unused common preferences * fix: typo * build(#10336): mock assets * build(#10336): impl `SatisfiesExpression` * build(#10336): control themes * refactor(#10336): semantics * build(#10336): make .storybook as an individual TypeScript project * style(#10336): use single quote * build(#10336): avoid intrinsic component names * chore: suppress linter * style: typing * build(#10336): update dependencies * docs: note about Storybook * build(#10336): sync * build(#10336): full reload server on change * chore: use defaultStore instead * build(#10336): show popups on Story * refactor(#10336): remove redundant div * docs: fix * build(#10336): interactions * build(#10336): add an interaction test for `<MkA/>` * build(#10336): bump storybook * docs(#10336): mention to pre-build misskey-js * build(#10336): write stories for `MkAcct` * build(#10336): write stories for `MkAd` * build(#10336): fix missing type definition * build(#10336): use `toHaveTextContent` * build(#10336): write some stories * build(#10336): hide internal args * build(#10336): generate `components/global` stories only * build(#10336): write stories for `MkMisskeyFlavoredMarkdown` * fix: conflict errors * build(#10336): subcomponents on sidebar * refactor: restore `SatisfiesExpression` * docs(#10336): note development status * build(#10336): use chokidar-cli * docs(#10336): note chokidar-cli mode * chore(#10336): untrack generated stories files * fix: pointer handling * build(#10336): finalize * chore: add static option to `MkLoading` * refactor(#10336): bind to local args * fix: missing case * revert: restore `SatisfiesExpression` This reverts commit f246699f38a28befbfccc11e9eade22cbaace4f3. * build(#10336): make storybook buildable * build(#10336): staticify assets * build(#10336): staticified directory structure * build(#10336): normalize path for Windows * ci(#10336): create actions * build(#10336): ignore tsc errors * build(#10336): ignore tsc errors * build(#10336): missing dependencies * build(#10336): missing dependencies * build(#10336): use fast-glob * fix: invalid lockfile * ci(#10336): increase heap size * build(#10336): use unpkg for storybook tabler icons * build(#10336): use unpkg for storybook twemojis * build(#10336): disable `ProfilePageCat` * build(#10336): blur `MkA` before interaction ends * ci(#10336): stabilize * ci(#10336): fetch-depth * build(#10336): isChromatic * ci(#10336): notify on changes * ci(#10336): fix typo * ci(#10336): missing working directory * ci(#10336): skip build * ci(#10336): fix path * build(#10336): fails on Windows * build(#10336): available on Windows * ci(#10336): disable animation on chromatic * ci(#10336): add static option to `PageHeader.tabs` * chore: void * ci(#10336): change parameters * docs(#10336): update CONTRIBUTING * docs(#10336): note about meta overriding and etc. * ci(#10336): use Chromatic for checks * ci(#10336): use `pull_request` instead of `pull_request_target` for now * ci(#10336): use `exitOnceUploaded` * ci(#10336): reuse built storybook * ci(#10336): back to `pull_request_target` * chore: unused dependencies * style(#10336): reduce prettier indents * style: note about `TSSatisfiesExpression`
Diffstat (limited to 'packages/frontend/src/components')
-rw-r--r--packages/frontend/src/components/MkAnalogClock.stories.impl.ts28
-rw-r--r--packages/frontend/src/components/MkButton.stories.impl.ts30
-rw-r--r--packages/frontend/src/components/MkCaptcha.stories.impl.ts2
-rw-r--r--packages/frontend/src/components/MkContextMenu.vue2
-rw-r--r--packages/frontend/src/components/MkMenu.vue20
-rw-r--r--packages/frontend/src/components/MkUrlPreview.vue2
-rw-r--r--packages/frontend/src/components/MkYouTubePlayer.vue (renamed from packages/frontend/src/components/MkYoutubePlayer.vue)0
-rw-r--r--packages/frontend/src/components/global/MkA.stories.impl.ts47
-rw-r--r--packages/frontend/src/components/global/MkAcct.stories.impl.ts43
-rw-r--r--packages/frontend/src/components/global/MkAcct.vue1
-rw-r--r--packages/frontend/src/components/global/MkAd.stories.impl.ts120
-rw-r--r--packages/frontend/src/components/global/MkAd.vue2
-rw-r--r--packages/frontend/src/components/global/MkAvatar.stories.impl.ts66
-rw-r--r--packages/frontend/src/components/global/MkAvatar.vue1
-rw-r--r--packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts45
-rw-r--r--packages/frontend/src/components/global/MkEllipsis.stories.impl.ts32
-rw-r--r--packages/frontend/src/components/global/MkEllipsis.vue16
-rw-r--r--packages/frontend/src/components/global/MkEmoji.stories.impl.ts31
-rw-r--r--packages/frontend/src/components/global/MkError.stories.meta.ts5
-rw-r--r--packages/frontend/src/components/global/MkLoading.stories.impl.ts60
-rw-r--r--packages/frontend/src/components/global/MkLoading.vue8
-rw-r--r--packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts74
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.stories.impl.ts98
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.tabs.stories.impl.ts3
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.tabs.vue18
-rw-r--r--packages/frontend/src/components/global/MkStickyContainer.stories.impl.ts3
-rw-r--r--packages/frontend/src/components/global/MkTime.stories.impl.ts312
-rw-r--r--packages/frontend/src/components/global/MkTime.vue6
-rw-r--r--packages/frontend/src/components/global/MkUrl.stories.impl.ts77
-rw-r--r--packages/frontend/src/components/global/MkUserName.stories.impl.ts57
-rw-r--r--packages/frontend/src/components/global/RouterView.stories.impl.ts3
31 files changed, 1186 insertions, 26 deletions
diff --git a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts
new file mode 100644
index 0000000000..05190aa268
--- /dev/null
+++ b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts
@@ -0,0 +1,28 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import MkAnalogClock from './MkAnalogClock.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkAnalogClock,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkAnalogClock v-bind="props" />',
+ };
+ },
+ parameters: {
+ layout: 'fullscreen',
+ },
+} satisfies StoryObj<typeof MkAnalogClock>;
diff --git a/packages/frontend/src/components/MkButton.stories.impl.ts b/packages/frontend/src/components/MkButton.stories.impl.ts
new file mode 100644
index 0000000000..e1c1c54d10
--- /dev/null
+++ b/packages/frontend/src/components/MkButton.stories.impl.ts
@@ -0,0 +1,30 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+/* eslint-disable import/no-default-export */
+/* eslint-disable import/no-duplicates */
+import { StoryObj } from '@storybook/vue3';
+import MkButton from './MkButton.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkButton,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkButton v-bind="props">Text</MkButton>',
+ };
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkButton>;
diff --git a/packages/frontend/src/components/MkCaptcha.stories.impl.ts b/packages/frontend/src/components/MkCaptcha.stories.impl.ts
new file mode 100644
index 0000000000..6ac437a277
--- /dev/null
+++ b/packages/frontend/src/components/MkCaptcha.stories.impl.ts
@@ -0,0 +1,2 @@
+import MkCaptcha from './MkCaptcha.vue';
+void MkCaptcha;
diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue
index 5bdf477241..b81c806b0c 100644
--- a/packages/frontend/src/components/MkContextMenu.vue
+++ b/packages/frontend/src/components/MkContextMenu.vue
@@ -17,8 +17,8 @@ import { onMounted, onBeforeUnmount } from 'vue';
import MkMenu from './MkMenu.vue';
import { MenuItem } from './types/menu.vue';
import contains from '@/scripts/contains';
-import * as os from '@/os';
import { defaultStore } from '@/store';
+import * as os from '@/os';
const props = defineProps<{
items: MenuItem[];
diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue
index 9e3022896c..e513a65a32 100644
--- a/packages/frontend/src/components/MkMenu.vue
+++ b/packages/frontend/src/components/MkMenu.vue
@@ -1,5 +1,5 @@
<template>
-<div>
+<div role="menu">
<div
ref="itemsEl" v-hotkey="keymap"
class="_popup _shadow"
@@ -8,37 +8,37 @@
@contextmenu.self="e => e.preventDefault()"
>
<template v-for="(item, i) in items2">
- <div v-if="item === null" :class="$style.divider"></div>
- <span v-else-if="item.type === 'label'" :class="[$style.label, $style.item]">
+ <div v-if="item === null" role="separator" :class="$style.divider"></div>
+ <span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]">
<span>{{ item.text }}</span>
</span>
- <span v-else-if="item.type === 'pending'" :tabindex="i" :class="[$style.pending, $style.item]">
+ <span v-else-if="item.type === 'pending'" role="menuitem" :tabindex="i" :class="[$style.pending, $style.item]">
<span><MkEllipsis/></span>
</span>
- <MkA v-else-if="item.type === 'link'" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
+ <MkA v-else-if="item.type === 'link'" role="menuitem" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
<span>{{ item.text }}</span>
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
</MkA>
- <a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
+ <a v-else-if="item.type === 'a'" role="menuitem" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<span>{{ item.text }}</span>
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
</a>
- <button v-else-if="item.type === 'user'" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
+ <button v-else-if="item.type === 'user'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/>
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
</button>
- <span v-else-if="item.type === 'switch'" :tabindex="i" :class="$style.item" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
+ <span v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" :class="$style.item" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<MkSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</MkSwitch>
</span>
- <button v-else-if="item.type === 'parent'" :tabindex="i" class="_button" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="showChildren(item, $event)">
+ <button v-else-if="item.type === 'parent'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="showChildren(item, $event)">
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<span>{{ item.text }}</span>
<span :class="$style.caret"><i class="ti ti-chevron-right ti-fw"></i></span>
</button>
- <button v-else :tabindex="i" class="_button" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
+ <button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
<span>{{ item.text }}</span>
diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue
index 635ac3e8bd..9c5622b1c5 100644
--- a/packages/frontend/src/components/MkUrlPreview.vue
+++ b/packages/frontend/src/components/MkUrlPreview.vue
@@ -150,7 +150,7 @@ function adjustTweetHeight(message: any) {
}
const openPlayer = (): void => {
- os.popup(defineAsyncComponent(() => import('@/components/MkYoutubePlayer.vue')), {
+ os.popup(defineAsyncComponent(() => import('@/components/MkYouTubePlayer.vue')), {
url: requestUrl.href,
});
};
diff --git a/packages/frontend/src/components/MkYoutubePlayer.vue b/packages/frontend/src/components/MkYouTubePlayer.vue
index 4d765fe2f7..4d765fe2f7 100644
--- a/packages/frontend/src/components/MkYoutubePlayer.vue
+++ b/packages/frontend/src/components/MkYouTubePlayer.vue
diff --git a/packages/frontend/src/components/global/MkA.stories.impl.ts b/packages/frontend/src/components/global/MkA.stories.impl.ts
new file mode 100644
index 0000000000..72d069e853
--- /dev/null
+++ b/packages/frontend/src/components/global/MkA.stories.impl.ts
@@ -0,0 +1,47 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { expect } from '@storybook/jest';
+import { userEvent, within } from '@storybook/testing-library';
+import { StoryObj } from '@storybook/vue3';
+import MkA from './MkA.vue';
+import { tick } from '@/scripts/test-utils';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkA,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkA v-bind="props">Text</MkA>',
+ };
+ },
+ async play({ canvasElement }) {
+ const canvas = within(canvasElement);
+ const a = canvas.getByRole<HTMLAnchorElement>('link');
+ await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
+ await userEvent.click(a, { button: 2 });
+ await tick();
+ const menu = canvas.getByRole('menu');
+ await expect(menu).toBeInTheDocument();
+ await userEvent.click(a, { button: 0 });
+ a.blur();
+ await tick();
+ await expect(menu).not.toBeInTheDocument();
+ },
+ args: {
+ to: '#test',
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkA>;
diff --git a/packages/frontend/src/components/global/MkAcct.stories.impl.ts b/packages/frontend/src/components/global/MkAcct.stories.impl.ts
new file mode 100644
index 0000000000..7dfa1a14f2
--- /dev/null
+++ b/packages/frontend/src/components/global/MkAcct.stories.impl.ts
@@ -0,0 +1,43 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import { userDetailed } from '../../../.storybook/fakes';
+import MkAcct from './MkAcct.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkAcct,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkAcct v-bind="props" />',
+ };
+ },
+ args: {
+ user: {
+ ...userDetailed,
+ host: null,
+ },
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkAcct>;
+export const Detail = {
+ ...Default,
+ args: {
+ ...Default.args,
+ user: userDetailed,
+ detail: true,
+ },
+} satisfies StoryObj<typeof MkAcct>;
diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue
index e06ab64e86..2b9f892fc6 100644
--- a/packages/frontend/src/components/global/MkAcct.vue
+++ b/packages/frontend/src/components/global/MkAcct.vue
@@ -18,4 +18,3 @@ defineProps<{
const host = toUnicode(hostRaw);
</script>
-
diff --git a/packages/frontend/src/components/global/MkAd.stories.impl.ts b/packages/frontend/src/components/global/MkAd.stories.impl.ts
new file mode 100644
index 0000000000..7d8a42a03c
--- /dev/null
+++ b/packages/frontend/src/components/global/MkAd.stories.impl.ts
@@ -0,0 +1,120 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { expect } from '@storybook/jest';
+import { userEvent, within } from '@storybook/testing-library';
+import { StoryObj } from '@storybook/vue3';
+import { i18n } from '@/i18n';
+import MkAd from './MkAd.vue';
+const common = {
+ render(args) {
+ return {
+ components: {
+ MkAd,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkAd v-bind="props" />',
+ };
+ },
+ async play({ canvasElement, args }) {
+ const canvas = within(canvasElement);
+ const a = canvas.getByRole<HTMLAnchorElement>('link');
+ await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
+ const img = within(a).getByRole('img');
+ await expect(img).toBeInTheDocument();
+ let buttons = canvas.getAllByRole<HTMLButtonElement>('button');
+ await expect(buttons).toHaveLength(1);
+ const i = buttons[0];
+ await expect(i).toBeInTheDocument();
+ await userEvent.click(i);
+ await expect(a).not.toBeInTheDocument();
+ await expect(i).not.toBeInTheDocument();
+ buttons = canvas.getAllByRole<HTMLButtonElement>('button');
+ await expect(buttons).toHaveLength(args.__hasReduce ? 2 : 1);
+ const reduce = args.__hasReduce ? buttons[0] : null;
+ const back = buttons[args.__hasReduce ? 1 : 0];
+ if (reduce) {
+ await expect(reduce).toBeInTheDocument();
+ await expect(reduce).toHaveTextContent(i18n.ts._ad.reduceFrequencyOfThisAd);
+ }
+ await expect(back).toBeInTheDocument();
+ await expect(back).toHaveTextContent(i18n.ts._ad.back);
+ await userEvent.click(back);
+ if (reduce) {
+ await expect(reduce).not.toBeInTheDocument();
+ }
+ await expect(back).not.toBeInTheDocument();
+ const aAgain = canvas.getByRole<HTMLAnchorElement>('link');
+ await expect(aAgain).toBeInTheDocument();
+ const imgAgain = within(aAgain).getByRole('img');
+ await expect(imgAgain).toBeInTheDocument();
+ },
+ args: {
+ prefer: [],
+ specify: {
+ id: 'someadid',
+ radio: 1,
+ url: '#test',
+ },
+ __hasReduce: true,
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkAd>;
+export const Square = {
+ ...common,
+ args: {
+ ...common.args,
+ specify: {
+ ...common.args.specify,
+ place: 'square',
+ imageUrl:
+ 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
+ },
+ },
+} satisfies StoryObj<typeof MkAd>;
+export const Horizontal = {
+ ...common,
+ args: {
+ ...common.args,
+ specify: {
+ ...common.args.specify,
+ place: 'horizontal',
+ imageUrl:
+ 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
+ },
+ },
+} satisfies StoryObj<typeof MkAd>;
+export const HorizontalBig = {
+ ...common,
+ args: {
+ ...common.args,
+ specify: {
+ ...common.args.specify,
+ place: 'horizontal-big',
+ imageUrl:
+ 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
+ },
+ },
+} satisfies StoryObj<typeof MkAd>;
+export const ZeroRatio = {
+ ...Square,
+ args: {
+ ...Square.args,
+ specify: {
+ ...Square.args.specify,
+ ratio: 0,
+ },
+ __hasReduce: false,
+ },
+} satisfies StoryObj<typeof MkAd>;
diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue
index b8f749bd1c..5799f99d5f 100644
--- a/packages/frontend/src/components/global/MkAd.vue
+++ b/packages/frontend/src/components/global/MkAd.vue
@@ -20,13 +20,13 @@
<script lang="ts" setup>
import { ref } from 'vue';
+import { i18n } from '@/i18n';
import { instance } from '@/instance';
import { host } from '@/config';
import MkButton from '@/components/MkButton.vue';
import { defaultStore } from '@/store';
import * as os from '@/os';
import { $i } from '@/account';
-import { i18n } from '@/i18n';
type Ad = (typeof instance)['ads'][number];
diff --git a/packages/frontend/src/components/global/MkAvatar.stories.impl.ts b/packages/frontend/src/components/global/MkAvatar.stories.impl.ts
new file mode 100644
index 0000000000..6c46f75b5f
--- /dev/null
+++ b/packages/frontend/src/components/global/MkAvatar.stories.impl.ts
@@ -0,0 +1,66 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import { userDetailed } from '../../../.storybook/fakes';
+import MkAvatar from './MkAvatar.vue';
+const common = {
+ render(args) {
+ return {
+ components: {
+ MkAvatar,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkAvatar v-bind="props" />',
+ };
+ },
+ args: {
+ user: userDetailed,
+ },
+ decorators: [
+ (Story, context) => ({
+ // eslint-disable-next-line quotes
+ template: `<div :style="{ display: 'grid', width: '${context.args.size}px', height: '${context.args.size}px' }"><story/></div>`,
+ }),
+ ],
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkAvatar>;
+export const ProfilePage = {
+ ...common,
+ args: {
+ ...common.args,
+ size: 120,
+ indicator: true,
+ },
+} satisfies StoryObj<typeof MkAvatar>;
+export const ProfilePageCat = {
+ ...ProfilePage,
+ args: {
+ ...ProfilePage.args,
+ user: {
+ ...userDetailed,
+ isCat: true,
+ },
+ },
+ parameters: {
+ ...ProfilePage.parameters,
+ chromatic: {
+ /* Your story couldn’t be captured because it exceeds our 25,000,000px limit. Its dimensions are 5,504,893x5,504,892px. Possible ways to resolve:
+ * * Separate pages into components
+ * * Minimize the number of very large elements in a story
+ */
+ disableSnapshot: true,
+ },
+ },
+} satisfies StoryObj<typeof MkAvatar>;
diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue
index 9a21941c8d..0cc30a887f 100644
--- a/packages/frontend/src/components/global/MkAvatar.vue
+++ b/packages/frontend/src/components/global/MkAvatar.vue
@@ -148,6 +148,7 @@ watch(() => props.user.avatarBlurhash, () => {
width: 100%;
height: 100%;
padding: 50%;
+ pointer-events: none;
&.mask {
-webkit-mask:
diff --git a/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts b/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts
new file mode 100644
index 0000000000..36ab85b579
--- /dev/null
+++ b/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts
@@ -0,0 +1,45 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import MkCustomEmoji from './MkCustomEmoji.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkCustomEmoji,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkCustomEmoji v-bind="props" />',
+ };
+ },
+ args: {
+ name: 'mi',
+ url: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkCustomEmoji>;
+export const Normal = {
+ ...Default,
+ args: {
+ ...Default.args,
+ normal: true,
+ },
+} satisfies StoryObj<typeof MkCustomEmoji>;
+export const Missing = {
+ ...Default,
+ args: {
+ name: Default.args.name,
+ },
+} satisfies StoryObj<typeof MkCustomEmoji>;
diff --git a/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts b/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts
new file mode 100644
index 0000000000..65405a9bc8
--- /dev/null
+++ b/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts
@@ -0,0 +1,32 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import isChromatic from 'chromatic/isChromatic';
+import MkEllipsis from './MkEllipsis.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkEllipsis,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkEllipsis v-bind="props" />',
+ };
+ },
+ args: {
+ static: isChromatic(),
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkEllipsis>;
diff --git a/packages/frontend/src/components/global/MkEllipsis.vue b/packages/frontend/src/components/global/MkEllipsis.vue
index b3cf69c075..c8f6cd3394 100644
--- a/packages/frontend/src/components/global/MkEllipsis.vue
+++ b/packages/frontend/src/components/global/MkEllipsis.vue
@@ -1,9 +1,19 @@
<template>
-<span :class="$style.root">
+<span :class="[$style.root, { [$style.static]: static }]">
<span :class="$style.dot">.</span><span :class="$style.dot">.</span><span :class="$style.dot">.</span>
</span>
</template>
+<script lang="ts" setup>
+import { } from 'vue';
+
+const props = withDefaults(defineProps<{
+ static?: boolean;
+}>(), {
+ static: false,
+});
+</script>
+
<style lang="scss" module>
@keyframes ellipsis {
0%, 80%, 100% {
@@ -15,7 +25,9 @@
}
.root {
-
+ &.static > .dot {
+ animation-play-state: paused;
+ }
}
.dot {
diff --git a/packages/frontend/src/components/global/MkEmoji.stories.impl.ts b/packages/frontend/src/components/global/MkEmoji.stories.impl.ts
new file mode 100644
index 0000000000..f9900375f7
--- /dev/null
+++ b/packages/frontend/src/components/global/MkEmoji.stories.impl.ts
@@ -0,0 +1,31 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import MkEmoji from './MkEmoji.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkEmoji,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkEmoji v-bind="props" />',
+ };
+ },
+ args: {
+ emoji: '❤',
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkEmoji>;
diff --git a/packages/frontend/src/components/global/MkError.stories.meta.ts b/packages/frontend/src/components/global/MkError.stories.meta.ts
new file mode 100644
index 0000000000..51d763ada7
--- /dev/null
+++ b/packages/frontend/src/components/global/MkError.stories.meta.ts
@@ -0,0 +1,5 @@
+export const argTypes = {
+ retry: {
+ action: 'retry',
+ },
+};
diff --git a/packages/frontend/src/components/global/MkLoading.stories.impl.ts b/packages/frontend/src/components/global/MkLoading.stories.impl.ts
new file mode 100644
index 0000000000..9dcc0cdea1
--- /dev/null
+++ b/packages/frontend/src/components/global/MkLoading.stories.impl.ts
@@ -0,0 +1,60 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import isChromatic from 'chromatic/isChromatic';
+import MkLoading from './MkLoading.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkLoading,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkLoading v-bind="props" />',
+ };
+ },
+ args: {
+ static: isChromatic(),
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkLoading>;
+export const Inline = {
+ ...Default,
+ args: {
+ ...Default.args,
+ inline: true,
+ },
+} satisfies StoryObj<typeof MkLoading>;
+export const Colored = {
+ ...Default,
+ args: {
+ ...Default.args,
+ colored: true,
+ },
+} satisfies StoryObj<typeof MkLoading>;
+export const Mini = {
+ ...Default,
+ args: {
+ ...Default.args,
+ mini: true,
+ },
+} satisfies StoryObj<typeof MkLoading>;
+export const Em = {
+ ...Default,
+ args: {
+ ...Default.args,
+ em: true,
+ },
+} satisfies StoryObj<typeof MkLoading>;
diff --git a/packages/frontend/src/components/global/MkLoading.vue b/packages/frontend/src/components/global/MkLoading.vue
index 64e12e3b44..4311f9fe8a 100644
--- a/packages/frontend/src/components/global/MkLoading.vue
+++ b/packages/frontend/src/components/global/MkLoading.vue
@@ -6,7 +6,7 @@
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
</g>
</svg>
- <svg :class="[$style.spinner, $style.fg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
+ <svg :class="[$style.spinner, $style.fg, { [$style.static]: static }]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1.125,0,0,1.125,12,12)">
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
</g>
@@ -19,11 +19,13 @@
import { } from 'vue';
const props = withDefaults(defineProps<{
+ static?: boolean;
inline?: boolean;
colored?: boolean;
mini?: boolean;
em?: boolean;
}>(), {
+ static: false,
inline: false,
colored: true,
mini: false,
@@ -97,5 +99,9 @@ const props = withDefaults(defineProps<{
.fg {
animation: spinner 0.5s linear infinite;
+
+ &.static {
+ animation-play-state: paused;
+ }
}
</style>
diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts
new file mode 100644
index 0000000000..f6811b6747
--- /dev/null
+++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts
@@ -0,0 +1,74 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import MkMisskeyFlavoredMarkdown from './MkMisskeyFlavoredMarkdown.vue';
+import { within } from '@storybook/testing-library';
+import { expect } from '@storybook/jest';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkMisskeyFlavoredMarkdown,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkMisskeyFlavoredMarkdown v-bind="props" />',
+ };
+ },
+ async play({ canvasElement, args }) {
+ const canvas = within(canvasElement);
+ if (args.plain) {
+ const aiHelloMiskist = canvas.getByText('@ai *Hello*, #Miskist!');
+ await expect(aiHelloMiskist).toBeInTheDocument();
+ } else {
+ const ai = canvas.getByText('@ai');
+ await expect(ai).toBeInTheDocument();
+ await expect(ai.closest('a')).toHaveAttribute('href', '/@ai');
+ const hello = canvas.getByText('Hello');
+ await expect(hello).toBeInTheDocument();
+ await expect(hello.style.fontStyle).toBe('oblique');
+ const miskist = canvas.getByText('#Miskist');
+ await expect(miskist).toBeInTheDocument();
+ await expect(miskist).toHaveAttribute('href', args.isNote ?? true ? '/tags/Miskist' : '/user-tags/Miskist');
+ }
+ const heart = canvas.getByAltText('❤');
+ await expect(heart).toBeInTheDocument();
+ await expect(heart).toHaveAttribute('src', '/twemoji/2764.svg');
+ },
+ args: {
+ text: '@ai *Hello*, #Miskist! ❤',
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
+export const Plain = {
+ ...Default,
+ args: {
+ ...Default.args,
+ plain: true,
+ },
+} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
+export const Nowrap = {
+ ...Default,
+ args: {
+ ...Default.args,
+ nowrap: true,
+ },
+} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
+export const IsNotNote = {
+ ...Default,
+ args: {
+ ...Default.args,
+ isNote: false,
+ },
+} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
diff --git a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts
new file mode 100644
index 0000000000..5519d60fc4
--- /dev/null
+++ b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts
@@ -0,0 +1,98 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import MkPageHeader from './MkPageHeader.vue';
+export const Empty = {
+ render(args) {
+ return {
+ components: {
+ MkPageHeader,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkPageHeader v-bind="props" />',
+ };
+ },
+ args: {
+ static: true,
+ tabs: [],
+ },
+ parameters: {
+ layout: 'centered',
+ chromatic: {
+ /* This component has animations that are implemented with JavaScript. So it's unstable to take a snapshot. */
+ disableSnapshot: true,
+ },
+ },
+} satisfies StoryObj<typeof MkPageHeader>;
+export const OneTab = {
+ ...Empty,
+ args: {
+ ...Empty.args,
+ tab: 'sometabkey',
+ tabs: [
+ {
+ key: 'sometabkey',
+ title: 'Some Tab Title',
+ },
+ ],
+ },
+} satisfies StoryObj<typeof MkPageHeader>;
+export const Icon = {
+ ...OneTab,
+ args: {
+ ...OneTab.args,
+ tabs: [
+ {
+ ...OneTab.args.tabs[0],
+ icon: 'ti ti-home',
+ },
+ ],
+ },
+} satisfies StoryObj<typeof MkPageHeader>;
+export const IconOnly = {
+ ...Icon,
+ args: {
+ ...Icon.args,
+ tabs: [
+ {
+ ...Icon.args.tabs[0],
+ title: undefined,
+ iconOnly: true,
+ },
+ ],
+ },
+} satisfies StoryObj<typeof MkPageHeader>;
+export const SomeTabs = {
+ ...Empty,
+ args: {
+ ...Empty.args,
+ tab: 'princess',
+ tabs: [
+ {
+ key: 'princess',
+ title: 'Princess',
+ icon: 'ti ti-crown',
+ },
+ {
+ key: 'fairy',
+ title: 'Fairy',
+ icon: 'ti ti-snowflake',
+ },
+ {
+ key: 'angel',
+ title: 'Angel',
+ icon: 'ti ti-feather',
+ },
+ ],
+ },
+} satisfies StoryObj<typeof MkPageHeader>;
diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.stories.impl.ts b/packages/frontend/src/components/global/MkPageHeader.tabs.stories.impl.ts
new file mode 100644
index 0000000000..6d4460d593
--- /dev/null
+++ b/packages/frontend/src/components/global/MkPageHeader.tabs.stories.impl.ts
@@ -0,0 +1,3 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import MkPageHeader_tabs from './MkPageHeader.tabs.vue';
+void MkPageHeader_tabs;
diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue
index 42760da08f..9e1da64e61 100644
--- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue
@@ -33,14 +33,18 @@
<script lang="ts">
export type Tab = {
key: string;
- title: string;
- icon?: string;
- iconOnly?: boolean;
onClick?: (ev: MouseEvent) => void;
-} & {
- iconOnly: true;
- iccn: string;
-};
+} & (
+ | {
+ iconOnly?: false;
+ title: string;
+ icon?: string;
+ }
+ | {
+ iconOnly: true;
+ icon: string;
+ }
+);
</script>
<script lang="ts" setup>
diff --git a/packages/frontend/src/components/global/MkStickyContainer.stories.impl.ts b/packages/frontend/src/components/global/MkStickyContainer.stories.impl.ts
new file mode 100644
index 0000000000..97b8cc0c5b
--- /dev/null
+++ b/packages/frontend/src/components/global/MkStickyContainer.stories.impl.ts
@@ -0,0 +1,3 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import MkStickyContainer from './MkStickyContainer.vue';
+void MkStickyContainer;
diff --git a/packages/frontend/src/components/global/MkTime.stories.impl.ts b/packages/frontend/src/components/global/MkTime.stories.impl.ts
new file mode 100644
index 0000000000..b72601b1ff
--- /dev/null
+++ b/packages/frontend/src/components/global/MkTime.stories.impl.ts
@@ -0,0 +1,312 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { expect } from '@storybook/jest';
+import { StoryObj } from '@storybook/vue3';
+import MkTime from './MkTime.vue';
+import { i18n } from '@/i18n';
+import { dateTimeFormat } from '@/scripts/intl-const';
+const now = new Date('2023-04-01T00:00:00.000Z');
+const future = new Date(8640000000000000);
+const oneHourAgo = new Date(now.getTime() - 3600000);
+const oneDayAgo = new Date(now.getTime() - 86400000);
+const oneWeekAgo = new Date(now.getTime() - 604800000);
+const oneMonthAgo = new Date(now.getTime() - 2592000000);
+const oneYearAgo = new Date(now.getTime() - 31536000000);
+export const Empty = {
+ render(args) {
+ return {
+ components: {
+ MkTime,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkTime v-bind="props" />',
+ };
+ },
+ async play({ canvasElement }) {
+ await expect(canvasElement).toHaveTextContent(i18n.ts._ago.invalid);
+ },
+ args: {
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const RelativeFuture = {
+ ...Empty,
+ async play({ canvasElement }) {
+ await expect(canvasElement).toHaveTextContent(i18n.ts._ago.future);
+ },
+ args: {
+ ...Empty.args,
+ time: future,
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const AbsoluteFuture = {
+ ...Empty,
+ async play({ canvasElement, args }) {
+ await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
+ },
+ args: {
+ ...Empty.args,
+ time: future,
+ mode: 'absolute',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const DetailFuture = {
+ ...Empty,
+ async play(context) {
+ await AbsoluteFuture.play(context);
+ await expect(context.canvasElement).toHaveTextContent(' (');
+ await RelativeFuture.play(context);
+ await expect(context.canvasElement).toHaveTextContent(')');
+ },
+ args: {
+ ...Empty.args,
+ time: future,
+ mode: 'detail',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const RelativeNow = {
+ ...Empty,
+ async play({ canvasElement }) {
+ await expect(canvasElement).toHaveTextContent(i18n.ts._ago.justNow);
+ },
+ args: {
+ ...Empty.args,
+ time: now,
+ origin: now,
+ mode: 'relative',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const AbsoluteNow = {
+ ...Empty,
+ async play({ canvasElement, args }) {
+ await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
+ },
+ args: {
+ ...Empty.args,
+ time: now,
+ origin: now,
+ mode: 'absolute',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const DetailNow = {
+ ...Empty,
+ async play(context) {
+ await AbsoluteNow.play(context);
+ await expect(context.canvasElement).toHaveTextContent(' (');
+ await RelativeNow.play(context);
+ await expect(context.canvasElement).toHaveTextContent(')');
+ },
+ args: {
+ ...Empty.args,
+ time: now,
+ origin: now,
+ mode: 'detail',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const RelativeOneHourAgo = {
+ ...Empty,
+ async play({ canvasElement }) {
+ await expect(canvasElement).toHaveTextContent(i18n.t('_ago.hoursAgo', { n: 1 }));
+ },
+ args: {
+ ...Empty.args,
+ time: oneHourAgo,
+ origin: now,
+ mode: 'relative',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const AbsoluteOneHourAgo = {
+ ...Empty,
+ async play({ canvasElement, args }) {
+ await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
+ },
+ args: {
+ ...Empty.args,
+ time: oneHourAgo,
+ origin: now,
+ mode: 'absolute',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const DetailOneHourAgo = {
+ ...Empty,
+ async play(context) {
+ await AbsoluteOneHourAgo.play(context);
+ await expect(context.canvasElement).toHaveTextContent(' (');
+ await RelativeOneHourAgo.play(context);
+ await expect(context.canvasElement).toHaveTextContent(')');
+ },
+ args: {
+ ...Empty.args,
+ time: oneHourAgo,
+ origin: now,
+ mode: 'detail',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const RelativeOneDayAgo = {
+ ...Empty,
+ async play({ canvasElement }) {
+ await expect(canvasElement).toHaveTextContent(i18n.t('_ago.daysAgo', { n: 1 }));
+ },
+ args: {
+ ...Empty.args,
+ time: oneDayAgo,
+ origin: now,
+ mode: 'relative',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const AbsoluteOneDayAgo = {
+ ...Empty,
+ async play({ canvasElement, args }) {
+ await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
+ },
+ args: {
+ ...Empty.args,
+ time: oneDayAgo,
+ origin: now,
+ mode: 'absolute',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const DetailOneDayAgo = {
+ ...Empty,
+ async play(context) {
+ await AbsoluteOneDayAgo.play(context);
+ await expect(context.canvasElement).toHaveTextContent(' (');
+ await RelativeOneDayAgo.play(context);
+ await expect(context.canvasElement).toHaveTextContent(')');
+ },
+ args: {
+ ...Empty.args,
+ time: oneDayAgo,
+ origin: now,
+ mode: 'detail',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const RelativeOneWeekAgo = {
+ ...Empty,
+ async play({ canvasElement }) {
+ await expect(canvasElement).toHaveTextContent(i18n.t('_ago.weeksAgo', { n: 1 }));
+ },
+ args: {
+ ...Empty.args,
+ time: oneWeekAgo,
+ origin: now,
+ mode: 'relative',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const AbsoluteOneWeekAgo = {
+ ...Empty,
+ async play({ canvasElement, args }) {
+ await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
+ },
+ args: {
+ ...Empty.args,
+ time: oneWeekAgo,
+ origin: now,
+ mode: 'absolute',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const DetailOneWeekAgo = {
+ ...Empty,
+ async play(context) {
+ await AbsoluteOneWeekAgo.play(context);
+ await expect(context.canvasElement).toHaveTextContent(' (');
+ await RelativeOneWeekAgo.play(context);
+ await expect(context.canvasElement).toHaveTextContent(')');
+ },
+ args: {
+ ...Empty.args,
+ time: oneWeekAgo,
+ origin: now,
+ mode: 'detail',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const RelativeOneMonthAgo = {
+ ...Empty,
+ async play({ canvasElement }) {
+ await expect(canvasElement).toHaveTextContent(i18n.t('_ago.monthsAgo', { n: 1 }));
+ },
+ args: {
+ ...Empty.args,
+ time: oneMonthAgo,
+ origin: now,
+ mode: 'relative',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const AbsoluteOneMonthAgo = {
+ ...Empty,
+ async play({ canvasElement, args }) {
+ await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
+ },
+ args: {
+ ...Empty.args,
+ time: oneMonthAgo,
+ origin: now,
+ mode: 'absolute',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const DetailOneMonthAgo = {
+ ...Empty,
+ async play(context) {
+ await AbsoluteOneMonthAgo.play(context);
+ await expect(context.canvasElement).toHaveTextContent(' (');
+ await RelativeOneMonthAgo.play(context);
+ await expect(context.canvasElement).toHaveTextContent(')');
+ },
+ args: {
+ ...Empty.args,
+ time: oneMonthAgo,
+ origin: now,
+ mode: 'detail',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const RelativeOneYearAgo = {
+ ...Empty,
+ async play({ canvasElement }) {
+ await expect(canvasElement).toHaveTextContent(i18n.t('_ago.yearsAgo', { n: 1 }));
+ },
+ args: {
+ ...Empty.args,
+ time: oneYearAgo,
+ origin: now,
+ mode: 'relative',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const AbsoluteOneYearAgo = {
+ ...Empty,
+ async play({ canvasElement, args }) {
+ await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
+ },
+ args: {
+ ...Empty.args,
+ time: oneYearAgo,
+ origin: now,
+ mode: 'absolute',
+ },
+} satisfies StoryObj<typeof MkTime>;
+export const DetailOneYearAgo = {
+ ...Empty,
+ async play(context) {
+ await AbsoluteOneYearAgo.play(context);
+ await expect(context.canvasElement).toHaveTextContent(' (');
+ await RelativeOneYearAgo.play(context);
+ await expect(context.canvasElement).toHaveTextContent(')');
+ },
+ args: {
+ ...Empty.args,
+ time: oneYearAgo,
+ origin: now,
+ mode: 'detail',
+ },
+} satisfies StoryObj<typeof MkTime>;
diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue
index 3fa8bb9adc..99169512db 100644
--- a/packages/frontend/src/components/global/MkTime.vue
+++ b/packages/frontend/src/components/global/MkTime.vue
@@ -14,8 +14,10 @@ import { dateTimeFormat } from '@/scripts/intl-const';
const props = withDefaults(defineProps<{
time: Date | string | number | null;
+ origin?: Date | null;
mode?: 'relative' | 'absolute' | 'detail';
}>(), {
+ origin: null,
mode: 'relative',
});
@@ -25,7 +27,7 @@ const _time = props.time == null ? NaN :
const invalid = Number.isNaN(_time);
const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
-let now = $ref((new Date()).getTime());
+let now = $ref((props.origin ?? new Date()).getTime());
const relative = $computed<string>(() => {
if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない
if (invalid) return i18n.ts._ago.invalid;
@@ -46,7 +48,7 @@ const relative = $computed<string>(() => {
let tickId: number;
function tick() {
- now = (new Date()).getTime();
+ now = props.origin ?? (new Date()).getTime();
const ago = (now - _time) / 1000/*ms*/;
const next = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000;
diff --git a/packages/frontend/src/components/global/MkUrl.stories.impl.ts b/packages/frontend/src/components/global/MkUrl.stories.impl.ts
new file mode 100644
index 0000000000..2344c4851a
--- /dev/null
+++ b/packages/frontend/src/components/global/MkUrl.stories.impl.ts
@@ -0,0 +1,77 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { expect } from '@storybook/jest';
+import { userEvent, within } from '@storybook/testing-library';
+import { StoryObj } from '@storybook/vue3';
+import { rest } from 'msw';
+import { commonHandlers } from '../../../.storybook/mocks';
+import MkUrl from './MkUrl.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkUrl,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkUrl v-bind="props">Text</MkUrl>',
+ };
+ },
+ async play({ canvasElement }) {
+ const canvas = within(canvasElement);
+ const a = canvas.getByRole<HTMLAnchorElement>('link');
+ await expect(a).toHaveAttribute('href', 'https://misskey-hub.net/');
+ await userEvent.hover(a);
+ /*
+ await tick(); // FIXME: wait for network request
+ const anchors = canvas.getAllByRole<HTMLAnchorElement>('link');
+ const popup = anchors.find(anchor => anchor !== a)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion
+ await expect(popup).toBeInTheDocument();
+ await expect(popup).toHaveAttribute('href', 'https://misskey-hub.net/');
+ await expect(popup).toHaveTextContent('Misskey Hub');
+ await expect(popup).toHaveTextContent('Misskeyはオープンソースの分散型ソーシャルネットワーキングプラットフォームです。');
+ await expect(popup).toHaveTextContent('misskey-hub.net');
+ const icon = within(popup).getByRole('img');
+ await expect(icon).toBeInTheDocument();
+ await expect(icon).toHaveAttribute('src', 'https://misskey-hub.net/favicon.ico');
+ */
+ await userEvent.unhover(a);
+ },
+ args: {
+ url: 'https://misskey-hub.net/',
+ },
+ parameters: {
+ layout: 'centered',
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ rest.get('/url', (req, res, ctx) => {
+ return res(ctx.json({
+ title: 'Misskey Hub',
+ icon: 'https://misskey-hub.net/favicon.ico',
+ description: 'Misskeyはオープンソースの分散型ソーシャルネットワーキングプラットフォームです。',
+ thumbnail: null,
+ player: {
+ url: null,
+ width: null,
+ height: null,
+ allow: [],
+ },
+ sitename: 'misskey-hub.net',
+ sensitive: false,
+ url: 'https://misskey-hub.net/',
+ }));
+ }),
+ ],
+ },
+ },
+} satisfies StoryObj<typeof MkUrl>;
diff --git a/packages/frontend/src/components/global/MkUserName.stories.impl.ts b/packages/frontend/src/components/global/MkUserName.stories.impl.ts
new file mode 100644
index 0000000000..41b1567a6f
--- /dev/null
+++ b/packages/frontend/src/components/global/MkUserName.stories.impl.ts
@@ -0,0 +1,57 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { expect } from '@storybook/jest';
+import { userEvent, within } from '@storybook/testing-library';
+import { StoryObj } from '@storybook/vue3';
+import { userDetailed } from '../../../.storybook/fakes';
+import MkUserName from './MkUserName.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkUserName,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkUserName v-bind="props"/>',
+ };
+ },
+ async play({ canvasElement }) {
+ await expect(canvasElement).toHaveTextContent(userDetailed.name);
+ },
+ args: {
+ user: userDetailed,
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkUserName>;
+export const Anonymous = {
+ ...Default,
+ async play({ canvasElement }) {
+ await expect(canvasElement).toHaveTextContent(userDetailed.username);
+ },
+ args: {
+ ...Default.args,
+ user: {
+ ...userDetailed,
+ name: null,
+ },
+ },
+} satisfies StoryObj<typeof MkUserName>;
+export const Wrap = {
+ ...Default,
+ args: {
+ ...Default.args,
+ nowrap: false,
+ },
+} satisfies StoryObj<typeof MkUserName>;
diff --git a/packages/frontend/src/components/global/RouterView.stories.impl.ts b/packages/frontend/src/components/global/RouterView.stories.impl.ts
new file mode 100644
index 0000000000..7910b8b3cb
--- /dev/null
+++ b/packages/frontend/src/components/global/RouterView.stories.impl.ts
@@ -0,0 +1,3 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import RouterView from './RouterView.vue';
+void RouterView;