Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #4044 from traPtitech/SSlime/access-sibling-from-h…
…eader 🎉 ヘッダーから関連チャンネルにアクセスできるように
- Loading branch information
Showing
10 changed files
with
543 additions
and
55 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
117 changes: 117 additions & 0 deletions
117
src/components/Main/MainView/ChannelView/ChannelHeader/ChannelHeaderRelationButton.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
<template> | ||
<button | ||
ref="trigger" | ||
type="button" | ||
aria-haspopup="true" | ||
:aria-expanded="isOpen" | ||
:aria-controls="popupId" | ||
title="関連チャンネルを表示" | ||
:class="$style.trigger" | ||
@click="toggle" | ||
> | ||
<a-icon :size="20" name="rounded-triangle" :class="$style.icon" /> | ||
</button> | ||
<!-- NOTE: ボタンから Tab 移動した際に popup のはじめに飛べるように Focus を管理する --> | ||
<div v-if="isOpen" ref="focusPopupRef" tabindex="0" @focus="focusPopup" /> | ||
<channel-header-relation-popup | ||
v-if="isOpen" | ||
ref="popup" | ||
:popup-id="popupId" | ||
:right-position="triggerBottomRightPosition" | ||
:channel-id="props.channelId" | ||
@outside-click="close" | ||
@focus-return="focusTrigger" | ||
/> | ||
</template> | ||
|
||
<script setup lang="ts"> | ||
import { onMounted, onUnmounted, ref } from 'vue' | ||
import AIcon from '/@/components/UI/AIcon.vue' | ||
import ChannelHeaderRelationPopup from './ChannelHeaderRelationPopup.vue' | ||
import { reactive } from 'vue' | ||
import type { Point } from '/@/lib/basic/point' | ||
import { randomString } from '/@/lib/basic/randomString' | ||
const props = defineProps<{ | ||
channelId: string | ||
}>() | ||
const trigger = ref<HTMLElement | null>(null) | ||
const focusPopupRef = ref<HTMLElement | null>(null) | ||
const popup = ref<InstanceType<typeof ChannelHeaderRelationPopup> | null>(null) | ||
const popupId = randomString() | ||
const isOpen = ref(false) | ||
const toggle = () => { | ||
updateTriggerPosition() | ||
isOpen.value = !isOpen.value | ||
} | ||
const close = (e: Event) => { | ||
// NOTE: popup の外側かつボタンをクリックしたときに、うまく閉じないため抑制する | ||
// 具体的には close -> toggle と呼ばれて閉じなくなる | ||
if (trigger.value !== null && e.composedPath().includes(trigger.value)) return | ||
isOpen.value = false | ||
} | ||
const triggerBottomRightPosition = reactive<Point>({ | ||
x: 0, | ||
y: 0 | ||
}) | ||
const updateTriggerPosition = () => { | ||
if (trigger.value === null) return | ||
const rect = trigger.value.getBoundingClientRect() | ||
triggerBottomRightPosition.x = rect.right | ||
triggerBottomRightPosition.y = rect.bottom | ||
} | ||
onMounted(() => { | ||
updateTriggerPosition() | ||
window.addEventListener('resize', updateTriggerPosition) | ||
}) | ||
onUnmounted(() => { | ||
window.removeEventListener('resize', updateTriggerPosition) | ||
}) | ||
const focusPopup = () => { | ||
popup.value?.focus() | ||
} | ||
const focusTrigger = () => { | ||
trigger.value?.focus() | ||
} | ||
</script> | ||
|
||
<style lang="scss" module> | ||
.trigger { | ||
@include color-ui-secondary; | ||
@include background-primary; | ||
cursor: pointer; | ||
overflow: hidden; | ||
height: 24px; | ||
width: 24px; | ||
margin: 0 8px; | ||
display: grid; | ||
place-items: center; | ||
flex-shrink: 0; | ||
position: sticky; | ||
right: 0; | ||
transition: transform 0.1s; | ||
&:hover { | ||
transform: scale(1.1); | ||
} | ||
.icon { | ||
transition: transform 0.5s; | ||
} | ||
&[aria-expanded='true'] .icon { | ||
transform: rotate(180deg); | ||
} | ||
} | ||
</style> |
71 changes: 71 additions & 0 deletions
71
src/components/Main/MainView/ChannelView/ChannelHeader/ChannelHeaderRelationListItem.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
<template> | ||
<router-link :id="linkId" :to="channelLink" :class="$style.wrap"> | ||
<div :class="$style.channelName"># {{ props.channel.name }}</div> | ||
<div :class="[$style.topic, isTopicEmpty && $style.empty]"> | ||
{{ topic }} | ||
</div> | ||
</router-link> | ||
</template> | ||
|
||
<script setup lang="ts"> | ||
import type { Channel } from '@traptitech/traq' | ||
import { computed } from 'vue' | ||
import useChannelPath from '/@/composables/useChannelPath' | ||
import { RouterLink } from 'vue-router' | ||
import { randomString } from '/@/lib/basic/randomString' | ||
const props = defineProps<{ | ||
channel: Channel | ||
}>() | ||
const linkId = randomString() | ||
const focus = () => { | ||
// HACK: RouterLink が focus できないので、id から HTMLElement を取得して focus する | ||
const link = document.getElementById(linkId) as HTMLElement | null | ||
link?.focus() | ||
} | ||
const { channelIdToLink } = useChannelPath() | ||
const channelLink = computed(() => channelIdToLink(props.channel.id)) | ||
const isTopicEmpty = computed(() => props.channel.topic.length === 0) | ||
const topic = computed(() => | ||
isTopicEmpty.value ? '[トピック未設定]' : props.channel.topic | ||
) | ||
defineExpose({ focus }) | ||
</script> | ||
|
||
<style lang="scss" module> | ||
.wrap { | ||
overflow: hidden; | ||
width: 100%; | ||
display: grid; | ||
} | ||
.channelName, | ||
.topic { | ||
overflow: hidden; | ||
text-overflow: ellipsis; | ||
white-space: nowrap; | ||
contain: strict; | ||
height: 1.5rem; | ||
line-height: 1.5rem; | ||
} | ||
.channelName { | ||
@include color-ui-primary; | ||
font-weight: bold; | ||
} | ||
.topic { | ||
@include color-ui-secondary; | ||
font-size: 0.75rem; | ||
&.empty { | ||
opacity: 0.5; | ||
} | ||
} | ||
</style> |
108 changes: 108 additions & 0 deletions
108
src/components/Main/MainView/ChannelView/ChannelHeader/ChannelHeaderRelationPanel.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
<template> | ||
<div :class="$style.wrap" :data-show-expand-button="showExpandButton"> | ||
<div v-if="isEmpty" :class="$style.empty"> | ||
{{ props.emptyMessage }} | ||
</div> | ||
|
||
<template v-else> | ||
<ul :class="$style.list"> | ||
<li v-for="channel in displayedChannels" :key="channel.id"> | ||
<channel-header-relation-list-item | ||
ref="listItemsRef" | ||
:channel="channel" | ||
/> | ||
</li> | ||
</ul> | ||
<form-button | ||
v-if="showExpandButton" | ||
:class="$style.expandButton" | ||
color="secondary" | ||
label="全て表示" | ||
@click="expand" | ||
/> | ||
</template> | ||
</div> | ||
</template> | ||
|
||
<script lang="ts" setup> | ||
import type { Channel } from '@traptitech/traq' | ||
import { computed, ref, type HTMLAttributes, nextTick } from 'vue' | ||
import ChannelHeaderRelationListItem from './ChannelHeaderRelationListItem.vue' | ||
import FormButton from '/@/components/UI/FormButton.vue' | ||
interface Props extends /* @vue-ignore */ HTMLAttributes { | ||
channels: Channel[] | ||
emptyMessage: string | ||
} | ||
const props = defineProps<Props>() | ||
const listItemsRef = ref<InstanceType<typeof ChannelHeaderRelationListItem>[]>( | ||
[] | ||
) | ||
const isExpanded = ref(false) | ||
const expand = () => { | ||
isExpanded.value = true | ||
nextTick(() => { | ||
// NOTE: すべて表示で新たに表示されるチャンネルにフォーカスする | ||
// ただし、listItemsRef の中身は表示順であることが保証されないため、find で探す | ||
listItemsRef.value | ||
.find(item => item.$props.channel.id === props.channels[3]?.id) | ||
?.focus() | ||
}) | ||
} | ||
const isEmpty = computed(() => props.channels.length === 0) | ||
const displayedChannels = computed(() => { | ||
if (isExpanded.value) { | ||
return props.channels | ||
} else { | ||
return props.channels.slice(0, 3) | ||
} | ||
}) | ||
const showExpandButton = computed( | ||
() => props.channels.length > 3 && !isExpanded.value | ||
) | ||
</script> | ||
|
||
<style lang="scss" module> | ||
.wrap { | ||
overflow: auto; | ||
// NOTE: スクロール可能な際に、一番下がちょっと見えるような高さに設定 | ||
max-height: 21.75rem; | ||
margin-bottom: -16px; | ||
padding-bottom: 16px; | ||
&[data-show-expand-button='false'] { | ||
overflow: scroll; | ||
margin-right: -4px; | ||
} | ||
&::-webkit-scrollbar-track { | ||
margin-bottom: 16px; | ||
} | ||
&::-webkit-scrollbar { | ||
width: 4px; | ||
} | ||
} | ||
.empty { | ||
@include color-ui-secondary; | ||
padding: 16px; | ||
place-items: center; | ||
text-align: center; | ||
font-size: 0.75rem; | ||
} | ||
.list { | ||
display: grid; | ||
grid-template-columns: 1fr; | ||
gap: 0.5rem; | ||
} | ||
.expandButton { | ||
margin-top: 0.5rem; | ||
width: 100%; | ||
} | ||
</style> |
Oops, something went wrong.