You've already forked AstralRinth
forked from didirus/AstralRinth
polish(frontend): technical review QA (#5097)
* feat: filtering + sorting alignment * polish: malicious summary modal changes * feat: better filter row using floating panel * fix: re-enable request * fix: lint * polish: jump back to files tab qol * feat: scroll to top of next card when done * fix: show lock icon on preview msg * feat: download no _blank * feat: show also marked in notif * feat: auto expand if only one class in the file * feat: proper page titles * fix: text-contrast typo * fix: lint * feat: QA changes * feat: individual report page + more qa * fix: back btn * fix: broken import * feat: quick reply msgs * fix: in other queue filter * fix: caching threads wrongly * fix: flag filter * feat: toggle enabled by default * fix: dont make btns opacity 50 --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
310
packages/ui/src/components/base/FloatingPanel.vue
Normal file
310
packages/ui/src/components/base/FloatingPanel.vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
const PANEL_VIEWPORT_MARGIN = 8
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'
|
||||
distance?: number
|
||||
disabled?: boolean
|
||||
buttonClass?: string
|
||||
panelClass?: string
|
||||
}>(),
|
||||
{
|
||||
placement: 'bottom-end',
|
||||
distance: 8,
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const triggerRef = ref<HTMLElement>()
|
||||
const panelRef = ref<HTMLElement>()
|
||||
const rafId = ref<number | null>(null)
|
||||
|
||||
const openDirection = ref<'up' | 'down'>('down')
|
||||
const horizontalAlignment = ref<'start' | 'end'>('end')
|
||||
|
||||
const panelStyle = ref({
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
})
|
||||
|
||||
const transformOrigin = computed(() => {
|
||||
const vertical = openDirection.value === 'down' ? 'top' : 'bottom'
|
||||
const horizontal = horizontalAlignment.value === 'end' ? 'right' : 'left'
|
||||
return `${vertical} ${horizontal}`
|
||||
})
|
||||
|
||||
function determineOpenDirection(
|
||||
triggerRect: DOMRect,
|
||||
panelRect: DOMRect,
|
||||
viewportHeight: number,
|
||||
): 'up' | 'down' {
|
||||
const preferDown = props.placement.startsWith('bottom')
|
||||
|
||||
const hasSpaceBelow =
|
||||
triggerRect.bottom + props.distance + panelRect.height + PANEL_VIEWPORT_MARGIN <= viewportHeight
|
||||
const hasSpaceAbove =
|
||||
triggerRect.top - props.distance - panelRect.height - PANEL_VIEWPORT_MARGIN >= 0
|
||||
|
||||
if (preferDown) {
|
||||
return hasSpaceBelow ? 'down' : hasSpaceAbove ? 'up' : 'down'
|
||||
} else {
|
||||
return hasSpaceAbove ? 'up' : hasSpaceBelow ? 'down' : 'up'
|
||||
}
|
||||
}
|
||||
|
||||
function calculateVerticalPosition(
|
||||
triggerRect: DOMRect,
|
||||
panelRect: DOMRect,
|
||||
direction: 'up' | 'down',
|
||||
): number {
|
||||
return direction === 'up'
|
||||
? triggerRect.top - panelRect.height - props.distance
|
||||
: triggerRect.bottom + props.distance
|
||||
}
|
||||
|
||||
function calculateHorizontalPosition(
|
||||
triggerRect: DOMRect,
|
||||
panelRect: DOMRect,
|
||||
viewportWidth: number,
|
||||
): number {
|
||||
const alignEnd = props.placement.endsWith('end')
|
||||
let left: number
|
||||
|
||||
if (alignEnd) {
|
||||
left = triggerRect.right - panelRect.width
|
||||
} else {
|
||||
left = triggerRect.left
|
||||
}
|
||||
|
||||
if (left + panelRect.width > viewportWidth - PANEL_VIEWPORT_MARGIN) {
|
||||
left = Math.max(PANEL_VIEWPORT_MARGIN, viewportWidth - panelRect.width - PANEL_VIEWPORT_MARGIN)
|
||||
}
|
||||
if (left < PANEL_VIEWPORT_MARGIN) {
|
||||
left = PANEL_VIEWPORT_MARGIN
|
||||
}
|
||||
|
||||
return left
|
||||
}
|
||||
|
||||
async function updatePanelPosition() {
|
||||
if (!triggerRef.value || !panelRef.value) return
|
||||
|
||||
await nextTick()
|
||||
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect()
|
||||
const panelRect = panelRef.value.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const viewportWidth = window.innerWidth
|
||||
|
||||
const direction = determineOpenDirection(triggerRect, panelRect, viewportHeight)
|
||||
const top = calculateVerticalPosition(triggerRect, panelRect, direction)
|
||||
const left = calculateHorizontalPosition(triggerRect, panelRect, viewportWidth)
|
||||
|
||||
panelStyle.value = {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
}
|
||||
|
||||
openDirection.value = direction
|
||||
horizontalAlignment.value = props.placement.endsWith('end') ? 'end' : 'start'
|
||||
}
|
||||
|
||||
function startPositionTracking() {
|
||||
function track() {
|
||||
updatePanelPosition()
|
||||
rafId.value = requestAnimationFrame(track)
|
||||
}
|
||||
rafId.value = requestAnimationFrame(track)
|
||||
}
|
||||
|
||||
function stopPositionTracking() {
|
||||
if (rafId.value !== null) {
|
||||
cancelAnimationFrame(rafId.value)
|
||||
rafId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function focusPanelContent() {
|
||||
if (!panelRef.value) return
|
||||
|
||||
const focusable = panelRef.value.querySelector<HTMLElement>(
|
||||
'button:not([data-focus-trap]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)
|
||||
if (focusable) {
|
||||
focusable.focus()
|
||||
}
|
||||
}
|
||||
|
||||
async function open() {
|
||||
if (props.disabled || isOpen.value) return
|
||||
|
||||
isOpen.value = true
|
||||
emit('open')
|
||||
|
||||
await nextTick()
|
||||
await updatePanelPosition()
|
||||
startPositionTracking()
|
||||
|
||||
setTimeout(() => {
|
||||
focusPanelContent()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!isOpen.value) return
|
||||
|
||||
stopPositionTracking()
|
||||
isOpen.value = false
|
||||
emit('close')
|
||||
|
||||
nextTick(() => {
|
||||
triggerRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (isOpen.value) {
|
||||
close()
|
||||
} else {
|
||||
open()
|
||||
}
|
||||
}
|
||||
|
||||
onClickOutside(
|
||||
panelRef,
|
||||
() => {
|
||||
close()
|
||||
},
|
||||
{ ignore: [triggerRef, '#teleports'] },
|
||||
)
|
||||
|
||||
function handleTriggerKeydown(event: KeyboardEvent) {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault()
|
||||
toggle()
|
||||
break
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
open()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
open()
|
||||
break
|
||||
case 'Escape':
|
||||
if (isOpen.value) {
|
||||
event.preventDefault()
|
||||
close()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handlePanelKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function handleWindowResize() {
|
||||
if (isOpen.value) {
|
||||
updatePanelPosition()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleWindowResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleWindowResize)
|
||||
stopPositionTracking()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative inline-block">
|
||||
<ButtonStyled v-bind="$attrs">
|
||||
<button
|
||||
ref="triggerRef"
|
||||
:class="buttonClass"
|
||||
:disabled="disabled"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="true"
|
||||
@click="toggle"
|
||||
@keydown="handleTriggerKeydown"
|
||||
>
|
||||
<slot></slot>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="floating-panel-enter-active"
|
||||
enter-from-class="floating-panel-enter-from"
|
||||
enter-to-class="floating-panel-enter-to"
|
||||
leave-active-class="floating-panel-leave-active"
|
||||
leave-from-class="floating-panel-leave-from"
|
||||
leave-to-class="floating-panel-leave-to"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="panelRef"
|
||||
class="fixed z-[9995] w-fit rounded-[14px] border border-surface-5 bg-surface-3 border-solid border-px p-3 shadow-2xl"
|
||||
:class="panelClass"
|
||||
:style="[panelStyle, { transformOrigin }]"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
@keydown="handlePanelKeydown"
|
||||
@mousedown.stop
|
||||
>
|
||||
<button class="sr-only" data-focus-trap @focusin="close"></button>
|
||||
<slot name="panel"></slot>
|
||||
<button class="sr-only" data-focus-trap @focusin="close"></button>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* .floating-panel-enter-active,
|
||||
.floating-panel-leave-active {
|
||||
transition:
|
||||
transform 0.125s ease-in-out,
|
||||
opacity 0.125s ease-in-out;
|
||||
}
|
||||
|
||||
.floating-panel-enter-from,
|
||||
.floating-panel-leave-to {
|
||||
transform: scale(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.floating-panel-enter-to,
|
||||
.floating-panel-leave-from {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
} */
|
||||
</style>
|
||||
@@ -27,6 +27,7 @@ export { default as FileInput } from './FileInput.vue'
|
||||
export type { FilterBarOption } from './FilterBar.vue'
|
||||
export { default as FilterBar } from './FilterBar.vue'
|
||||
export { default as FloatingActionBar } from './FloatingActionBar.vue'
|
||||
export { default as FloatingPanel } from './FloatingPanel.vue'
|
||||
export { default as HeadingLink } from './HeadingLink.vue'
|
||||
export { default as HorizontalRule } from './HorizontalRule.vue'
|
||||
export { default as IconSelect } from './IconSelect.vue'
|
||||
|
||||
Reference in New Issue
Block a user