diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index 643a41d661..c35b5bb1ee 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -34,7 +34,7 @@ "@vueuse/core": "^11.1.0", "ace-builds": "^1.43.5", "dayjs": "^1.11.10", - "floating-vue": "^5.2.2", + "@floating-ui/vue": "^2.0.1", "fuse.js": "^6.6.2", "intl-messageformat": "^10.7.7", "ofetch": "^1.3.4", diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 3277a2ea13..a2ae0e6e5f 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -55,6 +55,7 @@ import { providePopupNotificationManager, TeleportOverflowMenu, TextLogo, + TooltipDirective, useDebugLogger, useFormatBytes, useHostingIntercom, @@ -2189,6 +2190,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/AnalyticsChartLegend.vue b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/AnalyticsChartLegend.vue index 6dd495c9c9..af4838cc19 100644 --- a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/AnalyticsChartLegend.vue +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-header/AnalyticsChartLegend.vue @@ -62,15 +62,11 @@ {{ legendEntry.name }} - - + @@ -107,8 +103,7 @@ - - diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-table/ProjectCell.vue b/apps/frontend/src/components/analytics-dashboard/analytics-table/ProjectCell.vue index e2ba6b4801..61360d8a59 100644 --- a/apps/frontend/src/components/analytics-dashboard/analytics-table/ProjectCell.vue +++ b/apps/frontend/src/components/analytics-dashboard/analytics-table/ProjectCell.vue @@ -32,7 +32,6 @@ :href="organizationHref" :target="organizationHref ? '_blank' : undefined" :rel="organizationHref ? 'noopener noreferrer' : undefined" - :aria-label="organizationTooltip" class="flex size-4 shrink-0 items-center text-primary" :class="{ 'hover:underline': organizationHref }" > diff --git a/apps/frontend/src/components/analytics-dashboard/stat-cards/StatCard.vue b/apps/frontend/src/components/analytics-dashboard/stat-cards/StatCard.vue index 9585972b1f..33588b0e55 100644 --- a/apps/frontend/src/components/analytics-dashboard/stat-cards/StatCard.vue +++ b/apps/frontend/src/components/analytics-dashboard/stat-cards/StatCard.vue @@ -38,7 +38,7 @@
-
+
-
diff --git a/packages/ui/src/components/floating/FloatingMenu.vue b/packages/ui/src/components/floating/FloatingMenu.vue new file mode 100644 index 0000000000..35ea062e3b --- /dev/null +++ b/packages/ui/src/components/floating/FloatingMenu.vue @@ -0,0 +1,250 @@ + + diff --git a/packages/ui/src/components/floating/Tooltip.vue b/packages/ui/src/components/floating/Tooltip.vue new file mode 100644 index 0000000000..f07b648aee --- /dev/null +++ b/packages/ui/src/components/floating/Tooltip.vue @@ -0,0 +1,272 @@ + + + diff --git a/packages/ui/src/components/floating/TooltipDirective.vue b/packages/ui/src/components/floating/TooltipDirective.vue new file mode 100644 index 0000000000..be22309b16 --- /dev/null +++ b/packages/ui/src/components/floating/TooltipDirective.vue @@ -0,0 +1,14 @@ + + diff --git a/packages/ui/src/components/floating/index.ts b/packages/ui/src/components/floating/index.ts new file mode 100644 index 0000000000..8fb414cda6 --- /dev/null +++ b/packages/ui/src/components/floating/index.ts @@ -0,0 +1,3 @@ +export { default as FloatingMenu } from './FloatingMenu.vue' +export { default as Tooltip } from './Tooltip.vue' +export { default as TooltipDirective } from './TooltipDirective.vue' diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index dd0bc4cddc..497d26ac07 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -6,6 +6,7 @@ export * from './changelog' export * from './chart' export * from './content' export * from './external_files' +export * from './floating' export { default as ImageViewerEditor } from './image-viewer-editor/index.vue' export type { ImageViewerEditorData, diff --git a/packages/ui/src/components/modal/NewModal.vue b/packages/ui/src/components/modal/NewModal.vue index d6722abb38..8efe6f8ace 100644 --- a/packages/ui/src/components/modal/NewModal.vue +++ b/packages/ui/src/components/modal/NewModal.vue @@ -32,19 +32,19 @@ ref="modalBodyRef" role="dialog" aria-modal="true" + tabindex="-1" :aria-labelledby="headerId" - class="modal-body flex flex-col bg-bg-raised rounded-2xl border border-solid border-surface-5" + class="modal-body flex flex-col bg-bg-raised rounded-2xl border border-solid border-surface-5 outline-none" v-bind="$attrs" - @keydown="handleKeyDown" >
-
+
- + {{ header }} @@ -150,7 +150,13 @@ import { computed, nextTick, onUnmounted, ref } from 'vue' import { IconButton } from '#ui/components/base/buttons' import { useVIntl } from '../../composables/i18n' -import { useModalStack } from '../../composables/modal-stack' +import { + getModalStackZBase, + MODAL_CONTAINER_Z_OFFSET, + MODAL_OVERLAY_Z_OFFSET, + MODAL_TAURI_Z_OFFSET, + useModalStack, +} from '../../composables/modal-stack' import { useScrollIndicator } from '../../composables/scroll-indicator' import { injectModalBehavior } from '../../providers' import { commonMessages } from '../../utils/common-messages' @@ -248,8 +254,12 @@ function onTauriOverlayClick(event: MouseEvent) { } const computedFade = computed(() => { - if (props.fade) return props.fade - if (props.danger) return 'danger' + if (props.fade) { + return props.fade + } + if (props.danger) { + return 'danger' + } return 'standard' }) @@ -270,9 +280,79 @@ const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(scr const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' +const inputModality = { keyboard: false } +if (typeof window !== 'undefined') { + window.addEventListener( + 'keydown', + (event) => { + if (event.metaKey || event.altKey || event.ctrlKey) { + return + } + inputModality.keyboard = true + }, + true, + ) + window.addEventListener( + 'pointerdown', + () => { + inputModality.keyboard = false + }, + true, + ) +} + function getFocusableElements(): HTMLElement[] { - if (!modalBodyRef.value) return [] - return Array.from(modalBodyRef.value.querySelectorAll(FOCUSABLE_SELECTOR)) + if (!modalBodyRef.value) { + return [] + } + return Array.from(modalBodyRef.value.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0, + ) +} + +function isFocusInsideModal() { + return !!modalBodyRef.value?.contains(document.activeElement) +} + +function focusElement(el: HTMLElement) { + el.focus({ preventScroll: true, focusVisible: inputModality.keyboard }) +} + +function focusModal() { + const dialog = modalBodyRef.value + if (!dialog) { + return false + } + + if (inputModality.keyboard) { + for (const el of getFocusableElements()) { + focusElement(el) + if (isFocusInsideModal()) { + return true + } + } + } + + focusElement(dialog) + if (isFocusInsideModal()) { + return true + } + + for (const el of getFocusableElements()) { + focusElement(el) + if (isFocusInsideModal()) { + return true + } + } + + return false +} + +function scheduleFocus(attempt = 0) { + if (focusModal() || attempt >= 10) { + return + } + requestAnimationFrame(() => scheduleFocus(attempt + 1)) } function nextRenderedModalDepth(): number { @@ -295,7 +375,9 @@ function show(event?: MouseEvent) { open.value = true previousFocusEl = document.activeElement pushModal() - if (wasEmpty) modalBehavior?.onShow?.() + if (wasEmpty) { + modalBehavior?.onShow?.() + } document.body.style.overflow = 'hidden' window.addEventListener('keydown', handleWindowKeyDown) @@ -309,12 +391,7 @@ function show(event?: MouseEvent) { setTimeout(() => { visible.value = true nextTick(() => { - const focusable = getFocusableElements() - if (focusable.length > 0) { - focusable[0].focus() - } else { - modalBodyRef.value?.focus() - } + scheduleFocus() }) }, 50) } @@ -350,7 +427,9 @@ function hide(): boolean { async function scrollToBottom(behavior: ScrollBehavior = 'smooth') { await nextTick() - if (!scrollContainer.value) return + if (!scrollContainer.value) { + return + } scrollContainer.value.scrollTo({ top: scrollContainer.value.scrollHeight, @@ -369,11 +448,10 @@ defineExpose({ const mouseX = ref(0) const mouseY = ref(0) -const MODAL_STACK_BASE_Z = 100 -const stackZBase = computed(() => MODAL_STACK_BASE_Z + stackDepth.value * 10) -const stackOverlayZ = computed(() => stackZBase.value + 19) -const stackTauriZ = computed(() => stackZBase.value + 20) -const stackContainerZ = computed(() => stackZBase.value + 21) +const stackZBase = computed(() => getModalStackZBase(stackDepth.value)) +const stackOverlayZ = computed(() => stackZBase.value + MODAL_OVERLAY_Z_OFFSET) +const stackTauriZ = computed(() => stackZBase.value + MODAL_TAURI_Z_OFFSET) +const stackContainerZ = computed(() => stackZBase.value + MODAL_CONTAINER_Z_OFFSET) const resolvedMaxWidth = computed(() => props.maxWidth ?? '60rem') const resolvedWidth = computed(() => props.width ?? 'fit-content') const mouseXOffset = computed(() => `calc((-50vw + ${mouseX.value}px) / 16)`) @@ -406,31 +484,50 @@ onUnmounted(() => { }) function handleWindowKeyDown(event: KeyboardEvent) { + if (!isTopmostModal()) { + return + } + + if (event.key === 'Tab') { + trapFocus(event) + return + } + if (props.closeOnEsc && event.key === 'Escape' && props.closable) { - if (!isTopmostModal()) return hide() } } -function handleKeyDown(event: KeyboardEvent) { - if (event.key === 'Tab') { - const focusable = getFocusableElements() - if (focusable.length === 0) return +function trapFocus(event: KeyboardEvent) { + const dialog = modalBodyRef.value + if (!dialog) { + return + } - const first = focusable[0] - const last = focusable[focusable.length - 1] + const focusable = getFocusableElements() + if (focusable.length === 0) { + event.preventDefault() + focusElement(dialog) + return + } - if (event.shiftKey) { - if (document.activeElement === first) { - event.preventDefault() - last.focus() - } - } else { - if (document.activeElement === last) { - event.preventDefault() - first.focus() - } - } + const first = focusable[0] + const last = focusable[focusable.length - 1] + const active = document.activeElement + const inside = dialog.contains(active) + + if (!inside) { + event.preventDefault() + focusElement(event.shiftKey ? last : first) + return + } + + if (event.shiftKey && (active === first || active === dialog)) { + event.preventDefault() + focusElement(last) + } else if (!event.shiftKey && active === last) { + event.preventDefault() + focusElement(first) } } diff --git a/packages/ui/src/components/modal/ShareModal.vue b/packages/ui/src/components/modal/ShareModal.vue index f6f851a76a..6c5c959d87 100644 --- a/packages/ui/src/components/modal/ShareModal.vue +++ b/packages/ui/src/components/modal/ShareModal.vue @@ -192,7 +192,6 @@ defineExpose({ v-if="link" v-tooltip="'Copy Link'" type="button" - aria-label="Copy Link" class="flex h-10 w-full cursor-pointer items-center justify-between gap-2 rounded-xl border-none bg-button-bg px-3 pr-1.5 text-primary transition-all hover:bg-button-bg-hover hover:brightness-125 active:scale-95" @click="copyText" > @@ -222,7 +221,6 @@ defineExpose({ v-tooltip="'Send as an email'" :href="sendEmail" :target="targetParameter" - aria-label="Send as an email" class="!w-9 !px-0 !rounded-full" >
- +
@@ -184,11 +184,11 @@ {{ getPlatformLabel(platform) }} - @@ -211,7 +211,7 @@
- +
@@ -338,10 +338,10 @@ > {{ gameVersionGroup.label }} - @@ -363,7 +363,7 @@
- + @@ -379,10 +379,10 @@ {{ getPlatformLabel(platform) }} - @@ -402,7 +402,7 @@
- +