Merge branch 'develop' into fix/visibility-widening

This commit is contained in:
Acid Chicken (硫酸鶏)
2023-04-05 00:41:49 +09:00
committed by GitHub
111 changed files with 8019 additions and 1091 deletions

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -0,0 +1,2 @@
import MkCaptcha from './MkCaptcha.vue';
void MkCaptcha;

View File

@@ -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[];

View File

@@ -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>

View File

@@ -83,7 +83,7 @@
</template>
<script lang="ts" setup>
import { ref, shallowRef, onMounted, onUnmounted, watch } from 'vue';
import { ref, shallowRef } from 'vue';
import * as misskey from 'misskey-js';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkFollowButton from '@/components/MkFollowButton.vue';
@@ -94,7 +94,6 @@ import { notePage } from '@/filters/note';
import { userPage } from '@/filters/user';
import { i18n } from '@/i18n';
import * as os from '@/os';
import { stream } from '@/stream';
import { useTooltip } from '@/scripts/use-tooltip';
import { $i } from '@/account';
@@ -110,35 +109,6 @@ const props = withDefaults(defineProps<{
const elRef = shallowRef<HTMLElement>(null);
const reactionRef = ref(null);
let readObserver: IntersectionObserver | undefined;
let connection;
onMounted(() => {
if (!props.notification.isRead) {
readObserver = new IntersectionObserver((entries, observer) => {
if (!entries.some(entry => entry.isIntersecting)) return;
stream.send('readNotification', {
id: props.notification.id,
});
observer.disconnect();
});
readObserver.observe(elRef.value);
connection = stream.useChannel('main');
connection.on('readAllNotifications', () => readObserver.disconnect());
watch(props.notification.isRead, () => {
readObserver.disconnect();
});
}
});
onUnmounted(() => {
if (readObserver) readObserver.disconnect();
if (connection) connection.dispose();
});
const followRequestDone = ref(false);
const acceptFollowRequest = () => {

View File

@@ -29,7 +29,6 @@ import { notificationTypes } from '@/const';
const props = defineProps<{
includeTypes?: typeof notificationTypes[number][];
unreadOnly?: boolean;
}>();
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
@@ -40,23 +39,17 @@ const pagination: Paging = {
params: computed(() => ({
includeTypes: props.includeTypes ?? undefined,
excludeTypes: props.includeTypes ? undefined : $i.mutingNotificationTypes,
unreadOnly: props.unreadOnly,
})),
};
const onNotification = (notification) => {
const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type);
if (isMuted || document.visibilityState === 'visible') {
stream.send('readNotification', {
id: notification.id,
});
stream.send('readNotification');
}
if (!isMuted) {
pagingComponent.value.prepend({
...notification,
isRead: document.visibilityState === 'visible',
});
pagingComponent.value.prepend(notification);
}
};
@@ -65,30 +58,6 @@ let connection;
onMounted(() => {
connection = stream.useChannel('main');
connection.on('notification', onNotification);
connection.on('readAllNotifications', () => {
if (pagingComponent.value) {
for (const item of pagingComponent.value.queue) {
item.isRead = true;
}
for (const item of pagingComponent.value.items) {
item.isRead = true;
}
}
});
connection.on('readNotifications', notificationIds => {
if (pagingComponent.value) {
for (let i = 0; i < pagingComponent.value.queue.length; i++) {
if (notificationIds.includes(pagingComponent.value.queue[i].id)) {
pagingComponent.value.queue[i].isRead = true;
}
}
for (let i = 0; i < (pagingComponent.value.items || []).length; i++) {
if (notificationIds.includes(pagingComponent.value.items[i].id)) {
pagingComponent.value.items[i].isRead = true;
}
}
}
});
});
onUnmounted(() => {

View File

@@ -12,7 +12,7 @@ import { onMounted } from 'vue';
import { i18n } from '@/i18n';
const props = withDefaults(defineProps<{
maxHeight: number;
maxHeight?: number;
}>(), {
maxHeight: 200,
});

View File

@@ -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,
});
};

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -18,4 +18,3 @@ defineProps<{
const host = toUnicode(hostRaw);
</script>

View File

@@ -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>;

View File

@@ -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];

View File

@@ -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 couldnt 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>;

View File

@@ -148,6 +148,7 @@ watch(() => props.user.avatarBlurhash, () => {
width: 100%;
height: 100%;
padding: 50%;
pointer-events: none;
&.mask {
-webkit-mask:

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -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 {

View File

@@ -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>;

View File

@@ -0,0 +1,5 @@
export const argTypes = {
retry: {
action: 'retry',
},
};

View File

@@ -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>;

View File

@@ -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>

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -0,0 +1,3 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import MkPageHeader_tabs from './MkPageHeader.tabs.vue';
void MkPageHeader_tabs;

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import MkStickyContainer from './MkStickyContainer.vue';
void MkStickyContainer;

View File

@@ -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>;

View File

@@ -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;

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -0,0 +1,3 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import RouterView from './RouterView.vue';
void RouterView;