Skip to content

Commit

Permalink
Merge pull request #4044 from traPtitech/SSlime/access-sibling-from-h…
Browse files Browse the repository at this point in the history
…eader

🎉 ヘッダーから関連チャンネルにアクセスできるように
  • Loading branch information
mehm8128 committed Oct 8, 2023
2 parents 972da7f + bf32277 commit 0efb8f1
Show file tree
Hide file tree
Showing 10 changed files with 543 additions and 55 deletions.
1 change: 1 addition & 0 deletions index.html
Expand Up @@ -58,6 +58,7 @@
<div id="stamp-picker-popup"></div>
<div id="dropdown-suggester-popup"></div>
<div id="popup-navigator"></div>
<div id="popup-header-relation"></div>
<script type="module" src="/@/main.ts"></script>
</body>
</html>
Expand Up @@ -3,6 +3,10 @@
<template #header>
<div :class="$style.header">
<channel-header-channel-name :channel-id="channelId" />
<channel-header-relation-button
:key="channelId"
:channel-id="channelId"
/>
<channel-header-topic :class="$style.topic" :channel-id="channelId" />
</div>
</template>
Expand All @@ -15,6 +19,7 @@
<script lang="ts" setup>
import PrimaryViewHeader from '/@/components/Main/MainView/PrimaryViewHeader/PrimaryViewHeader.vue'
import ChannelHeaderChannelName from './ChannelHeaderChannelName.vue'
import ChannelHeaderRelationButton from './ChannelHeaderRelationButton.vue'
import ChannelHeaderTopic from './ChannelHeaderTopic.vue'
import ChannelHeaderTools from './ChannelHeaderTools.vue'
import type { ChannelId } from '/@/types/entity-ids'
Expand All @@ -36,7 +41,8 @@ defineProps<{
.header {
display: flex;
align-items: center;
overflow-x: auto;
overflow-x: scroll;
margin-bottom: -6px;
}
.topic {
margin-left: 16px;
Expand Down
@@ -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>
@@ -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>
@@ -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>

0 comments on commit 0efb8f1

Please sign in to comment.