diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index cb95264af58..006864606aa 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -14,6 +14,7 @@ "test": "vue-tsc --noEmit" }, "dependencies": { + "@dnd-kit/dom": "^0.5.0", "@dnd-kit/vue": "^0.5.0", "@modrinth/api-client": "workspace:^", "@modrinth/assets": "workspace:*", diff --git a/apps/app-frontend/src/app/shell/sidebar/index.vue b/apps/app-frontend/src/app/shell/sidebar/index.vue index 467fc0bf9de..a431fa52e3a 100644 --- a/apps/app-frontend/src/app/shell/sidebar/index.vue +++ b/apps/app-frontend/src/app/shell/sidebar/index.vue @@ -15,7 +15,7 @@ import { injectOnboardingChecklist } from '@/providers/onboarding-checklist' import Promotion from './promotion.vue' -defineProps<{ +const props = defineProps<{ credentials: ModrinthCredentials | null | undefined hasPlus: boolean showAd: boolean @@ -37,6 +37,9 @@ const friends = ref | null>(null) const scrollContainer = ref(null) const scrollViewport = ref(null) const upgradeHeight = ref(0) +const promotionPaddingStyle = computed(() => + props.showAd ? { paddingBottom: `calc(1rem + ${upgradeHeight.value}px)` } : undefined, +) const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(scrollViewport) let removeScrollbarListener: (() => void) | undefined @@ -103,7 +106,11 @@ const messages = defineMessages({ @login-minecraft="accounts?.login()" @login-modrinth="emit('login-modrinth')" /> - +
+import { PointerActivationConstraints } from '@dnd-kit/dom' import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue' import { CheckIcon, DownloadIcon, PlayIcon, SpinnerIcon, StopCircleIcon } from '@modrinth/assets' import { defineMessages, IconButton, injectNotificationManager, useVIntl } from '@modrinth/ui' @@ -23,7 +24,7 @@ const LOADING_INDICATOR_DELAY_MS = 100 const instanceCardSensors = [ PointerSensor.configure({ - preventActivation: () => false, + activationConstraints: [new PointerActivationConstraints.Distance({ value: 5 })], }), KeyboardSensor, ] diff --git a/apps/app-frontend/src/components/ui/library/library-toolbar/filter-menu.vue b/apps/app-frontend/src/components/ui/library/library-toolbar/filter-menu.vue index 2cb9bf25631..9a9e07d6b34 100644 --- a/apps/app-frontend/src/components/ui/library/library-toolbar/filter-menu.vue +++ b/apps/app-frontend/src/components/ui/library/library-toolbar/filter-menu.vue @@ -38,7 +38,7 @@ const filterCategories = computed(() => [ { key: 'gameVersion', label: formatMessage(messages.gameVersion), - searchable: true, + searchable: new Set(instances.value.map((instance) => instance.game_version)).size > 5, options: [...new Set(instances.value.map((instance) => instance.game_version))] .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })) .map((version) => ({ value: version, label: version })), diff --git a/apps/app-frontend/src/components/ui/library/use-library.ts b/apps/app-frontend/src/components/ui/library/use-library.ts index fab76119f95..6880bd9244e 100644 --- a/apps/app-frontend/src/components/ui/library/use-library.ts +++ b/apps/app-frontend/src/components/ui/library/use-library.ts @@ -293,7 +293,7 @@ function createLibraryState(instances: Ref) { const groupedDifference = Number(a.group_ids.length > 0) - Number(b.group_ids.length > 0) if (groupedDifference !== 0) return groupedDifference - return a.name.localeCompare(b.name) + return a.name.localeCompare(b.name, undefined, { numeric: true }) }) }) const groupInstancesModalGroup = computed(() => { @@ -311,7 +311,7 @@ function createLibraryState(instances: Ref) { return instances.value .filter((instance) => !query || instance.name.toLowerCase().includes(query)) .slice() - .sort((a, b) => a.name.localeCompare(b.name)) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })) }) const canCreateGroup = computed( () => normalizedNewGroupName.value.length > 0 && !creatingGroup.value, @@ -433,7 +433,7 @@ function createLibraryState(instances: Ref) { switch (displayState.value.sortBy) { case 'Name': - visibleInstances.sort((a, b) => a.name.localeCompare(b.name)) + visibleInstances.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })) break case 'Loader': visibleInstances.sort((a, b) => diff --git a/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue b/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue index d54f2703f80..0ba70859afb 100644 --- a/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue +++ b/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue @@ -9,6 +9,7 @@ import { useVIntl, } from '@modrinth/ui' import { useQueries, useQuery, useQueryClient } from '@tanstack/vue-query' +import { useResizeObserver } from '@vueuse/core' import type { Dayjs } from 'dayjs' import dayjs from 'dayjs' import { computed, onUnmounted, ref, watch } from 'vue' @@ -66,9 +67,16 @@ const gameVersions = computed(() => gameVersionsQuery.data.value ?? []) const MAX_JUMP_BACK_IN = 5 const MAX_NEW_INSTANCES = 3 -const ITEM_DRAG_DISTANCE = 92 +const ROW_DRAG_DISTANCE = 92 const STORAGE_KEY = 'modrinth-jump-back-in-count' +const itemsGrid = ref() +const columnCount = ref(1) +useResizeObserver(itemsGrid, () => { + if (!itemsGrid.value) return + columnCount.value = getComputedStyle(itemsGrid.value).gridTemplateColumns.split(' ').length +}) + const storedVisibleCount = Number.parseInt(localStorage.getItem(STORAGE_KEY) ?? '', 10) const visibleItemLimit = ref( Number.isFinite(storedVisibleCount) @@ -76,16 +84,19 @@ const visibleItemLimit = ref( : MAX_JUMP_BACK_IN, ) const maxVisibleItems = computed(() => Math.min(jumpBackInItems.value.length, MAX_JUMP_BACK_IN)) -const visibleItemCount = computed(() => Math.min(visibleItemLimit.value, maxVisibleItems.value)) +const minVisibleItems = computed(() => Math.min(columnCount.value, maxVisibleItems.value)) +const visibleItemCount = computed(() => + Math.max(minVisibleItems.value, Math.min(visibleItemLimit.value, maxVisibleItems.value)), +) const visibleJumpBackInItems = computed(() => jumpBackInItems.value.slice(0, visibleItemCount.value), ) -const canResize = computed(() => maxVisibleItems.value > 1) +const canResize = computed(() => maxVisibleItems.value > minVisibleItems.value) const resizing = ref(false) const showOverdrag = ref(false) function setVisibleItemLimit(count: number) { - const clamped = Math.max(1, Math.min(count, maxVisibleItems.value)) + const clamped = Math.max(minVisibleItems.value, Math.min(count, maxVisibleItems.value)) if (clamped >= maxVisibleItems.value) { visibleItemLimit.value = MAX_JUMP_BACK_IN localStorage.removeItem(STORAGE_KEY) @@ -95,9 +106,15 @@ function setVisibleItemLimit(count: number) { } } -function adjustVisibleItemLimit(delta: number) { - const target = visibleItemCount.value + delta - if (target < 1 || target > maxVisibleItems.value) { +function getRowResizeTarget(count: number, rowDelta: number) { + return rowDelta < 0 + ? (Math.ceil(count / columnCount.value) + rowDelta) * columnCount.value + : count + rowDelta * columnCount.value +} + +function adjustVisibleItemLimit(rowDelta: number) { + const target = getRowResizeTarget(visibleItemCount.value, rowDelta) + if (target < minVisibleItems.value || target > maxVisibleItems.value) { flashOverdrag() } setVisibleItemLimit(target) @@ -140,8 +157,9 @@ function onResizePointerDown(event: PointerEvent) { function onResizePointerMove(event: PointerEvent) { if (!resizing.value) return - const target = dragStartCount + Math.round((event.clientY - dragStartY) / ITEM_DRAG_DISTANCE) - const isOverdragging = target < 1 || target > maxVisibleItems.value + const rowDelta = Math.round((event.clientY - dragStartY) / ROW_DRAG_DISTANCE) + const target = getRowResizeTarget(dragStartCount, rowDelta) + const isOverdragging = target < minVisibleItems.value || target > maxVisibleItems.value if (isOverdragging && !wasOverdragging) { flashOverdrag() } @@ -180,8 +198,10 @@ type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem const showWorlds = computed(() => appSettings.showJumpIn) +const recentWorldsOptions = recentWorldsQueryOptions(MAX_JUMP_BACK_IN) const recentWorldsQuery = useQuery({ - ...recentWorldsQueryOptions(MAX_JUMP_BACK_IN), + ...recentWorldsOptions, + queryKey: [...recentWorldsOptions.queryKey] as const, enabled: showWorlds, }) const loading = computed(() => showWorlds.value && recentWorldsQuery.isLoading.value) @@ -423,7 +443,7 @@ onUnmounted(() => {
-
+
{ tabindex="0" aria-orientation="horizontal" :aria-label="formatMessage(messages.resize)" - :aria-valuemin="1" + :aria-valuemin="minVisibleItems" :aria-valuemax="maxVisibleItems" :aria-valuenow="visibleItemCount" - class="group/resize relative inset-x-0 bottom-0 flex h-6 -mt-3 w-1/3 mx-auto cursor-ns-resize touch-none select-none items-center justify-center" + class="group/resize relative col-span-full inset-x-0 bottom-0 flex h-6 -mt-3 w-1/3 mx-auto cursor-ns-resize touch-none select-none items-center justify-center" @pointerdown="onResizePointerDown" @pointermove="onResizePointerMove" @pointerup="endResize" @pointercancel="endResize" @keydown.up.prevent="adjustVisibleItemLimit(-1)" @keydown.down.prevent="adjustVisibleItemLimit(1)" - @keydown.home.prevent="setVisibleItemLimit(1)" + @keydown.home.prevent="setVisibleItemLimit(minVisibleItems)" @keydown.end.prevent="setVisibleItemLimit(maxVisibleItems)" >
diff --git a/packages/ui/src/components/base/Combobox.vue b/packages/ui/src/components/base/Combobox.vue index 81174c22fc1..ec85bd9dbdc 100644 --- a/packages/ui/src/components/base/Combobox.vue +++ b/packages/ui/src/components/base/Combobox.vue @@ -39,12 +39,25 @@ @clear="handleSearchClear" >
@@ -1024,13 +1037,16 @@ function handleSearchClick() { } } -function handleSearchIconClick() { - if (isOpen.value) { - closeDropdown(false) - return - } +function handleSearchChevronClick() { + if (props.disabled) return - openDropdown() + const wasOpen = isOpen.value + searchTriggerRef.value?.focus() + if (wasOpen) { + closeDropdown() + } else { + openDropdown() + } } function handleWindowResize() { @@ -1062,10 +1078,17 @@ function startPositionTracking() { listenerWindow.addEventListener('beforeunload', handleWindowClose) listenerWindow.visualViewport?.addEventListener('scroll', handleViewportChange) listenerWindow.visualViewport?.addEventListener('resize', handleViewportChange) - stopOutsideClick = onClickOutside(dropdownRef, () => closeDropdown(false), { - window: listenerWindow, - ignore: outsideClickIgnoreTargets, - }) + stopOutsideClick = onClickOutside( + dropdownRef, + (event) => { + if (containerRef.value && event.composedPath().includes(containerRef.value)) return + closeDropdown(false) + }, + { + window: listenerWindow, + ignore: outsideClickIgnoreTargets, + }, + ) } function stopPositionTracking() { diff --git a/packages/ui/src/components/search/SearchDependsOnFilter.vue b/packages/ui/src/components/search/SearchDependsOnFilter.vue index f52b5018bf0..5d4636146d5 100644 --- a/packages/ui/src/components/search/SearchDependsOnFilter.vue +++ b/packages/ui/src/components/search/SearchDependsOnFilter.vue @@ -4,6 +4,7 @@ :model-value="pendingProjectId" :project-types="selectableProjectTypes" :exclude-project-ids="dependencyProjectIds" + :force-direction="dropdownDirection" :search-placeholder="formatMessage(messages.searchContentPlaceholder)" :show-chevron="false" :sync-with-selection="false" @@ -70,6 +71,7 @@ :model-value="selectedProjectId" :project-types="selectableProjectTypes" :search-placeholder="formatMessage(messages.searchProjectPlaceholder)" + :force-direction="dropdownDirection" :show-chevron="false" clearable search-input-variant="button" @@ -97,6 +99,7 @@