Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🎉 ヘッダーから関連チャンネルにアクセスできるように #4044

Merged
merged 27 commits into from Oct 8, 2023
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
52cb4cd
♻️ Tab の部分をコンポーネントに分ける
SSlime-s Aug 18, 2023
cd34257
✨ ATab を focusable に
SSlime-s Aug 18, 2023
99e91df
🎉 ヘッダーから関連チャンネルにアクセスできるように
SSlime-s Aug 18, 2023
2855c51
👕 fix lint
SSlime-s Aug 18, 2023
d383a60
🎨 フォーカス制御最強に
SSlime-s Aug 18, 2023
1ef6800
👕 fix fmt
SSlime-s Aug 19, 2023
b16b8e3
♻️ focus trap ではない気がしたので変数名を変える
SSlime-s Aug 19, 2023
6b6c896
📚 focus 制御のための div にコメントを追加
SSlime-s Aug 19, 2023
74bcc05
🎨 トピック周りのデザイン変更
SSlime-s Aug 19, 2023
fd28718
🎨 title をつける
SSlime-s Aug 19, 2023
0c83252
♻️ 一部だけ focus to を使ってるのキモかったので focus のみに統一
SSlime-s Aug 19, 2023
95e4f9f
🎨 トピック未設定の文言を [] でくくる
SSlime-s Aug 19, 2023
160a86e
🎨 tablist を折り返すように
SSlime-s Aug 19, 2023
dc844b8
♻️ top + margin-top で計算してたのを top 内で完結させるように
SSlime-s Aug 19, 2023
35077d3
🎨 スクロール領域を調整
SSlime-s Aug 19, 2023
1f753b0
♻️ empty 周りちょいわかりやすく
SSlime-s Aug 19, 2023
c0d832d
🎨 角丸の数値が違ってた
SSlime-s Aug 19, 2023
2764165
♻️ ATab に role tab をつける
SSlime-s Aug 19, 2023
2daf969
✨ arrow key で tab を移動できるように
SSlime-s Aug 19, 2023
97bfc09
♻️ useRelatedChannels を汎用性の高い場所へ
SSlime-s Aug 19, 2023
35c8967
🎨 scrollbar の下を 16px 開けるように
SSlime-s Aug 20, 2023
e49a34c
🎨 popup の padding を調整
SSlime-s Aug 20, 2023
dd8f965
🎨 ヘッダーの関連チャンネルが右に溢れないように
SSlime-s Aug 20, 2023
4f4aba3
🎨 ヘッダーでスクロールバーが表示をずらさないように
SSlime-s Aug 20, 2023
f33be89
🎨 ATab を focus 時にも灰色にするように
SSlime-s Aug 20, 2023
c283b77
🎨 popup 内のスクロールバーのスタイルを調整
SSlime-s Aug 21, 2023
bf32277
🐛 arrow key で tab を移動した際に focus も移動させるように
SSlime-s Aug 21, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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"
/>
mehm8128 marked this conversation as resolved.
Show resolved Hide resolved
<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
mehm8128 marked this conversation as resolved.
Show resolved Hide resolved
@@ -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 {
mehm8128 marked this conversation as resolved.
Show resolved Hide resolved
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)
}
SSlime-s marked this conversation as resolved.
Show resolved Hide resolved
})
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>