mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
refactor: introduce PageHeader component (#6629)
* refactor: introduce PageHeader component * feat: split up according to component structure guide * fix: lint * fix: label inconsistencies * feat: refactor PageHeader (again) * refactor: old impls * fix: changes * fix: rescan * fix: prepr * fix: dedupe * fix: rev * fix: lint * fix: lint --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2" :class="rootClass">
|
||||
<div class="flex flex-wrap items-start gap-4 max-md:flex-col" :class="rowClass">
|
||||
<div class="flex min-w-0 flex-1 gap-4" :class="mainClass">
|
||||
<div v-if="$slots.leading" class="flex shrink-0 items-center gap-4">
|
||||
<slot name="leading" />
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center gap-2">
|
||||
<div class="flex flex-col justify-center gap-1.5">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1
|
||||
class="m-0 min-w-0 max-w-full text-2xl font-semibold leading-none text-contrast"
|
||||
:class="titleClassValue"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<slot name="badges" />
|
||||
</div>
|
||||
<p v-if="hasSummary" class="m-0 max-w-[44rem]" :class="summaryClass">
|
||||
<slot name="summary">{{ summary }}</slot>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.metadata" class="max-md:hidden">
|
||||
<slot name="metadata" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.metadata" class="flex justify-between md:hidden">
|
||||
<slot name="metadata" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
import type { PageHeaderProps } from './types'
|
||||
|
||||
const props = withDefaults(defineProps<PageHeaderProps>(), {
|
||||
summary: null,
|
||||
headerClass: '',
|
||||
rowClass: '',
|
||||
mainClass: '',
|
||||
titleClass: '',
|
||||
truncateTitle: false,
|
||||
divider: true,
|
||||
bottomPadding: true,
|
||||
disableLineClamp: false,
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const rootClass = computed(() => [
|
||||
props.divider ? 'border-0 border-b border-solid border-divider' : '',
|
||||
props.bottomPadding ? 'pb-4' : '',
|
||||
props.headerClass,
|
||||
])
|
||||
const titleClassValue = computed(() => [props.truncateTitle ? 'truncate' : '', props.titleClass])
|
||||
const summaryClass = computed(() => (props.disableLineClamp ? '' : 'line-clamp-2'))
|
||||
const hasSummary = computed(() => !!props.summary || !!slots.summary)
|
||||
</script>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div
|
||||
ref="metadata"
|
||||
class="page-header-metadata flex min-w-0 flex-wrap items-center gap-x-2 gap-y-2"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
const metadata = ref<HTMLElement | null>(null)
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let mutationObserver: MutationObserver | null = null
|
||||
const observedItems = new Set<Element>()
|
||||
|
||||
function getItems() {
|
||||
return Array.from(
|
||||
metadata.value?.querySelectorAll<HTMLElement>('[data-page-header-metadata-item]') ?? [],
|
||||
)
|
||||
}
|
||||
|
||||
function observeItems() {
|
||||
if (!resizeObserver) return
|
||||
|
||||
for (const item of getItems()) {
|
||||
if (observedItems.has(item)) continue
|
||||
|
||||
resizeObserver.observe(item)
|
||||
observedItems.add(item)
|
||||
}
|
||||
}
|
||||
|
||||
function updateRowStarts() {
|
||||
const root = metadata.value
|
||||
if (!root) return
|
||||
|
||||
const isRtl = getComputedStyle(root).direction === 'rtl'
|
||||
let previousOffset: number | null = null
|
||||
|
||||
for (const item of getItems()) {
|
||||
const offset = item.offsetLeft
|
||||
const startsRow =
|
||||
previousOffset === null ||
|
||||
(isRtl ? offset >= previousOffset - 1 : offset <= previousOffset + 1)
|
||||
|
||||
item.toggleAttribute('data-page-header-metadata-row-start', startsRow)
|
||||
previousOffset = offset
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleUpdate() {
|
||||
void nextTick(() => {
|
||||
observeItems()
|
||||
updateRowStarts()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
scheduleUpdate()
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(scheduleUpdate)
|
||||
if (metadata.value) {
|
||||
resizeObserver.observe(metadata.value)
|
||||
}
|
||||
observeItems()
|
||||
}
|
||||
|
||||
if (typeof MutationObserver !== 'undefined' && metadata.value) {
|
||||
mutationObserver = new MutationObserver(scheduleUpdate)
|
||||
mutationObserver.observe(metadata.value, { childList: true, subtree: true })
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
mutationObserver?.disconnect()
|
||||
observedItems.clear()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header-metadata
|
||||
:deep([data-page-header-metadata-item]:first-child .page-header-metadata-item-divider),
|
||||
.page-header-metadata
|
||||
:deep([data-page-header-metadata-row-start] .page-header-metadata-item-divider) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div :class="rootClass" data-page-header-metadata-item v-bind="$attrs">
|
||||
<BulletDivider class="page-header-metadata-item-divider shrink-0" />
|
||||
<AutoLink
|
||||
v-if="to && !disabled"
|
||||
v-tooltip="tooltip"
|
||||
:to="to"
|
||||
:aria-label="ariaLabel"
|
||||
:link-class="contentClassString"
|
||||
>
|
||||
<component
|
||||
:is="icon"
|
||||
v-if="icon"
|
||||
:class="iconClass ?? defaultIconClass"
|
||||
aria-hidden="true"
|
||||
v-bind="iconProps"
|
||||
/>
|
||||
<slot />
|
||||
</AutoLink>
|
||||
<button
|
||||
v-else-if="action || disabled"
|
||||
v-tooltip="tooltip"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:aria-label="ariaLabel"
|
||||
:class="contentClass"
|
||||
@click="handleClick"
|
||||
>
|
||||
<component
|
||||
:is="icon"
|
||||
v-if="icon"
|
||||
:class="iconClass ?? defaultIconClass"
|
||||
aria-hidden="true"
|
||||
v-bind="iconProps"
|
||||
/>
|
||||
<slot />
|
||||
</button>
|
||||
<div v-else v-tooltip="tooltip" :aria-label="ariaLabel" :class="contentClass">
|
||||
<component
|
||||
:is="icon"
|
||||
v-if="icon"
|
||||
:class="iconClass ?? defaultIconClass"
|
||||
aria-hidden="true"
|
||||
v-bind="iconProps"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AutoLink from '../../AutoLink.vue'
|
||||
import BulletDivider from '../../BulletDivider.vue'
|
||||
import type { PageHeaderMetadataItemProps } from '../types'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<PageHeaderMetadataItemProps>(), {
|
||||
icon: undefined,
|
||||
iconProps: undefined,
|
||||
iconClass: undefined,
|
||||
tooltip: undefined,
|
||||
ariaLabel: undefined,
|
||||
to: undefined,
|
||||
action: undefined,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const defaultIconClass = 'block size-5 shrink-0 text-current'
|
||||
const baseClass =
|
||||
'flex min-w-0 items-center gap-2 font-medium leading-none text-secondary text-nowrap'
|
||||
const contentBaseClass = 'inline-flex min-w-0 items-center gap-2 text-inherit'
|
||||
const interactiveClass = 'm-0 cursor-pointer border-0 bg-transparent p-0 hover:underline'
|
||||
|
||||
const rootClass = computed(() => [baseClass, props.disabled ? 'cursor-not-allowed opacity-60' : ''])
|
||||
const contentClass = computed(() => [
|
||||
contentBaseClass,
|
||||
props.to || props.action ? interactiveClass : '',
|
||||
])
|
||||
const contentClassString = computed(() => contentClass.value.filter(Boolean).join(' '))
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
void props.action?.(event)
|
||||
}
|
||||
</script>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<PageHeaderMetadataItem
|
||||
:icon="icon"
|
||||
:icon-props="iconProps"
|
||||
:icon-class="iconClass"
|
||||
:tooltip="resolvedTooltip"
|
||||
:aria-label="ariaLabel"
|
||||
:to="to"
|
||||
:action="action"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span class="inline-flex items-baseline gap-1">
|
||||
<span>{{ formattedValue }}</span>
|
||||
<span v-if="label">{{ label }}</span>
|
||||
</span>
|
||||
</PageHeaderMetadataItem>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { PageHeaderMetadataItemProps } from '../types'
|
||||
import PageHeaderMetadataItem from './page-header-metadata-item.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
PageHeaderMetadataItemProps & {
|
||||
value: number
|
||||
label?: string
|
||||
locale?: string
|
||||
compact?: boolean
|
||||
}
|
||||
>(),
|
||||
{
|
||||
label: '',
|
||||
locale: undefined,
|
||||
compact: true,
|
||||
icon: undefined,
|
||||
iconProps: undefined,
|
||||
iconClass: undefined,
|
||||
tooltip: undefined,
|
||||
ariaLabel: undefined,
|
||||
to: undefined,
|
||||
action: undefined,
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const formattedValue = computed(() =>
|
||||
new Intl.NumberFormat(props.locale, {
|
||||
notation: props.compact ? 'compact' : 'standard',
|
||||
maximumFractionDigits: props.compact ? 1 : 0,
|
||||
}).format(props.value),
|
||||
)
|
||||
const fullValue = computed(() => new Intl.NumberFormat(props.locale).format(props.value))
|
||||
const resolvedTooltip = computed(() => {
|
||||
if (props.tooltip) return props.tooltip
|
||||
if (!props.compact || formattedValue.value === fullValue.value) return undefined
|
||||
return [fullValue.value, props.label].filter(Boolean).join(' ')
|
||||
})
|
||||
</script>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<PageHeaderMetadataItem
|
||||
class="!text-wrap"
|
||||
:icon="icon"
|
||||
:icon-props="iconProps"
|
||||
:icon-class="iconClass"
|
||||
:tooltip="tooltip"
|
||||
:aria-label="ariaLabel"
|
||||
:to="to"
|
||||
:action="action"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<div class="flex min-w-0 flex-wrap gap-2">
|
||||
<slot />
|
||||
</div>
|
||||
</PageHeaderMetadataItem>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PageHeaderMetadataItemProps } from '../types'
|
||||
import PageHeaderMetadataItem from './page-header-metadata-item.vue'
|
||||
|
||||
withDefaults(defineProps<PageHeaderMetadataItemProps>(), {
|
||||
icon: undefined,
|
||||
iconProps: undefined,
|
||||
iconClass: undefined,
|
||||
tooltip: undefined,
|
||||
ariaLabel: undefined,
|
||||
to: undefined,
|
||||
action: undefined,
|
||||
disabled: false,
|
||||
})
|
||||
</script>
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<PageHeaderMetadataItem
|
||||
:icon="icon"
|
||||
:icon-props="iconProps"
|
||||
:icon-class="iconClass"
|
||||
:tooltip="resolvedTooltip"
|
||||
:aria-label="ariaLabel"
|
||||
:to="to"
|
||||
:action="action"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span>{{ displayLabel }}</span>
|
||||
</PageHeaderMetadataItem>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFormatDateTime, useRelativeTime } from '../../../../composables'
|
||||
import type { PageHeaderMetadataItemProps } from '../types'
|
||||
import PageHeaderMetadataItem from './page-header-metadata-item.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
PageHeaderMetadataItemProps & {
|
||||
date: string | number | Date
|
||||
label?: string
|
||||
relative?: boolean
|
||||
}
|
||||
>(),
|
||||
{
|
||||
label: '',
|
||||
relative: true,
|
||||
icon: undefined,
|
||||
iconProps: undefined,
|
||||
iconClass: undefined,
|
||||
tooltip: undefined,
|
||||
ariaLabel: undefined,
|
||||
to: undefined,
|
||||
action: undefined,
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const formatDateTime = useFormatDateTime({
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const parsedDate = computed(() => {
|
||||
const date = props.date instanceof Date ? props.date : new Date(props.date)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
})
|
||||
const absoluteDate = computed(() => {
|
||||
if (!parsedDate.value) return ''
|
||||
return formatDateTime(parsedDate.value)
|
||||
})
|
||||
const formattedDate = computed(() => {
|
||||
if (!parsedDate.value) return ''
|
||||
if (!props.relative) return absoluteDate.value
|
||||
return formatRelativeTime(parsedDate.value)
|
||||
})
|
||||
const resolvedTooltip = computed(() => props.tooltip ?? (absoluteDate.value || undefined))
|
||||
const displayLabel = computed(() => [props.label, formattedDate.value].filter(Boolean).join(' '))
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<AutoLink
|
||||
v-if="to && !disabled"
|
||||
v-tooltip="tooltip"
|
||||
:to="to"
|
||||
:aria-label="ariaLabel"
|
||||
:link-class="badgeClassString"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<component
|
||||
:is="icon"
|
||||
v-if="icon"
|
||||
:class="iconClass ?? defaultIconClass"
|
||||
aria-hidden="true"
|
||||
v-bind="iconProps"
|
||||
/>
|
||||
<slot />
|
||||
</AutoLink>
|
||||
<button
|
||||
v-else-if="action || disabled"
|
||||
v-tooltip="tooltip"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:aria-label="ariaLabel"
|
||||
:class="badgeClass"
|
||||
v-bind="$attrs"
|
||||
@click="handleClick"
|
||||
>
|
||||
<component
|
||||
:is="icon"
|
||||
v-if="icon"
|
||||
:class="iconClass ?? defaultIconClass"
|
||||
aria-hidden="true"
|
||||
v-bind="iconProps"
|
||||
/>
|
||||
<slot />
|
||||
</button>
|
||||
<div v-else v-tooltip="tooltip" :aria-label="ariaLabel" :class="badgeClass" v-bind="$attrs">
|
||||
<component
|
||||
:is="icon"
|
||||
v-if="icon"
|
||||
:class="iconClass ?? defaultIconClass"
|
||||
aria-hidden="true"
|
||||
v-bind="iconProps"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AutoLink from '../AutoLink.vue'
|
||||
import type { PageHeaderIconProps, PageHeaderInteractiveProps } from './types'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<PageHeaderIconProps & PageHeaderInteractiveProps>(), {
|
||||
icon: undefined,
|
||||
iconProps: undefined,
|
||||
iconClass: undefined,
|
||||
tooltip: undefined,
|
||||
ariaLabel: undefined,
|
||||
to: undefined,
|
||||
action: undefined,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const badgeClass = computed(() => [
|
||||
'inline-flex items-center gap-1 rounded-full border border-solid border-surface-5 bg-button-bg px-2 py-1 text-sm font-semibold leading-none text-secondary text-nowrap',
|
||||
props.to || props.action ? 'm-0 cursor-pointer hover:underline' : '',
|
||||
props.disabled ? 'cursor-not-allowed opacity-60' : '',
|
||||
])
|
||||
const badgeClassString = computed(() => badgeClass.value.filter(Boolean).join(' '))
|
||||
const defaultIconClass = 'block size-4 shrink-0 text-current'
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
void props.action?.(event)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Component, HTMLAttributes } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
export type PageHeaderTarget = string | RouteLocationRaw
|
||||
export type PageHeaderClass = HTMLAttributes['class']
|
||||
|
||||
export type PageHeaderClickHandler = (event: MouseEvent) => void | Promise<void>
|
||||
|
||||
export type PageHeaderIconProps = {
|
||||
icon?: Component
|
||||
iconProps?: Record<string, unknown>
|
||||
iconClass?: PageHeaderClass
|
||||
}
|
||||
|
||||
export type PageHeaderInteractiveProps = {
|
||||
tooltip?: string
|
||||
ariaLabel?: string
|
||||
to?: PageHeaderTarget
|
||||
action?: PageHeaderClickHandler
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export type PageHeaderMetadataItemProps = PageHeaderIconProps & PageHeaderInteractiveProps
|
||||
|
||||
export type PageHeaderProps = {
|
||||
title: string
|
||||
summary?: string | null
|
||||
headerClass?: PageHeaderClass
|
||||
rowClass?: PageHeaderClass
|
||||
mainClass?: PageHeaderClass
|
||||
titleClass?: PageHeaderClass
|
||||
truncateTitle?: boolean
|
||||
divider?: boolean
|
||||
bottomPadding?: boolean
|
||||
disableLineClamp?: boolean
|
||||
}
|
||||
Reference in New Issue
Block a user