refactor: content tab styling (#7091)

* refactor: managed content card

* fix: update filter pill styling to be chips-like

* fix: modpack exporting

* fixed: vanilla server projs + header

* refactor: filters

* refactor: detect external file icons + other metadata inside

* refactor: support json5 metadata

* fix: qa

* fix: qa

* fix: fmt + prepr

* fix: fmt

* fix: qa

* fix: headers

* feat: locking

* fix: clippy

* fix: qa

* fix: frozen loc

* fix: shared instances diff calc break

* fix: env filter + remove categories
This commit is contained in:
Calum H.
2026-08-13 14:55:35 +00:00
committed by GitHub
parent e087e571f3
commit 06b7c44dcc
126 changed files with 3654 additions and 3473 deletions
@@ -8,103 +8,126 @@
<template v-else>{{ label }}</template>
</span>
<MultiSelect
v-for="preview in appliedFilterPreviews"
:key="preview.key"
class="min-w-0 max-w-full"
:model-value="getPreviewSelectedValues(preview.key)"
:options="preview.options"
:max-height="500"
:clearable="false"
:show-chevron="false"
:fit-content="true"
:searchable="preview.category.searchable"
:search-placeholder="preview.category.searchPlaceholder"
trigger-type="base"
trigger-size="lg"
:trigger-class="effectivePreviewTriggerClass"
:dropdown-width="getPreviewDropdownWidth(preview.category)"
:dropdown-min-width="getPreviewDropdownMinWidth(preview.category)"
:checkbox-position="checkboxPosition"
show-selection-actions
@update:model-value="(nextValue) => setPreviewSelectedValues(preview.key, nextValue)"
@open="openPreviewFilterDraft(preview.key)"
@close="commitPreviewFilterDraft(preview.key)"
>
<template v-if="$slots['preview-top'] && preview.category.syntheticOptions?.length" #top>
<slot
name="preview-top"
:category="preview.category"
:selected-values="getPreviewSelectedValues(preview.key)"
:set-selected-values="(values) => setPreviewSelectedValues(preview.key, values)"
:close-menu="(event) => closePreviewFilterMenu(preview.key, event)"
></slot>
</template>
<template v-if="$slots['search-actions']" #search-actions>
<slot
name="search-actions"
:category="preview.category"
:selected-values="getPreviewSelectedValues(preview.key)"
:set-selected-values="(values) => setPreviewSelectedValues(preview.key, values)"
:close-menu="(event) => closePreviewFilterMenu(preview.key, event)"
></slot>
</template>
<template #input-content="{ isOpen, openDirection }">
<div class="flex min-h-8 min-w-0 max-w-full items-center gap-2 sm:max-w-80">
<FilterIcon
v-if="showPreviewFilterIcon"
class="size-5 shrink-0 text-primary"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 truncate">
<span class="font-medium">{{ preview.label }}:</span>
<span class="ml-1 font-semibold text-contrast">{{ preview.summary }}</span>
</span>
<div class="flex shrink-0 items-center gap-1.5">
<button
type="button"
class="flex cursor-pointer items-center justify-center rounded border-none bg-transparent p-0.5 text-secondary transition-colors hover:text-contrast"
:aria-label="`Clear ${preview.label} filter`"
@click.stop="clearFilterCategory(preview.key)"
>
<XIcon class="size-4 text-primary" />
</button>
<div class="h-5 w-[1px] shrink-0 bg-surface-5"></div>
<ChevronLeftIcon
class="size-5 shrink-0 text-secondary transition-transform duration-150"
:class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'"
<template v-for="preview in appliedFilterPreviews" :key="preview.key">
<Button
v-if="preview.category.direct"
type="outlined"
native-type="button"
size="lg"
:class="effectivePreviewTriggerClass"
:aria-label="`Clear ${preview.label} filter`"
@click="clearFilterCategory(preview.key)"
>
<span class="font-semibold">{{ preview.label }}</span>
<XIcon class="size-4" />
</Button>
<MultiSelect
v-else
class="min-w-0 max-w-full"
:model-value="getPreviewSelectedValues(preview.key)"
:options="preview.options"
:max-height="500"
:clearable="false"
:show-chevron="false"
:fit-content="true"
:searchable="preview.category.searchable"
:search-placeholder="preview.category.searchPlaceholder"
trigger-type="base"
trigger-size="lg"
:trigger-class="effectivePreviewTriggerClass"
:dropdown-width="getPreviewDropdownWidth(preview.category)"
:dropdown-min-width="getPreviewDropdownMinWidth(preview.category)"
:checkbox-position="checkboxPosition"
show-selection-actions
@update:model-value="(nextValue) => setPreviewSelectedValues(preview.key, nextValue)"
@open="openPreviewFilterDraft(preview.key)"
@close="commitPreviewFilterDraft(preview.key)"
>
<template v-if="$slots['preview-top'] && preview.category.syntheticOptions?.length" #top>
<slot
name="preview-top"
:category="preview.category"
:selected-values="getPreviewSelectedValues(preview.key)"
:set-selected-values="(values) => setPreviewSelectedValues(preview.key, values)"
:close-menu="(event) => closePreviewFilterMenu(preview.key, event)"
></slot>
</template>
<template v-if="$slots['search-actions']" #search-actions>
<slot
name="search-actions"
:category="preview.category"
:selected-values="getPreviewSelectedValues(preview.key)"
:set-selected-values="(values) => setPreviewSelectedValues(preview.key, values)"
:close-menu="(event) => closePreviewFilterMenu(preview.key, event)"
></slot>
</template>
<template #input-content="{ isOpen, openDirection }">
<div class="flex min-h-8 min-w-0 max-w-full items-center gap-2 sm:max-w-80">
<FilterIcon
v-if="showPreviewFilterIcon"
class="size-5 shrink-0 text-primary"
aria-hidden="true"
/>
<slot
name="preview-content"
:category="preview.category"
:selected-values="getPreviewSelectedValues(preview.key)"
:label="preview.label"
:summary="preview.summary"
>
<span class="min-w-0 flex-1 truncate">
<span class="font-medium">{{ preview.label }}:</span>
<span class="ml-1 font-semibold text-contrast">{{ preview.summary }}</span>
</span>
</slot>
<div class="flex shrink-0 items-center gap-1.5">
<button
type="button"
class="flex cursor-pointer items-center justify-center rounded border-none bg-transparent p-0.5 text-secondary transition-colors hover:text-contrast"
:aria-label="`Clear ${preview.label} filter`"
@click.stop="clearFilterCategory(preview.key)"
>
<XIcon class="size-4 text-primary" />
</button>
<div class="h-5 w-[1px] shrink-0 bg-surface-5"></div>
<ChevronLeftIcon
class="size-5 shrink-0 text-secondary transition-transform duration-150"
:class="
isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'
"
/>
</div>
</div>
</div>
</template>
<template v-if="$slots['preview-footer']" #bottom>
<slot
name="preview-footer"
:category="preview.category"
:selected-values="getPreviewSelectedValues(preview.key)"
:set-selected-values="(values) => setPreviewSelectedValues(preview.key, values)"
:close-menu="(event) => closePreviewFilterMenu(preview.key, event)"
></slot>
</template>
<template v-if="$slots.option" #option="{ item, selected, index }">
<slot
name="option"
:category="preview.category"
:option="item"
:selected="selected"
:index="index"
></slot>
</template>
<template v-else-if="$slots['option-right']" #option-right="{ item, selected, index }">
<slot
name="option-right"
:category="preview.category"
:option="item"
:selected="selected"
:index="index"
></slot>
</template>
</MultiSelect>
</template>
<template v-if="$slots['preview-footer']" #bottom>
<slot
name="preview-footer"
:category="preview.category"
:selected-values="getPreviewSelectedValues(preview.key)"
:set-selected-values="(values) => setPreviewSelectedValues(preview.key, values)"
:close-menu="(event) => closePreviewFilterMenu(preview.key, event)"
></slot>
</template>
<template v-if="$slots.option" #option="{ item, selected, index }">
<slot
name="option"
:category="preview.category"
:option="item"
:selected="selected"
:index="index"
></slot>
</template>
<template v-else-if="$slots['option-right']" #option-right="{ item, selected, index }">
<slot
name="option-right"
:category="preview.category"
:option="item"
:selected="selected"
:index="index"
></slot>
</template>
</MultiSelect>
</template>
<div class="flex h-10 min-w-0 max-w-full items-center gap-2">
<Button
@@ -132,7 +155,7 @@
<div
v-if="isAddMenuOpen && !isMobileActiveSubmenu"
ref="menuContainer"
class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] border border-solid border-surface-5 bg-surface-4 shadow-2xl"
class="fixed z-[9999] flex flex-col overflow-x-hidden overflow-y-auto rounded-[14px] border border-solid border-surface-5 bg-surface-4 shadow-2xl"
:style="addMenuStyle"
role="menu"
@mousedown.stop
@@ -145,15 +168,24 @@
:ref="(element) => setCategoryButtonRef(category.key, element)"
type="button"
class="group/filter-menu-button flex h-12 w-full appearance-none items-center justify-between gap-1 border-0 px-4 text-left text-base font-semibold text-primary shadow-none transition-all duration-150 hover:brightness-110 focus:brightness-110 bg-surface-4"
:class="category.key === activeCategoryKey ? '!brightness-110' : ''"
role="menuitem"
@click="activateCategory(category.key)"
@mouseenter="handleCategoryMouseEnter(category.key)"
@focus="handleCategoryFocus(category.key)"
:class="
category.key === activeCategoryKey || isDirectCategorySelected(category)
? '!brightness-110'
: ''
"
:role="category.direct ? 'menuitemcheckbox' : 'menuitem'"
:aria-checked="category.direct ? isDirectCategorySelected(category) : undefined"
@click="handleCategoryClick(category)"
@mouseenter="handleCategoryMouseEnter(category)"
@focus="handleCategoryFocus(category)"
>
<span>{{ category.label }}</span>
<div class="flex items-center gap-1">
<ChevronRightIcon class="size-5 text-secondary" />
<CheckIcon
v-if="category.direct && isDirectCategorySelected(category)"
class="size-5 text-brand"
/>
<ChevronRightIcon v-else-if="!category.direct" class="size-5 text-secondary" />
</div>
</button>
</div>
@@ -417,6 +449,7 @@ export type DropdownFilterBarCategory = {
key: string
label: string
options: DropdownFilterBarItem[]
direct?: boolean
syntheticOptions?: DropdownFilterBarOption[]
searchable?: boolean
disableLocalOptionsFilter?: boolean
@@ -461,6 +494,7 @@ type ViewportRect = {
type MenuPositionOptions = {
triggerRect: DOMRect
dropdownRect: DOMRect
dropdownHeight: number
viewport: ViewportRect
}
@@ -1341,7 +1375,42 @@ function getSubmenuWidthInPixels(category: DropdownFilterBarCategory): number {
}
function getWidestSubmenuWidthInPixels(categories: DropdownFilterBarCategory[]): number {
return Math.max(...categories.map((category) => getSubmenuWidthInPixels(category)), 0)
return Math.max(
...categories
.filter((category) => !category.direct)
.map((category) => getSubmenuWidthInPixels(category)),
0,
)
}
function getDirectCategoryOption(category: DropdownFilterBarCategory) {
if (!category.direct) return undefined
return category.options.find(isDropdownFilterOption)
}
function isDirectCategorySelected(category: DropdownFilterBarCategory) {
const option = getDirectCategoryOption(category)
return !!option && isFilterValueSelected(category.key, option.value)
}
function deactivateCategory() {
clearPendingCategoryTimeout()
pendingCategoryKey.value = null
activeCategoryKey.value = null
categorySearchQuery.value = ''
hasSubmenuPosition.value = false
}
function handleCategoryClick(category: DropdownFilterBarCategory) {
const option = getDirectCategoryOption(category)
if (!option) {
activateCategory(category.key)
return
}
deactivateCategory()
toggleFilterOption(category.key, option)
scheduleAddMenuPositionUpdate()
}
function activateCategory(categoryKey: string) {
@@ -1357,18 +1426,27 @@ function activateCategory(categoryKey: string) {
}
}
function handleCategoryFocus(categoryKey: string) {
function handleCategoryFocus(category: DropdownFilterBarCategory) {
if (category.direct) {
deactivateCategory()
return
}
if (isMobileAddMenuLayout.value) {
return
}
activateCategory(categoryKey)
activateCategory(category.key)
}
function handleCategoryMouseEnter(categoryKey: string) {
function handleCategoryMouseEnter(category: DropdownFilterBarCategory) {
if (category.direct) {
deactivateCategory()
return
}
if (isMobileAddMenuLayout.value) {
return
}
const categoryKey = category.key
if (!activeCategoryKey.value) {
activateCategory(categoryKey)
@@ -1448,7 +1526,12 @@ function getViewportRect(): ViewportRect {
}
}
function getAddMenuPosition({ triggerRect, dropdownRect, viewport }: MenuPositionOptions) {
function getAddMenuPosition({
triggerRect,
dropdownRect,
dropdownHeight,
viewport,
}: MenuPositionOptions) {
const dropdownWidth = Math.max(ADD_MENU_WIDTH, triggerRect.width)
const positionedDropdownWidth = Math.max(dropdownRect.width, dropdownWidth)
const triggerTop = triggerRect.top + viewport.offsetTop
@@ -1463,18 +1546,24 @@ function getAddMenuPosition({ triggerRect, dropdownRect, viewport }: MenuPositio
minLeft,
viewportRight - positionedDropdownWidth - DROPDOWN_VIEWPORT_MARGIN,
)
const hasSpaceBelow =
triggerBottom + dropdownRect.height + DROPDOWN_GAP + DROPDOWN_VIEWPORT_MARGIN <= viewportBottom
const hasSpaceAbove =
triggerTop - dropdownRect.height - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN > viewportTop
const opensUp = !hasSpaceBelow && hasSpaceAbove
const spaceBelow = Math.max(
0,
viewportBottom - triggerBottom - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN,
)
const spaceAbove = Math.max(0, triggerTop - viewportTop - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN)
const hasSpaceBelow = dropdownHeight <= spaceBelow
const hasSpaceAbove = dropdownHeight <= spaceAbove
const opensUp = !hasSpaceBelow && (hasSpaceAbove || spaceAbove > spaceBelow)
const availableHeight = opensUp ? spaceAbove : spaceBelow
const positionedDropdownHeight = Math.min(dropdownHeight, availableHeight)
const top = opensUp
? triggerTop - dropdownRect.height - DROPDOWN_GAP
? triggerTop - positionedDropdownHeight - DROPDOWN_GAP
: triggerBottom + DROPDOWN_GAP
const left = Math.min(Math.max(minLeft, triggerLeft), maxLeft)
return {
left: `${left}px`,
maxHeight: `${availableHeight}px`,
minWidth: `${triggerRect.width}px`,
top: `${Math.max(viewportTop + DROPDOWN_VIEWPORT_MARGIN, top)}px`,
width: `${dropdownWidth}px`,
@@ -1601,6 +1690,7 @@ function updateAddMenuPosition(): boolean {
addMenuStyle.value = getAddMenuPosition({
triggerRect,
dropdownRect,
dropdownHeight: positioningElement.scrollHeight,
viewport,
})
return true
+30 -20
View File
@@ -1,22 +1,26 @@
<template>
<div class="flex flex-wrap items-center gap-1.5">
<FilterIcon class="size-5 text-secondary" />
<button
:class="pillClass(modelValue.length === 0)"
:aria-pressed="modelValue.length === 0"
@click="modelValue = []"
>
<slot name="all"> All </slot>
</button>
<button
v-for="option in options"
:key="option.id"
:class="pillClass(modelValue.includes(option.id))"
:aria-pressed="modelValue.includes(option.id)"
@click="toggle(option.id)"
>
{{ option.label }}
</button>
<div class="flex items-center gap-2">
<FilterIcon class="size-5 shrink-0 text-secondary" />
<div class="filter-pills__chips flex flex-wrap items-center gap-1.5">
<button
type="button"
:class="pillClass(modelValue.length === 0)"
:aria-pressed="modelValue.length === 0"
@click="modelValue = []"
>
<slot name="all"> All </slot>
</button>
<button
v-for="option in options"
:key="option.id"
type="button"
:class="pillClass(modelValue.includes(option.id))"
:aria-pressed="modelValue.includes(option.id)"
@click="toggle(option.id)"
>
{{ option.label }}
</button>
</div>
</div>
</template>
@@ -36,10 +40,10 @@ defineProps<{
function pillClass(active: boolean) {
return [
'cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]',
'cursor-pointer rounded-xl border border-solid px-3 py-1.5 text-sm font-medium leading-5 transition-all duration-100 active:scale-[0.97] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow',
active
? 'border-brand bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5',
: 'border-surface-5 bg-transparent text-primary hover:bg-surface-3',
]
}
@@ -51,3 +55,9 @@ function toggle(id: string) {
}
}
</script>
<style scoped>
.filter-pills__chips {
filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.15));
}
</style>
@@ -46,7 +46,7 @@ const typeClasses: Record<ButtonType, string> = {
colored:
'button-frame--colored bg-[--button-color] text-[var(--color-accent-contrast)] [&>svg]:text-inherit',
outlined:
'button-frame--outlined bg-transparent text-[var(--button-color,var(--color-contrast))] [&>svg]:text-inherit',
'button-frame--outlined bg-transparent text-[var(--button-color,var(--color-contrast))] [&>svg]:text-[var(--button-color,var(--color-base))]',
quiet: 'button-frame--quiet bg-transparent [&>svg]:text-inherit',
}