Merge branch 'develop' into img-max

This commit is contained in:
tamaina
2023-04-04 01:09:14 +00:00
82 changed files with 7903 additions and 586 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

@@ -163,21 +163,22 @@ async function init(): Promise<void> {
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
await os.api(props.pagination.endpoint, {
...params,
limit: props.pagination.noPaging ? (props.pagination.limit || 10) : (props.pagination.limit || 10) + 1,
limit: props.pagination.limit ?? 10,
}).then(res => {
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (i === 3) item._shouldInsertAd_ = true;
}
if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) {
res.pop();
if (res.length === 0 || props.pagination.noPaging) {
items.value = res;
more.value = false;
} else {
if (props.pagination.reversed) moreFetching.value = true;
items.value = res;
more.value = true;
} else {
items.value = res;
more.value = false;
}
offset.value = res.length;
error.value = false;
fetching.value = false;
@@ -198,7 +199,7 @@ const fetchMore = async (): Promise<void> => {
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
await os.api(props.pagination.endpoint, {
...params,
limit: SECOND_FETCH_LIMIT + 1,
limit: SECOND_FETCH_LIMIT,
...(props.pagination.offsetMode ? {
offset: offset.value,
} : {
@@ -227,20 +228,7 @@ const fetchMore = async (): Promise<void> => {
});
};
if (res.length > SECOND_FETCH_LIMIT) {
res.pop();
if (props.pagination.reversed) {
reverseConcat(res).then(() => {
more.value = true;
moreFetching.value = false;
});
} else {
items.value = items.value.concat(res);
more.value = true;
moreFetching.value = false;
}
} else {
if (res.length === 0) {
if (props.pagination.reversed) {
reverseConcat(res).then(() => {
more.value = false;
@@ -251,6 +239,17 @@ const fetchMore = async (): Promise<void> => {
more.value = false;
moreFetching.value = false;
}
} else {
if (props.pagination.reversed) {
reverseConcat(res).then(() => {
more.value = true;
moreFetching.value = false;
});
} else {
items.value = items.value.concat(res);
more.value = true;
moreFetching.value = false;
}
}
offset.value += res.length;
}, err => {
@@ -264,20 +263,19 @@ const fetchMoreAhead = async (): Promise<void> => {
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
await os.api(props.pagination.endpoint, {
...params,
limit: SECOND_FETCH_LIMIT + 1,
limit: SECOND_FETCH_LIMIT,
...(props.pagination.offsetMode ? {
offset: offset.value,
} : {
sinceId: items.value[items.value.length - 1].id,
}),
}).then(res => {
if (res.length > SECOND_FETCH_LIMIT) {
res.pop();
items.value = items.value.concat(res);
more.value = true;
} else {
if (res.length === 0) {
items.value = items.value.concat(res);
more.value = false;
} else {
items.value = items.value.concat(res);
more.value = true;
}
offset.value += res.length;
moreFetching.value = false;

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

@@ -1,5 +1,5 @@
<template>
<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick">
<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.animation]: animation, [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick">
<img :class="$style.inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
<div v-if="user.isCat" :class="[$style.ears, { [$style.mask]: useBlurEffect }]">
@@ -27,6 +27,7 @@ import { acct, userPage } from '@/filters/user';
import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue';
import { defaultStore } from '@/store';
const animation = $ref(defaultStore.state.animation);
const squareAvatars = $ref(defaultStore.state.squareAvatars);
const useBlurEffect = $ref(defaultStore.state.useBlurEffect);
@@ -86,6 +87,18 @@ watch(() => props.user.avatarBlurhash, () => {
to { transform: rotate(-37.6deg) skew(-30deg); }
}
@keyframes eartightleft {
from { transform: rotate(37.6deg) skew(30deg); }
50% { transform: rotate(37.4deg) skew(30deg); }
to { transform: rotate(37.6deg) skew(30deg); }
}
@keyframes eartightright {
from { transform: rotate(-37.6deg) skew(-30deg); }
50% { transform: rotate(-37.4deg) skew(-30deg); }
to { transform: rotate(-37.6deg) skew(-30deg); }
}
.root {
position: relative;
display: inline-block;
@@ -135,6 +148,7 @@ watch(() => props.user.avatarBlurhash, () => {
width: 100%;
height: 100%;
padding: 50%;
pointer-events: none;
&.mask {
-webkit-mask:
@@ -144,6 +158,14 @@ watch(() => props.user.avatarBlurhash, () => {
mask:
url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><filter id="a"><feGaussianBlur in="SourceGraphic" stdDeviation="1"/></filter><circle cx="16" cy="16" r="15" filter="url(%23a)"/></svg>') exclude center / 50% 50%,
linear-gradient(#fff, #fff); // polyfill of `image(#fff)`
> .earLeft {
animation: eartightleft 6s infinite;
}
> .earRight {
animation: eartightright 6s infinite;
}
}
> .earLeft,
@@ -225,7 +247,7 @@ watch(() => props.user.avatarBlurhash, () => {
}
}
&:hover {
&.animation:hover {
> .ears {
> .earLeft {
animation: earwiggleleft 1s infinite;

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

@@ -8,7 +8,9 @@
<template v-if="metadata">
<div v-if="!hideTitle" :class="$style.titleContainer" @click="top">
<MkAvatar v-if="metadata.avatar" :class="$style.titleAvatar" :user="metadata.avatar" indicator/>
<div v-if="metadata.avatar" :class="$style.titleAvatarContainer">
<MkAvatar :class="$style.titleAvatar" :user="metadata.avatar" indicator/>
</div>
<i v-else-if="metadata.icon" :class="[$style.titleIcon, metadata.icon]"></i>
<div :class="$style.title">
@@ -249,13 +251,19 @@ onUnmounted(() => {
margin-left: 24px;
}
.titleAvatar {
.titleAvatarContainer {
$size: 32px;
display: inline-block;
contain: strict;
overflow: clip;
width: $size;
height: $size;
vertical-align: bottom;
margin: 0 8px;
padding: 8px;
flex-shrink: 0;
}
.titleAvatar {
width: 100%;
height: 100%;
pointer-events: none;
}

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;