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:
Calum H.
2026-07-20 18:25:12 +00:00
committed by GitHub
co-authored by Prospector
parent 8b6f0ec695
commit 932858b5f5
65 changed files with 3131 additions and 2091 deletions
@@ -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>
@@ -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>
@@ -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>
@@ -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
}