refactor: Button components (#6929)

* feat: refactor button components

* fix: qa

* fix: storybook

* a11y: pass

* fix: build

* fix: rename default ->md

* fix: lint

* docs: buttons.md

* refactor part 1

* refactor part 2

* fix: undo refactor for fresh restart

* refactor

* Revert "refactor"

This reverts commit 96d65902d7.

* refactor: part 1

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: remove text-contrast

* fix: qa

* fix: v-tooltip

* fix: lint

* fix: split broken

* fix: passkey qa

* fix: qa

* fix: qa

* fix: prepr

* fix: splitbutton

* improve button group seam

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-08-04 13:54:04 -07:00
committed by GitHub
co-authored by Prospector
parent 6cfe999cb3
commit bb2193b6f5
398 changed files with 13688 additions and 13133 deletions
+1
View File
@@ -12,6 +12,7 @@ export * from './server-content-installing'
export * from './server-search'
export * from './tag-messages'
export * from './truncate'
export * from './use-anchored-teleport'
export * from './version-compatibility'
export * from './vue-children'
export * from './webgl/skin-rendering'
@@ -0,0 +1,162 @@
import type { CSSProperties, Ref } from 'vue'
import { nextTick, onUnmounted, ref, watch } from 'vue'
export type AnchoredTeleportPlacement =
| 'bottom-start'
| 'bottom-end'
| 'top-start'
| 'top-end'
| 'right-start'
| 'right-end'
| 'left-start'
| 'left-end'
export type AnchoredTeleportSide = 'top' | 'right' | 'bottom' | 'left'
const viewportPadding = 8
const anchorPadding = 18
const defaultDistance = ref(8)
export function useAnchoredTeleport(
trigger: Readonly<Ref<HTMLElement | null>>,
panel: Readonly<Ref<HTMLElement | null>>,
placement: Readonly<Ref<AnchoredTeleportPlacement>>,
distance: Readonly<Ref<number>> = defaultDistance,
) {
const isOpen = ref(false)
const panelStyle = ref<CSSProperties>({
top: '0px',
left: '0px',
visibility: 'hidden',
})
const anchorStyle = ref<CSSProperties>({})
const resolvedSide = ref<AnchoredTeleportSide>('bottom')
let resizeObserver: ResizeObserver | undefined
function updatePosition() {
if (!isOpen.value || !trigger.value || !panel.value) return
const triggerRect = trigger.value.getBoundingClientRect()
const panelRect = panel.value.getBoundingClientRect()
const offset = distance.value
const alignsEnd = placement.value.endsWith('end')
const isHorizontal = placement.value.startsWith('right') || placement.value.startsWith('left')
let idealTop: number
let idealLeft: number
if (isHorizontal) {
const prefersRight = placement.value.startsWith('right')
const spaceRight = window.innerWidth - triggerRect.right - viewportPadding
const spaceLeft = triggerRect.left - viewportPadding
const opensRight = prefersRight
? panelRect.width + offset <= spaceRight || spaceRight > spaceLeft
: panelRect.width + offset > spaceLeft && spaceRight > spaceLeft
resolvedSide.value = opensRight ? 'right' : 'left'
idealTop = alignsEnd ? triggerRect.bottom - panelRect.height : triggerRect.top
idealLeft = opensRight
? triggerRect.right + offset
: triggerRect.left - panelRect.width - offset
} else {
const prefersTop = placement.value.startsWith('top')
const spaceBelow = window.innerHeight - triggerRect.bottom - viewportPadding
const spaceAbove = triggerRect.top - viewportPadding
const opensAbove = prefersTop
? panelRect.height + offset <= spaceAbove || spaceAbove > spaceBelow
: panelRect.height + offset > spaceBelow && spaceAbove > spaceBelow
resolvedSide.value = opensAbove ? 'top' : 'bottom'
idealTop = opensAbove
? triggerRect.top - panelRect.height - offset
: triggerRect.bottom + offset
idealLeft = alignsEnd ? triggerRect.right - panelRect.width : triggerRect.left
}
const maxTop = Math.max(
viewportPadding,
window.innerHeight - panelRect.height - viewportPadding,
)
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelRect.width - viewportPadding)
const panelTop = Math.min(Math.max(idealTop, viewportPadding), maxTop)
const panelLeft = Math.min(Math.max(idealLeft, viewportPadding), maxLeft)
panelStyle.value = {
top: `${panelTop}px`,
left: `${panelLeft}px`,
visibility: 'visible',
}
anchorStyle.value = isHorizontal
? {
top: `${Math.min(
Math.max(triggerRect.top + triggerRect.height / 2 - panelTop, anchorPadding),
Math.max(anchorPadding, panelRect.height - anchorPadding),
)}px`,
}
: {
left: `${Math.min(
Math.max(triggerRect.left + triggerRect.width / 2 - panelLeft, anchorPadding),
Math.max(anchorPadding, panelRect.width - anchorPadding),
)}px`,
}
}
function handlePointerDown(event: PointerEvent) {
const target = event.target as Node | null
if (!target || trigger.value?.contains(target) || panel.value?.contains(target)) return
close()
}
function addListeners() {
document.addEventListener('pointerdown', handlePointerDown)
window.addEventListener('resize', updatePosition)
window.addEventListener('scroll', updatePosition, true)
resizeObserver = new ResizeObserver(updatePosition)
if (trigger.value) resizeObserver.observe(trigger.value)
if (panel.value) resizeObserver.observe(panel.value)
}
function removeListeners() {
document.removeEventListener('pointerdown', handlePointerDown)
window.removeEventListener('resize', updatePosition)
window.removeEventListener('scroll', updatePosition, true)
resizeObserver?.disconnect()
resizeObserver = undefined
}
async function open() {
if (isOpen.value) return
panelStyle.value = { top: '0px', left: '0px', visibility: 'hidden' }
isOpen.value = true
await nextTick()
updatePosition()
addListeners()
}
function close(restoreFocus = false) {
if (!isOpen.value) return
isOpen.value = false
removeListeners()
if (restoreFocus) nextTick(() => trigger.value?.focus())
}
watch([placement, distance], updatePosition)
watch(panel, () => {
if (!isOpen.value) return
resizeObserver?.disconnect()
if (trigger.value) resizeObserver?.observe(trigger.value)
if (panel.value) resizeObserver?.observe(panel.value)
updatePosition()
})
onUnmounted(removeListeners)
return {
isOpen,
panelStyle,
anchorStyle,
resolvedSide,
open,
close,
}
}